diff --git a/docs/superpowers/specs/2026-07-03-camera-geometry-redesign.md b/docs/superpowers/specs/2026-07-03-camera-geometry-redesign.md new file mode 100644 index 0000000..ee75069 --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-camera-geometry-redesign.md @@ -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 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.