dropbear 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. dropbear/__init__.py +41 -0
  2. dropbear/arm.py +431 -0
  3. dropbear/assets/__init__.py +0 -0
  4. dropbear/assets/camera_reference.jpg +0 -0
  5. dropbear/assets/camera_reference.svg +82 -0
  6. dropbear/bench.py +236 -0
  7. dropbear/calibrate.py +52 -0
  8. dropbear/cli.py +320 -0
  9. dropbear/config.py +116 -0
  10. dropbear/constants.py +78 -0
  11. dropbear/control.py +233 -0
  12. dropbear/demo.py +66 -0
  13. dropbear/doctor.py +385 -0
  14. dropbear/errors.py +316 -0
  15. dropbear/franka.py +105 -0
  16. dropbear/hardware/__init__.py +0 -0
  17. dropbear/hardware/cameras.py +41 -0
  18. dropbear/hardware/fake.py +64 -0
  19. dropbear/hardware/lerobot_so101.py +122 -0
  20. dropbear/hardware/ports.py +39 -0
  21. dropbear/hardware/protocols.py +28 -0
  22. dropbear/libero.py +67 -0
  23. dropbear/motion/__init__.py +0 -0
  24. dropbear/motion/ensemble.py +91 -0
  25. dropbear/motion/loop.py +258 -0
  26. dropbear/motion/safety.py +112 -0
  27. dropbear/optional.py +15 -0
  28. dropbear/policy/__init__.py +50 -0
  29. dropbear/policy/config.py +176 -0
  30. dropbear/policy/lifecycle.py +552 -0
  31. dropbear/policy/remote.py +1202 -0
  32. dropbear/policy/run_loop.py +1187 -0
  33. dropbear/policy/runtime/__init__.py +1 -0
  34. dropbear/policy/runtime/action_buffer.py +251 -0
  35. dropbear/policy/runtime/calibration.py +489 -0
  36. dropbear/policy/runtime/contract.py +126 -0
  37. dropbear/policy/runtime/control.py +393 -0
  38. dropbear/policy/runtime/rtc.py +774 -0
  39. dropbear/policy/types.py +169 -0
  40. dropbear/quickstart.py +397 -0
  41. dropbear/region_probe.py +187 -0
  42. dropbear/sessions.py +242 -0
  43. dropbear/sessions_cmd.py +130 -0
  44. dropbear/sim/__init__.py +0 -0
  45. dropbear/sim/artifacts.py +88 -0
  46. dropbear/sim/metrics.py +336 -0
  47. dropbear/sim/runtime.py +401 -0
  48. dropbear/sim/sim_arm.py +1392 -0
  49. dropbear/sim/timing.py +100 -0
  50. dropbear/so101.py +79 -0
  51. dropbear/state.py +93 -0
  52. dropbear/status.py +168 -0
  53. dropbear/terminal.py +351 -0
  54. dropbear/transport_selection.py +141 -0
  55. dropbear/wire.py +179 -0
  56. dropbear-0.1.0a1.dist-info/METADATA +223 -0
  57. dropbear-0.1.0a1.dist-info/RECORD +60 -0
  58. dropbear-0.1.0a1.dist-info/WHEEL +4 -0
  59. dropbear-0.1.0a1.dist-info/entry_points.txt +2 -0
  60. dropbear-0.1.0a1.dist-info/licenses/LICENSE +176 -0
