#!/usr/bin/env python3
"""
Демо-режим хакатона с ползунками в браузере:
• высота до ленты (belt_distance_mm)
• порог уверенности (сколько кадров подряд одно и то же решение)
• мин. высота объекта, порог круга
Без MQTT / мотора / серво.
"""
from __future__ import annotations
import argparse
import base64
import json
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Dict, Optional
from urllib.parse import parse_qs, urlparse
import cv2
import numpy as np
import yaml
from camera import RealSenseV4L2
from classify import Category, ClassificationResult
from demo_hud import ContourSmoother, align_color, build_demo_frame
from journal import append_decision
from measure import (
is_plausible_measurement,
measure_flat_object,
measure_object,
merge_overlapping_measurements,
segment_objects,
segment_rgb_objects,
)
from stabilize import DecisionStabilizer
from tracker import MultiObjectTracker, slot_key
ZONE_TO_CATEGORY = {
"B": Category.SUITABLE,
"C": Category.OVERSIZE,
"D": Category.NEED_PACK,
}
HTML = r"""
Трек 3 — демо
Трек 3 — демо классификации (B / C / D)
Ползунки СВЕРХУ (отдельная панель). Картинка только камера. Выход: Ctrl+C в терминале. Обновите страницу Ctrl+F5.
Загрузка…
"""
class Params:
def __init__(self) -> None:
self.lock = threading.Lock()
self.belt_mm: float = 800.0
self.confidence_pct: int = 75 # порог фиксации
self.min_object_height_mm: float = 8.0
self.circle_threshold: float = 0.80
self.min_area_px: int = 400
# runtime status
self.confidence_now: int = 0
self.zone: Optional[str] = None
self.locked: bool = False
self.uncertain: bool = False
self.circle_ratio: Optional[float] = None
self.dims: Optional[tuple] = None
self.reason: str = ""
self.objects: list = [] # [{id, zone, label, dims, ratio, locked, uncertain, conf, crop}]
self.stats: dict = {} # счётчики за сессию
self.feed: list = [] # лента LOCK-событий (новые в конце)
self.feed_seq: int = 0 # версия ленты — клиент тянет только при изменении
self.feed_slots: set = set() # slot_key — один товар = одна карточка в ленте
self.crop_cache: dict = {} # track_id → base64, фиксируется при LOCK
self.jpeg: bytes = b""
self.last_print: str = ""
self.cam: Any = None
self.request_autocalib: bool = False
self.autocalib_result: Optional[Dict[str, Any]] = None
self.background: Optional[np.ndarray] = None # карта глубины пустой сцены
self.color_background: Optional[np.ndarray] = None # RGB пустой сцены (плоские товары)
STATE = Params()
def make_handler() -> type:
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None:
return
def _json(self, code: int, obj: Dict[str, Any]) -> None:
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
path = urlparse(self.path).path
if path.startswith("/frame.jpg"):
with STATE.lock:
data = STATE.jpeg
if not data:
self.send_error(503, "no frame yet")
return
self.send_response(200)
self.send_header("Content-Type", "image/jpeg")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
elif path == "/api/status":
with STATE.lock:
self._json(
200,
{
"belt_mm": round(STATE.belt_mm),
"confidence_pct": STATE.confidence_pct,
"confidence_now": STATE.confidence_now,
"min_object_height_mm": STATE.min_object_height_mm,
"circle_threshold": STATE.circle_threshold,
"min_area_px": STATE.min_area_px,
"zone": STATE.zone,
"locked": STATE.locked,
"uncertain": STATE.uncertain,
"circle_ratio": STATE.circle_ratio,
"dims": STATE.dims,
"reason": STATE.reason,
"objects": STATE.objects,
"stats": STATE.stats,
"feed_seq": STATE.feed_seq,
"feed": STATE.feed,
},
)
elif path == "/api/feed":
with STATE.lock:
self._json(200, {"seq": STATE.feed_seq, "items": STATE.feed})
else:
body = HTML.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self) -> None:
path = urlparse(self.path).path
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length) if length else b"{}"
try:
data = json.loads(raw.decode("utf-8") or "{}")
except json.JSONDecodeError:
data = {}
if path == "/api/params":
with STATE.lock:
if "belt_mm" in data:
new_belt = float(np.clip(float(data["belt_mm"]), 200, 3000))
# ручная правка высоты → фоновая карта устарела
if abs(new_belt - STATE.belt_mm) > 2.0:
STATE.background = None
STATE.belt_mm = new_belt
if "confidence_pct" in data:
STATE.confidence_pct = int(np.clip(int(data["confidence_pct"]), 10, 100))
if "min_object_height_mm" in data:
STATE.min_object_height_mm = float(np.clip(float(data["min_object_height_mm"]), 1, 200))
if "circle_threshold" in data:
STATE.circle_threshold = float(np.clip(float(data["circle_threshold"]), 0.4, 0.99))
if "min_area_px" in data:
STATE.min_area_px = int(np.clip(int(data["min_area_px"]), 50, 20000))
self._json(200, {"ok": True})
elif path == "/api/autocalib":
with STATE.lock:
STATE.request_autocalib = True
STATE.autocalib_result = None
# ждём результат от цикла камеры
for _ in range(80):
time.sleep(0.1)
with STATE.lock:
if STATE.autocalib_result is not None:
self._json(200, STATE.autocalib_result)
return
self._json(500, {"ok": False, "error": "timeout"})
else:
self.send_error(404)
return Handler
def resolve_config_path(path: Path) -> Path:
if path.exists():
return path
example = path.with_name("config.example.yaml")
if example.exists():
return example
raise FileNotFoundError(f"Config not found: {path} (and no config.example.yaml)")
def load_config(path: Path) -> Dict[str, Any]:
resolved = resolve_config_path(Path(path))
with open(resolved, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
def frames_needed(confidence_pct: int) -> int:
# 10% → 5, 100% → 12 кадров одной зоны после прогрева окна
return max(5, int(round(5 + (confidence_pct / 100.0) * 7)))
def crop_b64(img: np.ndarray, contour: np.ndarray, pad: int = 14, max_w: int = 260) -> Optional[str]:
"""Кроп объекта по bounding box контура → JPEG base64 для веб-панели."""
x, y, w, h = cv2.boundingRect(contour)
H, W = img.shape[:2]
x0, y0 = max(0, x - pad), max(0, y - pad)
x1, y1 = min(W, x + w + pad), min(H, y + h + pad)
if x1 - x0 < 4 or y1 - y0 < 4:
return None
crop = img[y0:y1, x0:x1]
if crop.shape[1] > max_w:
s = max_w / crop.shape[1]
crop = cv2.resize(crop, (max_w, max(1, int(crop.shape[0] * s))))
ok, buf = cv2.imencode(".jpg", crop, [int(cv2.IMWRITE_JPEG_QUALITY), 78])
return base64.b64encode(buf.tobytes()).decode("ascii") if ok else None
def main() -> int:
parser = argparse.ArgumentParser(description="Демо классификации с ползунками")
parser.add_argument("-c", "--config", default=str(Path(__file__).with_name("config.yaml")))
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8080)
args = parser.parse_args()
cfg = load_config(Path(args.config))
cam_cfg = cfg["camera"]
cls_cfg = cfg["classification"]
min_mm = cls_cfg.get("min_mm", [10, 10, 10])
max_mm = cls_cfg.get("max_mm", [450, 320, 320])
out_dir = Path(cfg.get("runtime", {}).get("debug_dir", "debug_frames"))
out_dir.mkdir(parents=True, exist_ok=True)
out_jpg = out_dir / "demo_live.jpg"
print("[demo] открываю RealSense D415…")
cam = RealSenseV4L2(
depth_device=cam_cfg.get("depth_device", "/dev/video0"),
color_device=cam_cfg.get("color_device", "/dev/video4"),
width=int(cam_cfg.get("width", 640)),
height=int(cam_cfg.get("height", 480)),
fps=int(cam_cfg.get("fps", 30)),
depth_scale_mm=float(cam_cfg.get("depth_scale_mm", 1.0)),
use_color=bool(cfg.get("use_color", False)),
)
STATE.cam = cam
belt0 = float(cfg.get("belt_distance_mm") or 0)
if belt0 <= 0:
print("[demo] калибровка ленты — уберите объекты…")
belt0 = cam.estimate_belt_distance_mm()
with STATE.lock:
STATE.belt_mm = belt0
STATE.circle_threshold = float(cls_cfg.get("circle_ratio_threshold", 0.8))
STATE.min_object_height_mm = float(cfg.get("min_object_height_mm", 8))
STATE.min_area_px = int(cfg.get("min_object_area_px", 400))
STATE.confidence_pct = 75
print(f"[demo] belt_distance_mm = {belt0:.0f}")
max_objects = int(cfg.get("max_objects_in_frame", 3))
detect_flat_rgb = bool(cfg.get("detect_flat_rgb", False))
print(f"[demo] max_objects={max_objects}, flat_rgb={'ON' if detect_flat_rgb else 'OFF'}")
print("[demo] фоновая карта: кнопка «Авто-высота» на пустой ленте")
server = ThreadingHTTPServer((args.host, args.port), make_handler())
threading.Thread(target=server.serve_forever, daemon=True).start()
print(f"[demo] браузер → http://127.0.0.1:{args.port}/")
print("[demo] ползунки СВЕРХУ страницы (не на картинке)")
print("[demo] зона только после LOCK (медиана 12 кадров + голосование)")
print("[demo] Ctrl+C — выход\n")
fx, fy = float(cam_cfg["fx"]), float(cam_cfg["fy"])
cx, cy = float(cam_cfg["cx"]), float(cam_cfg["cy"])
fallback_zone = str(cls_cfg.get("uncertain_fallback_zone", "C")).upper()
thr0 = float(cls_cfg.get("circle_ratio_threshold", 0.8))
settings = {"confirm": frames_needed(75), "circ": thr0}
def make_stabilizer() -> DecisionStabilizer:
return DecisionStabilizer(
window=12,
confirm_frames=settings["confirm"],
lost_frames=12,
enter_circle=settings["circ"],
exit_circle=settings["circ"] - 0.08,
uncertain_after=int(cls_cfg.get("uncertain_after_frames", 45)),
fallback=ZONE_TO_CATEGORY.get(fallback_zone, Category.OVERSIZE),
)
# lost_frames=30 ≈ 1.5–2 с: глянцевые/тёмные предметы (мышка) дают
# кратковременные выпадения depth — трек не должен умирать от них
tracker = MultiObjectTracker(make_stabilizer, max_dist_px=120, lost_frames=30)
contour_smoother = ContourSmoother(alpha=0.3)
color_align = (
float(cam_cfg.get("color_dx", 0.0)),
float(cam_cfg.get("color_dy", 0.0)),
float(cam_cfg.get("color_scale", 1.0)),
)
decisions_log = Path(cfg.get("runtime", {}).get("decisions_log", "logs/decisions.jsonl"))
frame_i = 0
last_conf_setting = 75
last_circ = thr0
try:
while True:
# автокалибровка по запросу из UI
with STATE.lock:
need_auto = STATE.request_autocalib
if need_auto:
STATE.request_autocalib = False
if need_auto:
try:
print("[demo] автокалибровка: снимаю фоновую карту (сцена должна быть пустой)…")
bg = cam.capture_background(samples=15)
color_bg = cam.capture_background_rgb(samples=10)
h, w = bg.shape
center = bg[h // 4 : 3 * h // 4, w // 4 : 3 * w // 4].astype(np.float32)
center = center[(center > 200) & (center < 4000)]
new_belt = float(np.median(center)) if center.size > 100 else cam.estimate_belt_distance_mm(samples=10)
with STATE.lock:
STATE.belt_mm = new_belt
STATE.background = bg
STATE.color_background = color_bg
STATE.autocalib_result = {"ok": True, "belt_mm": new_belt}
tracker.reset()
rgb_tag = "RGB-фон есть" if color_bg is not None else "RGB-фон недоступен"
print(f"[demo] высота = {new_belt:.0f} mm, фоновая карта активна, {rgb_tag}")
except Exception as exc:
with STATE.lock:
STATE.autocalib_result = {"ok": False, "error": str(exc)}
pair = cam.read()
if pair is None:
time.sleep(0.02)
continue
frame_i += 1
with STATE.lock:
belt_mm = STATE.belt_mm
conf_pct = STATE.confidence_pct
hmin = STATE.min_object_height_mm
circ_thr = STATE.circle_threshold
min_area = STATE.min_area_px
background = STATE.background
color_background = STATE.color_background
if conf_pct != last_conf_setting:
settings["confirm"] = frames_needed(conf_pct)
tracker.reset()
last_conf_setting = conf_pct
if abs(circ_thr - last_circ) > 1e-6:
settings["circ"] = float(circ_thr)
tracker.reset()
last_circ = circ_thr
# несколько объектов в кадре → трекер с ID
measurements = []
depth_union = None
seg_n = 0
for mask, contour in segment_objects(
pair.depth_mm,
belt_distance_mm=belt_mm,
belt_tolerance_mm=float(cfg.get("belt_tolerance_mm", 25)),
min_object_height_mm=hmin,
min_area_px=min_area,
background_mm=background,
max_objects=max_objects,
):
seg_n += 1
depth_union = mask if depth_union is None else cv2.bitwise_or(depth_union, mask)
m = measure_object(
pair.depth_mm,
mask,
contour,
belt_distance_mm=belt_mm,
fx=fx,
fy=fy,
cx=cx,
cy=cy,
background_mm=background,
min_object_height_mm=hmin,
)
if m is not None and is_plausible_measurement(m):
measurements.append(m)
# плоские товары — только если detect_flat_rgb: true (иначе тени → ложные C)
if (
detect_flat_rgb
and color_background is not None
and not pair.color_is_depth_preview
):
for mask, contour in segment_rgb_objects(
pair.color_bgr,
color_background,
min_area_px=min_area,
diff_threshold=int(cfg.get("rgb_diff_threshold", 35)),
max_objects=max(0, max_objects - len(measurements)),
exclude_mask=depth_union,
):
m = measure_flat_object(
pair.depth_mm,
mask,
contour,
belt_distance_mm=belt_mm,
fx=fx,
fy=fy,
cx=cx,
cy=cy,
background_mm=background,
color_bgr=pair.color_bgr,
color_bg_bgr=color_background,
)
if m is not None and is_plausible_measurement(m):
measurements.append(m)
measurements = merge_overlapping_measurements(measurements, overlap_thr=0.5)
tracks, events = tracker.update(measurements, min_mm=min_mm, max_mm=max_mm)
rgb_available = not pair.color_is_depth_preview
if rgb_available and float(np.std(pair.color_bgr)) > 4.0:
base_img = pair.color_bgr
if base_img.shape[:2] != pair.depth_mm.shape[:2]:
base_img = cv2.resize(base_img, (pair.depth_mm.shape[1], pair.depth_mm.shape[0]))
base_img = align_color(base_img, *color_align)
else:
# без живого RGB — colorize(depth), иначе веб был бы чёрным
from camera import depth_colormap
base_img = depth_colormap(pair.depth_mm)
rgb_available = False
crops: Dict[int, Optional[str]] = {}
for tr in tracks:
if tr.measurement is not None:
crops[tr.track_id] = crop_b64(base_img, tr.measurement.contour)
crop_updates: Dict[int, str] = {}
feed_add = []
for ev in events:
r = ev.decision.result
tag = "UNCERTAIN→" if ev.decision.uncertain else "LOCK "
print(
f"[demo] #{ev.track_id} {tag}{r.category.zone} | {r.category.short_label} | "
f"LWH={tuple(round(x, 1) for x in r.dims_sorted_mm)} | circle={r.circle_ratio:.3f}"
)
append_decision(
decisions_log, r,
uncertain=ev.decision.uncertain, source="demo", track_id=ev.track_id,
)
L, W, H = r.dims_sorted_mm
tr_ev = next((t for t in tracks if t.track_id == ev.track_id), None)
cx, cy = (tr_ev.centroid if tr_ev else (0, 0))
sk = slot_key(cx, cy, L, W, H, r.category.zone)
crop = crops.get(ev.track_id)
if crop:
crop_updates[ev.track_id] = crop
feed_add.append({
"slot": sk,
"id": ev.track_id,
"time": time.strftime("%H:%M:%S"),
"zone": r.category.zone,
"label": r.category.short_label,
"dims": [round(x, 1) for x in r.dims_sorted_mm],
"ratio": round(r.circle_ratio, 3),
"uncertain": bool(ev.decision.uncertain),
"crop": crop,
})
with STATE.lock:
STATE.crop_cache.update(crop_updates)
crop_cache = dict(STATE.crop_cache)
# статус для веба: список объектов + «главный» (первый залоченный)
objects_json = []
primary = None
for tr in tracks:
d = tr.decision
m = tr.measurement
if d is None or m is None:
continue
is_locked = bool(d.locked and d.result is not None)
if is_locked:
obj = {
"id": tr.track_id,
"locked": True,
"uncertain": bool(d.uncertain),
"conf": 100,
"zone": d.result.category.zone,
"label": d.result.category.short_label,
"dims": [round(x, 1) for x in d.result.dims_sorted_mm],
"ratio": round(d.result.circle_ratio, 3),
"reason": d.result.reason,
"crop": crop_cache.get(tr.track_id),
}
else:
obj = {
"id": tr.track_id,
"locked": False,
"uncertain": False,
"conf": d.confidence_pct,
"zone": None,
"label": "анализ…",
"dims": [round(m.length_mm, 1), round(m.width_mm, 1), round(m.height_mm, 1)],
"ratio": round(m.circle_ratio, 3),
"reason": "",
"crop": None,
}
objects_json.append(obj)
if primary is None or (obj["locked"] and not primary["locked"]):
primary = obj
with STATE.lock:
STATE.objects = objects_json
STATE.stats = dict(tracker.stats)
if feed_add:
fresh = [it for it in feed_add if it["slot"] not in STATE.feed_slots]
for it in fresh:
STATE.feed_slots.add(it["slot"])
if fresh:
STATE.feed.extend(fresh)
STATE.feed = STATE.feed[-20:]
STATE.feed_seq += 1
if primary is not None:
STATE.confidence_now = primary["conf"]
STATE.locked = primary["locked"]
STATE.uncertain = primary["uncertain"]
STATE.zone = primary["zone"]
STATE.circle_ratio = primary["ratio"]
STATE.dims = tuple(primary["dims"])
STATE.reason = primary["reason"] or (
f"накопление {primary['conf']}% → ждём LOCK" if not primary["locked"] else ""
)
else:
STATE.confidence_now = 0
STATE.locked = False
STATE.uncertain = False
STATE.zone = None
STATE.circle_ratio = None
STATE.dims = None
STATE.reason = ""
hud = build_demo_frame(
base_img,
pair.depth_mm,
tracks,
belt_mm,
stats=tracker.stats,
confidence_pct=conf_pct,
rgb_available=rgb_available,
background_active=background is not None,
color_align=(0.0, 0.0, 1.0),
contour_smoother=contour_smoother,
)
ok, buf = cv2.imencode(".jpg", hud, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
if ok:
jpeg = buf.tobytes()
with STATE.lock:
STATE.jpeg = jpeg
if frame_i % 3 == 0:
out_jpg.write_bytes(jpeg)
if seg_n > len(measurements) and STATE.last_print != "seg_drop":
print(f"[demo] depth: контуров {seg_n}, измерено {len(measurements)} "
f"(часть отфильтрована: низкая высота < {hmin:.0f} мм или шум)")
STATE.last_print = "seg_drop"
elif not tracks and STATE.last_print != "empty":
print("[demo] объектов нет")
STATE.last_print = "empty"
elif tracks and STATE.last_print in ("seg_drop", "empty"):
STATE.last_print = ""
time.sleep(0.03)
except KeyboardInterrupt:
print("\n[demo] stop")
finally:
server.shutdown()
cam.release()
return 0
if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).resolve().parent))
raise SystemExit(main())