Main architecture (data flow):
flowchart LR
Phones["π± Phone clients"]
Human["π§ Human reviewer"]
Server[("Server<br/>doodlebot.media.mit.edu")]
Bots["π€ Doodlebot pool"]
%% Drawing submission
Phones -->|drawings| Server
%% Human review loop
Server -->|A. Candidates| Human
Human -->|B. Selections| Server
Server -->|C. Combined w/ vectorization| Human
Human -->|D. Approve or modify| Server
%% Server <-> bots
Server -->|A. Positions of aruco markers| Bots
Bots -->|"B. name + 'ready to draw' + (x, y) in global frame, poll ~1s"| Server
Server -->|"C. drawing commands: navigate, draw, exit path"| Bots
%% Server behavior on approval
Approval["On approval: pick best bot from 'ready' pool<br/>by idle time + canvas availability;<br/>on next check-in send vectorization<br/>and update bot's canvas model"]
Approval -.-> Server
%% Design note
Note["Note: do as much processing on the<br/>server as possible β deploying to bots<br/>is cumbersome"]
Note -.-> Server
Doodlebot state machine:
stateDiagram-v2
[*] --> Locate
Locate : Locate self via aruco code detection
Poll : Poll server for a drawing
Draw : Do drawing
Locate --> Poll
Poll --> Poll : nothing yet, wait ~1s
Poll --> Draw : drawing received
Draw --> Locate : repeat
The repo is composed of independently-deployed git subrepos:
| Subrepo | Role |
|---|---|
| server-suede/ | FastAPI server β the brain. Submission, moderation, combine, vectorization, and robot orchestration. |
| client-suede/ | Static HTML front-ends (phone, gallery, display, robot admin). Served by the server, deployed via GitHub Pages. |
| robot-suede/ | The Python client flashed onto each Doodlebot (the Locate β Poll β Draw loop; hardware is stubbed). |
| shared-suede/ | Shared assets/tooling. |
The server wires every router together in server-suede/app.py; the front-end pages are served (and cached from GitHub Pages) by server-suede/pages.py. All server state is in-memory plus three on-disk folders: pending/, sketches/, combined/ (see config.py).
The whole system is one big loop: phones feed drawings in, a human curates and combines them, the result is vectorized and queued, a robot draws it, and the canvas fills until it's reset. Each numbered stage below names the exact code.
A visitor draws on the phone page client-suede/index.html and POSTs a base64 PNG to /api/submit (submit.py). The server saves sketch_<ts>.png + a .json sidecar into pending/, then calls broadcast("new_pending", β¦) (common.py) so every connected screen sees it instantly.
All front-ends subscribe to /stream (stream.py) via EventSource. The broadcast broker in common.py pushes server-sent events (new_pending, approved_sketch, selection_changed, combining, combined, deleted_sketch) to each listener queue. This is how the gallery and display.html update without polling.
A curator opens client-suede/gallery.html (admin-token gated). It lists the queue from /api/pending and approves via /api/pending/{filename}/approve (moderation.py), which moves the file pending/ β sketches/, flips its status to approved, and broadcasts approved_sketch. The big-screen display.html listens for that event and shows the sketch.
In the gallery the curator selects 2β4 approved sketches (POST /api/select β selection_changed) and hits Combine. combineSelected() posts to /api/combine (combine.py), which feeds the chosen images + a prompt to one or more image models (OpenAI / Gemini, via llms.py), saves the result into combined/, and returns it as base64. combining/combined events bracket the work.
The moment a combined image renders, the gallery automatically vectorizes it β no button press. buildResultCard() β startVectorization() in gallery.html shows a spinner and POSTs the image to /vectorize (vectorize.py). That runs default_pipeline() from the arc_line_vectorization_suede/ package, turning raster ink into a DrawingCommand list (line/spin/arc). The response includes low_geometry (the consolidated commands a robot will actually draw) and low_geometry_svg; the gallery renders only the low-geometry SVG and reveals a Publish to Robot button.
publishToRobot() in gallery.html POSTs the low_geometry commands to /api/robots/jobs (robots.py post_job β enqueue_drawing()). This wraps the commands in a DrawingJob and hands it to the in-memory _Coordinator. (An admin can do the same by hand from robots.html.)
coordinator.enqueue() runs _assign_locked() (robots.py), which is where the matchmaking happens:
- It computes the drawing's footprint once (
commands_to_strokes, then strips the pen-up lead-in viasplit_lead_in) and caches it (FootprintCache) β all in canvas.py. - It ranks the ready bot pool (most idle, most free region) and calls
Region.try_place()for the best candidate. Placement rasterizes the footprint into the region's occupancy grid and finds a collision-free pose via FFT cross-correlation (_free_offsets), rotating the drawing for a tighter fit (bottom_leftpacks densely;scatterspreads organically). It never overlaps existing ink. - On success it
commit()s the footprint to the grid (reserving the space) and stages the job on the chosen bot's record. Canvases, regions, markers, and per-region occupancy are all owned by the server'sCanvasStoreand configured via/api/robots/canvases.
The drawing is now reserved for a specific robot β but not yet delivered (the bot must poll for it).
Each Doodlebot runs robot-suede/main.py (run()), the client half of the protocol:
- Locate β
fetch_markers()(GET /api/robots/markers) returns the canvas's aruco positions; the bot solves its global pose (estimate_pose, a hardware stub). - Poll β ~once a second it
POSTs to/api/robots/checkinwith its name/status/pose. The coordinator'scheck_in()(robots.py) replieswait, or β if a job is staged for this bot βdrawwithnavigateTo(the first ink point + approach heading) and the lead-in-strippedcommands. - Draw β the bot drives to
navigateTo(navigate_to) and runs the commands (execute_commands); both are hardware stubs on the client. The server-side placement guarantees the path won't collide with anything already on the canvas.
Because delivery is pull-based, a freshly-staged job reaches the robot on its next poll (β€ ~1 s); the placement compute itself is ~10β155 ms (see commit history / canvas.py).
After drawing, the bot returns to Locate and the whole cycle repeats. New submissions keep arriving (1), curators keep combining (3β4), and the coordinator keeps packing drawings into each robot's region until it saturates β at which point regions can be reset by re-posting a canvas to /api/robots/canvases.
client-suede/robots.html is the operator console over robots.py: live robot-pool + queue inspection (GET /api/robots), a to-scale canvas/occupancy visualizer (GET /api/robots/canvases), the canvas/region/marker editor (POST /api/robots/canvases), and a test-drawing enqueuer (POST /api/robots/jobs).
flowchart TD
Phone["π± index.html"] -->|POST /api/submit| Pending[("pending/")]
Pending -->|broadcast new_pending| Gallery["π§ gallery.html"]
Gallery -->|POST /api/pending/.../approve| Sketches[("sketches/")]
Sketches -->|broadcast approved_sketch| Display["π₯οΈ display.html"]
Gallery -->|"POST /api/combine (2β4)"| Combined[("combined/")]
Combined -->|"auto POST /vectorize"| LowGeo["low_geometry commands"]
LowGeo -->|POST /api/robots/jobs| Coord["_Coordinator (robots.py)"]
Coord -->|"try_place + commit (canvas.py)"| Staged["job staged on best ready bot"]
Staged -->|"POST /api/robots/checkin (poll ~1s)"| Robot["π€ robot-suede/main.py"]
Robot -->|navigate + draw| Coord
Robot -->|loop| Robot