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
7.4 KiB
Python
169 lines
7.4 KiB
Python
"""Runtime side of the cell: releasing goods, the laser gate and the pusher stroke.
|
|
|
|
Two hard-won rules are encoded here and should not be "simplified" away:
|
|
|
|
1. During simulation, read poses from RigidPrim.get_world_poses(). BBoxCache / XformCache
|
|
return the AUTHORED transform, so a moving item looks frozen and every gate misfires.
|
|
2. The blade retracts only when (a) the pushed item has cleared the belt and (b) nothing
|
|
else is inside the blade's footprint. Retracting blindly sweeps the blade back through
|
|
the next item and knocks it over.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
from pxr import Gf, UsdGeom
|
|
|
|
from .. import config as C
|
|
from .scene import BLADE as BLADE_PATH, ITEMS_ROOT, BLADE_PARENT_Y
|
|
|
|
|
|
class Cell:
|
|
def __init__(self, stage, items):
|
|
self.stage = stage
|
|
self.items = list(items)
|
|
self._blade_op = self._blade_translate_op()
|
|
self._blade_base = self._blade_op.Get()
|
|
from isaacsim.core.experimental.prims import RigidPrim
|
|
self._rp = {n: RigidPrim(paths=[f"{ITEMS_ROOT}/{n}"]) for n in self.items}
|
|
from omni.physx import get_physx_scene_query_interface
|
|
self._query = get_physx_scene_query_interface()
|
|
self.blade_to(C.BLADE_HOME_Y)
|
|
|
|
# -- blade --------------------------------------------------------------
|
|
def _blade_translate_op(self):
|
|
prim = self.stage.GetPrimAtPath(BLADE_PATH)
|
|
for op in UsdGeom.Xformable(prim).GetOrderedXformOps():
|
|
if op.GetOpType() == UsdGeom.XformOp.TypeTranslate:
|
|
return op
|
|
raise RuntimeError(f"{BLADE_PATH} has no translate op to drive")
|
|
|
|
def blade_to(self, y):
|
|
"""y is a WORLD coordinate; the op lives in the diverter's frame"""
|
|
b = self._blade_base
|
|
self._blade_op.Set(Gf.Vec3d(b[0], y - BLADE_PARENT_Y, b[2]))
|
|
|
|
async def stroke(self, app, out=True, speed=None):
|
|
"""sweep the blade at a commanded m/s; fine steps keep the contact impulse sane.
|
|
Above ~2.5 m/s the kinematic blade throws goods off the line."""
|
|
speed = min(speed or C.PUSHER_SPEED, C.PUSHER_MAX_SAFE)
|
|
a, b = (C.BLADE_HOME_Y, C.BLADE_OUT_Y) if out else (C.BLADE_OUT_Y, C.BLADE_HOME_Y)
|
|
dt = 1.0 / 60.0
|
|
steps = max(4, int(round(abs(b - a) / max(speed * dt, 1e-6))))
|
|
for i in range(steps + 1):
|
|
self.blade_to(a + (b - a) * i / steps)
|
|
await app.update_app_async(steps=1)
|
|
return steps * dt
|
|
|
|
# -- item state ---------------------------------------------------------
|
|
def pose(self, name):
|
|
return self._rp[name].get_world_poses()[0].numpy()[0]
|
|
|
|
def place(self, name, pos):
|
|
prim = self.stage.GetPrimAtPath(f"{ITEMS_ROOT}/{name}")
|
|
for op in UsdGeom.Xformable(prim).GetOrderedXformOps():
|
|
if op.GetOpType() == UsdGeom.XformOp.TypeTranslate:
|
|
op.Set(Gf.Vec3d(*pos))
|
|
return
|
|
if op.GetOpType() == UsdGeom.XformOp.TypeTransform:
|
|
M = Gf.Matrix4d(op.Get())
|
|
M.SetTranslateOnly(Gf.Vec3d(*pos))
|
|
op.Set(M)
|
|
return
|
|
|
|
def _underside_gap(self, name):
|
|
"""distance from the prim origin down to its lowest point, so it can be seated"""
|
|
cache = UsdGeom.BBoxCache(0, ["default", "render"], useExtentsHint=True)
|
|
prim = self.stage.GetPrimAtPath(f"{ITEMS_ROOT}/{name}")
|
|
r = cache.ComputeWorldBound(prim).ComputeAlignedRange()
|
|
if r.IsEmpty():
|
|
return 0.0
|
|
origin = UsdGeom.Xformable(prim).ComputeLocalToWorldTransform(0).ExtractTranslation()
|
|
return origin[2] - r.GetMin()[2]
|
|
|
|
def park(self, name, index=0):
|
|
self.place(name, (9.0 + 1.2 * index, 5.0, 0.4))
|
|
|
|
def park_all(self):
|
|
"""park everything AND freeze it, so the queue does not fall out of the world.
|
|
|
|
Parked items are ordinary dynamic bodies sitting off to the side at y ~ +5, where
|
|
there is no floor under them - so the whole undispatched queue free-falls for the
|
|
entire run. Measured: parked stock at z = -665 after a minute and the stage bound
|
|
reaching z = -20438, which also wrecks every "frame the whole scene" camera because
|
|
the scene is suddenly 20 km tall. Freezing them costs nothing and they are woken in
|
|
`release`, which clears the flag before placing the item on the belt.
|
|
"""
|
|
from pxr import UsdPhysics
|
|
for i, n in enumerate(self.items):
|
|
self.park(n, i)
|
|
prim = self.stage.GetPrimAtPath(f"{ITEMS_ROOT}/{n}")
|
|
if prim.IsValid() and prim.HasAPI(UsdPhysics.RigidBodyAPI):
|
|
UsdPhysics.RigidBodyAPI(prim).CreateKinematicEnabledAttr().Set(True)
|
|
|
|
def _thaw(self, name):
|
|
"""let a parked item fall under gravity again, just before it is released"""
|
|
from pxr import UsdPhysics
|
|
prim = self.stage.GetPrimAtPath(f"{ITEMS_ROOT}/{name}")
|
|
if prim.IsValid() and prim.HasAPI(UsdPhysics.RigidBodyAPI):
|
|
UsdPhysics.RigidBodyAPI(prim).CreateKinematicEnabledAttr().Set(False)
|
|
|
|
def release(self, name, y=0.0):
|
|
self._thaw(name)
|
|
self.place(name, (C.SPAWN_X, y, C.BELT_Z + 0.005))
|
|
self.place(name, (C.SPAWN_X, y, C.BELT_Z + self._underside_gap(name) + 0.008))
|
|
|
|
# -- laser gate ---------------------------------------------------------
|
|
def laser(self):
|
|
"""name of whatever breaks the beam, else None. A real raycast, not a coordinate test."""
|
|
hit = self._query.raycast_closest(
|
|
[C.GATE_X, C.BEAM_Y0, C.BEAM_Z], [0.0, 1.0, 0.0], C.BEAM_Y1 - C.BEAM_Y0)
|
|
if not hit or not hit.get("hit"):
|
|
return None
|
|
path = str(hit.get("rigidBody") or hit.get("collision") or "")
|
|
for n in self.items:
|
|
if f"{ITEMS_ROOT}/{n}" in path:
|
|
return n
|
|
return None
|
|
|
|
def blade_path_busy(self, exclude):
|
|
for n in self.items:
|
|
if n == exclude:
|
|
continue
|
|
p = self.pose(n)
|
|
if (C.BLADE_X0 - 0.12 < p[0] < C.BLADE_X1 + 0.12) and (-0.30 < p[1] < 0.45):
|
|
return n
|
|
return None
|
|
|
|
async def divert(self, app, name, speed=None, max_wait_s=1.5):
|
|
"""full push cycle with both retract interlocks"""
|
|
dt = 1.0 / 60.0
|
|
t = await self.stroke(app, out=True, speed=speed)
|
|
for _ in range(int(max_wait_s / dt)): # item off the main line
|
|
if self.pose(name)[1] > 0.50:
|
|
break
|
|
await app.update_app_async(steps=1)
|
|
t += dt
|
|
held = 0.0
|
|
for _ in range(int(max_wait_s / dt)): # path clear for the return
|
|
if self.blade_path_busy(name) is None:
|
|
break
|
|
await app.update_app_async(steps=1)
|
|
t += dt
|
|
held += dt
|
|
t += await self.stroke(app, out=False, speed=speed)
|
|
return t, held
|
|
|
|
# -- outcome ------------------------------------------------------------
|
|
def where(self, name):
|
|
"""bin / branch / line-end / line, from the simulated pose"""
|
|
p = self.pose(name)
|
|
if C.BIN_X0 < p[0] < C.BIN_X1 and C.BIN_Y0 < p[1] < C.BIN_Y1 and p[2] < C.BIN_LIP_Z:
|
|
return "bin"
|
|
if p[2] < C.BELT_Z - 0.35: # dropped off the end of the run
|
|
return "line-end"
|
|
if p[1] > 0.5:
|
|
return "branch"
|
|
if p[0] < C.MAIN_X0 + 0.35: # the run stops at MAIN_X0, not beyond it
|
|
return "line-end"
|
|
return "line"
|