move_detect/edge/tools/fetch_hailo_hef.py
유창욱 9386dfc580 feat(delivery): install-path fixes + cafe24 package + delivery-audit hardening
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
2026-07-17 09:07:33 +09:00

261 lines
13 KiB
Python

"""빌드/온라인 머신 전용: Hailo yolov8s_pose HEF 다운로드(폐쇄망 번들용). MIT (Hailo Model Zoo).
Mirrors edge/tools/fetch_movenet.py: pure stdlib, no deps, idempotent (re-running with the
file already in place verifies and returns without re-downloading).
AIR GAP (rules/common/build-deployment.md): this runs on the BUILD machine or on an
INTERNET-CONNECTED Pi (deploy/setup_online.sh [6/8]) — never on the air-gapped target. The
offline bundle carries the resulting .hef across the gap (.gitignore ignores *.hef, so it is
a build artifact, never a committed one).
REFUSES TO GUESS THE BOARD. A Hailo-8 HEF does not load on a Hailo-8L and vice versa:
[HailoRT] [error] HEF format is not compatible with device. Device arch: HAILO8L, HEF arch: HAILO8
[HailoRT] [error] CHECK_SUCCESS failed with status=HAILO_INVALID_HEF(26)
so the arch is read from the board (`hailortcli fw-control identify`) or must be passed
explicitly with --arch. With neither, this exits non-zero rather than install a coin-flip.
WHY v2.14.0: it is what the official hailo-rpi5-examples download_resources.sh pins, and its
HEF header is version 1 — parsed by every HailoRT from 4.18 through 4.23, so one file works on
both Bookworm (hailo-all 4.20) and Trixie (hailo-all 5.1.1 -> hailort 4.23). Newer model-zoo
releases (v2.15.0+) emit HEF header version 2, which HailoRT 4.18/4.19 reject outright.
CONTRACT (verified 2026-07-16 by decoding the HEF protobuf itself, BOTH arches — matches
edge/adapters/hailo_pose.py and tests/test_hailo_pose.py exactly):
input_layer1 640x640x3 uint8 RGB; a `normalization1` layer does 0-255 scaling ON CHIP
output_layer1..9 3 strides x {box DFL (H,W,64), score (H,W,1, sigmoid on chip),
keypoints (H,W,51)} @ 80x80, 40x40, 20x20
NO on-chip NMS (zero nms/hailo_nms markers anywhere in either file) -> the host-side DFL
decode + NMS in hailo_pose.py is the correct and necessary post-processing.
"""
import argparse
import hashlib
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.request
MZ_VERSION = "v2.14.0"
BASE = "https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled"
MODEL = "yolov8s_pose"
# Hailo publishes NO checksum for these objects: .sha256 / .md5 / SHA256SUMS sidecars all
# return HTTP 403 and bucket listing is denied, so there is no upstream manifest to verify
# against. The digests below are therefore NOT vendor-published — they were computed from the
# bytes these URLs served on 2026-07-16 and are pinned so that a later download which differs
# (mirror swap, truncation, tampering) fails loudly instead of installing wrong weights.
# To re-pin deliberately: curl the URL, sha256sum it, update BOTH sha256 and size here.
#
# The S3 ETag is a usable cross-check for hailo8 ONLY (single-part upload -> ETag == MD5
# 9c0542f0009ce952e16f9d32febebe01). hailo8l is a MULTIPART upload (ETag ends "-2"), so its
# ETag is a digest-of-digests, not an MD5 — do not "fix" it to compare equal.
HEFS = {
"hailo8": { # 26 TOPS "AI HAT+"
"sha256": "4f06be93349a21fcc28ee0a746ca84f1c0e17aee99bfd1f59596a477d5160f99",
"size": 10294713,
},
"hailo8l": { # 13 TOPS "AI Kit"
"sha256": "7293a6b88a2a7bf9ea07819e92057d67e947300f8d0e87f49d50bb060fb959b1",
"size": 22658846,
},
}
DEFAULT_DEST = os.path.join(os.path.dirname(__file__), "..", "..", "models", f"{MODEL}.hef")
def url_for(arch):
return f"{BASE}/{MZ_VERSION}/{arch}/{MODEL}.hef"
def sha256_of(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def _arch_from_enum_name(name):
"""Map a hailo_platform DeviceArchitectureTypes member NAME to our HEF key.
-> 'hailo8' | 'hailo8l' | None. Same first-match 8L-before-8 discipline as the hailortcli
regex ('HAILO8L' contains 'HAILO8'), so it also folds silicon revisions ('HAILO8_A0' /
'HAILO8_B0') onto 'hailo8', and returns None for any non-8 family (Hailo-10/15) we have no
HEF for. Never guesses."""
n = (name or "").upper()
if "HAILO8L" in n:
return "hailo8l"
if "HAILO8" in n:
return "hailo8"
return None
def _arch_via_hailortcli(run):
"""First signal: parse `hailortcli fw-control identify`. -> 'hailo8'|'hailo8l'|None."""
try:
p = run(["hailortcli", "fw-control", "identify"],
capture_output=True, text=True, timeout=30)
except (OSError, subprocess.SubprocessError):
return None
if p.returncode != 0:
return None
# HAILO8L must precede HAILO8 in the alternation: regex alternation is first-match, so
# (HAILO8|HAILO8L) would match the "HAILO8" prefix of "HAILO8L" and mis-detect an AI Kit
# as an AI HAT+ — the exact HAILO_INVALID_HEF(26) this function exists to prevent.
m = re.search(r"Device Architecture\s*:\s*(HAILO8L|HAILO8)\b", p.stdout or "", re.I)
return m.group(1).lower() if m else None
def _arch_via_python_api(): # pragma: no cover (device-only: needs hailo_platform + a board)
"""Second signal: read the arch through the hailo_platform bindings instead of parsing
hailortcli text. Runs the SAME firmware identify underneath, so it does NOT rescue a board
whose firmware will not answer — but it (a) works when hailortcli is absent from PATH
(hailo-all ships the Python bindings independently) and (b) returns a typed enum, so it is
not defeated by the \\b regex on develop-firmware strings like 'HAILO8_B0'. Never raises:
any missing symbol/attr or absent board -> None.
UNVERIFIED: the API names (Device.scan / control.identify().device_architecture) are from
Hailo's pyhailort and are not runnable in this repo. The broad except makes a wrong name
safe — it yields None (refuse-to-guess), never a crash or a guess."""
try:
from hailo_platform import Device
ids = Device.scan()
if not ids:
return None
with Device(ids[0]) as dev:
arch = dev.control.identify().device_architecture
return _arch_from_enum_name(getattr(arch, "name", str(arch)))
except Exception:
return None
def detect_arch(run=subprocess.run, python_api=_arch_via_python_api):
"""Read the arch off the board itself. -> 'hailo8' | 'hailo8l' | None.
Two independent signals, in order: (1) `hailortcli fw-control identify` text, (2) the
hailo_platform bindings. Both read the board's ARCHITECTURE, which is reported even when
the serial/part/product identity fields are unprogrammed — a Hailo-8 commonly shows
'Device Architecture: HAILO8' with 'Serial Number: <N/A>'. So a serial-less board is NOT
why this returns None. None still means "no board answered": no hailortcli AND no
bindings, no /dev/hailo0 (driver installed, not rebooted), or an arch we have no HEF for.
None is never papered over with a default — an operator who knows the board bypasses
detection entirely with --arch / HAILO_ARCH."""
arch = _arch_via_hailortcli(run)
if arch is not None:
return arch
# hailortcli missing/non-zero, or its Device Architecture line absent/unparseable. The
# in-process bindings survive a missing CLI and the \b regex fragility; still None if no
# board answers.
return python_api()
def fetch(arch, dest, force=False, log=print):
"""Download (or verify in place) the yolov8s_pose HEF for `arch`. -> dest path."""
spec = HEFS[arch]
url = url_for(arch)
if os.path.exists(dest) and not force:
if sha256_of(dest) == spec["sha256"]:
log(f"{dest} already present and matches the pinned {arch} digest; nothing to do")
return dest
# Covers the nastiest case: a previous run left the OTHER arch's HEF (or a truncated
# one) here. Silently keeping it is how a board ends up loading a HEF it will reject.
# ASCII only in printed strings: a Windows build machine's console is often cp949/cp1252,
# where an em-dash raises UnicodeEncodeError. That would crash the tamper-detection path
# itself, replacing "digest mismatch" with a traceback about a dash.
log(f"WARNING: {dest} exists but does not match the pinned {arch} digest - replacing it")
dest_dir = os.path.dirname(dest) or "."
os.makedirs(dest_dir, exist_ok=True)
log(f"downloading {MODEL} HEF for {arch} ({spec['size']} bytes, model-zoo {MZ_VERSION})")
log(f" {url}")
log(f" -> {dest}")
tmp = None
try:
fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=f".{MODEL}.", suffix=".part")
os.close(fd)
# Staged through a temp file in the SAME directory, then os.replace()'d: an
# interrupted download must never leave a half-written .hef at `dest`, because the
# installer's next step only tests -f and would happily install the truncation.
with urllib.request.urlopen(url, timeout=60) as r, open(tmp, "wb") as f:
shutil.copyfileobj(r, f)
size = os.path.getsize(tmp)
digest = sha256_of(tmp)
if size != spec["size"] or digest != spec["sha256"]:
raise SystemExit(
f"ERROR: downloaded {MODEL}.hef for {arch} does not match the pinned digest.\n"
f" url : {url}\n"
f" expected: {spec['size']} bytes, sha256 {spec['sha256']}\n"
f" got : {size} bytes, sha256 {digest}\n"
f" Refusing to install it. If upstream legitimately re-published this model,\n"
f" re-pin HEFS['{arch}'] in {os.path.basename(__file__)} deliberately."
)
os.replace(tmp, dest)
tmp = None
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
log(f"done: {dest} (sha256 verified)")
return dest
def main(argv=None):
ap = argparse.ArgumentParser(
description="Download the Hailo yolov8s_pose HEF for THIS board "
"(build/online machine only - never the air-gapped target).")
ap.add_argument("--arch", choices=sorted(HEFS),
help="target arch. Omit to read it from the board via `hailortcli "
"fw-control identify`, or set the HAILO_ARCH env var. Pass "
"explicitly to cross-fetch on a build machine with no board.")
ap.add_argument("--dest", default=DEFAULT_DEST, help=f"output path (default: {DEFAULT_DEST})")
ap.add_argument("--force", action="store_true", help="re-download even if the digest matches")
args = ap.parse_args(argv)
# Operator override for a board auto-detect cannot read (e.g. a Hailo-8 whose firmware
# reports no identity fields, or a Pi where hailortcli is not yet on PATH). Precedence:
# --arch flag > HAILO_ARCH env > board auto-detect. argparse already validated the flag
# against HEFS; validate the env value against the same allow-list — an unknown value is a
# typo, not a request to fall back.
env_arch = os.environ.get("HAILO_ARCH", "").strip().lower() or None
if env_arch is not None and env_arch not in HEFS:
sys.exit(
f"ERROR: HAILO_ARCH={os.environ['HAILO_ARCH']!r} is not a supported arch.\n"
f" Use exactly one of: {', '.join(sorted(HEFS))} (lowercase)."
)
detected = detect_arch()
override = args.arch or env_arch
arch = override or detected
if arch is None:
sys.exit(
"ERROR: cannot determine the Hailo arch, and refusing to guess.\n"
" A Hailo-8 HEF does not load on a Hailo-8L (HAILO_INVALID_HEF(26)), so there is\n"
" no safe default here.\n"
"\n"
" On the Pi this usually means the board is not answering yet:\n"
" hailortcli fw-control identify # expect 'Device Architecture: HAILO8'\n"
" dpkg -s hailo-all >/dev/null 2>&1 || sudo apt-get install -y hailo-all\n"
" ls -l /dev/hailo0 # absent -> the driver needs a REBOOT\n"
"\n"
" If the board answers but prints no 'Device Architecture' line (some Hailo-8\n"
" boards report no serial/identity), name the arch yourself:\n"
" HAILO_ARCH=hailo8 python -m edge.tools.fetch_hailo_hef # 26 TOPS AI HAT+\n"
" HAILO_ARCH=hailo8l python -m edge.tools.fetch_hailo_hef # 13 TOPS AI Kit"
)
if override and detected and override != detected:
# Legitimate when cross-fetching for a fleet; disastrous when it is a typo. Say it
# loudly, but let the explicit override win — the operator stated an intent.
src = "--arch" if args.arch else "HAILO_ARCH"
print(f"WARNING: {src} {override} overrides the detected board ({detected}). "
f"This HEF will NOT load on this machine's board.", file=sys.stderr)
fetch(arch, os.path.abspath(args.dest), force=args.force)
if __name__ == "__main__":
main()