Skip to content

Commit 922b1a9

Browse files
authored
Merge pull request #4 from mitmedialab/high-geometry-labeling
High geometry labeling
2 parents cf26b9f + 5d4f6fb commit 922b1a9

6 files changed

Lines changed: 430 additions & 74 deletions

File tree

README.md

Lines changed: 95 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -484,13 +484,21 @@ commands_consolidated primitives_consolidated tour_consolidated
484484
output; `commands_fitted` is the pre-beautification snapshot kept for
485485
diagnostics.
486486

487-
### 6.7 Per-command raw-segment labels (`labels.py`)
488-
489-
When the vectorizer is built with `labeled_segments=segment.labeled_segments`,
490-
it produces `labeled_commands_consolidated` — one `LabeledCommand` per
491-
emitted command. Drawing commands (pen-down line / arc / circle) carry
492-
a list of `CommandSpan` runs naming the raw segments their primitive
493-
came from; spins and pen-up transit commands carry an empty span list.
487+
### 6.7 Per-command raw-segment labels (low geometry, `labels.py`)
488+
489+
The shared label types `CommandSpan` and `LabeledCommand` live in
490+
`vectorize/labels_common.py`; both vectorizers emit them, so low- and
491+
high-geometry labels are the same type and reference the same
492+
raw-segment ids (directly comparable). Low geometry produces them
493+
*structurally* (this section); high geometry produces them
494+
*geometrically* (§6.8).
495+
496+
When the low-geometry vectorizer is built with
497+
`labeled_segments=segment.labeled_segments`, it produces
498+
`labeled_commands_consolidated` — one `LabeledCommand` per emitted
499+
command. Drawing commands (pen-down line / arc / circle) carry a list
500+
of `CommandSpan` runs naming the raw segments their primitive came
501+
from; spins and pen-up transit commands carry an empty span list.
494502

495503
Each `CommandSpan` has:
496504

@@ -526,10 +534,41 @@ exactly one `CommandSpan` covering `raw[0:len]` and ratio
526534
the same code path when a primitive consumed only part of a raw
527535
segment (a corner split mid-stroke).
528536

529-
Important scope note: labels are produced for `commands_consolidated`
530-
only. `OptimizeRoute` (stage 5) reorders commands and re-emits transit
531-
spins/lines, so its `commands` output is **not** labeled — the labels
532-
are tied to the consolidated tour the labeler walked.
537+
Important scope note: the *structural* low-geometry labeler is tied to
538+
the consolidated tour it walked, so it only labels
539+
`commands_consolidated`. `OptimizeRoute` (stage 5) reorders commands
540+
and re-emits transit spins/lines, so that stream is **not** structurally
541+
labeled — but the geometric labeler in §6.8 can label any stream,
542+
including the optimized one, since it reads only the emitted geometry.
543+
544+
### 6.8 Per-command raw-segment labels (high geometry / geometric, `labels_common.py`)
545+
546+
The high-geometry baseline is frozen and tracks no point-range
547+
provenance through its segment/classify/order logic, so there is no
548+
structural command → primitive → polyline chain to walk. Its labels
549+
are recovered **geometrically** by `label_commands_geometric`, which
550+
needs only the emitted command stream plus the raw segments:
551+
552+
1. Replay the command stream to recover each pen-down command's drawn
553+
path (a line's two endpoints, an arc's center / radius / sweep).
554+
2. Sample that path at ~1 point per pixel.
555+
3. Match each sample to the nearest raw-segment pixel via a KDTree —
556+
the same KDTree construction the segment stage uses (§4.1).
557+
4. Compress consecutive same-id samples into `CommandSpan` runs, taking
558+
the raw-index range from the matched pixels and the ratio range
559+
from the sample parameters.
560+
561+
`HighGeometryVectorize` takes an optional `raw_segments` argument
562+
(the pipeline passes `segment.segments`); when supplied it populates
563+
`labeled_commands` parallel to `commands`. For the geometric labeler,
564+
`LabeledCommand.primitive_id` is the drawing command's ordinal in draw
565+
order (a stable handle, not an index into any primitive list) and
566+
`final_segment_index` is `None` (the baseline doesn't carry the
567+
final-segment abstraction).
568+
569+
Because it reads only geometry, the same function labels the
570+
route-optimized stream too — call it with `optimized.commands` and the
571+
same `segment.segments` when you need labels on stage 5's output.
533572

