Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
2d49b69
dotbot/site_editor: read and patch a site pack's site.toml over JSON
geonnave Sep 30, 2026
66ba7a1
dotbot/console-web: add the site editor page
geonnave Sep 30, 2026
f54ec65
dotbot/cli: add site new and site edit
geonnave Sep 30, 2026
8ca5de9
dotbot/cli: open site edit on the active pack, wherever it is
geonnave Oct 1, 2026
76d4715
dotbot/site_editor: refuse a request for any host but this machine
geonnave Oct 1, 2026
47918d1
dotbot/site_editor: answer only loopback clients and the editor's own…
geonnave Oct 4, 2026
9d0d901
dotbot/site_toml: refuse layouts it cannot patch, keep comments headi…
geonnave Oct 4, 2026
45ac48a
dotbot/site_editor: save one at a time, through symlinks, 422 on unre…
geonnave Oct 4, 2026
3bb1e6b
dotbot/cli: check site new's name and port before writing its pack
geonnave Oct 4, 2026
6cbba6a
dotbot/console-web: select an area without moving it, hold edits whil…
geonnave Oct 4, 2026
9173b13
dotbot/calibration: solve several LH2 stations jointly, write schema 4
geonnave Oct 2, 2026
8abd0e5
dotbot/calibration: build the joint solve's normal equations block by…
geonnave Oct 4, 2026
56a592f
dotbot/calibration: re-solve after the last round of untied circles
geonnave Oct 4, 2026
1c35c09
dotbot/calibration: floor the centre noise behind a link's yaw sigma
geonnave Oct 4, 2026
c69f964
dotbot/calibration: say what a lone station's solve is, not how it was
geonnave Oct 4, 2026
7838725
dotbot/calibration: key spins by collection round, find a calibration…
geonnave Oct 2, 2026
2f15563
dotbot/cli: add calibrate-lh2 show and collect --spin --append
geonnave Oct 2, 2026
6255c6b
dotbot/calibration: push and bake stations by mask, each with its rec…
geonnave Oct 2, 2026
2114ad7
dotbot/cli: name stations by index and channel in push, add push --dr…
geonnave Oct 2, 2026
9e79c4b
dotbot/console-web: read lh2_station_mask, take device info 4 as current
geonnave Oct 2, 2026
ccd6212
dotbot/tests: pin the wire fixture to stations 2 and 8 with rectangle…
geonnave Oct 2, 2026
e163dd0
dotbot: read the advertised LH2 station mask in 16 bits
geonnave Oct 4, 2026
c463199
dotbot/calibration: fix a spin calibration to the floor with anchor r…
geonnave Oct 2, 2026
9a24167
dotbot/cli: add collect --spin --anchor
geonnave Oct 2, 2026
a9c171c
dotbot/calibration: refuse anchors that put a station below the site'…
geonnave Oct 4, 2026
2f2eb7e
dotbot/cli: refuse an anchor robot that would not spin, before the spin
geonnave Oct 4, 2026
36f2f14
dotbot/calibration: suggest where robots should spin in the next round
geonnave Oct 2, 2026
b3532f8
dotbot/cli: add calibrate-lh2 show --next-round
geonnave Oct 2, 2026
523551e
dotbot/calibration: name next-round stations by index and channel
geonnave Oct 4, 2026
bfa93a7
dotbot/cli: refuse show --next-round for a calibration a round cannot…
geonnave Oct 4, 2026
1990adb
dotbot/calibration: use the site extent as a placed spin solve's rect…
geonnave Oct 14, 2026
2749095
dotbot/config: record a site's LH2 base stations by serial and channel
geonnave Oct 14, 2026
eeb705b
dotbot/controller: hold a batch until its robot has a heading
geonnave Oct 14, 2026
23e4186
dotbot/console-web: show a batch held for a heading as under way
geonnave Oct 14, 2026
77bffdc
dotbot/config: add a site's spin_radius_mm for spin calibration
geonnave Oct 14, 2026
3562082
dotbot/console-web: show the site and id of the robot's LH2 calibration
geonnave Oct 14, 2026
a6e5abd
dotbot/console-web: name the calibration's site on the full grid card
geonnave Oct 14, 2026
1216f90
dotbot/console-web: name the served calibration's site in its fleet c…
geonnave Oct 14, 2026
0eb35ab
dotbot/console-web: flag a robot calibrated for another site
geonnave Oct 14, 2026
85fd82d
dotbot/console-web: treat an empty station mask as no calibration
geonnave Oct 14, 2026
263977d
dotbot/console-web: name a failed LH2 sensor in the inspector position
geonnave Oct 14, 2026
978a8cd
dotbot/calibration: confirm a push by a new boot reporting its stations
geonnave Oct 14, 2026
72dd356
dotbot/calibration: record the spin calibration a placed one came from
geonnave Oct 15, 2026
2e725fe
dotbot/calibration: name the spin calibration to append to instead of…
geonnave Oct 15, 2026
818c2d4
Merge branch 'spin-append-placed-hint' into site-editor-p6
geonnave Oct 15, 2026
9440a16
dotbot/calibration: move a spin calibration onto a site as one rigid …
geonnave Oct 2, 2026
25fda01
dotbot/site_editor: list, draw and place LH2 calibrations
geonnave Oct 2, 2026
5099969
dotbot/console-web: place a spin calibration in the site editor
geonnave Oct 2, 2026
2a00ca0
dotbot/tests: call the placement routes from a loopback client
geonnave Oct 4, 2026
641ca91
dotbot/calibration: hold a placement's fence to zero, never overwrite…
geonnave Oct 4, 2026
40c933b
dotbot/console-web: keep a saved placement drawn as moved, list the n…
geonnave Oct 4, 2026
0beec3e
dotbot/site_editor: serve the site's calibration spans and camera stills
geonnave Oct 2, 2026
f9fca4c
dotbot/console-web: add undo, arrow nudges and backdrops to the site …
geonnave Oct 2, 2026
a107a47
dotbot/site_editor: skip backdrop files it cannot draw, cap and versi…
geonnave Oct 4, 2026
84ad3e1
dotbot/console-web: seal undo steps per selection, refresh backdrops …
geonnave Oct 4, 2026
c656ee0
dotbot/site: read walls and obstacles from site.toml and serve them
geonnave Oct 2, 2026
a8b5e7c
dotbot/sim: stop simulated robots at the site's walls and obstacles
geonnave Oct 2, 2026
b4683b3
dotbot/site_editor: patch walls and obstacles in site.toml
geonnave Oct 2, 2026
fa4b9cb
dotbot/console-web: draw the site's walls and obstacles on the map
geonnave Oct 2, 2026
ea280dd
dotbot/console-web: draw and edit walls and obstacles in the site editor
geonnave Oct 2, 2026
26b36f1
dotbot/sim: stop a touching robot going deeper or across, check only …
geonnave Oct 4, 2026
bde08b7
dotbot/site_toml: keep wall comments on delete, refuse inline walls, …
geonnave Oct 4, 2026
0b67064
dotbot/console-web: delete a selection only with the Select tool
geonnave Oct 4, 2026
f265df4
dotbot/site: read objects such as chargers from site.toml and serve them
geonnave Oct 2, 2026
5f3c3dc
dotbot/dotbot_simulator: charge a simulated robot standing on a site'…
geonnave Oct 2, 2026
aca5973
dotbot/examples: take the charging station's charger from the site
geonnave Oct 2, 2026
0cb3503
dotbot/site_editor: patch objects in site.toml
geonnave Oct 2, 2026
4399f20
dotbot/console-web: draw the site's objects on the map
geonnave Oct 2, 2026
313a2d7
dotbot/console-web: place and edit objects in the site editor
geonnave Oct 2, 2026
18c9ae4
dotbot/dotbot_simulator: charge a robot with a learned battery model too
geonnave Oct 4, 2026
96f4a05
dotbot/site_editor: leave omitted objects, allow a comma in an object…
geonnave Oct 4, 2026
0c3d602
dotbot/tests: undo the connect-error proxy test's monkeypatch
geonnave Oct 2, 2026
29041dc
dotbot/server: serve the site editor's routes over the controller's s…
geonnave Oct 2, 2026
245a138
dotbot/console-web: open the site editor as a console panel
geonnave Oct 2, 2026
44c5ea9
dotbot/controller: reload the site whole or not at all, session included
geonnave Oct 4, 2026
dabc202
dotbot/console-web: load camera stills from the panel's routes
geonnave Oct 4, 2026
d988689
dotbot/console-web: keep the console's keys off under the site editor…
geonnave Oct 4, 2026
2d6c3fe
dotbot/calibration: save a calibration with its station rectangles ch…
geonnave Oct 15, 2026
ec13382
dotbot/cli: add calibrate-lh2 rebox
geonnave Oct 15, 2026
96daf8d
dotbot/site_editor: save station boxes as a new calibration
geonnave Oct 15, 2026
3158b2a
dotbot/console-web: edit station boxes in the site editor
geonnave Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 113 additions & 73 deletions dotbot/calibration/conics.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,22 @@
import datetime
import itertools
from dataclasses import dataclass, field
from typing import Sequence
from typing import TYPE_CHECKING, Sequence

