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>
136 lines
5.2 KiB
Python
136 lines
5.2 KiB
Python
"""Self-running item feeder: press Play and goods appear on the infeed belt one at a time,
|
|
spaced by a fixed pitch along the belt.
|
|
|
|
It hooks a PhysX step callback rather than living in an outer async loop, so the scene runs
|
|
on its own from the Play button - no driver script has to be babysitting it. The same
|
|
callback also drives the laser gate and the pusher when `route` is enabled.
|
|
|
|
Pitch is measured along the belt between consecutive items, so the release condition is
|
|
simply "the last one released has travelled PITCH from the spawn point".
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from .. import config as C
|
|
|
|
|
|
class AutoFeeder:
|
|
def __init__(self, cell, order=None, pitch=None, loop=False,
|
|
route=None, on_event=None):
|
|
"""
|
|
cell : mechanics.Cell
|
|
order : release order; defaults to every loaded item
|
|
pitch : metres between consecutive items along the belt
|
|
route : dict name -> class; when given, class D is diverted by the pusher
|
|
on_event : optional callback(kind, name, payload) for logging
|
|
"""
|
|
self.cell = cell
|
|
self.order = list(order or cell.items)
|
|
self.pitch = pitch if pitch is not None else C.RELEASE_GAP
|
|
self.loop = loop
|
|
self.route = route or {}
|
|
self.on_event = on_event
|
|
self._sub = None
|
|
self.reset()
|
|
|
|
def reset(self):
|
|
self.next_index = 0
|
|
self.active = []
|
|
self.released = []
|
|
self.diverted = set()
|
|
self.finished = {}
|
|
self._busy = False # a push cycle owns the blade until it completes
|
|
self._cycle = None
|
|
|
|
# ------------------------------------------------------------------ install
|
|
def install(self):
|
|
"""subscribe to the physics step; from here on the cell runs itself on Play"""
|
|
from omni.physx import get_physx_interface
|
|
if self._sub is None:
|
|
self._sub = get_physx_interface().subscribe_physics_step_events(self._on_step)
|
|
return self
|
|
|
|
def remove(self):
|
|
self._sub = None
|
|
|
|
def _emit(self, kind, name, payload=None):
|
|
if self.on_event:
|
|
self.on_event(kind, name, payload or {})
|
|
|
|
# ------------------------------------------------------------------ per step
|
|
def _on_step(self, dt):
|
|
try:
|
|
self._release_due()
|
|
self._service_gate(dt)
|
|
self._retire()
|
|
except Exception as exc: # never let a callback kill the sim
|
|
self._emit("error", "", {"exc": repr(exc)})
|
|
|
|
def _release_due(self):
|
|
if self._busy or self.next_index >= len(self.order):
|
|
if self.loop and self.next_index >= len(self.order) and not self.active:
|
|
self.next_index = 0
|
|
return
|
|
if self.active:
|
|
travelled = C.SPAWN_X - float(self.cell.pose(self.active[-1])[0])
|
|
if travelled < self.pitch:
|
|
return
|
|
name = self.order[self.next_index]
|
|
self.cell.release(name)
|
|
self.active.append(name)
|
|
self.released.append(name)
|
|
self.next_index += 1
|
|
self._emit("release", name, {"pitch": self.pitch})
|
|
|
|
def _service_gate(self, dt):
|
|
"""laser gate -> pusher, as a small state machine so it spans several steps"""
|
|
if self._cycle is not None:
|
|
self._step_cycle(dt)
|
|
return
|
|
for name in list(self.active):
|
|
if name in self.diverted or self.route.get(name) != "D":
|
|
continue
|
|
if self.cell.laser() == name:
|
|
self._cycle = dict(name=name, phase="extend", t=0.0,
|
|
y=C.BLADE_HOME_Y, held=0.0)
|
|
self._busy = True
|
|
self._emit("gate", name, {})
|
|
return
|
|
|
|
def _step_cycle(self, dt):
|
|
c = self._cycle
|
|
name = c["name"]
|
|
speed = C.PUSHER_SPEED
|
|
if c["phase"] == "extend":
|
|
c["y"] = min(C.BLADE_OUT_Y, c["y"] + speed * dt)
|
|
self.cell.blade_to(c["y"])
|
|
if c["y"] >= C.BLADE_OUT_Y - 1e-6:
|
|
c["phase"] = "clear"
|
|
elif c["phase"] == "clear":
|
|
c["t"] += dt
|
|
if float(self.cell.pose(name)[1]) > 0.50 or c["t"] > 1.5:
|
|
c["phase"] = "wait"
|
|
c["t"] = 0.0
|
|
elif c["phase"] == "wait":
|
|
# do not sweep the blade back through whatever has already arrived
|
|
busy = self.cell.blade_path_busy(name)
|
|
c["t"] += dt
|
|
if busy is None or c["t"] > 1.5:
|
|
c["held"] = c["t"]
|
|
c["phase"] = "retract"
|
|
elif c["phase"] == "retract":
|
|
c["y"] = max(C.BLADE_HOME_Y, c["y"] - speed * dt)
|
|
self.cell.blade_to(c["y"])
|
|
if c["y"] <= C.BLADE_HOME_Y + 1e-6:
|
|
self.diverted.add(name)
|
|
self._busy = False
|
|
self._cycle = None
|
|
self._emit("divert", name, {"held": round(c["held"], 3)})
|
|
|
|
def _retire(self):
|
|
for name in list(self.active):
|
|
place = self.cell.where(name)
|
|
if place in ("bin", "line-end"):
|
|
self.finished[name] = place
|
|
self.active.remove(name)
|
|
self._emit("done", name, {"where": place})
|