534573
---
535574

@@ -638,7 +677,7 @@ become?" without re-deriving it from geometry. The chain is:
638677

639678
```
640679
binary pixel → skeleton pixel → raw segment id → final segment span → drawing command
641-
(§3.3) (§4.1) (§4.1) (§6.7)
680+
(§3.3) (§4.1) (§4.1) (§6.7 / §6.8)
642681
```
643682

644683
Each hop is implemented independently and exposes its lookup
@@ -649,7 +688,15 @@ granularity they need:
649688
|-----|-------|----------|-------|
650689
| binary pixel → skeleton pixel | Skeletonize | `labeling: (H, W) int32` (-1 = outside binary) | `Skeletonize.labeling` |
651690
| skeleton/final pixel → raw segment id (+ index, + raw range) | Segment | `labeled_segments: List[LabeledSegment]` with per-pixel `raw_ids`/`raw_indices` and run-compressed `RawSegmentSpan` lists | `Segment.labeled_segments` |
652-
| drawing command → raw segment id (+ index, + ratio) | Vectorize | `labeled_commands_consolidated: List[LabeledCommand]` with per-command `CommandSpan` lists | `LowGeometryVectorize.labeled_commands_consolidated` |
691+
| drawing command → raw segment id (+ index, + ratio), low geometry | Vectorize | `labeled_commands_consolidated: List[LabeledCommand]` (structural) | `LowGeometryVectorize.labeled_commands_consolidated` |
692+
| drawing command → raw segment id (+ index, + ratio), high geometry | Vectorize | `labeled_commands: List[LabeledCommand]` (geometric) | `HighGeometryVectorize.labeled_commands` |
693+
694+
Both vectorizers emit the same `CommandSpan` / `LabeledCommand` types
695+
(defined in `vectorize/labels_common.py`) against the same raw-segment
696+
ids, so a low- and a high-geometry command are directly comparable.
697+
They differ only in *how* the link is found: low geometry tracks
698+
provenance structurally (§6.7); high geometry, which is frozen and
699+
keeps no provenance, recovers it geometrically (§6.8).
653700

654701
Walking forward: a binary pixel `(y, x)` lands on
655702
`Skeletonize.labeling[y, x]`, a row-major flat index into the True
@@ -685,10 +732,14 @@ Scope limits worth knowing:
685732
the cascade — fusion bridges hug ink between the joined endpoints,
686733
and repair's LS-solved junction point sits within the original
687734
cluster.
688-
- Command labels are produced for `commands_consolidated` only;
689-
`OptimizeRoute` reorders commands and re-emits transit
690-
spins/lines, so its `commands` output is not labeled. Re-running
691-
the labeler against the optimized tour would close that gap.
735+
- The low-geometry command mapping is *structural* and is produced
736+
for `commands_consolidated` only; `OptimizeRoute` reorders commands
737+
and re-emits transit spins/lines, so that stream isn't structurally
738+
labeled. The high-geometry command mapping is *geometric*
739+
(sample-and-match against the raw segments) and so works on any
740+
command stream — including the route-optimized one. Use
741+
`label_commands_geometric(optimized.commands, segment.segments, …)`
742+
when you need labels on stage 5's output.
692743

693744
---
694745