import numpy as np

from dotbot.calibration.lighthouse2 import (
Calibration,
StationSolution,
TrackSample,
union_rect,
)
from dotbot.robots import ROBOT_DEFAULT, robot_geometry
from dotbot.site import Site, starter_layout

if TYPE_CHECKING:
from dotbot.calibration.multi_station import Anchor, JointSolution

# Post-rectification minor/major axis ratio under which a track is not a circle.
TRACK_AXIS_RATIO_MIN = 0.95

Expand All @@ -56,23 +60,37 @@
GATE_ROUNDS_MAX = 4


@dataclass(frozen=True)
class CircleKey:
"""One physical circle: a robot's spin in one collection round."""

name: str
round: int = 0


@dataclass(eq=False)
class Track:
"""One circle's camera points, in the order they were read.
"""One circle's camera points, as one station read them, in order.

`radius_mm` is the circle's true radius when known; tracks that share a
radius are fitted with one. `turn` is +1 when the robot turned counter
clockwise as drawn (right wheel faster), -1 clockwise, 0 unknown.
Tracks of several stations with the same `key` are the same circle.
"""

points: np.ndarray
radius_mm: float | None = None
turn: int = 0
name: str = ""
round: int = 0

def __post_init__(self):
self.points = np.asarray(self.points, dtype=np.float64).reshape(-1, 2)

@property
def key(self) -> CircleKey:
return CircleKey(self.name, self.round)


@dataclass
class TrackFit:
Expand All @@ -88,6 +106,11 @@ class TrackFit:
axis_ratio: float
rms_mm: float
why: str = ""
round: int = 0

@property
def key(self) -> CircleKey:
return CircleKey(self.name, self.round)


@dataclass
Expand Down Expand Up @@ -496,6 +519,7 @@ def fit_tracks(
axis_ratio=axis_ratio(t, H),
rms_mm=rms,
why=why.get(t, ""),
round=t.round,
)
)
return fits
Expand Down Expand Up @@ -666,6 +690,8 @@ def field_angle(centres: np.ndarray) -> float:

# What a free-mode calibration records as its site's anchor: no mark in the
# room is its origin.
# A site made by `site init` records the calibration it came from in its anchor
SELF_DEFINED_ANCHOR = "self-defined from spin calibration "
FREE_FRAME_ANCHOR = (
"free mode: zero at the top-left of the rectangle around the spinning "
"robots, grown by a robot's footprint; axes along that rectangle"
Expand All @@ -677,84 +703,90 @@ def solve_calibration(
site: Site,
robot: str = ROBOT_DEFAULT,
tag: str = "",
) -> tuple[Calibration, dict[int, ConicSolution], dict[int, str]]:
"""A free-mode calibration from circle tracks, one homography per station.

