Files
dasha_f 0d32f32db0 Сортировочная ячейка Isaac Sim: CV-пайплайн и меши товаров
Замкнутый контур "поток -> 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>
2026-08-01 13:07:24 +00:00

562 lines
32 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ВАРИАНТ БЕЗ СЕГМЕНТАЦИИ: CRE-Stereo по всему ROI, товар отделяется от полотна по высоте.
Зачем. Разбор показал, что и раздутые габариты, и непойманный класс D идут от сегментации:
у bag, bucket и detergent FastSAM выделял ПОЛОТНО, а не товар (маска 174-267 тыс. px против
17-143 тыс. у нормальных, выход точек падал с 96 % до 0.5-6.6 %). На кадре видно, что ведро
не выделено ни одной маской - залито всё полотно.
Здесь сегментации нет вовсе. CRE считается по общей зоне ленты, диспаратность переводится
в 3D, и товаром считается то, что ВЫСТУПАЕТ над плоскостью полотна выше порога. Плоскость
известна из калибровки (z = BELT_Z), поэтому её не надо ни искать, ни подгонять.
Это тот же приём, что дал рабочий результат на физическом стенде: там отказ от сегментации
снял две ошибки сразу - выбор маски и проекцию силуэта.
Порог над полотном 12 мм: измеренный разброс самого полотна в облаке меньше, а товары
здесь от 122 мм высотой. Дополнительно точки чистятся по связности в плане, чтобы соседний
товар потока (при шаге 700 мм он попадает в кадр) не приклеился к целевому.
"""
import os, sys, json, time
import numpy as np
import cv2
import torch
CV = "/home/dasha/isaac_assets/cv"
CT = "/home/dasha/robozon-sorter/control_test"
CAP = f"{CT}/captures/flow"
OUT = f"{CT}/diag/plane"
os.makedirs(OUT, exist_ok=True)
sys.path.insert(0, CT)
os.environ.setdefault("MASK_MODE", "gate")
import measure_flow as MF # берём cre_batch, backproj-математику, POLY3
import classify as CL
man = MF.man; calib = MF.calib
TARGET, BELT_Z, RIGS = MF.TARGET, MF.BELT_Z, MF.RIGS
FLOOR = 0.020 # порог над полотном, м
DENS_CELL = 0.010 # ячейка сетки плотности, м
DENS_MIN = 12 # точек в ячейке, чтобы считать её товаром
GATE_R = 0.28 # радиус вокруг точки осмотра, м - отсекает соседа по потоку
PITCH_S = 0.70
RIGCOL = {RIGS[0]: (0, 140, 255), RIGS[1]: (60, 230, 90), RIGS[2]: (240, 120, 240)}
def cloud_from_roi(disp, win, cam):
"""всё окно -> 3D, без маски. Товар выделяется превышением над полотном."""
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)
above = (P[:, 2] > BELT_Z + FLOOR) & (P[:, 2] < BELT_Z + 0.60)
return P[near & above], P[near & (P[:, 2] > BELT_Z - 0.03) & (P[:, 2] <= BELT_Z + FLOOR)]
def dense_only(P, cell=DENS_CELL, need=DENS_MIN):
"""оставить только ПЛОТНЫЕ ячейки.
Снимок облака показал, из чего состоит ошибка: сам товар - плотное пятно верного
размера, а вокруг него разреженный ореол на все 556 x 517 мм. Это точки полотна,
пережившие порог по высоте, и борта ленты. Связность их склеивает с товаром в один
сгусток, и габарит раздувается до размера зоны осмотра.
Полотно даёт РЕДКИЕ выбросы (шум диспаратности на однородной поверхности), товар -
сплошную поверхность. Поэтому отбор идёт по числу точек в ячейке, а не по связности.
"""
if len(P) < 60:
return np.ones(len(P), bool)
key = np.floor(P[:, :3] / cell).astype(np.int64)
key2 = key[:, :2]
uniq, inv, cnt = np.unique(key2, 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 scatter(P, cols, ax=(0, 1), w=460, h=460, title=""):
a, b = ax
x, y = P[:, a], P[:, b]
pad = 26
sx = (w - 2 * pad) / max(1e-6, x.max() - x.min())
sy = (h - 2 * pad) / max(1e-6, y.max() - y.min())
s = min(sx, sy)
img = np.zeros((h, w, 3), np.uint8)
for (px, py), c in zip(np.stack([x, y], 1), cols):
u = int((px - x.min()) * s) + pad
v = h - (int((py - y.min()) * s) + pad)
if 0 <= u < w and 0 <= v < h:
cv2.circle(img, (u, v), 1, (int(c[0]), int(c[1]), int(c[2])), -1)
cv2.putText(img, title, (8, 18), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (210, 210, 210), 1)
cv2.putText(img, f"{(x.max()-x.min())*1000:.0f} x {(y.max()-y.min())*1000:.0f} mm",
(8, h - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.42, (150, 150, 150), 1)
return img
def k_three_sections(P):
"""k по ТРЁМ взаимно перпендикулярным сечениям, берётся максимум.
Правило класса D в README: "k > 0.8 ХОТЯ БЫ В ОДНОМ СЕЧЕНИИ". До сих пор считалось
только горизонтальное сечение на середине высоты, и этого достаточно лишь для тел,
стоящих вертикально. Ведро на прогоне ЛЕЖИТ НА БОКУ: его горизонтальный срез - это
прямоугольник "длина x хорда", и k = 0.43 для него верен, просто отвечает не на тот
вопрос. Круглое сечение лежащего цилиндра - вертикальное, поперёк оси.
Замерено, что дело не в камерах: восстановленное полотно садится на эталонную
плоскость со смещением 0.6-1.1 мм и наклоном 0.35-1.72 градуса по всем трём ригам,
а дуга у ведра покрыта на 295-360 градусов. То есть ни интринсики, ни расположение
ригов, ни слияние тут ни при чём - не хватало именно второго и третьего сечения.
"""
if len(P) < 60:
return float("nan"), -1
best, bax = float("nan"), -1
for ax in range(3): # секущая плоскость перпендикулярна оси ax
keep = [i for i in range(3) if i != ax]
lo, hi = np.percentile(P[:, ax], 2), np.percentile(P[:, ax], 98)
mid = (lo + hi) / 2.0
half = max(0.012, (hi - lo) * 0.08)
band = P[np.abs(P[:, ax] - mid) < half][:, keep]
if len(band) < 25:
continue
pts = (band - band.mean(0)) * 1000.0
hull = cv2.convexHull(np.ascontiguousarray(pts.astype(np.float32)))
(_, _), R_out = cv2.minEnclosingCircle(hull)
if R_out < 1e-6:
continue
sc = 2.0
sh = int(np.ceil((pts.max() - pts.min()) * sc)) + 20
if sh < 8 or sh > 4000:
continue
img = np.zeros((sh, sh), np.uint8)
cv2.fillConvexPoly(img, np.int32((hull.reshape(-1, 2) - pts.min()) * sc + 10), 255)
k = min(1.0, float(cv2.distanceTransform(img, cv2.DIST_L2, 5).max()) / sc / R_out)
if np.isnan(best) or k > best:
best, bax = k, ax
return best, bax
K_SECTORS = 48 # угловых секторов, 7.5 градуса каждый
K_MIN_PER = 4 # точек в секторе, иначе сектор не в счёт
def k_smooth(P, mode="h"):
"""k по СГЛАЖЕННОМУ контуру сечения: радиус как функция угла, в секторе - медиана.
Прежний k брался как вписанный радиус к описанному по выпуклой ОБОЛОЧКЕ. Оболочка
строится по крайним точкам, а край товара размазан шумом диспаратности: разброс самого
полотна замерен в 7-10 мм, и такой же разброс сидит на границе предмета. Показатель
страдает дважды - выброс наружу увеличивает R_out, выброс внутрь уменьшает r_in,
поэтому k занижался у всего: у коробки с истинным 0.72 читался 0.59, у ведра с 0.99 - 0.43.
Здесь контур сглаживается по углу: точки сечения разбиваются на сектора вокруг центра,
в каждом берётся МЕДИАНА радиуса, и k считается по этим медианам. Единичный выброс в
секторе из десятков точек не проходит. Смысл сохраняется: у круга профиль радиуса
плоский -> k ~ 1, у квадрата меняется от a до a*sqrt2 -> k ~ 0.707.
Центр берётся медианой координат, а не средним: среднее тянется в сторону той дуги,
где точек больше.
"""
if len(P) < 60:
return float("nan")
top = np.percentile(P[:, 2], 98)
zmid = BELT_Z + (top - BELT_Z) * 0.5
band = P[np.abs(P[:, 2] - zmid) < 0.02][:, :2]
if len(band) < 40:
return float("nan")
c = np.median(band, axis=0)
d = (band - c) * 1000.0
r = np.hypot(d[:, 0], d[:, 1])
a = np.arctan2(d[:, 1], d[:, 0])
idx = ((a + np.pi) / (2 * np.pi) * K_SECTORS).astype(int) % K_SECTORS
prof = []
for i in range(K_SECTORS):
m = idx == i
if m.sum() >= K_MIN_PER:
prof.append(np.median(r[m]))
if len(prof) < K_SECTORS // 2: # дуга меньше половины круга - судить нельзя
return float("nan")
prof = np.array(prof)
lo, hi = np.percentile(prof, 10), np.percentile(prof, 90)
return float(min(1.0, lo / hi)) if hi > 1e-6 else float("nan")
# ---------------------------------------------------------------------------------------
# ПРОВЕРЕННЫЙ ПОКАЗАТЕЛЬ КРУГОВОГО СЕЧЕНИЯ, перенесён из isaac_assets/cv/circular_section.py
#
# Мои три попытки поднять k провалились подряд (выпуклая оболочка по одному горизонтальному
# сечению 0.85, три мировых сечения 0.83, сглаживание по секторам 0.67), и все три отличались
# от этой реализации одним и тем же: они резали облако по МИРОВЫМ осям и на ОДНОЙ высоте.
#
# Здесь облако сначала выравнивается по СОБСТВЕННЫМ главным осям (SVD), и только потом
# режется - на пяти высотах вдоль каждой из трёх осей, толщиной 7 % размаха. Для лежащего
# на боку ведра главная ось это и есть ось цилиндра, поэтому перпендикулярное сечение
# оказывается тем самым кругом; при резке по мировым осям такого сечения не существует.
#
# Плюс защита от вытянутых пятен: невязка подгонки окружности (Kasa) должна быть < 0.15,
# иначе скруглённый овал прошёл бы как круг.
#
# На эталонной геометрии мешей эта версия давала bucket 0.934 при истинных 0.995, bag 0.889
# при 0.896, и корректно отвергала коробки (0.685-0.688 при пороге 0.8).
# ---------------------------------------------------------------------------------------
K_ROUND = 0.80
STEREO = os.environ.get("STEREO", "defom") # cre | defom (бейзлайн: defom)
DEFOM_CKPT = os.environ.get("DEFOM_CKPT", "vitl") # vitl | vits
DEFOM_ITERS = int(os.environ.get("DEFOM_ITERS", "24"))
DEFOM_SITERS = int(os.environ.get("DEFOM_SITERS", "8"))
_defom = None
def _defom_model():
global _defom
if _defom is None:
import types, torch
DR = "/home/dasha/isaac_assets/cv/defom-stereo"
if DR not in sys.path:
sys.path.insert(0, DR); sys.path.insert(0, DR + "/core")
from core.defom_stereo import DEFOMStereo
a = types.SimpleNamespace(
dinov2_encoder=DEFOM_CKPT, idepth_scale=0.5, hidden_dims=[128] * 3,
corr_implementation="reg", shared_backbone=False, corr_levels=2, corr_radius=4,
scale_list=[0.125, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0], scale_corr_radius=2,
n_downsample=2, context_norm="batch", n_gru_layers=3, mixed_precision=False)
m = DEFOMStereo(a)
ck = torch.load(f"{DR}/checkpoints/defomstereo_{DEFOM_CKPT}_sceneflow.pth",
map_location="cpu", weights_only=False)
m.load_state_dict({k.replace("module.", ""): v for k, v in ck.items()}, strict=False)
_defom = m.to("cuda").eval()
return _defom
def defom_batch(pairs, iters=None, scale_iters=None):
"""DEFOM одним проходом по нескольким парам. Кропы дополняются нулями до общего размера,
кратного 32; диспаратность читается только внутри исходных границ каждого кропа."""
import torch
if not pairs:
return []
m = _defom_model()
iters = DEFOM_ITERS if iters is None else iters
scale_iters = DEFOM_SITERS if scale_iters is None else scale_iters
hs = [q[0].shape[0] for q in pairs]; ws = [q[0].shape[1] for q in pairs]
Hp = (max(hs) + 31) // 32 * 32; Wp = (max(ws) + 31) // 32 * 32
n = len(pairs)
A = np.zeros((n, 3, Hp, Wp), np.float32); B = np.zeros((n, 3, Hp, Wp), np.float32)
for i, (L, R) in enumerate(pairs):
h, w = L.shape[:2]
A[i, :, :h, :w] = L.transpose(2, 0, 1)
B[i, :, :h, :w] = R.transpose(2, 0, 1)
iL = torch.from_numpy(A).cuda(); iR = torch.from_numpy(B).cuda()
with torch.inference_mode():
d = m(iL, iR, iters=iters, scale_iters=scale_iters, test_mode=True)
d = np.abs(d.squeeze(1).detach().cpu().numpy())
return [d[i][:hs[i], :ws[i]] for i in range(n)]
CROP = os.environ.get("CROP", "1") == "1" # считать CRE по КРОПУ зоны осмотра, а не всей ленты
CROP_H = 0.45 # запас по высоте товара, м
SW = int(os.environ.get("SW", "480")) # ширина входа сети (0 - без понижения)
def cre_scaled(pairs, sw):
"""CRE на пониженном разрешении с обратным масштабом диспаратности.
Приём взят из прежнего DEFOM-скрипта проекта (flow_defom_cache.py): кадр сжимается до
ширины sw, сеть считает по нему, диспаратность возвращается к исходному размеру и
УМНОЖАЕТСЯ на W/sw. Диспаратность измеряется в пикселях, поэтому при сжатии кадра в k
раз она сжимается во столько же - без этого множителя глубина уехала бы ровно в k раз.
Пропорции сохраняются: масштаб по обеим осям один, иначе ломается эпиполярная геометрия.
"""
engine = defom_batch if STEREO == "defom" else MF.cre_batch
if sw <= 0:
return engine(pairs)
small, meta = [], []
for (L, R) in pairs:
h, w = L.shape[:2]
sh = max(8, int(round(h * sw / w / 8)) * 8)
small.append((cv2.resize(L, (sw, sh)), cv2.resize(R, (sw, sh))))
meta.append((w, h, w / float(sw)))
ds = engine(small)
out = []
for d, (w, h, f) in zip(ds, meta):
out.append(cv2.resize(d, (w, h), interpolation=cv2.INTER_LINEAR) * f)
return out
def gate_crop_px(cam, r=GATE_R, hmax=CROP_H, pad=24):
"""окно кадра, куда проецируется зона осмотра - цилиндр радиусом r над лентой.
Зона ленты, по которой сейчас считается CRE, занимает 84-92 % кадра, хотя товар всегда
внутри круга радиусом 280 мм вокруг точки осмотра. Проекция этого круга (с запасом по
высоте на сам товар) даёт окно в разы меньше, и стереосети достаётся во столько же раз
меньше пикселей. Локализация тут ГЕОМЕТРИЧЕСКАЯ - ни сегментации, ни грубого прохода
не нужно, поэтому лишнего вызова сети не появляется.
"""
th = np.linspace(0, 2 * np.pi, 24, endpoint=False)
ring = np.c_[TARGET[0] + r * np.cos(th), TARGET[1] + r * np.sin(th)]
pts = np.vstack([np.c_[ring, np.full(len(ring), BELT_Z)],
np.c_[ring, np.full(len(ring), BELT_Z + hmax)]])
Minv = np.linalg.inv(np.array(cam["M"]))
c = (np.c_[pts, np.ones(len(pts))] @ Minv)[:, :3]
z = -c[:, 2]
u = c[:, 0] / np.maximum(z, 1e-9) * cam["fx"] + cam["cx"]
v = -c[:, 1] / np.maximum(z, 1e-9) * cam["fy"] + cam["cy"]
x0 = int(max(0, np.floor(u.min()) - pad)); x1 = int(min(cam["width"], np.ceil(u.max()) + pad))
y0 = int(max(0, np.floor(v.min()) - pad)); y1 = int(min(cam["height"], np.ceil(v.max()) + pad))
return x0, y0, x1, y1
LR_THR = float(os.environ.get("LR_THR", "0")) # порог проверки лево-право, px (0 = выкл)
CYL = os.environ.get("CYL", "0") == "1" # подгонка цилиндра как второй признак
CYL_TOL = 0.008 # допуск на радиус, м
CYL_MIN_INLIER = 0.60 # доля точек в допуске, чтобы счесть цилиндром
def disp_lr(L, R):
"""диспаратность в обе стороны одним батчем: прямая пара и зеркально отражённая.
Отражение по горизонтали превращает задачу "справа налево" в обычную "слева направо",
поэтому вторую карту даёт та же сеть без правок: отражаем оба кадра, меняем их местами,
считаем, отражаем результат обратно.
"""
Lf = np.ascontiguousarray(L[:, ::-1])
Rf = np.ascontiguousarray(R[:, ::-1])
dL, dRf = MF.cre_batch([(L, R), (Rf, Lf)])
dR = np.ascontiguousarray(dRf[:, ::-1])
return dL, dR
def lr_valid(dL, dR, thr):
"""маска согласованных точек: d_L(x) должна совпасть с d_R в точке x - d_L(x)."""
h, w = dL.shape
xs = np.arange(w)[None, :].repeat(h, 0)
xr = np.rint(xs - dL).astype(int)
ok = (xr >= 0) & (xr < w)
xr = np.clip(xr, 0, w - 1)
dRs = np.take_along_axis(dR, xr, axis=1)
return ok & (np.abs(dL - dRs) <= thr)
def cylinder_ransac(P, tol=CYL_TOL, n_axis=64, seed=0):
"""RANSAC по МОДЕЛИ цилиндра (не по совмещению облаков).
Ось ищется перебором направлений: главные оси облака плюс случайные. Точки проецируются
на плоскость, перпендикулярную оси, туда подгоняется окружность, и считается доля точек,
чей радиус попал в допуск. Нормали не нужны - они на рыхлом облаке сами шумят.
"""
if len(P) < 200:
return 0.0, float("nan"), float("nan")
rng = np.random.default_rng(seed)
Q = P - P.mean(0)
_, _, V = np.linalg.svd(Q, full_matrices=False)
axes = [V[0], V[1], V[2]]
for _ in range(n_axis):
v = rng.normal(size=3); axes.append(v / (np.linalg.norm(v) + 1e-12))
best = (0.0, float("nan"), float("nan"))
for d in axes:
d = d / (np.linalg.norm(d) + 1e-12)
a = np.array([1.0, 0.0, 0.0])
if abs(d @ a) > 0.9:
a = np.array([0.0, 1.0, 0.0])
e1 = np.cross(d, a); e1 /= np.linalg.norm(e1)
e2 = np.cross(d, e1)
xy = np.c_[Q @ e1, Q @ e2]
x, y = xy[:, 0], xy[:, 1]
A = np.c_[2 * x, 2 * y, np.ones(len(x))]
try:
sol, *_ = np.linalg.lstsq(A, x * x + y * y, rcond=None)
except np.linalg.LinAlgError:
continue
cx, cy, cc = sol
R = np.sqrt(max(cc + cx * cx + cy * cy, 1e-12))
r = np.hypot(x - cx, y - cy)
inl = float(np.mean(np.abs(r - R) <= tol))
if inl > best[0]:
best = (inl, float(R), float(np.median(np.abs(r - R))))
return best
def _kasa(P):
x,y=P[:,0],P[:,1]; A=np.c_[2*x,2*y,np.ones(len(x))]; b=x*x+y*y
s,*_=np.linalg.lstsq(A,b,rcond=None); cx,cy,cc=s; r=np.sqrt(max(cc+cx*cx+cy*cy,1e-12))
return cx,cy,r,np.abs(np.hypot(x-cx,y-cy)-r).mean()
def section_K(xy):
"""True r_inscribed/R_circumscribed of a full cross-section outline via the convex-hull
incenter (largest inscribed circle) and the circumscribed radius from that centre.
K=1 for a circle, b/a for an ellipse, 0.707 for a square, short/long for a rectangle."""
from scipy.spatial import ConvexHull
if len(xy)<20: return None,0.0,1.0
try: h=ConvexHull(xy)
except Exception: return None,0.0,1.0
V=xy[h.vertices] # CCW hull vertices
A=V; B=np.roll(V,-1,axis=0); E=B-A; L=np.linalg.norm(E,axis=1)+1e-12
mn=xy.min(0); mx=xy.max(0)
G=np.stack(np.meshgrid(np.linspace(mn[0],mx[0],40),np.linspace(mn[1],mx[1],40)),-1).reshape(-1,2)
# signed distance from each grid point to each hull edge (CCW -> interior side positive)
d=(E[:,0][None,:]*(G[:,1][:,None]-A[:,1][None,:]) - E[:,1][None,:]*(G[:,0][:,None]-A[:,0][None,:]))/L[None,:]
inside=(d>0).all(1)
if inside.sum()<3: return 0.0,1.0,1.0
rin=float(d[inside].min(1).max()) # max inscribed circle radius (its own centre)
# min enclosing circle radius (its own centre): grid centre minimising max distance to hull vertices
Gd=np.stack(np.meshgrid(np.linspace(mn[0],mx[0],48),np.linspace(mn[1],mx[1],48)),-1).reshape(-1,2)
Rout=float(np.linalg.norm(Gd[:,None,:]-V[None,:,:],axis=2).max(1).min())
return rin/max(Rout,1e-9),1.0,0.0
def circular_section_K(points):
"""Max K over cross-sections sampled along each principal axis (a circle in ANY section
-> round). Returns (max_K, is_round, best_section)."""
if len(points)<60: return 0.0,False,None
c=points.mean(0); Q=points-c; _,_,V=np.linalg.svd(Q,full_matrices=False); proj=Q@V.T
best=0.0; best_sec=None
for a in range(3):
o=[i for i in range(3) if i!=a]; ca=proj[:,a]; sp=np.ptp(ca)+1e-9
for frac in (0.25,0.375,0.5,0.625,0.75): # sample slices along the axis
lvl=np.percentile(ca,frac*100)
sl=proj[np.abs(ca-lvl)<0.07*sp][:,o]
K,cov,rr=section_K(sl)
if K is not None and rr<0.15 and K>best:
best=K; best_sec=(a,round(frac,2),round(cov,2),rr)
return round(best,3), (best>K_ROUND), best_sec
# При импорте из рабочего процесса замкнутого контура прогон по девяти товарам не нужен -
# нужны только функции (defom_batch, gate_crop_px, cloud_from_roi, circular_section_K...).
if os.environ.get("IMPORT_ONLY") == "1":
import sys as _s
_s.modules[__name__].__dict__.setdefault("_ready", True)
else:
print(f"БЕЗ СЕГМЕНТАЦИИ: CRE по зоне ленты, товар выше полотна на {FLOOR*1000:.0f} мм | "
f"проверка лево-право {LR_THR if LR_THR>0 else 'выкл'} px | цилиндр {'вкл' if CYL else 'выкл'}"
f" | окно {'КРОП зоны осмотра' if CROP else 'вся лента'}"
f" | вход сети {SW if SW>0 else 'исходный'} | движок {STEREO.upper()}\n")
print(f" {'товар':18s} {'эталон, мм':>18s} {'предсказано, мм':>20s} {'MAE':>6s} "
f"{'k':>6s} {'ось':>3s} {'класс':>12s} {'точек':>7s} {'мс':>6s}")
print(" " + "-" * 100)
rows, times = [], []
FOCUS = {"bag", "bucket", "box_300x200x200"}
for name, e in man["items"].items():
t0 = time.time()
pairs, metas = [], []
for rig in RIGS:
camL = 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 = (gate_crop_px(camL) if CROP else MF.belt_roi_px(camL, MF.POLY3))
maxd = int(np.ceil(MF.DPAD * camL["fx"] * camL["baseline"] / MF.ZMIN))
x0 = max(0, x0 - maxd) # запас влево на максимальную диспаратность: правый
# двойник обязан попасть в ТО ЖЕ окно колонок
pairs.append((IL[y0:y1, x0:x1].astype(np.float32), IR[y0:y1, x0:x1].astype(np.float32)))
metas.append(((x0, y0, x1, y1), camL, rig, IL))
if LR_THR > 0:
# обе карты считаются ДО проекции: несогласованные пиксели вообще не становятся
# точками, а не удаляются потом из облака
disps, rejected = [], []
for (L, R) in pairs:
dL, dR = disp_lr(L, R)
good = lr_valid(dL, dR, LR_THR)
rejected.append(1.0 - float(good.mean()))
dd = dL.copy(); dd[~good] = 0.0 # 0 -> пиксель не пройдёт порог disp > 0.5
disps.append(dd)
else:
disps = cre_scaled(pairs, SW); rejected = []
clouds, cols, belts = [], [], []
for disp, (win, cam, rig, IL) in zip(disps, metas):
obj, belt = cloud_from_roi(disp, win, cam)
if len(obj):
clouds.append(obj); cols += [RIGCOL[rig]] * len(obj)
belts.append(len(belt))
if name in FOCUS:
dn = cv2.normalize(disp, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
hm = cv2.applyColorMap(dn, cv2.COLORMAP_TURBO)
cv2.imwrite(f"{OUT}/{name}_{rig}_disp.png", hm)
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); C = list(cols)
# самый крупный сгусток в плане: при шаге 700 мм сосед потока попадает в кадр
sel = dense_only(P)
if sel.sum() >= 60:
P = P[sel]; C = [C[i] for i in np.nonzero(sel)[0]]
sel = biggest_blob_idx(P)
P = P[sel]; C = [C[i] for i in np.nonzero(sel)[0]]
out = MF.dims_and_k(P)
if out is None:
print(f" {name:18s} габариты не взялись"); continue
dims, _k_hull = out
k, _is_round, _sec = circular_section_K(P)
kax = _sec[0] if _sec else -1
if not k:
k = _k_hull
cyl_inl = cyl_R = float("nan")
if CYL:
cyl_inl, cyl_R, cyl_res = cylinder_ransac(P)
if cyl_inl >= CYL_MIN_INLIER:
k = max(k, 0.81) # цилиндр подтверждён - признак D сработал
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} {'xyz'[kax] if kax>=0 else '-':>3s} "
f"{cls+' '+ok:>12s} {len(P):7d} {dt:6.0f}"
+ (f" цил {cyl_inl*100:3.0f}% R={cyl_R*1000:5.0f}мм" if CYL else "")
+ (f" отсев ЛП {np.mean(rejected)*100:3.0f}%" if LR_THR > 0 and rejected else ""))
rows.append(dict(name=name, gt=gtd, gt_cls=gt["zone_scene"], gt_k=gt["k"],
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)))
if name in FOCUS:
cv2.imwrite(f"{OUT}/{name}_cloud_top.png", scatter(P, C, (0, 1), title=f"{name} top"))
cv2.imwrite(f"{OUT}/{name}_cloud_side.png", scatter(P, C, (0, 2), title=f"{name} side"))
print("\n === МЕТРИКИ (без сегментации) ===")
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 'НЕ УКЛАДЫВАЕТСЯ'}")
json.dump(rows, open(f"{OUT}/metrics.json", "w"), indent=1, ensure_ascii=False)
print(f"\n картинки -> {OUT}")