Сортировочная ячейка 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>
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
"""STAGE 1 (inside Isaac, no torch): render rectified L/R pairs + an EXACT object mask.
|
||||
|
||||
Two fixes over capture_cfg.py:
|
||||
* the reference used to be composed onto the same prim whose xformOpOrder we then cleared,
|
||||
which destroyed the mesh's own placement and dropped every item 0.6 m under the belt.
|
||||
The reference now lives on a child, so only our holder carries the placement.
|
||||
* ground truth is the mesh's real bbox in the scene, not the product catalogue - the two
|
||||
disagree by 2.0-2.8x per item, so catalogue dims cannot score a size prediction.
|
||||
|
||||
The mask is analytic (mesh points projected through the camera), the same trick the earlier
|
||||
ROI pipeline used for its GT channel - no segmentation error contaminates a geometry study.
|
||||
"""
|
||||
import json, os, sys
|
||||
REPO = "/home/dasha/robozon-sorter"
|
||||
for e in (REPO, f"{REPO}/control_test"):
|
||||
if e not in sys.path:
|
||||
sys.path.insert(0, e)
|
||||
for _m in [k for k in list(sys.modules) if k.startswith(("cam_configs", "classify", "cell"))]:
|
||||
del sys.modules[_m]
|
||||
import importlib; importlib.invalidate_caches()
|
||||
|
||||
import asyncio, numpy as np, cv2
|
||||
import omni.usd, omni.timeline
|
||||
import omni.kit.viewport.utility as vp
|
||||
import isaacsim.core.experimental.utils.app as app_utils
|
||||
from pxr import UsdPhysics, Gf, Usd, UsdGeom, UsdLux
|
||||
|
||||
import cam_configs as CC
|
||||
import cell
|
||||
import classify as CL
|
||||
|
||||
try: # --file runs isolated, so an injected arg lands in
|
||||
CFG = cfg # LOCALS, not globals() - the bare name catches both
|
||||
except NameError:
|
||||
CFG = CC.DEFAULT
|
||||
ITEMS = globals().get("items", ["bag", "backpack", "lunchbox", "helmet", "pillow",
|
||||
"detergent", "bucket", "box_400x400x300", "box_300x200x200"])
|
||||
OUT = f"{REPO}/control_test/captures/{CFG}"
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
stage = omni.usd.get_context().get_stage()
|
||||
tl = omni.timeline.get_timeline_interface()
|
||||
if tl.is_playing():
|
||||
tl.stop(); await app_utils.update_app_async(steps=10)
|
||||
|
||||
calib = CC.apply_config(stage, CFG)
|
||||
print(f"config {CFG}: {len(calib)} камер | " + ", ".join(
|
||||
f"{n.replace('_Left','')} h={c['height_mm']:.0f} d={c['standoff_mm']:.0f}"
|
||||
for n, c in calib.items() if n.endswith("_Left")))
|
||||
|
||||
# without this the cell has no floor and no dome: everything off the belt renders as
|
||||
# void, which both looks wrong over WebRTC and starves the side views of bounce light
|
||||
_gl = cell.add_ground_and_light(stage)
|
||||
print(f"пол {_gl['ground']}, купол {_gl['light']}")
|
||||
|
||||
for nm, pos in (("K0", (-0.75, 1.6, 2.6)), ("K1", (-0.75, -1.6, 2.6)),
|
||||
("K2", (0.6, 0.0, 2.6)), ("K3", (-2.1, 0.0, 2.6))):
|
||||
p = f"/World/_CapLight_{nm}"
|
||||
if not stage.GetPrimAtPath(p).IsValid():
|
||||
sl = UsdLux.SphereLight.Define(stage, p)
|
||||
sl.CreateRadiusAttr().Set(0.25); sl.CreateIntensityAttr().Set(90000.0)
|
||||
UsdGeom.Xformable(sl.GetPrim()).AddTranslateOp().Set(Gf.Vec3d(*pos))
|
||||
dome = stage.GetPrimAtPath("/Environment/_BrightFill")
|
||||
if dome.IsValid():
|
||||
dome.GetAttribute("inputs:intensity").Set(3500.0)
|
||||
|
||||
lib = {r["name"]: r for r in CL.load_library(f"{REPO}/control_test/items") if "error" not in r}
|
||||
ROOT = "/World/CapItems2"
|
||||
UsdGeom.Xform.Define(stage, ROOT)
|
||||
BB = lambda: UsdGeom.BBoxCache(Usd.TimeCode.Default(),
|
||||
[UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
|
||||
|
||||
async def spawn(name):
|
||||
"""holder Xform carries the placement; the reference sits on a child so its own
|
||||
transform survives."""
|
||||
path = f"{ROOT}/{name}"
|
||||
if stage.GetPrimAtPath(path).IsValid():
|
||||
stage.RemovePrim(path)
|
||||
holder = UsdGeom.Xform.Define(stage, path).GetPrim()
|
||||
inner = UsdGeom.Xform.Define(stage, f"{path}/mesh").GetPrim()
|
||||
inner.GetReferences().AddReference(lib[name]["path"])
|
||||
r = None
|
||||
for _ in range(12): # a reference does not compose within the tick
|
||||
await app_utils.update_app_async(steps=2)
|
||||
r = BB().ComputeWorldBound(inner).ComputeAlignedRange()
|
||||
if not r.IsEmpty():
|
||||
break
|
||||
if r is None or r.IsEmpty():
|
||||
raise RuntimeError(f"{name}: пустой bbox - ссылка не разрешилась")
|
||||
mn, mx = r.GetMin(), r.GetMax()
|
||||
UsdGeom.Xformable(holder).AddTranslateOp(precision=UsdGeom.XformOp.PrecisionDouble).Set(
|
||||
Gf.Vec3d(CC.TARGET[0] - (mn[0] + mx[0]) / 2.0,
|
||||
CC.TARGET[1] - (mn[1] + mx[1]) / 2.0,
|
||||
CC.TARGET[2] - mn[2] + 0.001))
|
||||
# the exported item layers author visibility=invisible on their own root, and
|
||||
# MakeVisible on an ancestor does NOT clear a descendant's authored value - that is
|
||||
# why every earlier capture showed bare belt
|
||||
for d in Usd.PrimRange(holder):
|
||||
if d.IsA(UsdGeom.Imageable):
|
||||
UsdGeom.Imageable(d).GetVisibilityAttr().Set(UsdGeom.Tokens.inherited)
|
||||
UsdGeom.Imageable(holder).MakeVisible()
|
||||
r2 = BB().ComputeWorldBound(inner).ComputeAlignedRange()
|
||||
ext = [(r2.GetMax()[i] - r2.GetMin()[i]) * 1000.0 for i in range(3)]
|
||||
return holder, ext, [r2.GetMin()[i] for i in range(3)], [r2.GetMax()[i] for i in range(3)]
|
||||
|
||||
def world_geom(prim):
|
||||
"""every mesh of the item in world space, as vertices + triangles. Vertices alone are
|
||||
not enough: a box has eight of them, so a point-splat mask covers ~60 px and the ROI
|
||||
collapses. Filling the projected triangles gives the true silhouette."""
|
||||
xc = UsdGeom.XformCache(Usd.TimeCode.Default()); V = []; T = []; base = 0
|
||||
for d in Usd.PrimRange(prim):
|
||||
if not d.IsA(UsdGeom.Mesh):
|
||||
continue
|
||||
m = UsdGeom.Mesh(d)
|
||||
pts = m.GetPointsAttr().Get()
|
||||
if not pts:
|
||||
continue
|
||||
M = np.array(xc.GetLocalToWorldTransform(d), dtype=np.float64)
|
||||
P = np.asarray(pts, dtype=np.float64)
|
||||
V.append((np.c_[P, np.ones(len(P))] @ M)[:, :3])
|
||||
cnt = m.GetFaceVertexCountsAttr().Get() or []
|
||||
idx = m.GetFaceVertexIndicesAttr().Get() or []
|
||||
o = 0
|
||||
for c in cnt: # fan-triangulate each polygon
|
||||
for k in range(1, c - 1):
|
||||
T.append((base + idx[o], base + idx[o + k], base + idx[o + k + 1]))
|
||||
o += c
|
||||
base += len(P)
|
||||
if not V:
|
||||
return np.zeros((0, 3)), np.zeros((0, 3), int)
|
||||
return np.concatenate(V, 0), np.asarray(T, dtype=np.int64).reshape(-1, 3)
|
||||
|
||||
|
||||
def gt_mask(V, T, cam):
|
||||
W, H = cam["width"], cam["height"]
|
||||
Minv = np.linalg.inv(np.array(cam["M"]))
|
||||
c = (np.c_[V, np.ones(len(V))] @ 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"]
|
||||
uv = np.c_[u, v]
|
||||
m = np.zeros((H, W), np.uint8)
|
||||
if len(T):
|
||||
good = (z[T] > 1e-3).all(1)
|
||||
tri = uv[T[good]].astype(np.int32)
|
||||
tri = np.clip(tri, [-4 * W, -4 * H], [4 * W, 4 * H])
|
||||
cv2.fillPoly(m, list(tri), 1)
|
||||
else:
|
||||
ok = (z > 1e-3) & (u >= 0) & (u < W) & (v >= 0) & (v < H)
|
||||
m[v[ok].astype(int), u[ok].astype(int)] = 1
|
||||
m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
|
||||
n, lab, st, _ = cv2.connectedComponentsWithStats(m)
|
||||
if n > 1:
|
||||
m = (lab == (1 + np.argmax(st[1:, cv2.CC_STAT_AREA]))).astype(np.uint8)
|
||||
return m.astype(bool)
|
||||
|
||||
|
||||
w = vp.get_active_viewport(); orig_cam = w.camera_path
|
||||
manifest = {"config": CFG, "target": [float(v) for v in CC.TARGET],
|
||||
"standoff_m": CC.STANDOFF, "calib": calib, "items": {}}
|
||||
|
||||
for name in ITEMS:
|
||||
if name not in lib:
|
||||
print(f" пропуск {name} (нет в библиотеке)"); continue
|
||||
prim, ext, bmin, bmax = await spawn(name)
|
||||
await app_utils.update_app_async(steps=20)
|
||||
VV, TT = world_geom(prim)
|
||||
files, masks, cover, seen = {}, {}, {}, {}
|
||||
for cam_name, cam in calib.items():
|
||||
w.camera_path = f"/RigRS/{cam_name}" if stage.GetPrimAtPath(f"/RigRS/{cam_name}").IsValid() \
|
||||
else CC._cam(stage, cam_name).GetPath()
|
||||
await app_utils.update_app_async(steps=22); await asyncio.sleep(0)
|
||||
f = f"{OUT}/{name}__{cam_name}.png"
|
||||
vp.capture_viewport_to_file(w, file_path=f)
|
||||
await app_utils.update_app_async(steps=12); await asyncio.sleep(0)
|
||||
files[cam_name] = f
|
||||
mk = gt_mask(VV, TT, cam)
|
||||
_img = cv2.imread(f)
|
||||
if _img is not None and _img.shape[:2] == mk.shape:
|
||||
_g = cv2.cvtColor(_img, cv2.COLOR_BGR2GRAY).astype(float)
|
||||
_ring = cv2.dilate(mk.astype(np.uint8), np.ones((41, 41), np.uint8)).astype(bool) & ~mk
|
||||
seen[cam_name] = round(float(abs(_g[mk].mean() - _g[_ring].mean())), 1)
|
||||
np.savez_compressed(f"{OUT}/{name}__{cam_name}_mask.npz", m=mk)
|
||||
masks[cam_name] = f"{OUT}/{name}__{cam_name}_mask.npz"
|
||||
cover[cam_name] = int(mk.sum())
|
||||
manifest["items"][name] = dict(
|
||||
files=files, masks=masks, mask_px=cover, nverts=int(len(VV)), ntris=int(len(TT)),
|
||||
contrast=seen, gt_catalogue=lib[name]["dims_mm"], cls=lib[name]["cls"],
|
||||
gt_scene_mm=[round(e, 1) for e in ext], bmin=bmin, bmax=bmax)
|
||||
print(f" {name}: в сцене {[round(e) for e in sorted(ext, reverse=True)]} мм "
|
||||
f"маска {min(cover.values())}-{max(cover.values())} px, контраст {min(seen.values()):.0f}-{max(seen.values()):.0f}"
|
||||
+ (" <-- НЕ ВИДЕН" if min(seen.values()) < 3 else ""))
|
||||
# Спрятать МАЛО: невидимость не убирает коллайдер, и снятый товар остаётся твёрдой
|
||||
# стеной ровно в точке осмотра, посреди рабочей линии. После двух прогонов там стояло
|
||||
# 18 невидимых предметов, и поток вставал на них, не доезжая до плуга.
|
||||
UsdGeom.Imageable(prim).MakeInvisible()
|
||||
for _d in Usd.PrimRange(prim):
|
||||
_a = _d.GetAttribute("physics:collisionEnabled")
|
||||
if _a and _a.IsValid():
|
||||
_a.Set(False)
|
||||
elif _d.HasAPI(UsdPhysics.CollisionAPI):
|
||||
UsdPhysics.CollisionAPI(_d).CreateCollisionEnabledAttr().Set(False)
|
||||
|
||||
w.camera_path = orig_cam
|
||||
await app_utils.update_app_async(steps=10)
|
||||
json.dump(manifest, open(f"{OUT}/manifest.json", "w"), indent=1)
|
||||
print(f"\nmanifest -> {OUT}/manifest.json")
|
||||
Reference in New Issue
Block a user