The station with the most tracks sets the frame; every other station is
aligned to it through the centres of the circles both saw. Returns the
calibration, each solved station's solution, and why each other station
was not solved. The calibration records `site`'s name only, with
`FREE_FRAME_ANCHOR` for its anchor.
drop_stations: Sequence[int] = (),
anchors: Sequence[Anchor] = (),
) -> tuple[Calibration, JointSolution, dict[int, str]]:
"""A free-mode calibration from circle tracks, every station in one frame.

One station is solved as `solve` solves it. Several are put in one frame
by `multi_station.solve_joint` through the circles they share; one that
is not tied to the rest raises `MultiStationError` unless it is in
`drop_stations`. Returns the calibration, the joint solution, and why
each station with tracks was not solved. The calibration records
`site`'s name only, with `FREE_FRAME_ANCHOR` for its anchor; with
`anchors` it is in `site`'s own frame and records `site`'s anchor.
"""
by_station: dict[int, list[Track]] = {}
for sample in samples:
by_station.setdefault(sample.station, []).append(
Track(
points=sample.camera_points(),
radius_mm=sample.radius_mm,
turn=sample.turn,
name=sample.name,
)
)
# pylint: disable=import-outside-toplevel,cyclic-import
from dotbot.calibration.multi_station import solve_joint, tracks_by_station