dropbear/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """Dropbear - fast serverless inference for your robots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+ from typing import TYPE_CHECKING
7
+
8
+ try:
9
+ __version__ = version("dropbear")
10
+ except PackageNotFoundError: # Direct source import outside an installed environment.
11
+ __version__ = "0+unknown"
12
+
13
+ from dropbear import franka, libero, so101
14
+ from dropbear.policy import RegionPreference, RunHooks, RunStrategy, aconnect, connect
15
+
16
+ if TYPE_CHECKING:
17
+ from dropbear.arm import Arm
18
+
19
+
20
+ def connect_so101(*, region: RegionPreference = "nearest") -> Arm:
21
+ """Connect to the legacy physical SO-101 control path.
22
+
23
+ Prefer dropbear.connect(model="molmoact2-so101", ...) for new cloud-policy work.
24
+ """
25
+ from dropbear.arm import connect_so101 as _connect
26
+
27
+ return _connect(region=region)
28
+
29
+
30
+ __all__ = [
31
+ "RunHooks",
32
+ "RunStrategy",
33
+ "RegionPreference",
34
+ "aconnect",
35
+ "connect",
36
+ "connect_so101",
37
+ "franka",
38
+ "libero",
39
+ "so101",
40
+ "__version__",
41
+ ]
dropbear/arm.py ADDED
@@ -0,0 +1,431 @@
1
+ """connect_so101() and the Arm handle (spec §8).
2
+
3
+ Zero args: ~/.dropbear/config.toml supplies everything. Owns session acquisition
4
+ (reusing quickstart's pre-warm), UDP preflight, pinned QUIC connect, the LeRobot
5
+ hardware adapter, the 30 Hz safety loop, heartbeats, and reconnect-on-freeze.
6
+ Dependency-injection and tuning knobs live behind _connect_so101_internal().
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import inspect
13
+ import threading
14
+ from collections.abc import Callable
15
+ from contextlib import suppress
16
+ from typing import Any
17
+
18
+ import httpx
19
+ import numpy as np
20
+
21
+ from dropbear import errors, wire
22
+ from dropbear.config import Config, load_config
23
+ from dropbear.constants import FIRST_ACTION_TIMEOUT_S, MODEL_SO101
24
+ from dropbear.control import ControlPlaneClient, Session
25
+ from dropbear.motion.loop import ControlLoop, LoopResult
26
+ from dropbear.motion.safety import EStop, SpacebarEStop, install_sigint_estop
27
+ from dropbear.policy.lifecycle import _LoopRunner
28
+ from dropbear.policy.types import RegionPreference
29
+ from dropbear.sessions import Heartbeat, acquire_session, stop_session
30
+ from dropbear.state import load_state
31
+ from dropbear.transport_selection import connect_selected_transport
32
+
33
+
34
+ def _default_transport_connect(addr: str, port: int, *, cert_fingerprint: str,
35
+ session_id: str, session_token: str):
36
+ from dropbear_transport import QuicClient
37
+
38
+ return QuicClient.connect(addr, port, cert_fingerprint=cert_fingerprint,
39
+ session_id=session_id, session_token=session_token)
40
+
41
+
42
+ def _default_confirm_retry() -> bool:
43
+ answer = input("AWS is out of GPU capacity. Auto-retry with backoff? [Y/n] ")
44
+ return answer.strip().lower() != "n"
45
+
46
+
47
+ def _default_doctor_runner() -> None:
48
+ from dropbear.doctor import assert_motion_ready
49
+
50
+ assert_motion_ready()
51
+
52
+
53
+ def connect_so101(
54
+ *,
55
+ region: RegionPreference = "nearest",
56
+ ) -> Arm:
57
+ """Connect to the physical SO-101 compatibility control loop."""
58
+ return _connect_so101_internal(region=region)
59
+
60
+
61
+ def _connect_so101_internal(
62
+ *,
63
+ client_factory: Callable[..., Any] = ControlPlaneClient,
64
+ transport_connect: Callable[..., Any] = _default_transport_connect,
65
+ robot_factory: Callable[[], Any] | None = None,
66
+ preflight_fn: Callable[..., Any] | None = None,
67
+ allow_fallback: bool = True,
68
+ relay_connect: Callable[..., Any] | None = None,
69
+ doctor_runner: Callable[[], None] = _default_doctor_runner,
70
+ on_line: Callable[[str], None] = print,
71
+ confirm_retry: Callable[[], bool] = _default_confirm_retry,
72
+ duration_default_s: float = 60.0,
73
+ first_action_timeout_s: float = FIRST_ACTION_TIMEOUT_S,
74
+ region: RegionPreference = "nearest",
75
+ idle_timeout: int | None = None,
76
+ ) -> Arm:
77
+ """Internal SO-101 connector with dependency-injection hooks for tests/tools."""
78
+ config = load_config()
79
+ if not config.api_key:
80
+ raise errors.catalog("not_configured")
81
+ if robot_factory is None:
82
+ robot_factory = _make_default_robot_factory(config)
83
+ if preflight_fn is None:
84
+ from dropbear_transport import diagnose_udp as preflight_fn
85
+ preflight_fn = _compatible_diagnose_udp(preflight_fn)
86
+
87
+ runner = _LoopRunner()
88
+ try:
89
+ return runner.run(_connect_async(
90
+ runner=runner, config=config, client_factory=client_factory,
91
+ transport_connect=transport_connect, robot_factory=robot_factory,
92
+ preflight_fn=preflight_fn, allow_fallback=allow_fallback,
93
+ relay_connect=relay_connect, doctor_runner=doctor_runner, on_line=on_line,
94
+ confirm_retry=confirm_retry, duration_default_s=duration_default_s,
95
+ first_action_timeout_s=first_action_timeout_s,
96
+ region=region, idle_timeout=idle_timeout,
97
+ ))
98
+ except BaseException:
99
+ runner.shutdown()
100
+ raise
101
+
102
+
103
+ def _make_default_robot_factory(config: Config) -> Callable[[], Any]:
104
+ def factory():
105
+ from dropbear.hardware.lerobot_so101 import create_so101
106
+ from dropbear.optional import require_so101_extra
107
+
108
+ require_so101_extra()
109
+ if config.robot_port is None:
110
+ raise errors.catalog("robot_port_not_found", port="(none configured)")
111
+ if config.camera_index is None or config.wrist_camera_index is None:
112
+ raise errors.catalog("camera_pair_not_configured")
113
+ return create_so101(port=config.robot_port,
114
+ side_camera_index=config.camera_index,
115
+ wrist_camera_index=config.wrist_camera_index,
116
+ calibration_path=config.calibration_path)
117
+
118
+ return factory
119
+
120
+
121
+ async def _connect_async(*, runner, config, client_factory, transport_connect,
122
+ robot_factory, preflight_fn, allow_fallback,
123
+ relay_connect, doctor_runner, on_line, confirm_retry,
124
+ duration_default_s, first_action_timeout_s,
125
+ region="nearest", idle_timeout=None) -> Arm:
126
+ client = None
127
+ session: Session | None = None
128
+ transport = None
129
+ robot = None
130
+ robot_connect_attempted = False
131
+ heartbeat: Heartbeat | None = None
132
+ try:
133
+ client = client_factory(config.control_plane_url, config.api_key)
134
+ session = await acquire_session(client, MODEL_SO101, on_progress=on_line,
135
+ region=region, idle_timeout=idle_timeout,
136
+ confirm_retry=confirm_retry)
137
+
138
+ async def quic_connect(ready_session: Session):
139
+ return await _pin_connect(transport_connect, ready_session)
140
+
141
+ async def relay_connect_for_session(ready_session: Session):
142
+ connect = relay_connect or getattr(client, "connect_relay", None)
143
+ if connect is None:
144
+ raise errors.catalog(
145
+ "relay_unavailable",
146
+ retry_command="dropbear run pick-place",
147
+ )
148
+ return await connect(ready_session)
149
+
150
+ transport = await connect_selected_transport(
151
+ session,
152
+ diagnose_udp_fn=preflight_fn,
153
+ quic_connect=quic_connect,
154
+ relay_connect=relay_connect_for_session,
155
+ allow_fallback=allow_fallback,
156
+ on_line=on_line,
157
+ report_transport_fn=getattr(client, "report_transport", None),
158
+ )
159
+ robot = robot_factory()
160
+ robot_connect_attempted = True
161
+ await asyncio.to_thread(robot.connect)
162
+ # Pre-warm the JPEG encoder: wire.encode_jpeg lazily imports cv2, whose
163
+ # first call costs ~200-700 ms cold. Python's import lock holds the GIL
164
+ # through the dlopen, so asyncio.to_thread cannot keep that one-time cost
165
+ # off the event loop — it would starve the 30 Hz executor on the first
166
+ # do(). Pay it here, before any timed loop exists.
167
+ await asyncio.to_thread(wire.encode_jpeg, np.zeros((8, 8, 3), dtype=np.uint8))
168
+ heartbeat = Heartbeat(client, session.session_id)
169
+ heartbeat.start()
170
+
171
+ try:
172
+ status = await client.status()
173
+ active = int(status.get(MODEL_SO101, {}).get("active_sessions", 0))
174
+ if active >= 2:
175
+ on_line(f"{active - 1} other session(s) are sharing this GPU — "
176
+ "motion will be slower than usual")
177
+ except (errors.DropbearError, httpx.HTTPError):
178
+ pass
179
+
180
+ return Arm(runner=runner, client=client, session=session, transport=transport,
181
+ transport_connect=transport_connect, diagnose_udp_fn=preflight_fn,
182
+ allow_fallback=allow_fallback, relay_connect=relay_connect,
183
+ robot=robot, heartbeat=heartbeat, on_line=on_line,
184
+ doctor_runner=doctor_runner, duration_default_s=duration_default_s,
185
+ first_action_timeout_s=first_action_timeout_s)
186
+ except BaseException:
187
+ await _cleanup_connect_failure(
188
+ client=client,
189
+ session=session,
190
+ transport=transport,
191
+ robot=robot,
192
+ robot_connect_attempted=robot_connect_attempted,
193
+ heartbeat=heartbeat,
194
+ )
195
+ raise
196
+
197
+
198
+ def _compatible_diagnose_udp(preflight_fn: Callable[..., Any]) -> Callable[..., Any]:
199
+ async def diagnose(addr: str, port: int, **kwargs):
200
+ call_kwargs = _accepted_preflight_kwargs(preflight_fn, kwargs)
201
+ result = preflight_fn(addr, port, **call_kwargs)
202
+ if inspect.isawaitable(result):
203
+ result = await result
204
+ return _as_udp_reachability_report(result)
205
+
206
+ return diagnose
207
+
208
+
209
+ def _accepted_preflight_kwargs(preflight_fn: Callable[..., Any],
210
+ kwargs: dict[str, Any]) -> dict[str, Any]:
211
+ try:
212
+ params = inspect.signature(preflight_fn).parameters
213
+ except (TypeError, ValueError):
214
+ return kwargs
215
+ if any(param.kind == inspect.Parameter.VAR_KEYWORD for param in params.values()):
216
+ return kwargs
217
+ return {key: value for key, value in kwargs.items() if key in params}
218
+
219
+
220
+ def _as_udp_reachability_report(result: Any) -> Any:
221
+ if hasattr(result, "ok") and hasattr(result, "worker"):
222
+ return result
223
+ if not hasattr(result, "status"):
224
+ return result
225
+ from dropbear_transport import PreflightResult, UdpReachabilityReport
226
+
227
+ status = result.status
228
+ remediation = getattr(result, "remediation", "")
229
+ reason = "ok"
230
+ if status == "udp_blocked":
231
+ reason = "udp_refused"
232
+ elif status != "ok":
233
+ reason = "data_plane_unreachable"
234
+ return UdpReachabilityReport(
235
+ worker=PreflightResult(status=status, remediation=remediation),
236
+ diagnostic=None,
237
+ reason=reason,
238
+ remediation="" if status == "ok" else remediation,
239
+ )
240
+
241
+
242
+ async def _cleanup_connect_failure(*, client, session, transport, robot,
243
+ robot_connect_attempted: bool,
244
+ heartbeat: Heartbeat | None) -> None:
245
+ if heartbeat is not None:
246
+ with suppress(Exception):
247
+ await heartbeat.stop()
248
+ if transport is not None:
249
+ with suppress(Exception):
250
+ await transport.close()
251
+ if robot is not None and robot_connect_attempted:
252
+ with suppress(Exception):
253
+ await asyncio.to_thread(robot.disconnect)
254
+ if client is not None and session is not None:
255
+ with suppress(Exception):
256
+ await stop_session(client, session.session_id, clear_prewarm_state=True)
257
+ if client is not None:
258
+ with suppress(Exception):
259
+ await client.close()
260
+
261
+
262
+ async def _pin_connect(transport_connect, session: Session):
263
+ try:
264
+ return await transport_connect(
265
+ session.worker_addr, session.port,
266
+ cert_fingerprint=session.cert_fingerprint,
267
+ session_id=session.session_id,
268
+ session_token=session.session_token,
269
+ )
270
+ except errors.DropbearError:
271
+ raise
272
+ except Exception as exc:
273
+ text = str(exc).lower()
274
+ if "fingerprint" in text or "pin" in text:
275
+ raise errors.catalog("cert_fingerprint_mismatch") from exc
276
+ raise
277
+
278
+
279
+ class Arm:
280
+ """Live handle: arm.do(instruction), arm.estop(), arm.close(), context manager."""
281
+
282
+ def __init__(self, *, runner, client, session, transport, transport_connect,
283
+ diagnose_udp_fn, allow_fallback, relay_connect, robot, heartbeat,
284
+ on_line, doctor_runner, duration_default_s, first_action_timeout_s) -> None:
285
+ self._runner = runner
286
+ self._client = client
287
+ self._session = session
288
+ self._transport = transport
289
+ self._transport_connect = transport_connect
290
+ self._diagnose_udp_fn = diagnose_udp_fn
291
+ self._allow_fallback = allow_fallback
292
+ self._relay_connect = relay_connect
293
+ self._robot = robot
294
+ self._heartbeat = heartbeat
295
+ self._on_line = on_line
296
+ self._doctor_runner = doctor_runner
297
+ self._duration_default_s = duration_default_s
298
+ self._first_action_timeout_s = first_action_timeout_s
299
+ self._active_estop: EStop | None = None
300
+ self._closed = False
301
+
302
+ def do(self, instruction: str, duration_s: float | None = None) -> LoopResult:
303
+ config = load_config()
304
+ if config.motion_enabled:
305
+ self._ensure_doctor()
306
+ estop = EStop()
307
+ self._active_estop = estop
308
+ # signal handlers may only be installed from the main thread
309
+ if threading.current_thread() is threading.main_thread():
310
+ restore = install_sigint_estop(estop)
311
+ else:
312
+ def restore() -> None:
313
+ return None
314
+ keys = SpacebarEStop(estop)
315
+ keys.start()
316
+ mode = "LIVE" if config.motion_enabled else "DRY RUN (no motion until you enable it)"
317
+ self._on_line(f"{mode} — press SPACE or Ctrl-C to e-stop.")
318
+ try:
319
+ return self._runner.run(self._do_async(
320
+ instruction, duration_s or self._duration_default_s, estop,
321
+ config.motion_enabled,
322
+ ))
323
+ finally:
324
+ keys.stop()
325
+ restore()
326
+ self._active_estop = None
327
+
328
+ def _ensure_doctor(self) -> None:
329
+ if load_state().doctor_last_pass is None:
330
+ self._on_line("First motion run — running checks (dropbear doctor)…")
331
+ self._doctor_runner() # raises DropbearError on failure
332
+
333
+ async def _do_async(self, instruction: str, duration_s: float, estop: EStop,
334
+ motion_enabled: bool) -> LoopResult:
335
+ reconnects = 0
336
+ while True:
337
+ loop = ControlLoop(
338
+ transport=self._transport, robot=self._robot,
339
+ session_id=self._session.session_id, instruction=instruction,
340
+ motion_enabled=motion_enabled, estop=estop, duration_s=duration_s,
341
+ on_line=self._on_line,
342
+ first_action_timeout_s=self._first_action_timeout_s,
343
+ )
344
+ result = await loop.run()
345
+ if not result.froze:
346
+ return result
347
+ self._on_line(errors.catalog("connection_lost").render())
348
+ if reconnects >= 1:
349
+ raise errors.catalog("connection_lost")
350
+ reconnects += 1
351
+ prefer_relay = (
352
+ self._allow_fallback
353
+ and getattr(self._transport, "mode", None) == "quic"
354
+ and result.freeze_reason in {"transport_lost", "first_action_timeout"}
355
+ )
356
+ await self._reconnect(prefer_relay=prefer_relay)
357
+
358
+ async def _reconnect(self, *, prefer_relay: bool = False) -> None:
359
+ try:
360
+ await self._transport.close()
361
+ except Exception: # noqa: BLE001 — best-effort close of a dead transport
362
+ pass
363
+ refreshed = await self._client.get_session(self._session.session_id)
364
+ if refreshed.status not in ("ready", "active"):
365
+ raise errors.catalog("session_expired")
366
+ if refreshed.session_token:
367
+ self._session = refreshed
368
+
369
+ async def quic_connect(ready_session: Session):
370
+ return await _pin_connect(self._transport_connect, ready_session)
371
+
372
+ async def relay_connect_for_session(ready_session: Session):
373
+ connect = self._relay_connect or getattr(self._client, "connect_relay", None)
374
+ if connect is None:
375
+ raise errors.catalog(
376
+ "relay_unavailable",
377
+ retry_command="dropbear run pick-place",
378
+ )
379
+ return await connect(ready_session)
380
+
381
+ if prefer_relay:
382
+ self._on_line("QUIC/UDP stopped delivering actions; using HTTPS fallback.")
383
+ self._on_line("Motion may feel less responsive while using fallback.")
384
+ self._transport = await relay_connect_for_session(self._session)
385
+ self._transport.mode = getattr(self._transport, "mode", "relay")
386
+ self._transport.fallback_reason = "quic_runtime_lost"
387
+ self._transport.primary_preflight_status = None
388
+ self._transport.diagnostic_preflight_status = None
389
+ else:
390
+ self._transport = await connect_selected_transport(
391
+ self._session,
392
+ diagnose_udp_fn=self._diagnose_udp_fn,
393
+ quic_connect=quic_connect,
394
+ relay_connect=relay_connect_for_session,
395
+ allow_fallback=self._allow_fallback,
396
+ on_line=self._on_line,
397
+ report_transport_fn=getattr(self._client, "report_transport", None),
398
+ )
399
+ self._on_line("Reconnected.")
400
+
401
+ def estop(self) -> None:
402
+ if self._active_estop is not None:
403
+ self._active_estop.trigger("api")
404
+
405
+ def close(self) -> None:
406
+ if self._closed:
407
+ return
408
+ self._closed = True
409
+ try:
410
+ self._runner.run_cleanup(self._close_async())
411
+ finally:
412
+ self._runner.shutdown()
413
+
414
+ async def _close_async(self) -> None:
415
+ with suppress(Exception):
416
+ await self._heartbeat.stop()
417
+ with suppress(Exception):
418
+ await self._transport.close()
419
+ with suppress(Exception):
420
+ await stop_session(self._client, self._session.session_id,
421
+ clear_prewarm_state=True)
422
+ with suppress(Exception):
423
+ await self._client.close()
424
+ with suppress(Exception):
425
+ await asyncio.to_thread(self._robot.disconnect)
426
+
427
+ def __enter__(self) -> Arm:
428
+ return self
429
+
430
+ def __exit__(self, exc_type, exc, tb) -> None:
431
+ self.close()
File without changes
Binary file
@@ -0,0 +1,82 @@
1
+ <svg viewBox="0 0 960 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Dropbear golden-path camera setup reference">
2
+ <title>Dropbear camera setup: third-person side webcam for the SO-101 golden path</title>
3
+ <rect width="960" height="540" fill="#FAF7F2"/>
4
+ <text x="32" y="44" font-family="sans-serif" font-size="24" font-weight="700" fill="#1F2933">Camera setup — third-person side view</text>
5
+ <text x="32" y="68" font-family="sans-serif" font-size="14" fill="#52606D">Match this layout, then check your live preview against the framing guide on the right.</text>
6
+
7
+ <!-- ============ LEFT PANEL: top view geometry ============ -->
8
+ <text x="32" y="108" font-family="sans-serif" font-size="15" font-weight="700" fill="#1F2933">Top view</text>
9
+ <rect x="32" y="120" width="420" height="270" rx="8" fill="#FFFFFF" stroke="#CBD2D9"/>
10
+ <!-- table -->
11
+ <rect x="72" y="150" width="340" height="150" rx="4" fill="#F1EDE6" stroke="#9AA5B1"/>
12
+ <text x="242" y="316" font-family="sans-serif" font-size="12" fill="#52606D" text-anchor="middle">table</text>
13
+ <!-- arm base -->
14
+ <rect x="340" y="195" width="44" height="60" rx="4" fill="#3E4C59"/>
15
+ <text x="362" y="186" font-family="sans-serif" font-size="12" font-weight="700" fill="#1F2933" text-anchor="middle">SO-101 base</text>
16
+ <!-- workspace -->
17
+ <rect x="120" y="185" width="170" height="80" rx="4" fill="none" stroke="#7B8794" stroke-dasharray="6 4"/>
18
+ <text x="205" y="230" font-family="sans-serif" font-size="12" fill="#52606D" text-anchor="middle">Workspace (props)</text>
19
+ <!-- reach arrow arm->workspace -->
20
+ <line x1="340" y1="225" x2="296" y2="225" stroke="#7B8794" stroke-width="2"/>
21
+ <text x="318" y="216" font-family="sans-serif" font-size="11" fill="#52606D" text-anchor="middle">~25 cm</text>
22
+ <!-- webcam -->
23
+ <g>
24
+ <rect x="190" y="430" width="48" height="26" rx="6" fill="#3E4C59"/>
25
+ <circle cx="214" cy="443" r="7" fill="#FAF7F2"/>
26
+ <circle cx="214" cy="443" r="3.5" fill="#1F2933"/>
27
+ <text x="214" y="478" font-family="sans-serif" font-size="13" font-weight="700" fill="#1F2933" text-anchor="middle">Webcam</text>
28
+ </g>
29
+ <!-- distance webcam->table, side-on angle -->
30
+ <line x1="214" y1="430" x2="214" y2="300" stroke="#7B8794" stroke-width="2" stroke-dasharray="4 4"/>
31
+ <text x="226" y="372" font-family="sans-serif" font-size="12" fill="#52606D">60–80 cm</text>
32
+ <text x="226" y="390" font-family="sans-serif" font-size="12" fill="#52606D">side-on (90° to arm)</text>
33
+
34
+ <!-- ============ MIDDLE PANEL: side view ============ -->
35
+ <text x="492" y="108" font-family="sans-serif" font-size="15" font-weight="700" fill="#1F2933">Side view</text>
36
+ <rect x="492" y="120" width="200" height="270" rx="8" fill="#FFFFFF" stroke="#CBD2D9"/>
37
+ <!-- table line -->
38
+ <line x1="512" y1="330" x2="672" y2="330" stroke="#9AA5B1" stroke-width="3"/>
39
+ <!-- arm silhouette -->
40
+ <rect x="618" y="296" width="22" height="34" fill="#3E4C59"/>
41
+ <line x1="629" y1="296" x2="600" y2="250" stroke="#3E4C59" stroke-width="7" stroke-linecap="round"/>
42
+ <line x1="600" y1="250" x2="566" y2="276" stroke="#3E4C59" stroke-width="6" stroke-linecap="round"/>
43
+ <!-- props -->
44
+ <rect x="540" y="318" width="12" height="12" fill="#7B8794"/>
45
+ <!-- camera at lens height -->
46
+ <rect x="500" y="266" width="26" height="16" rx="4" fill="#3E4C59"/>
47
+ <line x1="526" y1="274" x2="556" y2="296" stroke="#7B8794" stroke-width="1.5" stroke-dasharray="3 3"/>
48
+ <!-- lens height above table -->
49
+ <line x1="513" y1="282" x2="513" y2="330" stroke="#7B8794" stroke-width="2" stroke-dasharray="4 4"/>
50
+ <text x="521" y="312" font-family="sans-serif" font-size="11" fill="#52606D">20–30 cm</text>
51
+ <text x="592" y="368" font-family="sans-serif" font-size="11" fill="#52606D" text-anchor="middle">lens just above prop height, slight downward tilt</text>
52
+
53
+ <!-- ============ RIGHT PANEL: framing guide ============ -->
54
+ <text x="732" y="108" font-family="sans-serif" font-size="15" font-weight="700" fill="#1F2933">Your frame should look like this</text>
55
+ <rect x="732" y="120" width="196" height="146" rx="8" fill="#FFFFFF" stroke="#CBD2D9"/>
56
+ <rect x="740" y="128" width="180" height="130" fill="#F1EDE6"/>
57
+ <!-- rule-of-thirds guides -->
58
+ <line x1="800" y1="128" x2="800" y2="258" stroke="#CBD2D9" stroke-width="1"/>
59
+ <line x1="860" y1="128" x2="860" y2="258" stroke="#CBD2D9" stroke-width="1"/>
60
+ <!-- arm in the right third -->
61
+ <rect x="872" y="206" width="16" height="32" fill="#3E4C59"/>
62
+ <line x1="880" y1="206" x2="852" y2="172" stroke="#3E4C59" stroke-width="5" stroke-linecap="round"/>
63
+ <line x1="852" y1="172" x2="824" y2="192" stroke="#3E4C59" stroke-width="4" stroke-linecap="round"/>
64
+ <!-- props middle third -->
65
+ <rect x="806" y="226" width="10" height="10" fill="#7B8794"/>
66
+ <rect x="826" y="228" width="10" height="8" fill="#9AA5B1"/>
67
+ <!-- table edge -->
68
+ <line x1="740" y1="238" x2="920" y2="238" stroke="#9AA5B1" stroke-width="2"/>
69
+ <text x="830" y="282" font-family="sans-serif" font-size="11" fill="#52606D" text-anchor="middle">whole arm + whole workspace visible,</text>
70
+ <text x="830" y="297" font-family="sans-serif" font-size="11" fill="#52606D" text-anchor="middle">nothing important on the frame edge</text>
71
+
72
+ <!-- ============ CHECKLIST ============ -->
73
+ <text x="732" y="336" font-family="sans-serif" font-size="14" font-weight="700" fill="#1F2933">Checklist</text>
74
+ <text x="732" y="358" font-family="sans-serif" font-size="12" fill="#3E4C59">• even, diffuse light — no backlight</text>
75
+ <text x="732" y="377" font-family="sans-serif" font-size="12" fill="#3E4C59">• plain background, no clutter</text>
76
+ <text x="732" y="396" font-family="sans-serif" font-size="12" fill="#3E4C59">• camera on a stable mount</text>
77
+ <text x="732" y="415" font-family="sans-serif" font-size="12" fill="#3E4C59">• 1080p, 16:9, landscape</text>
78
+ <text x="732" y="441" font-family="sans-serif" font-size="13" font-weight="700" fill="#B7410E">• Lock white balance + exposure</text>
79
+ <text x="744" y="459" font-family="sans-serif" font-size="11" fill="#B7410E">auto-WB drift mid-task confuses the model</text>
80
+
81
+ <text x="32" y="512" font-family="sans-serif" font-size="11" fill="#7B8794">dropbear/assets/camera_reference.svg — ships with the dropbear SDK; full recipe at /docs/golden-path</text>
82
+ </svg>