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:
@@ -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.
|
||||||
Reference in New Issue
Block a user