Сортировочная ячейка 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:
dasha_f
2026-08-01 13:07:24 +00:00
parent 6ce460378a
commit 0d32f32db0
342 changed files with 18000 additions and 0 deletions
+265
View File
@@ -0,0 +1,265 @@
"""Single source of truth for the cell's geometry and timing.
Every number here was measured or derived on the reference build; the docstrings say
which, because several of them are not free parameters.
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ASSETS = ROOT / "assets"
CONFIG = ROOT / "config"
MESHES = ASSETS / "meshes"
MODELS = ASSETS / "models" # populated by scripts/fetch_models.py
# ---------------------------------------------------------------- line geometry
BELT_Z = 1.781 # belt surface height (world Z)
BELT_HALF_W = 0.225 # 450 mm belt
FLOOR_Z = 0.0
SPAWN_X = 2.30 # items are released here, on the infeed belt
INFEED_X0, INFEED_X1 = 0.0, 2.60 # infeed belt, upstream of the camera stand
MAIN_X0, MAIN_X1 = -8.00, 0.0 # main run, items travel in -X (line ends at -8)
CAM_X = -0.750 # inspection point, under the camera portal
PUSH_X = -3.900 # pusher centre
BLADE_X0, BLADE_X1 = -4.25, -3.55 # blade footprint along the belt (0.70 m)
BLADE_HOME_Y = -0.300 # retracted
BLADE_OUT_Y = 0.420 # extended, item is clear onto the branch
BLADE_STROKE = BLADE_OUT_Y - BLADE_HOME_Y
# branch that carries pushed items away, +Y, 40 mm below the main line is NOT used -
# the reference branch sits at the same height
# measured off the authored branch /World/ConveyorTrack_03/Belt_01
BRANCH_X0, BRANCH_X1 = -4.366, -3.466
BRANCH_Y0, BRANCH_Y1 = 0.219, 1.225
BIN_X0, BIN_X1 = -4.42, -3.42 # collection bin at the end of the branch
BIN_Y0, BIN_Y1 = 1.27, 2.05
BIN_FLOOR_Z = 1.20
BIN_LIP_Z = 1.72
# laser through-beam, just upstream of the blade. The beam must start clear of the
# blade's retracted footprint (y <= -0.27) or it simply reads the blade.
GATE_X = PUSH_X + 0.16
BEAM_Y0, BEAM_Y1 = -0.240, 0.300
BEAM_Z = BELT_Z + 0.025
# ---------------------------------------------------------------- motion
BELT_SPEED = 0.8 # m/s - slow enough that the plow can lean goods over
# without the belt dragging them past the blade
# Blade speed is NOT free. The blade covers 0.70 m of belt and the beam trips with the
# item centred at x=-3.61, so extend+retract must fit inside 0.64 m of travel = 0.64 s
# at 1 m/s. Stroke 0.72 m => extend alone needs >1.12 m/s, a full cycle >2.25 m/s.
# Measured: 1.0/1.5/2.0/2.5 m/s all deliver the item to the bin; 3.0 m/s throws it
# (the kinematic blade injects too much impulse). 2.5 m/s = 0.29 s per direction.
PUSHER_SPEED = 2.5
PUSHER_MAX_SAFE = 2.5
# 700 mm pitch between goods. At 1 m/s that is 0.7 s head-to-head, and the pusher's own
# cycle is ~0.58 s plus the clearing wait - so back-to-back class-D items WILL make the
# retract interlock hold. See the pitch study in the README.
RELEASE_GAP = 0.70 # metres between released items
# ---------------------------------------------------------------- plow (DiverterEnd)
# The second diverter, at the far end of the main run. Unlike the pusher it does not shove
# the item sideways; it swings a blade into the lane so goods are deflected as they arrive.
# Used by scene/plow_cell.usd; see sim/plow.py.
PLOW_ROOT = "/World/Diverters/DiverterEnd"
PLOW_BASE = PLOW_ROOT + "/Base" # kinematic pedestal
PLOW_ARM = PLOW_ROOT + "/Arm" # dynamic blade, gravity disabled, 12 kg
PLOW_HINGE = PLOW_ROOT + "/ArmHinge" # revolute about Z, angular force drive
PLOW_POS = (-7.05, 0.0, 1.76) # authored placement (the prim also carries rotateZ=180)
# 42 deg puts the tip at 0.6*sin42 = 0.402 m. The lane edge is at 0.450 m, but an item is
# not a point: a ~150 mm box is taken by the lane once its near edge crosses, so a centre
# at 0.402 delivers it. 30 deg (0.300 m) never could - that was the dead zone.
PLOW_SWING = 42.0 # degrees either side of centre
PLOW_LIMIT = 45.0 # joint hard limit, raised by fix_plow_reach_and_trays.py
PLOW_HOLD = 1.5 # authored dwell at each end, seconds
# Authored gains on the angular drive, read back off plow_cell.usd. Force-type, so the
# blade is compliant: it yields on contact instead of teleporting through cargo the way the
# kinematic pusher blade does. (The earlier fixed.usd build used 50000/5000 - stiffer arm,
# softer damping; these are the 90_degree.usd figures the scene actually ships with.)
PLOW_ARM_MASS = 12.0 # kg, gravity disabled - the hinge alone holds the arm
PLOW_STIFFNESS = 120000.0
PLOW_DAMPING = 1500.0
PLOW_MAX_FORCE = 1.0e6
# Blade motion is specified as a **tip speed**, not an angular rate, because tip speed is
# what the cargo feels. The arm is PLOW_ARM_LEN long, so a tip speed v needs w = v / L:
#
# 0.8 m/s tip -> 1.333 rad/s -> 76.4 deg/s
#
# Matching the tip to the belt (0.8 m/s) means the blade never overtakes the goods it is
# steering: it meets them at their own speed and leans them across instead of batting them.
# The authored graph instead swings 30 deg in 7 ms (4125 deg/s, 72 rad/s) - a display
# animation, not a sortable motion; at that rate the blade arrives as an impulse and throws
# goods off the line, the same failure the pusher shows above 2.5 m/s.
PLOW_ARM_LEN = 0.60 # metres, hinge to tip (after scripts/narrow_plow.py)
PLOW_TIP_SPEED = 0.80 # m/s at the blade tip
PLOW_RATE = 76.4 # deg/s = degrees(PLOW_TIP_SPEED / PLOW_ARM_LEN)
PLOW_RATE_AUTHORED = 4125.29612494 # deg/s baked into the scene's OmniGraph script
# Ceiling on the drive's own angular velocity, the authored figure (72 rad/s). It is a
# limit, not a demand: the commanded angle is ramped at PLOW_RATE, so the drive never gets
# anywhere near this. Left at the authored value so the joint is not quietly re-specified.
PLOW_MAX_ANGULAR_VELOCITY = 72.0 # rad/s
# --- sweeping, as opposed to leaning -------------------------------------------------
# With the pivot at the belt centre and the free end pointing UPSTREAM, the blade cannot
# work as an inclined plane: goods driven along it slide toward its downstream end, which is
# the pivot at y = 0, so they are funnelled back to the middle. Measured ceiling 0.39 m and
# goods wedged between blade and centreline.
#
# So the blade is used as a **sweeper** instead of a ramp. It waits at centre, and once the
# item is in front of it it swings through, giving the item a lateral push it carries onto
# the lane. That needs a brisk rate - a slow sweep just leans on the item - but it must stay
# inside the range the pusher blade already proved safe (<= 2.5 m/s at the contact point).
#
# 200 deg/s at a 0.6 m arm = 2.09 m/s at the tip, under the 2.5 m/s limit.
PLOW_SWEEP_RATE = 200.0 # deg/s during the push
# The zone must start UPSTREAM of the blade's own leading tip, not at it. With the pivot
# moved upstream the arm at rest lies along the centreline from -7.05 to -6.45, so its tip
# at -6.45 stands in the flow: a centred item runs into it and stops before the plow has
# been told to do anything. Observed live - four items piled at x -6.34..-6.45 with the
# blade still at 0 deg. Starting the zone 250 mm earlier gives the sweep time to begin
# while the item is still approaching.
PLOW_SWEEP_X0, PLOW_SWEEP_X1 = -7.15, -6.20
# --- fork: the blade is a POINT, not a sweeper --------------------------------------
# The discharge is now a Y: channel C runs straight on (y -0.45..0.00), channel B branches
# 45 deg away (y 0.00..+1.73), and they share the inner edge y = 0 where the blade pivots.
#
# So the blade has two states, not a symmetric swing about centre:
#
# rest -> lies across the B mouth. Class C needs NO action at all, which removes every
# timing failure the sweeper had: C is simply the default route.
# B -> swings over, the B mouth opens and the C side is blocked instead.
#
# Which sign closes which is MEASURED, never reasoned: signs in this scene have come out
# backwards three times. Run one class-B item and read lane_of().
# The point SELECTS the route; it does not deliver on its own. With the pivot downstream at
# the apex, goods sliding along a stationary blade travel toward that pivot - i.e. to y = 0,
# the boundary between the channels - and are released there with no sideways speed at all.
# They then straddle both belts, C pulling straight and B pulling at 45 deg, the two cancel,
# and the item stands still against the blade. That is the stall in the viewport.
#
# So the blade holds a SHALLOW angle to select, then keeps turning while it is in contact:
# the extra travel is the push that commits the item to one channel.
# GUIDE, not gate. At 42-45 deg to the flow the blade's normal points mostly backwards: it
# takes the item's forward speed away and the item stops dead against it - measured, goods
# halted at x -6.945 with the belt still running under them. A shallow blade lets the item
# keep its travel and only drifts it sideways, which is what a plough is supposed to do.
#
# With the pivot on the +Y edge the blade parks ACROSS the lane at a shallow angle, so C is
# the default route and needs no command; for a B item it retracts flush to the edge and B
# passes straight through. Only B actuates, and the blade never blocks anything.
#
# The sign that swings INTO the lane is to be MEASURED - signs in this scene have come out
# backwards four times.
# Pivot is on the -Y edge, so the blade can only drift goods toward +Y - i.e. into B. That
# is the right way round: C is reached by doing nothing, which is what "C is the default"
# has to mean mechanically. From the +Y edge B was simply unreachable, and both B items in
# the last run ran straight on into C no matter what the blade did.
PLOW_REST_ANGLE = 0.0 # C route (default): blade flush with the edge, lane clear
PLOW_B_ANGLE = +26.0 # B route: enlarged so the deflection is visible
# upstream of this an item counts as a new pass and the plow re-arms for it
PLOW_REARM_X = -4.00
PLOW_NUDGE_S = 0.35 # how long the blade stays out - then it releases
PLOW_PUSH_ANGLE = 38.0 # while touching: a little more drift, still not a wall
# ---- pre-positioned steer, driven by the laser gate rather than by contact ----------
# The blade used to wait until it FELT the item, then swing. That is too late: the item is
# already against a moving edge, so the swing lands as a shove. Now the gate at SENSE_X
# (1.02 m upstream of the blade's leading edge at x -7.32) names the class 1.28 s ahead at
# 0.8 m/s, and the blade is already standing at the right angle when the item arrives. The
# item then just grazes the face and is walked sideways while the belt keeps carrying it.
#
# Signs are measured, not reasoned: a POSITIVE swing deflects toward -Y.
# B - the +Y branch -> negative
# C - the -Y lane, centreline y -0.225 -> positive
# Small on purpose: at 16 deg the 0.60 m face walks an item 0.60*tan(16) = 172 mm sideways
# over its length, which reaches lane B's mouth (+0.16) and lane C's centreline without the
# blade ever becoming a wall.
PLOW_PRESET = {"B": -16.0, "C": +16.0, "D": 0.0}
# past this x the item is clear of the blade, so the arm may go home for the next one
PLOW_RELEASE_X = -8.10
# where the blade pivots, measured after it was moved onto the fork apex. The queue serves
# whichever armed item is closest to THIS, so it must track the pivot if the plow moves.
PLOW_X = -7.85
# Gains for *sorting*, as opposed to the authored display gains above.
#
# Measured failure with the authored 120000/1500/1e6: a 0.6 kg item on a 1 m/s belt reaches
# the blade, pushes it from 30 deg back to 10 deg, and the drive answers with a restoring
# torque that saturates maxForce. The contact then resolves as an impulse and the item
# leaves at 6 m/s, ending several kilometres below the floor. Traces show it every time:
# t=10.53 arm=+30.0 v=0.50 -> t=10.73 arm=+19.6 v=0.50 -> t=11.36 v=5.98 (launched)
# The blade only has to redirect goods that weigh well under a kilogram, so it needs enough
# authority to hold its angle and no more. Softer and better damped, it deflects instead of
# batting.
# Second correction, from a measured limit cycle: at 3000/600 the arm did not settle at all
# but oscillated between +21.4 and -21.4 deg at ~99 deg/s - faster than the 76.4 deg/s ramp
# that was commanding it, which is the giveaway that the drive, not the command, was moving
# it. With the arm's inertia around 1.1 kg m^2 a stiffness of 3000 puts the natural
# frequency near 395 rad/s, far too high to be integrated at 120 Hz, so the solver rings.
# Dropping to 300 puts it near 125 rad/s, comfortably resolved, and damping 150 keeps it
# close to critically damped.
PLOW_SORT_STIFFNESS = 300.0
PLOW_SORT_DAMPING = 150.0
PLOW_SORT_MAX_FORCE = 2000.0
# PhysX separates overlapping bodies by moving them apart, and by default it may do so at
# any speed. A thin blade sweeping into a box penetrates deeply in one step, and the
# uncapped separation is itself enough to fire the item off the line. Cap it for the items
# and for the arm; 1 m/s is the belt speed, so separation can never outrun the process.
MAX_DEPENETRATION = 1.0
# ---------------------------------------------------------------- plow friction
# Goods piled up against the blade instead of sliding along it, and the reason is friction,
# not geometry. Two surfaces decide whether a plow leads or blocks:
#
# the blade face - the item has to slide ALONG it. With no physics material authored the
# arm ran on PhysX defaults, which is roughly rubber on rubber.
# the belt - the carrying belt is deliberately grippy (1.1 / 0.95) so goods do not
# slip while being driven. That same grip pins them against moving
# sideways: to cross 0.45 m the blade must overcome mu * m * g the whole
# way, and at mu = 0.95 it simply wins the tug of war by stopping them.
#
# Real plough sorters solve it exactly this way - a polished (UHMW) blade over a low-
# friction slider bed on the diverting section only. The carrying sections keep their grip.
PLOW_BLADE_FRICTION = (0.05, 0.04) # static, dynamic - polished blade face
# Low friction belongs ONLY on the narrow handover plates, never on the carrying belt. A
# belt moves goods by friction alone: at 0.30 the drive on a 0.6 kg item is about 1.8 N, and
# any resistance at all - the blade edge, the belt lip, a skewed pose - stops it. Binding
# this to /World/ConveyorTrack_04/Belt made the whole 2 m run through the plow slippery, so
# goods could be nudged sideways but not carried onward. That is why neither the long belt
# nor the short one helped: the run-out existed, it just had no grip.
# 0.30/0.25 was too slippery to CARRY. The plates are driven (configure_lanes gives each a
# surfaceVelocity pointing down its lane), but friction is what transmits that drive, and at
# 0.30 it transmits almost nothing: every item nudged onto PlowTransition_B (x -8.03..-7.45)
# coasted and stopped at x ~ -7.7 - the exact "pushed, then stops dead" trace. Raised to a
# middle ground: enough grip to keep goods moving toward their lane, still far below the
# carrying belt (1.10/0.95) so the blade can still slide them sideways.
PLOW_SECTION_FRICTION = (0.70, 0.60)
PLOW_SECTION_PLATES = ["/World/PlowTransition_B", "/World/PlowTransition_C"]
# ---------------------------------------------------------------- vision
# Scene meshes are 0.49x real size, so metres -> real millimetres needs this divisor.
DIM_SCALE = 1.0 / 2.041
ROI_FIXED = 320 # CRE-ROI v2b: fixed ROI side
ROI_PAD_V, ROI_PAD_X = 24, 24
DISP_PAD = 1.35 # left pad = DISP_PAD * max disparity
Z_MIN = 0.35 # closest expected surface, sets the pad width
MAX_MASK_FRAC = 0.08 # a blob bigger than this is belt, not cargo
VIEW_CONSISTENCY = 0.10 # per-view centroid must agree within 10 cm
# B/C/D thresholds (millimetres, on the real-size scale)
OVERSIZE_MAX = (451, 321, 321)
MIN_DIM = 10
ROUND_K = 0.80
CLASS_LABELS = {"B": "sortable", "C": "oversize", "D": "round"}