18d6276ab9
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>
223 lines
12 KiB
Markdown
223 lines
12 KiB
Markdown
# 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.
|