release: consolidate web simulation and real CV prototype

Add cleaned RealSense/OpenCV CV under cv/ with secret-free example
config, MQTT disabled by default, and canonical README coverage for
web + CV relationship without claiming production integration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-08-02 22:23:05 +02:00
parent 3777fecacf
commit 21e83be26e
26 changed files with 5050 additions and 178 deletions

188
cv/calibrate.py Normal file
View File

@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
Калибровка камеры для точных габаритов (правила ТЗ: >10×10×10, <450×320×320 мм).
Два шага:
1) пустая лента → высота belt_distance_mm;
2) коробка известного размера в центре → фокусное fx=fy.
Запуск (пример для коробки 300×200 мм, высотой ≥ 30 мм):
.venv/bin/python calibrate.py --length 300 --width 200
Результат пишется прямо в config.yaml (fx, fy, belt_distance_mm).
"""
from __future__ import annotations
import argparse
import re
import sys
import time
from pathlib import Path
import cv2
import numpy as np
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent))
from camera import RealSenseV4L2
from measure import segment_object
def _wait_key(prompt: str) -> None:
"""Ждёт одиночное нажатие: 1 — продолжить, q — выйти (Enter не нужен)."""
print(prompt + " [1 — продолжить, q — выйти]", flush=True)
if not sys.stdin.isatty():
# stdin не терминал (пайп/IDE) — читаем строку
line = sys.stdin.readline().strip().lower()
if line.startswith("q"):
raise KeyboardInterrupt
return
import termios
import tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
while True:
ch = sys.stdin.read(1)
if ch in ("1", "\r", "\n"): # Enter тоже принимаем на всякий случай
return
if ch in ("q", "Q", "\x03"): # q или Ctrl+C
raise KeyboardInterrupt
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def _collect_belt(cam: RealSenseV4L2, samples: int = 25) -> float:
vals = []
for _ in range(samples):
pair = cam.read()
if pair is None:
time.sleep(0.05)
continue
d = pair.depth_mm
h, w = d.shape
roi = d[h // 4 : 3 * h // 4, w // 4 : 3 * w // 4]
valid = roi[(roi > 200) & (roi < 4000)]
if valid.size > 100:
vals.append(float(np.median(valid)))
time.sleep(0.04)
if not vals:
raise RuntimeError("Не вижу ленту: проверьте, что камера на 0.5–1.5 м над поверхностью")
return float(np.median(vals))
def _collect_focal(
cam: RealSenseV4L2,
belt_mm: float,
known_length_mm: float,
known_width_mm: float,
samples: int = 40,
) -> float:
"""fx=fy по площади minAreaRect в пикселях: f = z * sqrt(S_px / S_mm)."""
focals = []
for _ in range(samples):
pair = cam.read()
if pair is None:
time.sleep(0.05)
continue
seg = segment_object(pair.depth_mm, belt_distance_mm=belt_mm, min_area_px=400)
if seg is None:
time.sleep(0.04)
continue
mask, contour = seg
ys, xs = np.where(mask > 0)
z = pair.depth_mm[ys, xs].astype(np.float32)
z = z[z > 0]
if z.size < 100:
continue
z_med = float(np.median(z))
rect = cv2.minAreaRect(contour)
pw, ph = rect[1]
if pw < 10 or ph < 10:
continue
f = z_med * float(np.sqrt((pw * ph) / (known_length_mm * known_width_mm)))
focals.append(f)
time.sleep(0.04)
if len(focals) < 10:
raise RuntimeError(
f"Стабильно вижу коробку только в {len(focals)} кадрах из {samples}. "
"Коробка должна быть высотой ≥ 30 мм и лежать в центре кадра."
)
return float(np.median(focals))
def _patch_config(path: Path, fx: float, belt_mm: float) -> None:
text = path.read_text(encoding="utf-8")
text = re.sub(r"(?m)^(\s*fx:\s*)[\d.]+", rf"\g<1>{fx:.1f}", text)
text = re.sub(r"(?m)^(\s*fy:\s*)[\d.]+", rf"\g<1>{fx:.1f}", text)
text = re.sub(r"(?m)^(belt_distance_mm:\s*)[\d.]+", rf"\g<1>{belt_mm:.0f}", text)
path.write_text(text, encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="Калибровка fx/fy и высоты ленты")
parser.add_argument("-c", "--config", default=str(Path(__file__).with_name("config.yaml")))
parser.add_argument("--length", type=float, required=True, help="Длина коробки, мм (рулеткой)")
parser.add_argument("--width", type=float, required=True, help="Ширина коробки, мм (рулеткой)")
parser.add_argument("--yes", action="store_true", help="Не ждать Enter (сцена уже готова на каждом шаге)")
args = parser.parse_args()
cfg_path = Path(args.config)
if not cfg_path.exists():
example = cfg_path.with_name("config.example.yaml")
if not example.exists():
raise SystemExit(f"Config not found: {cfg_path}")
cfg_path = example
print(f"[calib] using {cfg_path.name} (copy to config.yaml before saving results)")
cfg = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
cam_cfg = cfg["camera"]
print("[calib] открываю 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=False,
)
try:
if not args.yes:
_wait_key("[calib] Шаг 1/2: УБЕРИТЕ всё с ленты")
belt_mm = _collect_belt(cam)
print(f"[calib] высота до ленты: {belt_mm:.0f} мм")
if not args.yes:
_wait_key(
f"[calib] Шаг 2/2: положите коробку {args.length:.0f}×{args.width:.0f} мм "
"в центр кадра"
)
time.sleep(1.0)
fx = _collect_focal(cam, belt_mm, args.length, args.width)
old_fx = float(cam_cfg.get("fx", 0))
print(f"[calib] фокусное fx=fy: {fx:.1f} (было {old_fx:.1f})")
if old_fx > 0:
k = fx / old_fx
print(f"[calib] габариты со старым fx были завышены/занижены в {k:.2f} раза")
_patch_config(cfg_path, fx, belt_mm)
print(f"[calib] записано в {cfg_path}: fx=fy={fx:.1f}, belt_distance_mm={belt_mm:.0f}")
print("[calib] проверьте: .venv/bin/python demo.py — размеры LWH должны совпадать с рулеткой")
except KeyboardInterrupt:
print("\n[calib] отменено, config.yaml не изменён")
return 1
finally:
cam.release()
return 0
if __name__ == "__main__":
raise SystemExit(main())