Files
he11lib/docs/superpowers/specs/2026-07-03-camera-geometry-redesign.md
T
Martino Ferrari 18d6276ab9 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>
2026-07-03 09:00:22 +02:00

12 KiB
Raw Blame History

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)

@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_degpointing_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.