4개 관점 병렬 감사 후 각 발견을 반증 시도해 확정. 반증 5건은 문서에 남겼다. 이번 감사의 요지: **구현도 단위테스트도 초록인데 납품 경로에서 동작하지 않는** 결함이 셋 있었다. 전부 같은 모양이다 — 테스트가 기본값이 아닌 설정을 썼거나, 값이 존재해도 되는지 결정하는 상류 허용 목록을 보지 않았다. CRITICAL 4건: R4-1 추락 승격이 기본 설정에서 한 번도 발화하지 않았다. link_s(3.0)를 now 기준으로 쟀는데 넘어짐은 prone_confirm_s(3.0) 지속 후 확정되므로 확정 시점에는 항상 3초 넘게 지나 있다. 다른 테스트가 전부 prone_confirm_s=0.5 를 써서 초록이었다. 착지(prone 시작) 시각과 비교하도록 고치고, 기본값 그대로 도는 회귀 테스트를 넣었다. R4-2 max_workers:0 이 상류 두 곳에서 막혀 있었다 — 서버 규칙 경계가 [1,50] 이라 저장이 400 이고, 엣지 파서가 v<=0 으로 규칙 전체를 무효화해 같이 넣은 2인1조까지 사라졌다. 앞 커밋에서 고친 네 곳은 전부 이 둘보다 하류라 도달 불가였다. R4-3 고소작업 프리셋(elevated)이 완전히 무동작이었다. 서버 경계에 키가 없어 400, 통과해도 엣지 파서가 침묵 삭제. 프리셋 안내문과 커밋 메시지가 시스템이 하지 않는 일을 약속하고 있었다. R4-4 동시 다발이 한 사람을 두 명으로 셀 수 있었다. 트래커는 1초 가려짐에 트랙을 버리고 새 id 를 주고(max_age=1.0) 누운 사람은 새 트랙에서도 즉시 prone 이라 두 번째 fall 이 난다. 그러면 다친 한 사람에게 "공기호흡기 없이 진입 금지"가 나가 구조가 늦어진다 — 기능의 가치가 정반대로 뒤집힌다. ghost 를 인원 집계에서 빼고, 거의 같은 자리의 사건은 같은 사람으로 본다. HIGH 5건: 구역 그룹핑 사문화(엣지가 zone_id 를 안 실어 카메라가 다른 두 희생자가 절대 안 묶였다 — 밀폐공간이 정확히 그 상황), 창 미끄러짐으로 한 사고가 매 분 새 위급 건으로 쏟아지고 인원이 줄면 앞선 알림을 스스로 뒤집던 문제, 무장 미만료·forget 누락·이중 발보, 업그레이드 안내의 마이그레이션 2개 누락(신규 유형 INSERT 가 전부 DB CHECK 에 걸린다), BUILD-INFO 커밋 스탬프. BUILD-INFO 는 1.1.0 에서 고친 R3-6 의 재발이다. 절차로 막으려다 두 번 실패했으므로 HEAD 일치를 검사하는 테스트로 막았다. README 마이그레이션 목록도 같은 이유로 테스트를 붙였다. MEDIUM 14건 중 눈에 띄는 것: 정비 억제가 SCBA 경고를 삼켰고(허가가 발급된 그 구역·그 시간이 질식이 일어나는 자리다) 위급 3종을 억제 예외로 뺐다. 데모 시더 심각도가 제품과 7종 어긋나 fall 이 위급으로 찍혀 1.2.0 의 요점인 넘어짐/추락 구분이 데모에서 지워져 있었다 — 라벨과 달리 화면이 안 깨져 눈으로는 못 잡으므로 엣지를 단일 출처로 강제하는 테스트를 붙였다. 문서의 "17종"은 실제 20종(엣지 16 + 서버 4)으로 정정하고 신규 2종을 소개서 감지 표에 넣었다. 검증: pytest 335 passed / phpunit 269 passed. 신규 테스트 30건. dist 는 이 커밋 이후 재생성한다(BUILD-INFO 가 HEAD 를 가리켜야 한다). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
105 lines
5.1 KiB
Python
105 lines
5.1 KiB
Python
import json
|
|
import logging
|
|
|
|
from edge.detectors.zone import Zone
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Zones are stored NORMALIZED (0..1 of the image) by the current dashboard. Polygons drawn by the
|
|
# pre-normalization dashboard were saved in canvas PIXELS (values in the hundreds); after the edge
|
|
# normalizes the foot point, a pixel polygon can never match — silently killing that zone's
|
|
# intrusion detection with no error. Flag such rows loudly so the operator redraws them, instead
|
|
# of failing dark. A normalized coordinate may round slightly past 1.0, so allow a small margin.
|
|
_NORMALIZED_MAX = 1.5
|
|
|
|
|
|
def _looks_like_pixels(polygon: list[tuple[float, float]]) -> bool:
|
|
return any(abs(x) > _NORMALIZED_MAX or abs(y) > _NORMALIZED_MAX for x, y in polygon)
|
|
|
|
|
|
_RULE_INT_KEYS = ("min_workers", "max_workers", "max_dwell_s")
|
|
|
|
# 키마다 하한이 다르다. max_workers 만 0 이 유효하다 — "아무도 있으면 안 되는 구역"
|
|
# (크레인으로 든 짐 아래, 굴착기 선회 반경). 전 키에 v <= 0 을 걸면 그 규칙을 저장한 구역이
|
|
# 통째로 '규칙 없음'이 되고, 같이 넣은 2인1조까지 함께 사라진다.
|
|
_RULE_MIN = {"min_workers": 1, "max_workers": 0, "max_dwell_s": 1}
|
|
|
|
# 정수가 아니라 참/거짓인 규칙. 이 목록에 없으면 파서가 조용히 버린다 — elevated 를 여기
|
|
# 넣지 않아 고소작업 프리셋이 통째로 무동작이었다.
|
|
_RULE_FLAG_KEYS = ("elevated",)
|
|
|
|
|
|
def _rules_of(row: dict) -> dict | None:
|
|
"""Optional occupancy rules from the zone row's rules_json. A malformed rules blob must
|
|
not kill the zone's INTRUSION detection (the polygon is still valid) — raise here and the
|
|
caller keeps the zone but logs the rules loudly as inactive."""
|
|
raw = row.get("rules_json")
|
|
if raw in (None, "", "null"):
|
|
return None
|
|
data = json.loads(raw) if isinstance(raw, str) else raw
|
|
if not isinstance(data, dict):
|
|
raise ValueError(f"rules_json is not an object: {data!r}")
|
|
rules = {}
|
|
for key in _RULE_INT_KEYS:
|
|
if data.get(key) is None:
|
|
continue
|
|
v = int(data[key])
|
|
lo = _RULE_MIN[key]
|
|
if v < lo:
|
|
raise ValueError(f"{key} must be >= {lo}, got {v}")
|
|
rules[key] = v
|
|
for key in _RULE_FLAG_KEYS:
|
|
if data.get(key) is None:
|
|
continue
|
|
rules[key] = bool(data[key])
|
|
return rules or None
|
|
|
|
|
|
def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]:
|
|
"""Parse server config zone rows into Zone objects. When camera_id is given, only
|
|
rows for that camera are kept (multi-camera routing); None keeps all rows."""
|
|
out: list[Zone] = []
|
|
rows = config.get("zones", []) or []
|
|
if not isinstance(rows, list):
|
|
return []
|
|
for row in rows:
|
|
try:
|
|
if camera_id is not None and str(row["camera_id"]) != str(camera_id):
|
|
continue
|
|
raw = row["polygon_json"]
|
|
pts = json.loads(raw) if isinstance(raw, str) else raw
|
|
polygon = [(float(p[0]), float(p[1])) for p in pts]
|
|
if len(polygon) < 3:
|
|
continue
|
|
if _looks_like_pixels(polygon):
|
|
log.warning(
|
|
"zone %s on camera %s has un-normalized (pixel-space) coordinates %r — it "
|
|
"would match nothing after foot normalization. Skipping; redraw this zone in "
|
|
"the dashboard so it is stored normalized (0..1).",
|
|
row.get("zone_id"), row.get("camera_id"), polygon[:2])
|
|
continue
|
|
try:
|
|
rules = _rules_of(row)
|
|
except (ValueError, TypeError, json.JSONDecodeError) as e:
|
|
# Same "never fail dark" principle: the polygon is fine, so intrusion detection
|
|
# stays live — but the occupancy rules the operator THINKS are active are not.
|
|
log.warning("zone %s on camera %s has malformed rules_json (%s: %s) — its "
|
|
"occupancy rules are INACTIVE until fixed in the dashboard.",
|
|
row.get("zone_id"), row.get("camera_id"), type(e).__name__, e)
|
|
rules = None
|
|
out.append(Zone(str(row["zone_id"]), str(row["level"]), polygon, rules))
|
|
except (KeyError, ValueError, TypeError, IndexError, AttributeError, json.JSONDecodeError) as e:
|
|
# Same principle as the pixel-space branch above: never fail dark. A row missing a key
|
|
# (level/zone_id), with unparseable polygon_json, or degenerate points would otherwise be
|
|
# dropped silently — intrusion detection for that zone dead with no log, no dashboard
|
|
# signal. Flag it so it can be fixed instead of discovered after a worker enters the zone.
|
|
zid = row.get("zone_id") if isinstance(row, dict) else None
|
|
log.warning("skipping malformed zone row %r (%s: %s) — its intrusion detection is "
|
|
"inactive until the row is fixed in the dashboard.", zid, type(e).__name__, e)
|
|
continue
|
|
return out
|
|
|
|
|
|
def thresholds_from_config(config: dict) -> dict:
|
|
t = config.get("thresholds")
|
|
return t if isinstance(t, dict) else {}
|