feat(delivery-audit): privacy — face-blur sizing + model integrity (P3-1, P3-3)
- P3-1 [H] _head_boxes sizes the anonymization region from the person bbox (scales with distance) + bbox-top fallback when the nose keypoint is low-confidence; a fixed ~40px patch had left close faces identifiable. test_faceblur.py. - P3-3 [H] fetch_movenet.py rewritten to stage-validate (reject HTML/LFS-pointer, optional pinned sha256+size, fail loud) per the SHA256 spec requirement. Discovery: the old PINTO raw URL is DEAD (GitHub 404 → it was silently saving an HTML page as the .onnx). Non-blocking for the Hailo delivery path; the CPU MoveNet fallback needs a valid MOVENET_ONNX_URL pinned. python + phpunit suites green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
This commit is contained in:
parent
1187df5628
commit
7a86545367
4 changed files with 124 additions and 11 deletions
|
|
@ -110,8 +110,10 @@ misconfig/degradation fail LOUD.
|
||||||
|
|
||||||
## Phase 3 — Privacy / PIPA (delivery compliance)
|
## Phase 3 — Privacy / PIPA (delivery compliance)
|
||||||
|
|
||||||
- [ ] **P3-1 [H]** `edge/app.py:18` — `FaceBlurrer` blurs a fixed ~40×40px patch regardless of
|
- [x] **P3-1 [H]** `edge/app.py:18` — `FaceBlurrer` blurs a fixed ~40×40px patch regardless of
|
||||||
face size; close faces stay identifiable. **Fix:** size the blur box from the person bbox.
|
face size; close faces stay identifiable. **Fix:** size the blur box from the person bbox.
|
||||||
|
DONE — `_head_boxes` sizes the box from the person bbox (scales with distance) + bbox-top
|
||||||
|
fallback when the nose keypoint is low-confidence. test_faceblur.py locks both.
|
||||||
- [x] **P3-2 [H]** `cloud/src/Ingest/IngestController.php:68` — worker snapshots are stored
|
- [x] **P3-2 [H]** `cloud/src/Ingest/IngestController.php:68` — worker snapshots are stored
|
||||||
forever; no retention/purge anywhere. PIPA violation. **Fix:** `snapshot_retention_days`
|
forever; no retention/purge anywhere. PIPA violation. **Fix:** `snapshot_retention_days`
|
||||||
config + a token-guarded purge route + `SnapshotRepository::purgeOlderThan`.
|
config + a token-guarded purge route + `SnapshotRepository::purgeOlderThan`.
|
||||||
|
|
@ -125,9 +127,14 @@ misconfig/degradation fail LOUD.
|
||||||
column and `events` link only by `device_id`. Enforcing scope needs a site↔device model
|
column and `events` link only by `device_id`. Enforcing scope needs a site↔device model
|
||||||
(e.g. add `devices.site`, filter events by the caller's site). Deferred until that is
|
(e.g. add `devices.site`, filter events by the caller's site). Deferred until that is
|
||||||
decided. Interim mitigation: keep every dashboard account admin/single-site so scope is moot.
|
decided. Interim mitigation: keep every dashboard account admin/single-site so scope is moot.
|
||||||
- [ ] **P3-3 [H]** `edge/tools/fetch_movenet.py:12` — downloads the ONNX from a mutable ref
|
- [x] **P3-3 [H]** `edge/tools/fetch_movenet.py:12` — downloads the ONNX from a mutable ref
|
||||||
with no checksum; spec mandates SHA256. **Fix:** pin URL to a commit + sha256+size, mirror
|
with no checksum; spec mandates SHA256. **Fix:** pin URL to a commit + sha256+size, mirror
|
||||||
`fetch_hailo_hef.py`.
|
`fetch_hailo_hef.py`.
|
||||||
|
DONE (with a caveat) — rewrote to stage-validate: rejects HTML/LFS-pointer, optional pinned
|
||||||
|
sha256+size, temp-file staging, fails LOUD. DISCOVERY: the old URL is DEAD (GitHub 404 / HTML
|
||||||
|
— PINTO hosts models off-repo), so it was silently saving garbage. No known-good sha256 to
|
||||||
|
pin until a valid MOVENET_ONNX_URL is set. NON-BLOCKING for the Hailo delivery path (MoveNet
|
||||||
|
is the optional CPU fallback); flag for whoever needs CPU MoveNet.
|
||||||
|
|
||||||
## Phase 4 — Cloud hardening (shared hosting)
|
## Phase 4 — Cloud hardening (shared hosting)
|
||||||
|
|
||||||
|
|
|
||||||
14
edge/app.py
14
edge/app.py
|
|
@ -11,11 +11,21 @@ from edge.models import BBox
|
||||||
|
|
||||||
|
|
||||||
def _head_boxes(persons: list) -> list:
|
def _head_boxes(persons: list) -> list:
|
||||||
# 배경인물 머리영역 근사: 코 주변 작은 박스 (MVP; 실제는 얼굴 검출 포트로 교체)
|
# Head region for face anonymization (PIPA). Sized from the PERSON bbox, not a fixed
|
||||||
|
# ~40x40 patch — a close worker fills far more than 40px, so a constant box left the face
|
||||||
|
# fully identifiable. Centered on the nose when it is confident, else on the bbox's top
|
||||||
|
# (the head is the upper part of the person) so a bad keypoint can't move the blur off it.
|
||||||
boxes = []
|
boxes = []
|
||||||
for p in persons:
|
for p in persons:
|
||||||
|
bb = p.bbox
|
||||||
nose = p.keypoints[0]
|
nose = p.keypoints[0]
|
||||||
boxes.append(BBox(nose.x - 20, nose.y - 25, nose.x + 20, nose.y + 15))
|
half_w = max(8.0, 0.25 * bb.width)
|
||||||
|
if nose.conf >= 0.3:
|
||||||
|
cx, cy = nose.x, nose.y
|
||||||
|
else:
|
||||||
|
cx, cy = bb.cx, bb.y1 + 0.12 * bb.height
|
||||||
|
boxes.append(BBox(cx - half_w, cy - 0.18 * bb.height,
|
||||||
|
cx + half_w, cy + 0.08 * bb.height))
|
||||||
return boxes
|
return boxes
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,87 @@
|
||||||
"""빌드머신 전용: MoveNet SinglePose Lightning ONNX 다운로드(폐쇄망 번들). Apache-2.0."""
|
"""빌드/온라인 머신 전용: MoveNet SinglePose Lightning ONNX 다운로드(폐쇄망 번들). Apache-2.0.
|
||||||
|
|
||||||
|
Integrity is enforced (spec: SHA256/GPG verify of every vendored model). The download is staged
|
||||||
|
to a temp file, validated, and only then moved into place — a truncated / HTML-error-page /
|
||||||
|
tampered download must never be saved as the model (onnxruntime would then fail to load it, and
|
||||||
|
the operator would see a cryptic runtime error instead of a clear fetch failure).
|
||||||
|
|
||||||
|
STATUS: the historical PINTO_model_zoo raw URL below is DEAD (that repo hosts its models on
|
||||||
|
external storage, not as repo blobs — the raw path now returns a GitHub HTML 404). So there is
|
||||||
|
no known-good sha256 to pin here yet. Until a working source is set:
|
||||||
|
* this tool FAILS LOUD if the download is not a real ONNX (rejects the HTML it currently gets),
|
||||||
|
instead of silently saving garbage;
|
||||||
|
* set MOVENET_ONNX_URL to a valid source and pin PINNED_SHA256/PINNED_SIZE from its bytes.
|
||||||
|
For the Hailo delivery path this model is optional (POSE_BACKEND=hailo), so a missing MoveNet is
|
||||||
|
non-blocking; the CPU fallback needs this resolved.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
# MoveNet Lightning ONNX (예시 소스; 조직 미러로 교체 가능)
|
URL = os.environ.get(
|
||||||
URL = "https://github.com/PINTO0309/PINTO_model_zoo/raw/main/115_MoveNet/resources/movenet_singlepose_lightning_4.onnx"
|
"MOVENET_ONNX_URL",
|
||||||
|
"https://github.com/PINTO0309/PINTO_model_zoo/raw/main/115_MoveNet/resources/"
|
||||||
|
"movenet_singlepose_lightning_4.onnx",
|
||||||
|
)
|
||||||
DEST = os.path.join(os.path.dirname(__file__), "..", "..", "models", "movenet_lightning.onnx")
|
DEST = os.path.join(os.path.dirname(__file__), "..", "..", "models", "movenet_lightning.onnx")
|
||||||
|
|
||||||
|
# Pin from the bytes of a known-good source once MOVENET_ONNX_URL points at one. Empty = unpinned
|
||||||
|
# (still validated as a real ONNX below, but not byte-identical-verified).
|
||||||
|
PINNED_SHA256 = os.environ.get("MOVENET_ONNX_SHA256", "")
|
||||||
|
PINNED_SIZE = int(os.environ.get("MOVENET_ONNX_SIZE", "0") or "0")
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_onnx(head: bytes) -> bool:
|
||||||
|
# ONNX is a protobuf ModelProto; it is NOT text. Reject an HTML error page / LFS pointer
|
||||||
|
# outright (the current dead URL returns exactly that).
|
||||||
|
stripped = head.lstrip()
|
||||||
|
if stripped[:1] in (b"<", b"{") or stripped[:5].lower() == b"<!doc":
|
||||||
|
return False
|
||||||
|
if b"version https://git-lfs" in head:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
os.makedirs(os.path.dirname(DEST), exist_ok=True)
|
dest = os.path.abspath(DEST)
|
||||||
print(f"downloading MoveNet ONNX -> {DEST}")
|
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||||
urllib.request.urlretrieve(URL, DEST)
|
print(f"downloading MoveNet ONNX\n {URL}\n -> {dest}")
|
||||||
print("done")
|
tmp = None
|
||||||
|
try:
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(dest), prefix=".movenet.", suffix=".part")
|
||||||
|
os.close(fd)
|
||||||
|
urllib.request.urlretrieve(URL, tmp)
|
||||||
|
|
||||||
|
with open(tmp, "rb") as f:
|
||||||
|
head = f.read(64)
|
||||||
|
if not _looks_like_onnx(head):
|
||||||
|
raise SystemExit(
|
||||||
|
f"ERROR: {URL}\n did not return an ONNX model (looks like HTML / an error page /"
|
||||||
|
f" an LFS pointer).\n Set MOVENET_ONNX_URL to a valid source and pin"
|
||||||
|
f" MOVENET_ONNX_SHA256. (Hailo path does not need this model.)"
|
||||||
|
)
|
||||||
|
|
||||||
|
size = os.path.getsize(tmp)
|
||||||
|
digest = hashlib.sha256(open(tmp, "rb").read()).hexdigest()
|
||||||
|
if PINNED_SHA256 and (digest != PINNED_SHA256 or (PINNED_SIZE and size != PINNED_SIZE)):
|
||||||
|
raise SystemExit(
|
||||||
|
f"ERROR: MoveNet ONNX does not match the pinned digest.\n"
|
||||||
|
f" expected: {PINNED_SIZE} bytes, sha256 {PINNED_SHA256}\n"
|
||||||
|
f" got : {size} bytes, sha256 {digest}\n Refusing to install it."
|
||||||
|
)
|
||||||
|
if not PINNED_SHA256:
|
||||||
|
print(f" WARNING: integrity not pinned (MOVENET_ONNX_SHA256 unset). Got {size} bytes,"
|
||||||
|
f" sha256 {digest}. Pin this once the source is trusted.", file=sys.stderr)
|
||||||
|
|
||||||
|
os.replace(tmp, dest)
|
||||||
|
tmp = None
|
||||||
|
print(f"done: {dest} ({size} bytes)")
|
||||||
|
finally:
|
||||||
|
if tmp and os.path.exists(tmp):
|
||||||
|
os.remove(tmp)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
25
tests/test_faceblur.py
Normal file
25
tests/test_faceblur.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""Face-anonymization sizing (PIPA). _head_boxes must scale the blur region with the person's
|
||||||
|
size — a fixed ~40x40px patch leaves a close worker's face fully identifiable, defeating the
|
||||||
|
anonymization the spec requires. No hardware: synthetic Person/BBox."""
|
||||||
|
from edge.app import _head_boxes
|
||||||
|
from edge.models import Person, BBox, Keypoint
|
||||||
|
|
||||||
|
|
||||||
|
def _person(bbox, nose_conf=0.9):
|
||||||
|
nose = Keypoint(bbox.cx, bbox.y1 + 0.1 * bbox.height, nose_conf)
|
||||||
|
return Person(bbox, [nose] + [Keypoint(bbox.cx, bbox.cy, nose_conf) for _ in range(16)])
|
||||||
|
|
||||||
|
|
||||||
|
def test_head_box_scales_with_person_size():
|
||||||
|
small = _head_boxes([_person(BBox(0, 0, 40, 120))])[0]
|
||||||
|
big = _head_boxes([_person(BBox(0, 0, 400, 1200))])[0] # 10x bigger person
|
||||||
|
assert big.width > small.width * 5, "head box must scale with the person, not be fixed"
|
||||||
|
assert big.width > 40, "a close face must be covered by more than the old ~40px box"
|
||||||
|
|
||||||
|
|
||||||
|
def test_head_box_falls_back_to_bbox_top_when_nose_low_confidence():
|
||||||
|
p = _person(BBox(0, 0, 100, 300), nose_conf=0.1) # nose keypoint unreliable
|
||||||
|
box = _head_boxes([p])[0]
|
||||||
|
assert box.width > 0 and box.height > 0
|
||||||
|
# anchored to the head region (top of the bbox), not wherever the low-conf nose landed
|
||||||
|
assert box.y1 < p.bbox.cy
|
||||||
Loading…
Reference in a new issue