Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
3328a8a
Initial commit of Camera-IMU calibration algo
Jun 20, 2026
d1065b6
Fix & add comments
Jun 20, 2026
95b3853
Merge branch 'main' into feature/camera_imu_alignment
Aug 2, 2026
1d43636
Move camera/IMU alignment code to imu/
Aug 2, 2026
9341db0
Comment where the changes need to be made in integrator
Aug 2, 2026
d9c4c8e
Refactor ensure_quat_continuity(): Move to quaternion_transforms.py
Aug 2, 2026
4bea85f
Rename
Aug 2, 2026
524e128
Fix RaDecRoll.from_quaternion(): Set valid to True
Aug 3, 2026
62150bc
Commit preprocess_imu_alignment.py
Aug 3, 2026
e83cc3a
Rename
Aug 3, 2026
1091ada
Transfer functionality to imu_alignment.py and leave pure solver func…
Aug 3, 2026
386f071
Generalise solve funcs
Aug 3, 2026
98a2220
Routine to process pairings in loop and solve
Aug 6, 2026
f69d68d
Move to imu_align/
Aug 7, 2026
a44acb7
Generalise
Aug 7, 2026
88d2d94
Move funcs around
Aug 7, 2026
03af963
Fix
Aug 7, 2026
3b23d65
Use sets for remove_lidx for efficiency & simplicity. Refactor.
Aug 7, 2026
ff88d5a
Simplify. Return solution
Aug 7, 2026
b71e81e
Refactor. Class for diagnostics, use logging
Aug 15, 2026
3c6b79c
Merge branch 'main' into feature/camera_imu_alignment
Aug 15, 2026
05ac43c
Runs but solution isn't sensible
Aug 15, 2026
6edea94
Trying out quaternion continuity
Aug 15, 2026
975667b
Fixed by giving relative rotation quaternions dq to the solver
Aug 16, 2026
66f7a11
Refactor diagnostics
Aug 16, 2026
5081d93
Calculate uncertainty at solution. Get outlier removing working - imp…
Aug 16, 2026
b27d469
Add logging
Aug 19, 2026
026139e
Fixed issue with mis-matching time difference list length after outli…
Aug 19, 2026
37458d7
Save diagnostics from first pass
Aug 19, 2026
4e88286
Use SD as an additional metric for outlier detection
Aug 19, 2026
fb4e81e
Clean up, rename
Aug 19, 2026
375fe3a
Merge branch 'main' into feature/camera_imu_alignment
Aug 19, 2026
e513fee
Refactor. Assign default values to ImuCameraAlignment that works well
Aug 19, 2026
3e1888a
Integrate IMU/Camera alignement into integrator.py. Not tested yet!
Aug 19, 2026
df284da
Add commentary
Aug 19, 2026
71a501a
Estimate the DoF properly accounting for connected samples. Uncertain…
Aug 21, 2026
6691083
Fix import
Aug 21, 2026
1803365
Fix incorrect change in coordinates.py
Aug 21, 2026
1f0f67e
Move main block to tests
Aug 21, 2026
b9fc235
Relabeling this as 2.6.3 to skip private 2.6.2 release
brickbots Aug 19, 2026
31090c4
fix(optics): no FOV gate for frames from an unknown optical train (#632)
brickbots Aug 20, 2026
b5d6a48
Refactor
Aug 21, 2026
6f581ee
Lint
Aug 22, 2026
9b9a49a
Lint: Format
Aug 22, 2026
9b2cb05
Fix type hint errors
Aug 22, 2026
7ce82e0
Remove dead code
Aug 22, 2026
281861f
Update tests
Aug 22, 2026
2b24c56
Lint
Aug 22, 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
103 changes: 103 additions & 0 deletions docs/adr/0029-fov-gate-width-follows-lens-confidence.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,106 @@ One caveat, recorded honestly: 13.04 mm rests on a **single 12 mm sample**. The
16 mm earned its 15.61 by reproducing two independently calibrated field
widths on two different sensors. A second 12 mm — ideally on an imx296, so the
sensor half varies too — would put it on the same footing.

## Amendment: a third rung — no gate when the optical train is unknown

The ladder above has two rungs, and both assume the frames came through the
optics the device is configured with. `--camera debug` breaks that assumption:
it replays archived frames, and the train those frames were shot through is
not the train this machine is configured for. So the ladder gains a third
rung — **stated → ±15%, assumed → union over shipped lenses, unknown → no gate
at all** — and the confidence the width follows becomes confidence in the
pairing rather than only in the lens.

### The failure

ADR 0027 relabelled the debug camera's sensor from `imx296` to `hq`, because
the frames in `test_images/` are 10.2° and only the hq's train covers that.
That fixed the sensor half and left the lens half reading live from
`camera_lens`, which `resolve_lens` honours whatever profile it is handed. The
result is a train that has never physically existed:

| train | derived FOV | gate | debug frames |
|---|---|---|---|
| hq + no stated lens | 10.33° | [8.78, 11.88] | solve (fitted 10.20°) |
| hq + stated `25mm` | 10.33° | [8.78, 11.88] | solve |
| hq + stated `16mm` | 17.12° | [14.55, 19.69] | **no solve** |
| hq + stated `12mm` | 20.43° | [17.37, 23.49] | **no solve** |

Any developer who has ever opened the Lens menu, and every device whose lens
this ADR's own self-heal has written, loses `--camera debug` entirely. It is
the same shape as the regression this document exists to fix, mirrored: there
an assumption wore a statement's confidence; here a statement is made about
hardware that is not in the loop.

`tests/test_optics_solving.py` did not catch it because every case calls
`build_optical_train("hq")` with no lens key — only ever the assumed path.

### Why no hint, rather than the alternatives

**Declaring a lens from the camera** (debug publishes hq + 25 mm, config
loses) keeps the gate and its mis-solve rejection, and was the first
instinct. Rejected because it needs a lens statement that must never reach
`camera_lens` — a second, differently-scoped writer for a key whose whole
meaning is "the user's claim" — and because it pins debug mode to the one
frame set we happen to ship. Dropping your own unsolvable field into
`test_images/` is the main thing debug mode is *for*, and under a declared
lens those frames are gated out exactly as the shipped ones are today.

**Emulating the configured train** (serve frames matching the user's sensor
and lens, so `-fh` behaves like their rev4) is the right answer to a different
question and needs a real archived frame per shipped combination. The frames
we have are 10.2°, too narrow to re-project outward from; this stays open if
anyone wants it, and it wants captures, not code.

**A database-range gate** of `[10, 30]°` is no-hint with extra steps — the
database floor is already doing that pruning — and leaves a constant to
maintain for no protection gained.

**Rejecting non-shipped pairings** in `resolve_lens` (an hq never shipped with
a 12 mm, so that statement is not credible) would fix debug as a side effect
and close the sensor-swap deadlock too. Rejected here as blast radius: it
makes a user's explicit statement conditionally non-authoritative, which is a
direct contradiction of 0027, and it deserves its own decision rather than
riding along with a development-tool fix.

The measured cost of no-hint is small and known. On the shipped frames all
three widths return identical RA/Dec, `Matches` and `Prob` within ~1 ms of
each other. What no-hint gives up is the upper bound that rejected confident
mis-solves in this document's noise trials — a protection whose value is that
a device under the stars never reports a wrong pointing. Nothing is under the
stars in debug mode.

### Consequences

**The gate is omitted, not widened.** `fov_estimate` is not passed at all when
the train is unknown, so there is no third window to keep consistent with the
other two, and any frame from any train solves.

**Self-heal is barred under an unknown train**, and this is a live bug the
amendment fixes rather than a hazard it introduces. With no `camera_lens` in
config — the ordinary state of a development machine — `--camera debug` solves
at 10.20°, `identify_lens_from_fitted_fov` matches that to the hq's `25mm`
within 1.3%, and after three frames the integrator writes `camera_lens: 25mm`
into the developer's config. It is not wrong about the *frames*; it is a
statement about a device made from a recording. The trap springs later: attach
a real imx462 and the now-stated 25 mm derives 6.4°, nothing solves, and
self-heal cannot undo it because it only ever writes into an absence. A fitted
FOV measures the train the frames passed through, so under an unknown train it
measures nothing about this device and must not be learned from.

**Only the gate and self-heal follow the unknown train.** The **frustum** keeps
deriving from the configured optics, deliberately: it answers "what would my
camera image", which is a question about the device and stays meaningful while
a recording plays. Propagating would also delete a frustum that is *correct*
on a dev laptop (10.33° derived against 10.20° frames) to avoid one that is
wrong only when a lens is stated. SQM is unaffected either way — only
`camera_pi` publishes radiometer samples, so the radiometric path is inert
under the debug camera.

**A stated lens still means no solves on real hardware.** Nothing here softens
0027. The unknown train is a property of where the frames came from, not a
new escape hatch for a wrong statement — which is why the sensor-swap
deadlock (switch imx296 → hq from the Camera Type menu with a stated 16 mm,
derive 17.12°, and be unable to measure out of it) is untouched by this and
remains a documentation path (#613).
27 changes: 25 additions & 2 deletions docs/ax/camera/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ the one field of view it implies, and nothing overrides it, so a wrong
statement still means no solves (that is [ADR
0027](../../adr/0027-fov-gate-derived-from-optical-train.md)'s deliberate
consequence, not a regression). Written by the user from the Lens menu, or
once by the device itself from a fitted FOV it is confident about.
once by the device itself from a fitted FOV it is confident about — but never
from a fitted FOV measured under an **unknown optical train**, which measures
a recording rather than this device.
_Avoid_: configured lens (true of a self-healed value too, so it does not
distinguish), selected lens, user lens.

Expand All @@ -133,7 +135,9 @@ stated. Not a claim about the hardware — an admission that nobody has said,
which is the ordinary condition of an install predating the setting. Because
there is nothing to trust, the FOV gate widens to cover *every* lens that
sensor has shipped with rather than centring on the fallback. An assumed lens
is a temporary state: the first confident solve turns it into a stated one.
is a temporary state: the first confident solve turns it into a stated one —
unless the train is **unknown**, in which case no solve ever ends the
assumption, because none of them measured this device.
_Avoid_: default lens (reads as a preference rather than an absence of
information), unset lens (the field of view is never unset — some lens is
always assumed).
Expand Down Expand Up @@ -166,6 +170,25 @@ _Avoid_: optical configuration ("configuration" already names the physical
build variants — see [Positioning](../positioning/CONTEXT.md) *screen
direction*), camera setup, imaging train.

**Unknown optical train**:
The state of a camera whose frames did not come through this device's optics
at all — today only the debug camera, which replays archived frames. The
device's train still resolves to *something* (the debug camera declares the
sensor its frames were shot on), but that train describes the machine, not
the frames, so two things follow: nothing about the device may be **asserted**
about the frames — the solver is handed no FOV gate rather than a derived one
— and nothing about the device may be **inferred** from them, so a fitted FOV
cannot promote an **assumed lens** to a **stated** one. This is the third rung
of the confidence ladder the FOV gate's width follows: stated → ±15%, assumed
→ the union over the sensor's shipped lenses, unknown → no gate at all (see
[ADR 0029](../../adr/0029-fov-gate-width-follows-lens-confidence.md)).
Deliberately *not* a blanket "everything derived goes dark": the **frustum**
still answers what the configured camera would image, which is a question
about the device and stays meaningful while a recording is being replayed.
_Avoid_: debug FOV, no FOV (the frames have one — nobody here knows it),
unknown lens (the lens resolves normally; it is the pairing that means
nothing), fake camera.

**Field of view**:
The angular width of the **crop**, in degrees — the edge-to-edge extent, not
the diagonal. Derived from the optical train. Every consumer that needs to
Expand Down
25 changes: 20 additions & 5 deletions python/PiFinder/camera_debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@ class CameraDebug(CameraInterface):
def __init__(self, exposure_time) -> None:
logger.debug("init camera debug")
# Format matches PI cameras for compatibility. The sensor named here
# is not cosmetic: it half-determines the solver's FOV gate, and the
# frames in test_images/ are ~10.2 deg, which only the hq profile's
# train covers. Declaring imx296 centres the gate on 13.71 deg and
# every debug frame is rejected before verification -- no solves at
# all under `--camera debug`. See docs/adr/0027.
# is the one the frames in test_images/ were actually shot on, which
# is what makes SQM's profile lookup and the Lens menu resolve to
# something plausible. It is *not* a claim that this machine has an hq
# attached, and it does not settle the field of view on its own: the
# other half of the train comes from config, and a config that states
# a lens (16mm -> 17.12 deg, 12mm -> 20.43 deg) gates these ~10.2 deg
# frames straight out. Hence optical_train_known below. See
# docs/adr/0027 and the third-rung amendment to docs/adr/0029.
self.camType = "Debug hq"
self.path = utils.pifinder_dir / "test_images"
self.exposure_time = exposure_time
Expand Down Expand Up @@ -106,6 +109,18 @@ def set_camera_config(
def get_cam_type(self) -> str:
return self.camType

def optical_train_known(self) -> bool:
"""No. These frames are a recording, not a view through this device.

Whatever lens config states is a claim about glass that is not in the
loop, so pairing it with the sensor above produces a field of view
that describes nothing. Saying so is what keeps `--camera debug`
solving whatever the config happens to hold -- including frames a
developer drops into test_images/ from another train entirely, which
no derived gate could have anticipated.
"""
return False


def get_images(shared_state, camera_image, command_queue, console_queue, log_queue):
"""
Expand Down
28 changes: 28 additions & 0 deletions python/PiFinder/camera_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,22 @@ def set_camera_config(
def get_cam_type(self) -> str:
return "foo"

def optical_train_known(self) -> bool:
"""Whether these frames came through this device's own optics.

True for anything pointed at the sky, which is why it defaults that
way: a camera has to opt *out*, so a new hardware backend inherits the
FOV gate rather than silently losing it.

False is an **unknown optical train** (docs/ax/camera/CONTEXT.md): the
frames were captured through some other train, so the resolved one
describes this machine and not them. Two things follow, both handled
by the consumers rather than here -- the solver asserts no FOV gate,
and lens self-heal declines to infer a lens from a fitted FOV that
measured a recording. See docs/adr/0029.
"""
return True

def start_camera(self) -> None:
pass

Expand All @@ -308,6 +324,18 @@ def get_image_loop(
shared_state.set_camera_type(camera_type)
logger.info(f"Camera type set to: {camera_type}")

# Published beside the sensor because it qualifies it: the sensor
# a playback camera declares is the one its *frames* were shot on,
# which is not the same claim a live camera makes.
train_known = self.optical_train_known()
shared_state.set_optical_train_known(train_known)
if not train_known:
logger.info(
"Optical train is unknown: these frames did not come "
"through this device's optics, so the solver gets no FOV "
"gate and the lens cannot self-heal from them"
)

# Check if auto-exposure was previously enabled in config
config_exp = cfg.get_option("camera_exp")
if config_exp == "auto":
Expand Down
Empty file added python/PiFinder/imu/__init__.py
Empty file.
Loading
Loading