0d32f32db0
Замкнутый контур "поток -> CV -> механика": товары идут по конвейеру с шагом 700 мм, класс определяется стереопайплайном во время движения, пушер и плуг реагируют физически. Состав: * control_test/ - ячейка и CV. run_sorting_cv.py + cv_worker.py (два процесса, потому что torch внутри Isaac роняет сцену), cell.py (физика лент, плуга, пушера), measure_plane.py (замер габаритов), README.md и .memory.md с замерами, проблемами и ловушками * robozon_sorter/ - модули симуляции, scripts/ - утилиты, scene/ - сцены * assets/ - меши товаров, плуг, объекты Objaverse Бейзлайн CV: DEFOM-Stereo vitl, вход 480, iters 24, кроп зоны осмотра, без сегментации. На потоке 700 мм - классы 8/9, габариты MAE 32.8 мм, 469 мс на товар при такте 700 мс. Веса моделей (4.5 ГБ) и пропсы конвейера NVIDIA (274 МБ) не включены - источники и команды скачивания в MODELS.md. Выход прогонов (captures/, runtime/) не включён: воспроизводится. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
169 lines
8.6 KiB
Python
169 lines
8.6 KiB
Python
"""Fast FoundationStereo вместо CREStereo, на том же прогоне потока 700 мм.
|
||
|
||
Модель взята ONNX-экспортом (23_36_37, iters 4, 320x736) и гоняется через onnxruntime-gpu:
|
||
это снимает зависимость от окружения репозитория (torch 2.6 + xformers), которое с
|
||
питоном Isaac не совпадает. Веса лежат в fast-foundationstereo/weights.
|
||
|
||
ВХОД У МОДЕЛИ ФИКСИРОВАННЫЙ - 320x736. Кроп приводится к этому размеру, а полученная
|
||
диспаратность масштабируется ОБРАТНО по горизонтали: диспаратность измеряется в пикселях,
|
||
и при сжатии кадра в k раз по ширине она сжимается во столько же. Без этого множителя
|
||
глубина уехала бы ровно в k раз - самая частая ошибка при подмене стереосети.
|
||
|
||
Сравнение честное: сегментация, отделение полотна, слияние и метрики те же, что в
|
||
варианте без сегментации (лучшем на сегодня). Меняется ТОЛЬКО стереодвижок.
|
||
"""
|
||
import os, sys, json, time
|
||
import numpy as np
|
||
import cv2
|
||
|
||
CV = "/home/dasha/isaac_assets/cv"
|
||
CT = "/home/dasha/robozon-sorter/control_test"
|
||
FFS = f"{CV}/fast-foundationstereo/weights/ffs_23_36_37_iters4_320x736.onnx"
|
||
OUT = f"{CT}/diag/ffs"
|
||
os.makedirs(OUT, exist_ok=True)
|
||
sys.path.insert(0, CT)
|
||
os.environ.setdefault("MASK_MODE", "gate")
|
||
import measure_flow as MF
|
||
import classify as CL
|
||
|
||
import onnxruntime as ort
|
||
so = ort.SessionOptions()
|
||
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||
sess = ort.InferenceSession(FFS, so, providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
|
||
IN_H, IN_W = 320, 736
|
||
print(f"Fast FoundationStereo: {os.path.basename(FFS)}, вход {IN_W}x{IN_H}, "
|
||
f"провайдер {sess.get_providers()[0]}")
|
||
|
||
man, calib = MF.man, MF.calib
|
||
TARGET, BELT_Z, RIGS = MF.TARGET, MF.BELT_Z, MF.RIGS
|
||
FLOOR, GATE_R = 0.020, 0.28
|
||
DENS_CELL, DENS_MIN = 0.010, 12
|
||
PITCH_S = 0.70
|
||
|
||
|
||
def ffs_disp(L, R):
|
||
"""диспаратность одной пары. Кроп -> 320x736 -> обратный масштаб по горизонтали."""
|
||
# cv2.imread даёт BGR, а модель обучена на RGB (run_demo.py читает через imageio).
|
||
# Нормализацию (ImageNet mean/std после деления на 255) модель делает ВНУТРИ forward,
|
||
# см. core/foundation_stereo.py:32 - значит она экспортирована в ONNX, и подавать надо
|
||
# именно СЫРЫЕ 0-255. Ошибкой был только порядок каналов.
|
||
h, w = L.shape[:2]
|
||
sx = IN_W / float(w)
|
||
lr = cv2.resize(cv2.cvtColor(L, cv2.COLOR_BGR2RGB), (IN_W, IN_H)).astype(np.float32)
|
||
rr = cv2.resize(cv2.cvtColor(R, cv2.COLOR_BGR2RGB), (IN_W, IN_H)).astype(np.float32)
|
||
a = lr.transpose(2, 0, 1)[None]
|
||
b = rr.transpose(2, 0, 1)[None]
|
||
d = sess.run(["disparity"], {"left_image": a, "right_image": b})[0][0, 0]
|
||
d = cv2.resize(d, (w, h), interpolation=cv2.INTER_LINEAR)
|
||
return np.abs(d) / sx # пиксели исходного кропа, а не приведённого
|
||
|
||
|
||
def dense_only(P, cell=DENS_CELL, need=DENS_MIN):
|
||
if len(P) < 60:
|
||
return np.ones(len(P), bool)
|
||
key = np.floor(P[:, :2] / cell).astype(np.int64)
|
||
uniq, inv, cnt = np.unique(key, axis=0, return_inverse=True, return_counts=True)
|
||
return cnt[inv] >= need
|
||
|
||
|
||
def biggest_blob_idx(P, grid=0.012):
|
||
if len(P) < 40:
|
||
return np.ones(len(P), bool)
|
||
cx = np.round(P[:, 0] / grid).astype(int); cy = np.round(P[:, 1] / grid).astype(int)
|
||
im = np.zeros((cy.max() - cy.min() + 3, cx.max() - cx.min() + 3), np.uint8)
|
||
im[cy - cy.min() + 1, cx - cx.min() + 1] = 255
|
||
im = cv2.morphologyEx(im, cv2.MORPH_CLOSE, np.ones((3, 3), np.uint8))
|
||
n, lab = cv2.connectedComponents(im)
|
||
li = lab[cy - cy.min() + 1, cx - cx.min() + 1]
|
||
best, bn = None, 0
|
||
for k in range(1, n):
|
||
m = li == k
|
||
if m.sum() > bn:
|
||
best, bn = m, m.sum()
|
||
return np.ones(len(P), bool) if best is None else best
|
||
|
||
|
||
def cloud_from_roi(disp, win, cam):
|
||
x0, y0, x1, y1 = win
|
||
depth = np.where(disp > 0.5, cam["fx"] * cam["baseline"] / np.maximum(disp, 1e-6), np.nan)
|
||
vs, us = np.mgrid[y0:y0 + depth.shape[0], x0:x0 + depth.shape[1]]
|
||
ok = np.isfinite(depth) & (depth > 1e-3)
|
||
u, v, z = us[ok], vs[ok], depth[ok]
|
||
P = np.stack([(u - cam["cx"]) * z / cam["fx"],
|
||
-(v - cam["cy"]) * z / cam["fy"], -z, np.ones_like(z)], 1)
|
||
P = (P @ np.array(cam["M"]))[:, :3]
|
||
near = np.hypot(P[:, 0] - TARGET[0], P[:, 1] - TARGET[1]) < GATE_R
|
||
return P[near & (P[:, 2] > BELT_Z + FLOOR) & (P[:, 2] < BELT_Z + 0.60)]
|
||
|
||
|
||
for _ in range(2): # прогрев
|
||
ffs_disp(np.zeros((240, 480, 3), np.uint8), np.zeros((240, 480, 3), np.uint8))
|
||
print("подача: RGB 0-255, нормализация внутри графа")
|
||
|
||
print(f"\n {'товар':18s} {'эталон, мм':>18s} {'предсказано, мм':>20s} {'MAE':>6s} "
|
||
f"{'k':>6s} {'класс':>12s} {'точек':>7s} {'мс':>6s}")
|
||
print(" " + "-" * 100)
|
||
rows, times = [], []
|
||
for name, e in man["items"].items():
|
||
t0 = time.time()
|
||
clouds = []
|
||
for rig in RIGS:
|
||
cam = calib[f"{rig}_Left"]
|
||
IL = cv2.imread(e["files"][f"{rig}_Left"])
|
||
IR = cv2.imread(e["files"][f"{rig}_Right"])
|
||
if IL is None or IR is None:
|
||
continue
|
||
x0, y0, x1, y1 = MF.belt_roi_px(cam, MF.POLY3)
|
||
maxd = int(np.ceil(MF.DPAD * cam["fx"] * cam["baseline"] / MF.ZMIN))
|
||
x0 = max(0, x0 - maxd)
|
||
d = ffs_disp(IL[y0:y1, x0:x1], IR[y0:y1, x0:x1])
|
||
P = cloud_from_roi(d, (x0, y0, x1, y1), cam)
|
||
if len(P):
|
||
clouds.append(P)
|
||
if name in ("bag", "bucket", "box_300x200x200"):
|
||
dn = cv2.normalize(d, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
|
||
cv2.imwrite(f"{OUT}/{name}_{rig}_disp.png", cv2.applyColorMap(dn, cv2.COLORMAP_TURBO))
|
||
dt = (time.time() - t0) * 1000
|
||
times.append(dt)
|
||
gt = e["gt"]; gtd = sorted(gt["dims_mm"], reverse=True)
|
||
if not clouds:
|
||
print(f" {name:18s} {str([round(v) for v in gtd]):>18s} облако пустое"); continue
|
||
P = np.vstack(clouds)
|
||
s1 = dense_only(P)
|
||
if s1.sum() >= 60:
|
||
P = P[s1]
|
||
P = P[biggest_blob_idx(P)]
|
||
out = MF.dims_and_k(P)
|
||
if out is None:
|
||
print(f" {name:18s} габариты не взялись"); continue
|
||
dims, k = out
|
||
mae = float(np.mean(np.abs(np.array(dims) - np.array(gtd))))
|
||
cls = CL.classify(dims, 0.0 if np.isnan(k) else k)
|
||
ok = "верно" if cls == gt["zone_scene"] else f"ОШ({gt['zone_scene']})"
|
||
print(f" {name:18s} {str([round(v) for v in gtd]):>18s} "
|
||
f"{str([round(v) for v in dims]):>20s} {mae:6.1f} {k:6.2f} {cls+' '+ok:>12s} "
|
||
f"{len(P):7d} {dt:6.0f}")
|
||
rows.append(dict(name=name, gt=gtd, gt_cls=gt["zone_scene"], pred=[round(v, 1) for v in dims],
|
||
pred_k=round(float(k), 3), pred_cls=cls, mae=round(mae, 1),
|
||
n=len(P), ms=round(dt)))
|
||
|
||
print("\n === МЕТРИКИ (Fast FoundationStereo, без сегментации) ===")
|
||
if rows:
|
||
maes = [r["mae"] for r in rows]
|
||
acc = sum(1 for r in rows if r["pred_cls"] == r["gt_cls"])
|
||
print(f" габариты: MAE медиана {np.median(maes):.1f} мм, среднее {np.mean(maes):.1f}, "
|
||
f"худший {max(maes):.1f} ({max(rows, key=lambda r: r['mae'])['name']})")
|
||
print(f" классы: {acc}/{len(rows)} = {100.0*acc/len(rows):.0f}%")
|
||
print(f" k макс {max(r['pred_k'] for r in rows):.2f}")
|
||
lab = ["B", "C", "D"]
|
||
print(" матрица (строки истина, столбцы предсказание):")
|
||
print(" " + "".join(f"{c:>5s}" for c in lab))
|
||
for a in lab:
|
||
print(f" {a:3s} " + "".join(
|
||
f"{sum(1 for r in rows if r['gt_cls']==a and r['pred_cls']==b):5d}" for b in lab))
|
||
print(f" время: медиана {np.median(times):.0f} мс, такт {PITCH_S*1000:.0f} мс -> "
|
||
f"{'УКЛАДЫВАЕТСЯ' if np.median(times) < PITCH_S*1000 else 'НЕ УКЛАДЫВАЕТСЯ'} "
|
||
f"(запас {PITCH_S*1000 - np.median(times):+.0f} мс)")
|
||
json.dump(rows, open(f"{OUT}/metrics.json", "w"), indent=1, ensure_ascii=False)
|
||
print(f"\n -> {OUT}/metrics.json")
|