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>
208 lines
8.9 KiB
Python
208 lines
8.9 KiB
Python
"""Loads the real sorting cell (scene/sorter.usd) and applies the runtime configuration
|
|
it needs to actually run.
|
|
|
|
The scene file is the original build - conveyor art, diverters, camera portal, camera
|
|
bodies, laser gate and collection bin exactly as authored. Nothing here rebuilds geometry.
|
|
What this module does is re-apply the handful of runtime settings that USD does not carry
|
|
and that the cell does not work without; each one is documented where it is applied,
|
|
because every one of them was a silent failure at some point.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from pxr import Gf, PhysxSchema, UsdGeom, UsdPhysics, UsdShade
|
|
|
|
from .. import config as C
|
|
|
|
SCENE = C.ROOT / "scene" / "sorter.usd"
|
|
|
|
# --- prim paths in the authored scene -------------------------------------------------
|
|
BELTS = ["/World/ConveyorTrack/Belt", "/World/ConveyorTrack_02/Belt",
|
|
"/World/ConveyorTrack_03/Belt", "/World/ConveyorTrack_04/Belt",
|
|
"/World/ConveyorTrack_01/Belt"]
|
|
SPAWN_BELT = "/World/SortingRig/SpawnBelt"
|
|
BRANCH = "/World/ConveyorTrack_03/Belt_01" # the branch the pusher feeds
|
|
BLADE = "/World/Diverters/DiverterY_Split/Pusher"
|
|
PUSHER_JOINT = "/World/Diverters/DiverterY_Split/PusherSlide"
|
|
ANIM_GRAPH = "/World/Diverters/DiverterAnimGraph"
|
|
CAMERA_BODIES = "/World/CameraBodies"
|
|
ITEMS_ROOT = "/World/Items"
|
|
RIG = "/RigRS"
|
|
|
|
# The blade's parent carries this offset; world_y = PARENT_Y + local_y.
|
|
BLADE_PARENT_Y = -0.35
|
|
|
|
|
|
def open_scene(usd_path: str | Path | None = None):
|
|
"""open sorter.usd into the current context"""
|
|
import omni.usd
|
|
path = str(usd_path or SCENE)
|
|
if not Path(path).exists():
|
|
raise FileNotFoundError(
|
|
f"{path} not found. The conveyor art it references lives in assets/conveyors/ - "
|
|
"run scripts/fetch_assets.py if that folder is empty."
|
|
)
|
|
omni.usd.get_context().open_stage(path)
|
|
return omni.usd.get_context().get_stage()
|
|
|
|
|
|
# --------------------------------------------------------------------- runtime config
|
|
def configure_physics(stage):
|
|
scene = stage.GetPrimAtPath("/World/PhysicsScene")
|
|
if not scene.IsValid():
|
|
scene = UsdPhysics.Scene.Define(stage, "/World/PhysicsScene").GetPrim()
|
|
UsdPhysics.Scene(scene).CreateGravityMagnitudeAttr().Set(9.81)
|
|
px = PhysxSchema.PhysxSceneAPI.Apply(scene)
|
|
# 120 Hz is what the cell was tuned and validated at, together with the 2.5 m/s blade.
|
|
# Raising it changes the contact response and the pushed item stops landing in the bin,
|
|
# so treat this number and PUSHER_SPEED as a matched pair.
|
|
px.CreateTimeStepsPerSecondAttr().Set(120)
|
|
px.CreateEnableCCDAttr().Set(True)
|
|
px.CreateSolverTypeAttr().Set("TGS")
|
|
|
|
|
|
def configure_belts(stage, speed=None, grip_path="/World/SortingRig/M_beltPhysics"):
|
|
"""explicit surface velocities; the authored ConveyorBeltGraphs carry no speed and
|
|
would only fight these, so they are switched off.
|
|
|
|
`grip_path` is where the belt friction material is authored. It defaults to a prim
|
|
under the sorter's rig; plow_cell.usd has no SortingRig and passes its own path so the
|
|
scene does not grow an empty one.
|
|
"""
|
|
speed = speed if speed is not None else C.BELT_SPEED
|
|
|
|
grip = stage.GetPrimAtPath(grip_path)
|
|
if not grip.IsValid():
|
|
grip = stage.DefinePrim(grip_path, "Material")
|
|
pm = UsdPhysics.MaterialAPI.Apply(grip)
|
|
pm.CreateStaticFrictionAttr().Set(1.1)
|
|
pm.CreateDynamicFrictionAttr().Set(0.95)
|
|
pm.CreateRestitutionAttr().Set(0.02)
|
|
grip_mat = UsdShade.Material(grip)
|
|
|
|
def drive(path, vel):
|
|
prim = stage.GetPrimAtPath(path)
|
|
if not prim.IsValid():
|
|
return False
|
|
if not prim.HasAPI(UsdPhysics.RigidBodyAPI):
|
|
UsdPhysics.RigidBodyAPI.Apply(prim)
|
|
UsdPhysics.RigidBodyAPI(prim).CreateKinematicEnabledAttr().Set(True)
|
|
PhysxSchema.PhysxSurfaceVelocityAPI.Apply(prim)
|
|
PhysxSchema.PhysxSurfaceVelocityAPI(prim).CreateSurfaceVelocityAttr().Set(Gf.Vec3f(*vel))
|
|
api = UsdShade.MaterialBindingAPI.Apply(prim)
|
|
api.Bind(grip_mat, bindingStrength=UsdShade.Tokens.strongerThanDescendants,
|
|
materialPurpose="physics")
|
|
return True
|
|
|
|
for path in BELTS + [SPAWN_BELT]:
|
|
drive(path, (-speed, 0, 0))
|
|
|
|
# The branch is rotated: its LOCAL X points along world -Y. surfaceVelocity is given
|
|
# in the body's local frame, so carrying goods toward the bin (+Y) needs (-speed,0,0).
|
|
# Setting the "obvious" (0,+speed,0) drags them sideways and they sit there.
|
|
drive(BRANCH, (-speed, 0, 0))
|
|
|
|
for track in ["ConveyorTrack", "ConveyorTrack_02", "ConveyorTrack_03",
|
|
"ConveyorTrack_04", "ConveyorTrack_01"]:
|
|
for graph in (f"/World/{track}/ConveyorBeltGraph", f"/World/{track}/ConveyorBeltGraph_01"):
|
|
g = stage.GetPrimAtPath(graph)
|
|
if g.IsValid():
|
|
g.SetActive(False)
|
|
|
|
|
|
def configure_pusher(stage):
|
|
"""the blade is driven kinematically from script.
|
|
|
|
Its authored PhysicsPrismaticJoint is unusable at runtime: USD drive-target writes
|
|
reach PhysX about a second late, so the blade never completes its stroke while the
|
|
item is still in reach. The joint is disabled and the blade is moved directly.
|
|
"""
|
|
blade = stage.GetPrimAtPath(BLADE)
|
|
if not blade.IsValid():
|
|
raise RuntimeError(f"{BLADE} missing - is this the right scene?")
|
|
UsdPhysics.RigidBodyAPI(blade).CreateKinematicEnabledAttr().Set(True)
|
|
|
|
joint = stage.GetPrimAtPath(PUSHER_JOINT)
|
|
if joint.IsValid():
|
|
joint.GetAttribute("physics:jointEnabled").Set(False)
|
|
|
|
graph = stage.GetPrimAtPath(ANIM_GRAPH)
|
|
if graph.IsValid():
|
|
graph.SetActive(False) # otherwise it rewrites the diverter targets every tick
|
|
|
|
# the blade must sweep through the conveyor rails rather than grind on them
|
|
filt = UsdPhysics.FilteredPairsAPI.Apply(blade)
|
|
rel = filt.CreateFilteredPairsRel()
|
|
have = {str(t) for t in rel.GetTargets()}
|
|
for path in BELTS + [SPAWN_BELT, BRANCH, "/World/Diverters/DiverterY_Split/Base"]:
|
|
if stage.GetPrimAtPath(path).IsValid() and path not in have:
|
|
rel.AddTarget(path)
|
|
|
|
# seat the blade just over the belt so flat items cannot slip underneath
|
|
for op in UsdGeom.Xformable(blade).GetOrderedXformOps():
|
|
if op.GetOpType() == UsdGeom.XformOp.TypeTranslate:
|
|
v = op.Get()
|
|
op.Set(Gf.Vec3d(v[0], C.BLADE_HOME_Y - BLADE_PARENT_Y, -0.135))
|
|
break
|
|
|
|
|
|
def hide_aim_markers(stage):
|
|
"""the camera bodies carry cosmetic aim-ray cones that sit right over the inspection
|
|
point; left visible they dominate the frame and segmentation locks onto them."""
|
|
n = 0
|
|
for rig in ["RealSense_D435", "Orbbec_Gemini305", "Orbbec_Gemini345"]:
|
|
p = stage.GetPrimAtPath(f"{CAMERA_BODIES}/{rig}/AimRay")
|
|
if p.IsValid():
|
|
UsdGeom.Imageable(p).MakeInvisible()
|
|
n += 1
|
|
return n
|
|
|
|
|
|
def load_test_items(stage, meshes_dir=None):
|
|
"""add the bundled per-class test meshes as dynamic rigid bodies"""
|
|
meshes_dir = Path(meshes_dir or C.MESHES)
|
|
manifest = json.loads((meshes_dir / "manifest.json").read_text())
|
|
UsdGeom.Xform.Define(stage, ITEMS_ROOT)
|
|
items = {}
|
|
for i, (name, meta) in enumerate(sorted(manifest.items())):
|
|
usd = meshes_dir / f"{name}.usd"
|
|
if not usd.exists():
|
|
continue
|
|
prim = UsdGeom.Xform.Define(stage, f"{ITEMS_ROOT}/{name}").GetPrim()
|
|
refs = prim.GetReferences()
|
|
refs.ClearReferences() # idempotent: prepare() may run more than once
|
|
refs.AddReference(str(usd))
|
|
xf = UsdGeom.Xformable(prim)
|
|
xf.ClearXformOpOrder()
|
|
xf.AddTranslateOp().Set(Gf.Vec3d(9.0 + 1.2 * i, 5.0, 0.4))
|
|
UsdPhysics.RigidBodyAPI.Apply(prim)
|
|
# meshes exported from a streaming scene arrive kinematic and hidden - both make
|
|
# them inert: kinematic ignores gravity and belt friction, hidden shows nothing
|
|
UsdPhysics.RigidBodyAPI(prim).CreateKinematicEnabledAttr().Set(False)
|
|
UsdPhysics.MassAPI.Apply(prim).CreateMassAttr().Set(0.6)
|
|
px = PhysxSchema.PhysxRigidBodyAPI.Apply(prim)
|
|
px.CreateEnableCCDAttr().Set(True)
|
|
px.CreateSolverPositionIterationCountAttr().Set(24)
|
|
px.CreateSleepThresholdAttr().Set(0.0) # a settled item must still be draggable
|
|
UsdGeom.Imageable(prim).MakeVisible()
|
|
items[name] = meta
|
|
return items
|
|
|
|
|
|
def prepare(stage, belt_speed=None, meshes_dir=None):
|
|
"""everything the authored scene needs before it will run"""
|
|
configure_physics(stage)
|
|
configure_belts(stage, belt_speed)
|
|
configure_pusher(stage)
|
|
hidden = hide_aim_markers(stage)
|
|
items = load_test_items(stage, meshes_dir)
|
|
calib = json.loads((C.CONFIG / "calib.json").read_text())
|
|
return dict(items=items, calib=calib, aim_markers_hidden=hidden)
|
|
|
|
|
|
def load(usd_path=None, belt_speed=None, meshes_dir=None):
|
|
stage = open_scene(usd_path)
|
|
return stage, prepare(stage, belt_speed, meshes_dir)
|