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
261 lines
13 KiB
Python
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()
|