Files
isaac/robozon_sorter/sim/plow_contact.py
T
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

154 lines
6.8 KiB
Python

"""Contact sensing on the plow blade.
**Why a contact report and not another beam.** The cell already has two through-beams: the
laser gate before the pusher and the arming beam at x = -6.30 that pre-positions the plow.
Both answer "something is about to arrive". Neither can answer "the blade is now touching
*this* item", and that is the question that matters at the plow, because the arm is only
useful while it is actually in contact - before that it is waving at nothing, and after it
the item is already committed to a lane. A beam at the blade would also be broken by the
blade itself as it swings, which is the trap the gate beam at y = -0.24 was placed to dodge.
So the sensor is a **PhysX contact report on the arm body**
(``PhysxSchema.PhysxContactReportAPI``). It fires on the real collision pair, names both
bodies, and needs no extra geometry that could foul the belt. Isaac's
``sensors.experimental.physics.Contact`` wraps the same mechanism with an authored prim and
a threshold; the raw report is used here because the plow needs the *identity* of what it
touched, which is what carries the class through.
**Keeping the class.** Classification happens once, far upstream under the camera portal.
That verdict is stored per item and travels with it:
camera portal ──▶ classes[item] = "B" | "C" | "D"
arming beam ────────▶ pre-position the blade for that class
blade contact ───────▶ CONFIRM against the same stored class, and hold the side while
contact lasts - the item is steered by the class it was given,
not by anything re-derived at the blade
:class:`PlowContact` therefore takes the same ``classes`` mapping the sorter uses, and
reports, per touch: which item, what class it carries, the blade angle at first touch, and
how long contact lasted. A touch whose class is unknown is reported as such rather than
guessed - an unclassified item must not be steered anywhere.
"""
from __future__ import annotations
from pxr import PhysicsSchemaTools, PhysxSchema
from .. import config as C
ITEMS_PREFIX = "/World/Items/"
class PlowContact:
"""PhysX contact reporting on the plow arm, resolved to item + class"""
def __init__(self, stage, classes: dict, arm_path: str | None = None,
plow=None, threshold: float = 0.0):
"""
classes : the SAME dict the sorter steers by - vision writes into it, so the
sensor sees whatever verdict the item is carrying at the moment of touch
plow : optional sim.plow.Plow, so the angle at contact can be recorded
"""
self.stage = stage
self.classes = classes
self.plow = plow
self.arm_path = arm_path or C.PLOW_ARM
prim = stage.GetPrimAtPath(self.arm_path)
if not prim.IsValid():
raise RuntimeError(f"{self.arm_path} missing - is this plow_cell.usd?")
api = PhysxSchema.PhysxContactReportAPI.Apply(prim)
api.CreateThresholdAttr().Set(float(threshold)) # 0 = report every touch
self.touches: dict[str, dict] = {} # item -> first/last touch record
self.in_contact: set[str] = set()
self.events: list[dict] = []
self._t = 0.0
self._sub = None
# -- lifecycle ----------------------------------------------------------
def install(self):
from omni.physx import get_physx_simulation_interface
if self._sub is None:
self._sub = get_physx_simulation_interface(
).subscribe_contact_report_events(self._on_report)
return self
def remove(self):
self._sub = None
def tick(self, dt):
"""advance the sensor's clock; contact reports carry no timestamp of their own"""
self._t += dt
# -- the report ---------------------------------------------------------
def _item_of(self, path: str):
if not path.startswith(ITEMS_PREFIX):
return None
name = path[len(ITEMS_PREFIX):].split("/")[0]
return name or None
def _on_report(self, contact_headers, contact_data):
touching = set()
for h in contact_headers:
a0 = str(PhysicsSchemaTools.intToSdfPath(h.actor0))
a1 = str(PhysicsSchemaTools.intToSdfPath(h.actor1))
if self.arm_path not in (a0, a1):
continue
other = a1 if self.arm_path == a0 else a0
name = self._item_of(other)
if name is None: # the blade also brushes belts and rails
continue
touching.add(name)
self._register(name)
# contact that has ended
for gone in self.in_contact - touching:
rec = self.touches.get(gone)
if rec is not None:
rec["released_t"] = round(self._t, 3)
rec["duration"] = round(self._t - rec["first_t"], 3)
self.in_contact = touching
def _register(self, name):
cls = self.classes.get(name)
angle = round(self.plow.angle, 1) if self.plow is not None else None
rec = self.touches.get(name)
if rec is None:
rec = dict(item=name, cls=cls, classified=cls is not None,
first_t=round(self._t, 3), angle_at_touch=angle,
commanded_at_touch=(round(self.plow.commanded, 1)
if self.plow is not None else None),
angle_min=angle, angle_max=angle,
released_t=None, duration=None, samples=0)
self.touches[name] = rec
self.events.append(dict(t=rec["first_t"], item=name, cls=cls,
angle=angle, kind="touch"))
rec["samples"] += 1
rec["cls"] = cls if cls is not None else rec["cls"]
if angle is not None:
rec["angle_min"] = min(rec["angle_min"], angle)
rec["angle_max"] = max(rec["angle_max"], angle)
# -- what the plow asks it ----------------------------------------------
def is_touching(self, name: str) -> bool:
return name in self.in_contact
def touched(self, name: str) -> bool:
return name in self.touches
def side_for(self, name: str, mapping: dict, swing: float):
"""the angle this item's stored class asks for, or None if it has no class.
Deliberately returns None rather than 0 for an unknown class: 0 is a real command
(drive straight on) and must not double as "no idea".
"""
cls = self.classes.get(name)
if cls is None:
return None
want = mapping.get(cls, "straight")
return {"pos": swing, "neg": -swing}.get(want, 0.0)
def report(self):
return dict(touches=list(self.touches.values()), events=self.events)