@@ -757,6 +808,7 @@ release/
757808
labels.py final-polyline pixel → raw segment id (KDTree)
758809
graph/ stage 3 — StrokeGraph (endpoints→vertices)
759810
vectorize/
811+
labels_common.py shared CommandSpan/LabeledCommand + geometric labeler
760812
low_geometry/ stage 4, the real path
761813
fitting.py polyline → primitive chain (corners/inflections/MDL)
762814
primitives.py Line / Arc / Circle
@@ -765,8 +817,9 @@ release/
765817
beautify.py detect near-relations; merge arcs into circles
766818
manifest.py constraint bundle types
767819
routing.py Eulerian / Chinese-Postman ordering
768-
labels.py drawing command → raw segment spans (+ ratios)
820+
labels.py drawing command → raw segment spans (structural)
769821
high_geometry/ stage 4, the naive comparison baseline
822+
(command labels recovered geometrically)
770823
771824
test.py runs every example end to end, prints metrics
772825
test.sh unit tests, then the example run
@@ -802,7 +855,12 @@ print(opt_low.estimated_time_after) # estimated draw time (s)
802855
# Cross-stage labels (see §9):
803856
print(skeleton.labeling.shape) # (H, W) int32 per-pixel
804857
print(segment.labeled_segments[0].spans) # raw-segment runs
805-
print(low.labeled_commands_consolidated[0].spans) # raw-segment per command
858+
print(low.labeled_commands_consolidated[0].spans) # low-geom command labels
859+
print(high.labeled_commands[0].spans) # high-geom command labels
860+
861+
# Geometric labeler works on any command stream, incl. the optimized one:
862+
from release.vectorize.labels_common import label_commands_geometric
863+
opt_labels = label_commands_geometric(opt_low.commands, segment.segments)
806864
```
807865

808866
A single example processes in ~10–15 s; the full suite takes several
@@ -819,13 +877,17 @@ For each example the harness writes a set of debug PNGs into
819877
| `<name>.segments.labeling.png` | 2 | every final-polyline span coloured by its raw-segment id (§4.1) |
820878
| `<name>.graph.png` | 3 | stroke graph with vertices and edges |
821879
| `<name>.vectorized.svg` | 4 | high / low / optimized 3-panel comparison |
822-
| `<name>.commands.labeling.png` | 4 | every drawing command coloured by its raw-segment-span ids (§6.7) |
880+
| `<name>.commands.labeling.png` | 4 | every low-geometry drawing command coloured by its raw-segment-span ids (§6.7) |
881+
| `<name>.commands.labeling.high.png` | 4 | same for the high-geometry baseline, recovered geometrically (§6.8) |
823882
| `<name>.heatmap.png`, `<name>.overlay.png`, `<name>.overlay.clean.png` | 5–6 | firmware-time heatmap and source-image overlays |
824883

825-
Adjacent labels in the three `*.labeling.png` renders always land at
884+
Adjacent labels in the four `*.labeling*.png` renders always land at
826885
well-separated hues (golden-ratio palette + stride renumbering), so
827886
an over-merge shows as a single colour where two would be expected
828-
and an over-split shows as a hue jump inside one continuous run.
887+
and an over-split shows as a hue jump inside one continuous run. The
888+
two `commands.labeling` renders share the raw-segment colour basis,
889+
so you can compare how the low and high vectorizers carve the same
890+
raw strokes into commands.
829891

830892
---
831893

@@ -844,7 +906,10 @@ and an over-split shows as a hue jump inside one continuous run.
844906
every caller — a stale 5-tuple unpack is exactly the bug that used
845907
to crash the test harness.
846908
- **High geometry is frozen.** It is a baseline, not a place to add
847-
features. Improvements go in low geometry.
909+
features. Improvements go in low geometry. (The raw-segment command
910+
labels it now carries are the exception that proves the rule: they're
911+
recovered *geometrically* from its emitted commands without touching
912+
the frozen segment/classify/order logic — see §6.8.)
848913
- **Two command snapshots.** `commands_consolidated` is the real
849914
output; `commands_fitted` is a diagnostic snapshot. Don't confuse
850915
them. Neither is route-optimized — that's `OptimizeRoute`.
@@ -867,11 +932,13 @@ and an over-split shows as a hue jump inside one continuous run.
867932
"all but last" slice sentinel. To slice the raw segment regardless
868933
of direction, use
869934
`raw_segments[id][min(raw_start, raw_end) : max(raw_start, raw_end) + 1]`.
870-
- **`OptimizeRoute` invalidates command labels.** Stage 5 reorders
871-
primitives and re-emits transit commands, so its `commands` output
872-
is unlabeled. Use `low.labeled_commands_consolidated` (against
873-
`low.commands_consolidated`) if you need labels; re-label the
874-
optimized tour if you need labels on the post-optimization output.
935+
- **`OptimizeRoute` invalidates the structural command labels.**
936+
Stage 5 reorders primitives and re-emits transit commands, so the
937+
low-geometry structural labels (`low.labeled_commands_consolidated`,
938+
tied to `low.commands_consolidated`) don't carry over to its output.
939+
The geometric labeler does carry over: call
940+
`label_commands_geometric(opt_low.commands, segment.segments)` for
941+
labels on the post-optimization stream (§6.8).
875942

876943
---
877944

release/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def default_pipeline(source: ImageSource):
5656
start_pos=start_pos,
5757
start_heading=start_heading,
5858
commands=HighGeometryVectorize.Config.ToCommands(**cfg["high_geometry_commands"]),
59+
raw_segments=segment.segments,
5960
)
6061

6162
optimized_low_geometry = OptimizeRoute(

release/vectorize/high_geometry/__init__.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from __future__ import annotations
1414
import math
15-
from typing import List, Tuple, TypedDict
15+
from typing import List, Optional, Tuple, TypedDict
1616

1717
import numpy as np
1818
from numpy.typing import NDArray
@@ -29,6 +29,7 @@
2929
SpinCommand,
3030
Stroke,
3131
)
32+
from ..labels_common import LabeledCommand, label_commands_geometric
3233

3334
# ============================================================================
3435
# Stage 3: curvature-based segmentation
@@ -580,6 +581,7 @@ def __init__(
580581
start_pos: NDArray[np.float64],
581582
start_heading: float,
582583
commands: Config.ToCommands,
584+
raw_segments: List[NDArray[np.float64]] | None = None,
583585
):
584586
# NOTE: the high-geometry path is a deliberately *naive* baseline
585587
# used only for comparison against the low-geometry pipeline. It
@@ -590,6 +592,8 @@ def __init__(
590592
# than the low-geometry solver, so it was removed; consolidate
591593
# high-geometry output (if ever needed) by running the polylines
592594
# through ``LowGeometryVectorize`` instead.
595+
self.start_pos = np.asarray(start_pos, dtype=float)
596+
self.start_heading = float(start_heading)
593597
self.commands = polylines_to_commands(
594598
polylines,
595599
sigma=commands["sigma"],
@@ -598,3 +602,21 @@ def __init__(
598602
start_pos=start_pos,
599603
start_heading=start_heading,
600604
)
605+
606+
# Optional per-command raw-segment labels. Because this pipeline
607+
# tracks no point-range provenance through its frozen
608+
# segment/classify/order logic, the labels are recovered
609+
# GEOMETRICALLY: each drawing command's path is sampled and each
610+
# sample matched to the nearest raw-segment pixel (see
611+
# ``release/vectorize/labels_common.py``). When the caller passes
612+
# ``raw_segments`` (``Segment.segments``), this populates
613+
# ``labeled_commands`` parallel to ``commands``; otherwise it
614+
# stays ``None``.
615+
self.labeled_commands: Optional[List[LabeledCommand]] = None
616+
if raw_segments is not None:
617+
self.labeled_commands = label_commands_geometric(
618+
self.commands,
619+
raw_segments,
620+
start_pos=(float(self.start_pos[0]), float(self.start_pos[1])),
621+
start_heading=self.start_heading,
622+
)

0 commit comments

Comments
 (0)