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:
188
cv/calibrate.py
Normal file
188
cv/calibrate.py
Normal 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())
|
||||
Reference in New Issue
Block a user