by_station = tracks_by_station(samples)
if not by_station:
raise ValueError("no tracks to solve")
margin_mm = robot_geometry(robot).axle_reach_mm
order = sorted(by_station, key=lambda k: (-len(by_station[k]), k))
reference = order[0]
solutions = {reference: solve(by_station[reference], margin_mm=margin_mm)}
unsolved: dict[int, str] = {}
centres = {t.name: t.centre_mm for t in solutions[reference].tracks}
for station in order[1:]:
try:
own = solve(by_station[station], margin_mm=margin_mm)
except ValueError as exc:
unsolved[station] = str(exc)
continue
common = [t for t in own.tracks if t.name in centres]
if len(common) < 2:
unsolved[station] = (
f"shares {len(common)} circle(s) with station {reference}; 2 are "
"needed to put both in one frame"
)
continue
S = similarity(
np.array([t.centre_mm for t in common]),
np.array([centres[t.name] for t in common]),
scale=False,
)
H = S @ own.homography
own.homography = H / H[2, 2]
why = {t.name: t.why for t in own.dropped}
own.tracks = fit_tracks(
[t for t in by_station[station] if t.name not in why], own.homography
)
dropped = [t for t in by_station[station] if t.name in why]
own.dropped = fit_tracks(
dropped, own.homography, {t: why[t.name] for t in dropped}
)
solutions[station] = own
width, height = solutions[reference].field_mm
joint = solve_joint(
by_station,
margin_mm=robot_geometry(robot).axle_reach_mm,
drop=drop_stations,
anchors=anchors,
)
solved_from = "conics-joint" if joint.joint else "conics-free"
anchor = FREE_FRAME_ANCHOR
rectangles, valid_mm = dict(joint.rectangles), joint.valid_mm
if joint.anchored:
solved_from, anchor = "conics-anchored", site.anchor
# In the site's frame, every station's rectangle is the site extent
if site.valid_mm is not None:
valid_mm = site.valid_mm
rectangles = {station: valid_mm for station in rectangles}
stations = [
StationSolution(
index=station,
homography=[[float(v) for v in row] for row in sol.homography],
points=sum(t.points for t in sol.tracks),
residual_mm=sol.residual_mm,
solved_from="conics-free",
homography=[[float(v) for v in row] for row in H],
points=sum(t.points for t in joint.solutions[station].tracks),
residual_mm=joint.station_rms_mm[station],
solved_from=solved_from,
valid_mm=rectangles[station],
)
for station, sol in sorted(solutions.items())
for station, H in sorted(joint.homographies.items())
]
now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
calibration = Calibration(
site=Site(name=site.name, anchor=FREE_FRAME_ANCHOR),
valid_mm=(0, 0, width, height),
site=Site(name=site.name, anchor=anchor),
valid_mm=valid_mm,
stations=stations,
tracks=list(samples),
links=sorted(joint.graph.links, key=lambda k: (k.a, k.b)),
created_at=now,
tag=tag,
robot=robot,
)
return calibration, solutions, unsolved
return calibration, joint, dict(joint.unsolved)


