26 — The Eufy CV Segmentor — Implementation & Segmenter-Engine Pattern¶
Scope: The concrete computer-vision room segmenter (
adapters/eufy/segmentor.py, ~1600 lines) and the engine contract it implements (mapping/segmenter_engines.py). Dual-purpose: it documents howeufy_cv_v1actually turns a map image into rooms, and it is the pattern guide for a second brand writing its own segmenter.The generic mapping subsystem (coordinate tracking, bounds learning, the segmenter seam) is 11-mapping-system; the adapter that selects this engine is 25-eufy-adapter. This doc is the engine internals.
1. Where this fits¶
The framework never calls segmentor.py directly. The adapter declares
mapping.segmenter_engine: "eufy_cv_v1"; the framework looks that name up in
mapping/segmenter_engines.py and calls the engine through a small protocol. A
brand with no map image declares "noop_fallback" instead — the card stops
drawing polygon overlays, but trace-based tracking (vacuum-space coordinates)
keeps working. So the segmenter is optional and swappable; this is the seam
that keeps CV out of the brand-agnostic core.
The Eufy pipeline is pure NumPy + Pillow + SciPy — there is no cv2
(OpenCV is absent on the HA host). All morphology is scipy.ndimage; contour
tracing + simplification is hand-rolled (RDP) in mapping/segment_primitives.py.
2. The segmenter-engine contract (the pattern)¶
Defined in mapping/segmenter_engines.py. Any brand engine implements this — it
is the entire surface area:
class MapSegmenter(Protocol): # segmenter_engines.py
engine_name: str
def validate_tuning(self, tuning: dict) -> list[str]: ...
def segment_map_image(self, *, image_path, tuning, context=None) -> SegmentationResult: ...
- Registration: no decorator. A module-level dict
_SEGMENTER_ENGINESmaps name → singleton instance.get_segmenter_engine(name)returns the instance, ornoop_fallbackwith a warning on an unknown name.known_engine_names()feeds the adapter validator, so a config naming a missing engine is flagged at registration (see 21 §2.2). validate_tuningreturns a list of issue strings (empty = valid). The Eufy engine rejects keys outside its known set and type-checks each.segment_map_imageis the framework entry point. The Eufy wrapper (EufyCVSegmenter,engine_name = "eufy_cv_v1") guards a nullimage_path, maps tuning →detect_room_segments(...)kwargs, runs the pipeline inside atry, and reshapes the result (hoisting CV-only diagnostic blocks intoengine_diagnostics).- Statelessness: singletons hold no per-call state; the same input must produce the same output.
Pattern for a new brand: add a
{Brand}Segmenterclass with these three members, register it in_SEGMENTER_ENGINES, and you're done — the rest of the framework already knows how to call it.
3. eufy_cv_v1 — what it wraps¶
EufyCVSegmenter.segment_map_image calls detect_room_segments() (the public
function in segmentor.py), which delegates to the private
_detect_room_segments_pipeline(). The pipeline is built on the
brand-agnostic primitives in mapping/segment_primitives.py (polygon math, mask
ops, HSV helpers, RDP). Input is a filesystem path to a clean, unlabeled
Eufy map PNG; output is a pure-Python SegmentationResult dict.
Coordinate space is canvas pixel space — origin top-left, y-down. Segment IDs
are positional (segment_1, segment_2, …, 1-based), not vendor room IDs; matching
canvas segments to vendor room IDs happens downstream (matched_room_id is always
None here).
4. The pipeline, end to end¶
All logic lives in _detect_room_segments_pipeline(). Stages in order:
- Load & readiness. If Pillow/SciPy aren't importable → degraded return. Open the PNG, convert to RGB; derive HSV and a hue plane.
- Room mask (
_build_room_mask_from_hsv). Threshold on value + saturation, thenbinary_opening→binary_closing(×2) →binary_fill_holesto get a clean "floor" mask. - Assist-variant reconciliation (optional). If an
assist_image_pathis given, build its room mask, estimate scale/shift alignment, reproject the assist masks/RGB/HSV into the primary frame, and cut only seam-zone walls (room_mask & ~seam_wall_mask) so valid pixels aren't erased. - No-room guard. Empty mask → degraded
no_room_pixels_detected. - Hue clustering. Median-filter the hue plane, bin into 16-wide buckets; iterate the active bins (Eufy renders each room in a distinct hue).
- Connected components per hue bin. Merge same-hue fragments (closing /
opening / fill),
ndimage.label,find_objects, per-component cleanup. - Per-component metrics + issue flags. fill ratio, area %, compactness,
aspect ratio, mean color, agreement vs the assist mask; flags like
tiny_region,touches_border,possible_merge,oversized_region,fragmented_candidate. - Suspicious-merge split. Large, low-fill / oversized components run the splitter cascade (§5).
- Emit candidates. For each (possibly split) mask: crop, trace the outer
boundary via the hand-rolled edge-follower in
segment_primitives.mask_to_polygon(per-cell boundary edges → closed-loop walk with angle-sorted junctions → largest-area loop), then RDP-simplify it (simplify_epsilon, auto-derived whenNone), offset to global coords, recompute metrics, computeconfidence, assignquality/structural_role/segmentation_state/edit_readiness, then keep/drop rules. Kept →segments; dropped →deferred_small_regions. - Localized-bins dedup. Localized child rooms sorted by confidence, pruned at overlap ≥ 0.35, capped at 4.
- Global dedup. Drop near-duplicates (high overlap or near-identical center).
- Recovery pass. If fewer segments than
expected_room_count, re-admit the best deferred regions (recovery_mode=True). (This is the count-deficit backfill — distinct from the failure path in §7.) - Finalize. Sort, apply
max_segments, strip private_*keys, build thesummaryand nestedsegmentation.stagesdiagnostics.
5. The splitter cascade¶
When stage 8 flags a component as a probable merge of multiple rooms,
_split_suspicious_component() tries strategies in priority order and returns the
first that yields ≥ 2 masks:
| # | Strategy | How |
|---|---|---|
| 1 | _split_component_via_wall_cuts |
subtract dilated wall-hint pixels, re-label, regrow seeds |
| 2 | _localize_oversized_component |
very large blobs: quantize color into 8³ "localized bins", seed/grow distinct color pockets |
| 3 | _split_component_via_color_distance |
two farthest color-bin centers, distance-threshold + propagate |
| 4 | _split_component_via_local_support |
per-channel sat/value percentile scoring, require score ≥ 2 (3 with assist), label + grow |
| 5 | _split_component_via_assist_hue |
bin the assist image's hue, pick ≥ 2 separated bins, seed/grow |
| 6 | _split_component_via_erosion |
progressive binary_erosion (1–4 iters) until ≥ 2 seeds, regrow |
| 7 | _split_component_via_opening |
same idea via binary_opening |
_reclaim_localized_child_mask repairs vertically-clipped localized children;
_component_should_keep arbitrates small-region keep/drop. This cascade plus the
recovery loop is why the module is a deliberate coverage thin spot (~91%) — most
strategies only fire on specific multi-room imagery (§9).
6. Tuning parameters¶
The segmenter_tuning dict (validated against the engine's known-key set):
| Key | Default | Controls |
|---|---|---|
min_area_pixels |
1200 | minimum component area to keep; scales most split/keep thresholds |
simplify_epsilon |
None |
RDP polygon simplification tolerance. None = auto-derive the epsilon (max(1.0, sqrt(raw_point_count) * 0.42)); a positive float overrides it. Simplification always runs — None is not "off". |
expected_room_count |
None |
triggers the recovery backfill when fewer rooms are found |
max_segments |
None |
hard cap on emitted segments |
assist_image_path |
None |
second image variant enabling wall-cut / color refinement |
image_variant / assist_variant |
None |
provenance labels (diagnostics only) |
Eufy ships min_area_pixels: 1200, simplify_epsilon: None,
expected_room_count: None (see 25 §4 mapping).
7. Degraded & recovery paths¶
Distinguish two unrelated concepts that both contain the word "recovery":
- Hard failure (the wrapper). Any exception inside
detect_room_segmentsis caught bysegment_map_image, which returns the canonical empty result via_engine_unavailable(reason="engine_exception")—available: False,segments: [], empty summary, plusengine_diagnostics["runtime"]. The card falls back to no overlay; nothing crashes upstream. - Pipeline-internal degraded returns (also
available: False):pipeline_unavailable(no SciPy/Pillow),image_unreadable,no_room_pixels_detected. These carry partial diagnostics. - Recovery pass (stage 12) is not a failure path — it's the count-deficit
backfill that re-admits deferred regions when
expected_room_countwasn't met.
Pattern: never raise out of a segmenter. Return the empty
SegmentationResultwith areason; the framework treats "no segments" as a normal, recoverable state.
8. Output data shapes¶
SegmentationResult (defined in segmenter_engines.py):
{
"available": bool,
"reason": str, "message": str, "engine": str,
"image": {"width": int, "height": int},
"segments": [ <segment>, … ],
"summary": {"segment_count": int, "quality_counts": {...}, "good_or_better_count": int},
"engine_diagnostics": {...} # CV-only blocks hoisted here by the wrapper
}
Each segment (canonical fields):
segment_id str # "segment_1", positional (1-based) — NOT a vendor room id
polygon_pixel [[x, y], …] # canvas pixel space
bbox {x, y, width, height}
area_pixels int
area_percent float
center_pixel [x, y]
confidence float 0–1
quality "strong" | "good" | "usable" | "poor"
structural_role str # engine-defined; eufy_cv_v1 emits "hub" | "connector" | "spine" | "room" | "uncertain"
segmentation_state str
edit_readiness str
matched_room_id None # filled downstream, not here
matched_room_label None
Enriched diagnostics also include cluster_index, fill_ratio, compactness,
aspect_ratio, issues[], suggested_color_bgr, mean saturation/value, and
variant-agreement scores.
9. Gotchas (and why coverage is ~91%)¶
- No cv2. Everything is
scipy.ndimage+ hand-rolled geometry insegment_primitives.py. A second-brand author copies_build_room_mask_from_hsv+ the scoring heuristics and re-tunes the thresholds for their palette. - Magic-number heaviness. Dozens of empirically calibrated thresholds (HSV cutoffs, percentiles, area floors, overlap ratios) tuned to Eufy's map palette — brittle to theme/palette changes.
- Real coordinate transforms. Assist-variant alignment estimates scale + shift and reprojects four image planes; it's a genuine registration step.
- Performance. O(active hue bins × components), each running repeated
binary_*morphology and propagation over full-image arrays — the splitter is the hot path on complex maps. - Coverage. The 7-strategy splitter cascade and the localized-bins + recovery branches have many data-dependent paths that only fire on specific multi-room imagery; on simple maps most are untaken. This is the deliberate thin spot called out in testing/subsystems/15-adapters.
10. Writing a segmenter for a new brand¶
- Add
{Brand}Segmenter(a class withengine_name,validate_tuning,segment_map_image) — typically wrapping your owndetect_room_segments-style function. Build onmapping/segment_primitives.pyfor geometry so you don't re-implement polygon math. - Register the instance in
_SEGMENTER_ENGINESunder a versioned name ({brand}_cv_v1). - Return a
SegmentationResultwith positionalsegment_ids andmatched_room_id: None; never raise — degrade with areason. - Declare
mapping.segmenter_engine: "{brand}_cv_v1"in the adapter config, with asegmenter_tuningyourvalidate_tuningaccepts. - If you have no map asset, declare
"noop_fallback"and skip all of the above — trace tracking still works off coordinates.