# 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 ray–plane 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.