def spin_field(calibration: Calibration) -> tuple[int, int]:
"""Width and height of a free-mode calibration's field, from its file.

The field is what `solve` reports: the box from the frame's zero to the
furthest kept spin centre, grown by a robot's reach. Kept means passing
the health gate under the stored homography.
"""
margin_mm = robot_geometry(calibration.robot).axle_reach_mm
centres = []
for st in calibration.stations:
tracks = [
Track(points=t.camera_points(), radius_mm=t.radius_mm, turn=t.turn)
for t in calibration.tracks
if t.station == st.index and len(t.count1) >= TRACK_POINTS_MIN
]
if not tracks:
continue
gate = health_gate(tracks, st.matrix)
centres += [
_circle_fit(apply(st.matrix, t.points))[0] for t in tracks if t not in gate
]
if not centres:
return calibration.valid_mm[2], calibration.valid_mm[3]
hi = np.ceil(np.max(centres, axis=0) + margin_mm)
return int(hi[0]), int(hi[1])


def self_defined_site(
Expand All @@ -764,30 +796,35 @@ def self_defined_site(
) -> tuple[Site, Calibration]:
"""A site pack's site from a free-mode calibration, and the calibration in it.

The site is the starter site (`starter_layout`) around the calibration's
own fence, the field (see `solve`): a margin of floor round it, or a
`size_mm` site with it centred, and a staging strip below it. The
calibration is shifted by the field's offset, and a tag gets `-<name>`
appended, so the two files answer to different tags.
The site is the starter site (`starter_layout`) around the robots'
field (`spin_field`): a margin of floor round it, or a `size_mm` site
with it centred, and a staging strip below it. The calibration is
shifted by the field's offset, each station's rectangle becomes the
site extent, and a tag gets `-<name>` appended, so the two files answer
to different tags.
"""
if (
not calibration.stations
or calibration.site.anchor != FREE_FRAME_ANCHOR
or any(st.solved_from != "conics-free" for st in calibration.stations)
or any(
st.solved_from not in ("conics-free", "conics-joint")
for st in calibration.stations
)
):
raise ValueError(
f"calibration {calibration.id8} is not a free-mode spin calibration; "
"a site can only be defined from one"
)
x0, y0, field_w, field_h = calibration.valid_mm
x0, y0, _, _ = calibration.valid_mm
if (x0, y0) != (0, 0):
raise ValueError(
f"a free-mode fence starts at (0, 0), got {list(calibration.valid_mm)}"
)
field_w, field_h = spin_field(calibration)
extent, field_area, staging = starter_layout((field_w, field_h), size_mm)
dx, dy = field_area.x, field_area.y
shift = np.array([[1, 0, dx], [0, 1, dy], [0, 0, 1.0]])
anchor = f"self-defined from spin calibration {calibration.id8}"
anchor = f"{SELF_DEFINED_ANCHOR}{calibration.id8}"
stations = []
for st in calibration.stations:
H = shift @ st.matrix
Expand All @@ -799,6 +836,7 @@ def self_defined_site(
points=st.points,
residual_mm=st.residual_mm,
solved_from=st.solved_from,
valid_mm=(0, 0, int(extent[0]), int(extent[1])),
)
)
site = Site(
Expand All @@ -809,12 +847,14 @@ def self_defined_site(
)
placed = Calibration(
site=Site(name=name, anchor=anchor),
valid_mm=(0, 0, *extent),
valid_mm=union_rect(st.valid_mm for st in stations),
placements=calibration.placements,
stations=stations,
tracks=calibration.tracks,
links=calibration.links,
created_at=calibration.created_at,
tag=f"{calibration.tag}-{name}" if calibration.tag else "",
robot=calibration.robot,
placed_from=calibration.id,
)
return site, placed
6 changes: 5 additions & 1 deletion dotbot/calibration/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,11 @@ async def push(
client.send_lh2_calibration, payload, check.send_to
)
stale = await asyncio.to_thread(
push_worklist, client, session.saved, check.addresses
push_worklist,
client,
session.saved,
check.addresses,
check.info_gens,
)
finally:
await asyncio.to_thread(self._listen_for_buttons, "")
Expand Down
Loading
Loading