Rewrite ModalFitter.fit around the CameraModel tolerance mechanism
The optimizer's parameter vector is now built dynamically: LG coefficients, per-plane center, and both pointing angles stay always free; each CameraModel field and each plane's z join the fit (bounded to its +/- tolerance) only when its paired tolerance is nonzero, and are otherwise substituted as fixed constants. Also seeds the first mode's coefficient with a small imaginary offset (1.0 + 0.05j instead of 1.0 + 0j) rather than exactly on the real axis: for a single mode, intensity depends only on |c| (phase is unobservable), so Im=0 sits exactly on that flat/degenerate valley, aligned with a coordinate axis, giving a zero-gradient Jacobian column that destabilized trf's trust-region step and prevented pointing-angle recovery from a default (0, 0) initial guess. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+161
-28
@@ -3,6 +3,7 @@ import pytest
|
||||
|
||||
from he11lib.data import validate_planes
|
||||
from he11lib.fitting import ModalFitter, generate_mode_shells
|
||||
from he11lib.geometry import CameraModel, CameraModelTolerance
|
||||
from he11lib.modes import LGBasis
|
||||
from he11lib.synthetic import SyntheticBeamGenerator
|
||||
|
||||
@@ -10,6 +11,7 @@ W0 = 5e-3
|
||||
Z0 = 0.5
|
||||
WAVELENGTH = 1.76e-3
|
||||
PIXEL_SCALE = 4e-4
|
||||
CAMERA_DISTANCE = 5.0
|
||||
IMAGE_SHAPE = (61, 61)
|
||||
Z_LIST = [0.35, 0.5, 0.65, 0.8]
|
||||
|
||||
@@ -18,8 +20,21 @@ def make_basis():
|
||||
return LGBasis(w0=W0, z0=Z0, wavelength=WAVELENGTH)
|
||||
|
||||
|
||||
def make_generator(basis):
|
||||
return SyntheticBeamGenerator(basis=basis, image_shape=IMAGE_SHAPE, pixel_scale=PIXEL_SCALE)
|
||||
def make_camera(pixel_scale=PIXEL_SCALE, position=(0.0, 0.0, -CAMERA_DISTANCE), orientation_deg=(0.0, 0.0, 0.0)):
|
||||
focal_length_px = (CAMERA_DISTANCE + Z0) / pixel_scale
|
||||
return CameraModel(
|
||||
focal_length_px=focal_length_px, position=position, orientation_deg=orientation_deg
|
||||
)
|
||||
|
||||
|
||||
def zero_tolerance():
|
||||
return CameraModelTolerance(
|
||||
focal_length_px=0.0, position=(0.0, 0.0, 0.0), orientation_deg=(0.0, 0.0, 0.0)
|
||||
)
|
||||
|
||||
|
||||
def make_generator(basis, camera):
|
||||
return SyntheticBeamGenerator(basis=basis, camera=camera)
|
||||
|
||||
|
||||
def test_generate_mode_shells_orders_by_2p_plus_abs_l():
|
||||
@@ -31,13 +46,14 @@ def test_generate_mode_shells_orders_by_2p_plus_abs_l():
|
||||
|
||||
def test_fit_recovers_pure_fundamental_mode():
|
||||
basis = make_basis()
|
||||
gen = make_generator(basis)
|
||||
camera = make_camera()
|
||||
gen = make_generator(basis, camera)
|
||||
planes = gen.generate(
|
||||
coefficients={(0, 0): 1.0 + 0j}, z_list=Z_LIST, noise_std=1e-4, seed=0
|
||||
coefficients={(0, 0): 1.0 + 0j}, z_list=Z_LIST, image_shape=IMAGE_SHAPE, noise_std=1e-4, seed=0
|
||||
)
|
||||
|
||||
fitter = ModalFitter(basis)
|
||||
result = fitter.fit(planes, modes=[(0, 0)])
|
||||
result = fitter.fit(planes, modes=[(0, 0)], camera=camera, camera_tolerance=zero_tolerance())
|
||||
|
||||
power_fraction, _ = result.purity[(0, 0)]
|
||||
assert power_fraction == pytest.approx(1.0, abs=1e-6)
|
||||
@@ -48,12 +64,17 @@ def test_fit_recovers_pure_fundamental_mode():
|
||||
|
||||
def test_fit_recovers_two_mode_purity_ratio():
|
||||
basis = make_basis()
|
||||
gen = make_generator(basis)
|
||||
camera = make_camera()
|
||||
gen = make_generator(basis, camera)
|
||||
true_coeffs = {(0, 0): 0.9 + 0j, (1, 0): 0.3 + 0.1j}
|
||||
planes = gen.generate(coefficients=true_coeffs, z_list=Z_LIST, noise_std=1e-4, seed=1)
|
||||
planes = gen.generate(
|
||||
coefficients=true_coeffs, z_list=Z_LIST, image_shape=IMAGE_SHAPE, noise_std=1e-4, seed=1
|
||||
)
|
||||
|
||||
fitter = ModalFitter(basis)
|
||||
result = fitter.fit(planes, modes=list(true_coeffs.keys()))
|
||||
result = fitter.fit(
|
||||
planes, modes=list(true_coeffs.keys()), camera=camera, camera_tolerance=zero_tolerance()
|
||||
)
|
||||
|
||||
true_total = sum(abs(c) ** 2 for c in true_coeffs.values())
|
||||
for mode, c in true_coeffs.items():
|
||||
@@ -64,49 +85,161 @@ def test_fit_recovers_two_mode_purity_ratio():
|
||||
|
||||
def test_fit_recovers_center_offset():
|
||||
basis = make_basis()
|
||||
gen = make_generator(basis)
|
||||
camera = make_camera()
|
||||
gen = make_generator(basis, camera)
|
||||
true_center = (10 * PIXEL_SCALE, -5 * PIXEL_SCALE)
|
||||
planes = gen.generate(
|
||||
coefficients={(0, 0): 1.0 + 0j},
|
||||
z_list=Z_LIST,
|
||||
image_shape=IMAGE_SHAPE,
|
||||
center=true_center,
|
||||
noise_std=1e-4,
|
||||
seed=2,
|
||||
)
|
||||
|
||||
fitter = ModalFitter(basis)
|
||||
result = fitter.fit(planes, modes=[(0, 0)], initial_center=true_center)
|
||||
result = fitter.fit(
|
||||
planes,
|
||||
modes=[(0, 0)],
|
||||
camera=camera,
|
||||
camera_tolerance=zero_tolerance(),
|
||||
initial_center=true_center,
|
||||
)
|
||||
|
||||
for cx, cy in result.centers:
|
||||
assert cx == pytest.approx(true_center[0], abs=2 * PIXEL_SCALE)
|
||||
assert cy == pytest.approx(true_center[1], abs=2 * PIXEL_SCALE)
|
||||
|
||||
|
||||
def test_fit_recovers_unknown_pixel_scale():
|
||||
# Use a coarser pixel scale so the (much wider, far-field) beam at the
|
||||
# outer z distances still fits within the frame -- otherwise pixel scale
|
||||
# becomes unobservable from clipped images.
|
||||
def test_fit_recovers_pointing_angles_independently():
|
||||
basis = make_basis()
|
||||
local_pixel_scale = 1.5e-3
|
||||
gen = SyntheticBeamGenerator(basis=basis, image_shape=IMAGE_SHAPE, pixel_scale=local_pixel_scale)
|
||||
planes = gen.generate(coefficients={(0, 0): 1.0 + 0j}, z_list=Z_LIST, noise_std=1e-4, seed=3)
|
||||
camera = make_camera()
|
||||
gen = make_generator(basis, camera)
|
||||
planes = gen.generate(
|
||||
coefficients={(0, 0): 1.0 + 0j},
|
||||
z_list=Z_LIST,
|
||||
image_shape=IMAGE_SHAPE,
|
||||
pointing_angle_horizontal_deg=0.3,
|
||||
pointing_angle_vertical_deg=-0.15,
|
||||
noise_std=1e-4,
|
||||
seed=6,
|
||||
)
|
||||
|
||||
# hide the known calibration to force the fitter to solve for it
|
||||
for plane in planes:
|
||||
plane.pixel_scale = None
|
||||
plane.viewing_angle_deg = None
|
||||
fitter = ModalFitter(basis)
|
||||
result = fitter.fit(planes, modes=[(0, 0)], camera=camera, camera_tolerance=zero_tolerance())
|
||||
|
||||
assert result.pointing_angle_horizontal_deg == pytest.approx(0.3, abs=0.05)
|
||||
assert result.pointing_angle_vertical_deg == pytest.approx(-0.15, abs=0.05)
|
||||
|
||||
|
||||
def test_fit_holds_zero_tolerance_camera_field_fixed_at_wrong_nominal():
|
||||
# A tolerance=0 field must stay exactly at its (deliberately wrong)
|
||||
# nominal value rather than being corrected.
|
||||
basis = make_basis()
|
||||
true_camera = make_camera()
|
||||
gen = make_generator(basis, true_camera)
|
||||
planes = gen.generate(
|
||||
coefficients={(0, 0): 1.0 + 0j}, z_list=Z_LIST, image_shape=IMAGE_SHAPE, noise_std=1e-4, seed=7
|
||||
)
|
||||
|
||||
wrong_focal_length = true_camera.focal_length_px * 1.2
|
||||
nominal_camera = CameraModel(
|
||||
focal_length_px=wrong_focal_length,
|
||||
position=true_camera.position,
|
||||
orientation_deg=true_camera.orientation_deg,
|
||||
)
|
||||
|
||||
fitter = ModalFitter(basis)
|
||||
result = fitter.fit(
|
||||
planes,
|
||||
modes=[(0, 0)],
|
||||
initial_pixel_scale=local_pixel_scale * 1.1,
|
||||
initial_viewing_angle_deg=0.0,
|
||||
planes, modes=[(0, 0)], camera=nominal_camera, camera_tolerance=zero_tolerance()
|
||||
)
|
||||
|
||||
fitted_scales = [result.geometry[f"pixel_scale_{i}"] for i in range(len(planes))]
|
||||
for scale in fitted_scales:
|
||||
assert scale == pytest.approx(local_pixel_scale, rel=0.05)
|
||||
assert result.geometry["focal_length_px"] == wrong_focal_length
|
||||
|
||||
|
||||
def test_fit_recovers_offset_camera_field_within_tolerance():
|
||||
# A tolerance>0 field recovers a ground-truth offset from nominal, but
|
||||
# within its band.
|
||||
basis = make_basis()
|
||||
true_camera = make_camera()
|
||||
gen = make_generator(basis, true_camera)
|
||||
planes = gen.generate(
|
||||
coefficients={(0, 0): 1.0 + 0j}, z_list=Z_LIST, image_shape=IMAGE_SHAPE, noise_std=1e-4, seed=8
|
||||
)
|
||||
|
||||
offset = true_camera.focal_length_px * 0.02 # 2% off nominal
|
||||
nominal_camera = CameraModel(
|
||||
focal_length_px=true_camera.focal_length_px + offset,
|
||||
position=true_camera.position,
|
||||
orientation_deg=true_camera.orientation_deg,
|
||||
)
|
||||
tolerance = CameraModelTolerance(
|
||||
focal_length_px=true_camera.focal_length_px * 0.05, # +/-5% band
|
||||
position=(0.0, 0.0, 0.0),
|
||||
orientation_deg=(0.0, 0.0, 0.0),
|
||||
)
|
||||
|
||||
fitter = ModalFitter(basis)
|
||||
result = fitter.fit(planes, modes=[(0, 0)], camera=nominal_camera, camera_tolerance=tolerance)
|
||||
|
||||
assert result.geometry["focal_length_px"] == pytest.approx(
|
||||
true_camera.focal_length_px, rel=0.02
|
||||
)
|
||||
|
||||
|
||||
def test_fit_clips_out_of_band_ground_truth_to_bound():
|
||||
# A ground truth placed outside a deliberately too-tight band is
|
||||
# clipped to the bound rather than escaping it.
|
||||
basis = make_basis()
|
||||
true_camera = make_camera()
|
||||
gen = make_generator(basis, true_camera)
|
||||
planes = gen.generate(
|
||||
coefficients={(0, 0): 1.0 + 0j}, z_list=Z_LIST, image_shape=IMAGE_SHAPE, noise_std=1e-4, seed=9
|
||||
)
|
||||
|
||||
# nominal is 10% off true, but the band only allows +/-1%.
|
||||
nominal_focal_length = true_camera.focal_length_px * 1.10
|
||||
nominal_camera = CameraModel(
|
||||
focal_length_px=nominal_focal_length,
|
||||
position=true_camera.position,
|
||||
orientation_deg=true_camera.orientation_deg,
|
||||
)
|
||||
tight_tolerance = CameraModelTolerance(
|
||||
focal_length_px=nominal_focal_length * 0.01,
|
||||
position=(0.0, 0.0, 0.0),
|
||||
orientation_deg=(0.0, 0.0, 0.0),
|
||||
)
|
||||
|
||||
fitter = ModalFitter(basis)
|
||||
result = fitter.fit(
|
||||
planes, modes=[(0, 0)], camera=nominal_camera, camera_tolerance=tight_tolerance
|
||||
)
|
||||
|
||||
lower_bound = nominal_focal_length - tight_tolerance.focal_length_px
|
||||
assert result.geometry["focal_length_px"] == pytest.approx(lower_bound, rel=1e-3)
|
||||
|
||||
|
||||
def test_fit_recovers_offset_z_within_tolerance():
|
||||
basis = make_basis()
|
||||
camera = make_camera()
|
||||
gen = make_generator(basis, camera)
|
||||
true_z_list = [0.35, 0.5, 0.65, 0.8]
|
||||
offsets = {z: 0.01 for z in true_z_list} # nominal is 1 cm off true
|
||||
planes = gen.generate(
|
||||
coefficients={(0, 0): 1.0 + 0j},
|
||||
z_list=true_z_list,
|
||||
image_shape=IMAGE_SHAPE,
|
||||
nominal_z_offsets=offsets,
|
||||
z_tolerance=0.03,
|
||||
noise_std=1e-4,
|
||||
seed=10,
|
||||
)
|
||||
|
||||
fitter = ModalFitter(basis)
|
||||
result = fitter.fit(planes, modes=[(0, 0)], camera=camera, camera_tolerance=zero_tolerance())
|
||||
|
||||
for i, true_z in enumerate(true_z_list):
|
||||
assert result.geometry[f"z_{i}"] == pytest.approx(true_z, abs=0.005)
|
||||
|
||||
|
||||
def test_fit_auto_does_not_add_modes_for_pure_fundamental():
|
||||
|
||||
Reference in New Issue
Block a user