Per-zone declarative safety rules, set in the zone editor and enforced on the
edge from the tracking the pipeline already does (no new geometry, no new model):
- min_workers (2인 1조): 0 < count < minimum sustained 30s -> lone_worker/HIGH.
A solo worker in a buddy-rule zone is a compliance violation and a rescue-
delay risk, so it outranks the other two.
- max_workers: count > limit sustained 10s -> overcapacity/WARNING.
- max_dwell_s: one track's continuous stay past the limit -> overstay/WARNING.
A short detection dropout (<=5s grace) does NOT reset the dwell timer -
without that, run_once's stale-track filter would quietly neuter every
10-minute rule at real-world detection rates.
Edge: detectors/occupancy.py consumes Zone.rules; the engine flips the zone
detector's per-track membership into per-zone headcounts (stale tracks already
excluded), so counts always match what was actually detected this tick. Fired
states clear on condition recovery - re-alerts need a fresh violation, not a
cooldown expiry. Malformed rules_json keeps the zone's intrusion detection
alive and logs the rules as inactive (never fail dark).
Server: move_zones.rules_json (nullable JSON, migration included) validated
strictly in ZoneConfigController (bounds, min<=max, unknown keys rejected -
a silently dropped rule is one the operator believes is active). Delivered to
the edge unchanged via GET /api/config. EventValidator accepts the 3 new types.
UI: zone editor gains the three rule fields (dwell entered in minutes, wired
in seconds) and shows each zone's rules in its list row; the events console
and detail view now render every event type in Korean (wire codes fall back).
Verified: pytest 216 pass / PHPUnit 123 pass + real-render harness in Chrome
(editor save payload, list labels, console type labels).
Three keypoint-trust hardening fixes that reuse the codebase's existing
"visible" confidence convention (no new tuning knobs):
- P2-9 zone.py: _foot averaged both ankles unconditionally, so a
hallucinated ankle (conf~0, often pinned to (0,0)) dragged the foot
point into or out of a zone. Now averages only ankles with conf >= 0.2
and falls back to the bbox bottom-centre when neither is trustworthy.
- P2-11 geometry.py: head_below_hips used the NOSE regardless of
confidence. Now returns False when the nose is below the visibility
threshold, so an unconfident/hallucinated nose can't fire the
head-below-hips fall cue. The confident-nose path is unchanged
(test_fall.py stays green).
- P2-10 movenet_pose.py: the CPU MoveNet fallback is single-person, so at
a multi-worker site every worker but one is silently invisible to
fall/zone/ghost detection. Construction now logs a loud startup WARNING
pointing to the Hailo backend.
Tests: test_zone (bbox fallback), test_geometry (low-conf nose), and
test_movenet_pose (caplog warning). Full python suite: 157 passed, 2
skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
Three intertwined bodies of work developed and tested together this session.
Full test suite green: python (edge) + phpunit (cloud).
## Edge/Pi install path
- setup_online.sh: apt-lock retry (apt-daily contention), Type=exec unit +
StartLimitIntervalSec in [Unit], journalctl-on-failure, crash-loop detection.
- onnxruntime is now best-effort (no sdist / no 32-bit-ARM wheel); [6/8] probes the
real backend and hard-fails if none works.
- Hailo HEF auto-fetch: edge/tools/fetch_hailo_hef.py (pinned sha256, refuses to guess
8 vs 8L, HAILO_ARCH override for serial-less boards); hailo_pose.py run() timeout fix.
- install_offline.sh: mirrors the backend gate; onnxruntime optional.
## Cafe24 cloud package (cloud/deploy/)
- build_cafe24_package.sh emits move_detect_app/ (private, one folder) + www/move_detect/
(docroot subdir); no-dev classmap autoloader; preflight.php; hardened .htaccess.
- Subdirectory mount support: Request::baseFrom/pathFor strip the mount prefix; index.php
walks up to find the app root; views/JS prefix every URL with APP_BASE; session cookie
scoped to the mount.
## Delivery audit — silent-failure & correctness (docs/delivery-audit.md)
All 4 blockers + all Phase-1 silent-failure + Phase-2 HIGH correctness:
- Edge cloud clients send a custom User-Agent (cafe24 WAF blocks python-requests) and
allow_redirects=False.
- Daemon fails loud on blank CAFE24_URL/DEVICE_SECRET; run_loop logs swallowed exceptions;
heartbeat carries real fps + per-camera health; UvcSource reopens a dead camera;
offline queue is size-capped and drops poison rows; TARGET_FPS/QUEUE_DB validated.
- Zone coordinates normalized 0..1 on both sides (were canvas-px vs camera-px — every zone
misplaced); pinch zones emit PINCH/HIGH (were downgraded to WARNING).
- Fall detection uses bbox cues when keypoints are low-confidence (occluded fallen person);
ghost incident not cancelled by a bystander; displacement normalized per-second (FPS-invariant).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
fall._states, immobility._still_since/_fired, zone._inside_since/_fired,
and engine._last_emit were never pruned when a track was lost (ghost
already pruned via on_lost). On a 24/7 edge device this is unbounded
growth as track IDs keep incrementing.
Add forget(track_id) to FallDetector, ImmobilityWatchdog, and
ZoneIntrusionDetector (drops that track's per-track keys/sets).
EventEngine.forget(track_id) calls each sub-detector's forget and removes
_last_emit cooldown keys whose tuple's second element is that track_id
(covers FALL/NO_MOTION/IMMOBILITY/zone/GHOST — GHOST keys are
(GHOST, track_id) after FIX 4). EventEngine.process now calls
self.forget(tr.track_id) for every track in lost_tracks after emitting,
since ghost.on_lost already consumed that track's ghost-relevant state at
the top of process().
app.py run_once already calls pool.forget(tr.track_id) for lost tracks as
part of FIX 1, completing the cleanup chain (smoother pool + detector
state + dedup cache).
Tests: tests/test_engine.py adds test_forget_prunes_state_on_lost —
fires a FALL event, then passes the track as lost, and asserts
engine._last_emit no longer has that track's keys and fall._states no
longer has the track id.