Skip to content

feat(ml): make zone resolution selectable with zone_match_strategy - #69

Draft
jantman wants to merge 1 commit into
ZoneMinder:masterfrom
jantman:issues/68
Draft

feat(ml): make zone resolution selectable with zone_match_strategy#69
jantman wants to merge 1 commit into
ZoneMinder:masterfrom
jantman:issues/68

Conversation

@jantman

@jantman jantman commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Fixes the zone-resolution problem described in #68.

The problem

Zone matching is bounding-box intersection, not containment, so one detection can land in several zones at once. Until now a detection rejected by one zone's pattern fell through and could be kept by any later zone it also intersected. An exclusion zone bordering a permissive one was therefore defeated by any object whose box clipped a few pixels across the shared edge, and an ES 6 config — written against pyzm 0.3.x, where the first intersecting zone decided — silently widened on migration with no way back.

The change

New ZoneMatchStrategy enum and a zone_match_strategy setting on DetectorConfig, read from the top-level ml_sequence.general section:

value behaviour
any_matching Keep as soon as any intersecting zone's pattern matches. Unchanged, and still the default.
first_intersecting The first intersecting zone decides, keep or reject. pyzm 0.3.x / ES 6 parity. Order-dependent.
largest_overlap The zone covering the largest share of the box decides; ties go to the earlier zone. Order-independent.

Under the two new strategies a rejection is final — by ignore_pattern or by a pattern mismatch — so an exclusion zone cannot be overridden by a zone the box merely clips. That is what makes "never alert on this label here" expressible, which #68 notes is currently impossible even with ignore_pattern.

zone_match_strategy is a global-only key: zone filtering runs once, after all model types have been sequenced, so a per-type section logs the usual "no per-type effect" warning.

The default is unchanged. Upgrading pyzm does not alter which detections survive; the new strategies are opt-in.

  • pyzm/models/config.py — enum, config field, from_dict parsing, _GLOBAL_ONLY_KEYS
  • pyzm/ml/filters.pyfilter_by_zone() gains a strategy argument (defaulted, so existing callers are unaffected); one helper per strategy plus a shared _zone_verdict() for the two where a single zone decides
  • pyzm/ml/pipeline.py, pyzm/ml/detector.py — both call sites pass config.zone_match_strategy
  • docs/guide/detection.rst — new "Resolving overlapping zones" section, cross-referenced from the ignore_pattern docs; also drops a stale docstring claim that an empty zone list synthesises a full-image zone (it doesn't — filtering is skipped)

Tests

Written first, watched fail. 20 new cases in tests/test_ml/test_filters.py built on the exact sliver scenario from the issue — verified with shapely that street holds 70.7% of the car's box and drivewayfar 10.4%, matching the numbers reported there. They cover the order-dependence of first_intersecting, the order-independence and tie-breaking of largest_overlap, terminal ignore_pattern, and rejection of an unknown strategy value.

Plus config-parsing tests and wiring tests that exercise the real filter through both ModelPipeline.run() and Detector._apply_filters(). The wiring tests were mutation-checked: removing the strategy= kwarg from either call site turns them red.

Tier-1 gate: 1120 passed, 81 skipped. 6 pre-existing failures remain (5 PyJWT/starlette deprecation warnings promoted to errors by filterwarnings=error, 1 alpr subprocess test) — they fail identically on master in the same environment and are unrelated to this change. Tier-2 (make release-gate) not run; it needs models and a live ZoneMinder.

refs #68

🤖 Generated with Claude Code

https://claude.ai/code/session_01CYSaA3uVUvRUku9LApa3pq

Zone matching is bounding-box intersection, not containment, so a
detection can land in several zones at once. Until now a zone that
rejected a detection by pattern simply fell through to the next zone,
letting any later zone it clipped keep it. An exclusion zone bordering a
permissive one was therefore defeated by a few pixels of overlap, and an
ES 6 config -- written against pyzm 0.3.x, where the first intersecting
zone decided -- silently widened on migration with no way back.

Add ZoneMatchStrategy and a zone_match_strategy setting on
DetectorConfig, read from the top-level ml_sequence general section:

  any_matching        keep on any intersecting zone whose pattern matches
                      (unchanged, still the default)
  first_intersecting  the first intersecting zone decides (pyzm 0.3.x)
  largest_overlap     the zone covering most of the box decides

Under the two new strategies a rejection is final -- by ignore_pattern
or by a pattern mismatch -- which is what makes "never alert on this
label here" expressible. The default is unchanged so upgrading does not
alter which detections survive.

refs ZoneMinder#68

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CYSaA3uVUvRUku9LApa3pq
@jantman

jantman commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Provisional soak report — deployed on live traffic

Status: provisional. This PR stays in Draft for now; I'm posting evidence as it accumulates rather than waiting. Planning to convert to Ready next week once there's a fuller soak behind it.

Running this branch on a live 10-camera ZoneMinder since 2026-08-18, with zone_match_strategy: first_intersecting set, on a config originally written for ES 6 / pyzm 0.3.x.

The case the whole change exists for

A real detection where the old and new behaviour genuinely disagree:

detection truck, confidence 0.40, box [1463, 11, 1580, 96]
intersects Street 11.0% (pattern (NeverMatchThis)), DrivewayFar 1.3% (patternless)
any_matching KEPT — rescued by the 1.3% clip
first_intersecting REJECTED
live pipeline kept nothing

That 1.3% sliver rescuing a detection whose bounding box is 11% inside an explicit exclusion zone is exactly the failure mode in #68, and first_intersecting resolves it. A second real example from the same deployment, before the strategy was set: a car at Street 87.6% / DrivewayFar 0.1% was kept under any_matching — a 0.1% overlap defeating an 87.6% exclusion.

Correctness cross-check

Before deploying, I replayed a 48-hour corpus of real detections through both filter_by_zone() and an independent reimplementation of the three rules, per strategy:

monitor detections first_intersecting any_matching largest_overlap agreement
1 131 34 131 40 131/131
7 265 51 218 171 265/265

Exact agreement on every detection. Note monitor 1 under any_matching: 131 of 131 kept — the zone filter was a complete no-op, which is what sent me to #68 in the first place.

Live outcomes have matched the configured strategy's verdict on every detection observed so far (16 at time of writing).

One documentation suggestion

The ml_sequence.general placement is correct and the PR body states it, but it's an easy thing to get wrong from a consumer's side: zmeventnotificationNg's objectconfig.yml has its own top-level general: section (portal, user, import_zm_zones), and ES validates that section against a whitelist, dropping unknown keys with a single Info line. I put zone_match_strategy there first. The result was silent — the setting simply never reached pyzm, any_matching stayed in force, and the only trace was one log line.

Nothing for this PR to fix; pyzm can't see where the key was written. But a sentence in docs/guide/detection.rst along the lines of "this goes in ml_sequence.general, not the event server's own general section" would likely save someone the same afternoon.

Caveats

  • One day of live traffic so far. Monitor 7's predicted 218 → 51 reduction hasn't been observed at volume yet; today's detections there landed in zones where all three strategies agree.
  • Deployed from a fork branch that merges this PR with fix(ml): make the GPU->CPU fallback recoverable and observable #67, so both are in the same image.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant