Add design spec for full camera geometry & measurement uncertainty

Generalizes camera modeling from a single pixel-scale/viewing-angle pair
to a full pinhole camera (3D orientation + position + intrinsics),
projected via true perspective (not just uniform cosine compression),
plus 2D beam pointing and per-plane z uncertainty. Every nominal
geometry value is now paired with an explicit tolerance that determines
whether it's held fixed or refined jointly with the mode fit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-07-03 09:00:22 +02:00
parent 03b63ba03a
commit 18d6276ab9
@@ -0,0 +1,222 @@
# he11lib — Full Camera Geometry & Measurement Uncertainty Redesign
**Date:** 2026-07-03
**Status:** Approved for planning
**Supersedes:** parts of `docs/superpowers/specs/2026-07-02-gyrotron-mode-purity-design.md`
relating to `MeasurementPlane.pixel_scale`/`viewing_angle_deg`, `GeometryCalibration`,
and the single-scalar beam pointing angle. All other decisions in the original spec
(mode basis, noise, deconvolution, phase-retrieval fallback, package layout) are
unchanged and still apply.
## Purpose
The original design modeled camera geometry as a single, isotropic pixel scale plus
one viewing-angle tilt (a uniform, affine correction across the whole frame), and
treated each `MeasurementPlane`'s `z` distance as exact. Real measurement setups are
more demanding:
- The camera's orientation relative to the beam axis has a full 3 rotational degrees
of freedom (yaw, pitch, roll), not one.
- The camera is close/wide-angle enough that true perspective projection (keystoning
that varies across the frame) matters, not just a uniform compression factor.
- A physical calibration step (e.g. fiducial markers) gives nominal values for camera
position, orientation, and intrinsics — but mechanical vibration means none of these
can be trusted as exact; all must be refined jointly with everything else.
- The beam's pointing/tilt is two-dimensional (independent horizontal and vertical
tilt), not a single scalar angle.
- Each plane's `z` distance (from a translation stage or tape measure) is also only
known to a nominal precision and should be refined, not trusted exactly.
## Scope of this change
- Replace the single pixel-scale/viewing-angle model with a full pinhole camera model
shared across all planes in one reconstruction.
- Generalize beam pointing from one angle to two (horizontal, vertical).
- Model each plane's `z` as a nominal value with its own refinable uncertainty.
- Unify "trusted/fixed" vs. "uncertain/refined" behind a single tolerance mechanism
(see below), replacing the old `None`-means-unknown convention for geometry.
- Out of scope: lens distortion (radial/tangential), rolling-shutter effects, and
multi-camera setups. These are not part of the current measurement setup and would
need a separate design if they become relevant.
## Architecture
### Data model changes (`data.py`)
- `MeasurementPlane` drops `pixel_scale` and `viewing_angle_deg` (per-plane geometry
no longer makes sense once the camera pose is a single shared physical setup for
the whole reconstruction). It gains `z_tolerance: float`, the ± bound (in meters)
around the nominal `z` within which the true distance is refined. `z_tolerance` must
be `>= 0`; `0` means `z` is trusted exactly and held fixed.
- `ReconstructionResult.pointing_angle_deg: float` becomes two fields:
`pointing_angle_horizontal_deg: float` and `pointing_angle_vertical_deg: float`.
- `ReconstructionResult.geometry` gains entries for the fitted `CameraModel` fields
(see below) alongside the existing per-plane fitted `z` values.
### `CameraModel` and `CameraModelTolerance` (`geometry.py`)
```python
@dataclass
class CameraModel:
focal_length_px: float
position: tuple[float, float, float] # (x, y, z) in the beam-axis frame,
# z=0 at the output window
orientation_deg: tuple[float, float, float] # (yaw, pitch, roll); all-zero =
# boresight normal to the target
# plane, no in-plane rotation
principal_point: tuple[float, float] = (0.0, 0.0) # (px, px) offset from frame center
@dataclass
class CameraModelTolerance:
focal_length_px: float
position: tuple[float, float, float]
orientation_deg: tuple[float, float, float]
principal_point: tuple[float, float] = (0.0, 0.0) # (px, px)
```
`CameraModel` always represents a nominal point estimate (from calibration, or an
assumed default), never a value trusted as exact by itself — trust/uncertainty is
expressed entirely through the paired `CameraModelTolerance` (see "Tolerance
mechanism" below).
`GeometryCalibration` is rewritten around these two types. Given a `CameraModel` and a
target plane's `z`, it provides:
- **Forward projection** — physical `(X, Y)` at that `z` to pixel `(row, col)`, via
standard pinhole projection: rotate/translate the world point into the camera
frame using `orientation_deg`/`position`, then perspective-divide using
`focal_length_px`/`principal_point`.
- **Inverse projection** — pixel `(row, col)` to physical `(X, Y)`, via rayplane
intersection: cast a ray from the camera through each pixel and intersect it with
the known `z =` const target plane. This is what produces genuine keystoning (the
correction varies across the frame), replacing the old uniform
`x = col * scale / cos(angle)` formula.
- Raises `ValueError` for degenerate poses where the target plane is edge-on to or
behind the camera (no valid intersection).
### Tolerance mechanism (applies to `CameraModel`/`CameraModelTolerance` and
`MeasurementPlane.z`/`z_tolerance`)
Every nominal geometry value is paired with a tolerance that determines how
`ModalFitter` treats it:
- **`tolerance == 0`**: held fixed at the nominal value. Excluded entirely from the
optimizer's parameter vector and substituted as a constant into the model function.
This recovers the old "fully known, don't fit it" behavior.
- **`tolerance > 0`**: included in the fit, bounded to
`[nominal - tolerance, nominal + tolerance]` via `scipy.optimize.least_squares`'
`bounds=`.
There is no "fully unbounded" mode for these parameters — if a value is genuinely
unconstrained, its tolerance should be set generously wide rather than infinite,
since an unbounded 7-9 parameter homography fit from 3-10 planes has little chance of
converging usefully without some bound.
### `ModalFitter` changes (`fitting.py`)
The optimizer's parameter vector is now built dynamically per reconstruction:
- Always free (unchanged in kind, generalized in the pointing case): complex LG mode
coefficients, per-plane beam transverse center `(x, y)`, and the two beam pointing
angles (`pointing_angle_horizontal_deg`, `pointing_angle_vertical_deg`).
- Conditionally free (new): any `CameraModel` field whose paired
`CameraModelTolerance` entry is nonzero, and any plane's `z` whose `z_tolerance` is
nonzero — each bounded as described above.
`BeamReconstructor.__init__` gains required `camera: CameraModel` and
`camera_tolerance: CameraModelTolerance` parameters (alongside existing `w0`, `z0`,
`wavelength`), replacing the old optional per-plane pixel-scale/viewing-angle
handling.
### `SyntheticBeamGenerator` changes (`synthetic.py`)
Takes an exact ground-truth `CameraModel` (position/orientation/intrinsics) and the
two beam pointing angles, and generates each plane at its own exact/ground-truth `z`
— which may deliberately differ from the nominal `z` given to the resulting
`MeasurementPlane`, so tests can verify the fit recovers the true `z` despite a
deliberately offset nominal input.
## Data flow (updated)
1. Build a list of `MeasurementPlane` (flux array + nominal `z` + `z_tolerance` +
label), plus a nominal `CameraModel` + `CameraModelTolerance` for the whole
reconstruction.
2. `GeometryCalibration` resolves the pixel↔physical mapping per plane from the
(possibly-still-being-refined) `CameraModel` and that plane's (possibly-being-
refined) `z`.
3. `NoiseEstimator` computes per-plane noise weights (unchanged).
4. `DiffusionDeconvolver` optionally deblurs each plane (unchanged; still assumes an
isotropic pixel-space kernel — noted as an existing approximation, not addressed
by this redesign).
5. `ModalFitter` runs the joint noise-weighted nonlinear least-squares fit over LG
coefficients + per-plane center + 2 pointing angles + any nonzero-tolerance camera
and `z` parameters, growing the mode set automatically as before.
6. Phase-retrieval fallback and `ReconstructionResult` assembly proceed as in the
original design, with `pointing_angle_deg` replaced by the horizontal/vertical
pair and `geometry` extended to include the fitted `CameraModel` fields and
per-plane fitted `z`.
## Testing strategy
In addition to the original design's synthetic-ground-truth-recovery approach:
- **`CameraModel` projection round-trip**: for several poses (on-axis, tilted,
off-center) and several `z` values, physical→pixel→physical recovers the original
point.
- **Keystone regression**: projecting a symmetric grid through a tilted/off-axis
`CameraModel` produces non-uniform spacing across the frame (distinguishing it from
the old uniform cosine-compression model).
- **Degenerate pose**: a pose placing the target plane edge-on to or behind the
camera raises `ValueError`.
- **Tolerance semantics**: a `tolerance=0` field stays exactly at its (deliberately
wrong) nominal value rather than being corrected; a `tolerance>0` field recovers a
ground truth offset from nominal but within its band; a ground truth placed outside
a deliberately too-tight band is clipped to the bound rather than escaping it.
- **End-to-end**: the full pipeline recovers mode purity, both pointing angles, the
camera pose, and per-plane `z`, when `SyntheticBeamGenerator`'s ground truth is
offset from the nominal inputs (within their tolerances) — simulating realistic
calibration/measurement error rather than assuming perfect nominal values.
## Error handling
- `CameraModelTolerance` fields and `MeasurementPlane.z_tolerance` must be `>= 0`;
`ValueError` otherwise. Validated at construction, consistent with the existing
"validate only at boundaries" approach.
- Degenerate camera geometry (target plane edge-on to or behind the camera) raises
`ValueError` from the projection code rather than producing NaNs.
- **New documented pitfall**: with only 3-10 planes, adding ~7-9 shared camera
unknowns plus one `z` correction per plane can be practically underdetermined even
though each plane contributes many pixels of data, because those unknowns are
*global* and only weakly constrained by subtle keystone differences between planes.
`fit_auto`/`BeamReconstructor` emit a `UserWarning` (not an error, consistent with
existing warn-don't-raise style) when the free-parameter count is large relative to
the number of planes, prompting the user toward tighter tolerances rather than
silently returning an ill-conditioned fit.
## Migration impact
This is a breaking change to the public API, acceptable pre-1.0 with no external
users yet:
- `MeasurementPlane(pixel_scale=..., viewing_angle_deg=...)`
`MeasurementPlane(z_tolerance=...)` + a reconstruction-level `CameraModel`/
`CameraModelTolerance`.
- `BeamReconstructor(...)` requires new `camera`/`camera_tolerance` arguments.
- `SyntheticBeamGenerator.generate(...)` requires a `CameraModel` and two pointing
angles instead of scalar `viewing_angle_deg`/`pixel_scale`/`pointing_angle_deg`.
- `ReconstructionResult.pointing_angle_deg``pointing_angle_horizontal_deg` +
`pointing_angle_vertical_deg`.
- `docs/api.md` and `examples/full_pipeline_example.py` need updating to match.
- The existing pitfalls documented in `CLAUDE.md` (Rayleigh-range clipping,
mode-growth overfitting) still apply unchanged; the new degeneracy pitfall above is
additive.
## Deliverables
- Updated `geometry.py` (`CameraModel`, `CameraModelTolerance`, rewritten
`GeometryCalibration`), `data.py`, `fitting.py`, `synthetic.py`, `reconstruct.py`.
- Updated tests covering projection round-trip, keystone behavior, degenerate poses,
tolerance semantics, and end-to-end recovery under offset nominal inputs.
- Updated `docs/api.md` and `examples/full_pipeline_example.py` reflecting the new
public interface.
- Updated `CLAUDE.md` with the new degeneracy pitfall.