Skip to content

Commit d054194

Browse files
author
Mihou
committed
Merge branch 'code-update/design-constraints-20260919' into dev
2 parents 661124d + e9b49a3 commit d054194

4 files changed

Lines changed: 278 additions & 46 deletions

File tree

‎deeplens/geolens.py‎

Lines changed: 65 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
Xinge Yang, Qiang Fu, and Wolfgang Heidrich, "Curriculum learning for ab initio deep learned refractive optics," Nature Communications 2024.
1111
"""
1212

13-
import logging
1413
import math
1514

1615
import numpy as np
@@ -972,8 +971,10 @@ def calc_fov(self):
972971
1. **Perspective projection** — from focal length and sensor size
973972
(effective FoV, ignoring distortion).
974973
2. **Forward ray tracing** — sweeps FOV angles from object side,
975-
traces to sensor, and finds the angle whose centroid image height
976-
matches the sensor half-diagonal. This avoids the failure of the
974+
traces to sensor, and interpolates the first angle whose centroid
975+
reaches the sensor half-diagonal. If a field loses its rays first,
976+
uses the preceding surviving field (zero if the on-axis field
977+
is unavailable). This avoids the failure of the
977978
old backward-tracing approach on wide-angle lenses where pupil
978979
aberration at full field leaves zero valid rays.
979980
@@ -1002,33 +1003,46 @@ def calc_fov(self):
10021003
# Sweep FOV angles from object side, trace to sensor, and find which
10031004
# angle produces an image height matching r_sensor.
10041005
num_fov = 64
1005-
fov_lo = float(np.rad2deg(self.rfov_eff)) * 0.5
10061006
fov_hi = min(float(np.rad2deg(self.rfov_eff)) * 1.8, 89.0)
10071007
fov_samples = torch.linspace(
1008-
fov_lo, fov_hi, num_fov, device=self.device, dtype=self.dtype
1008+
0.0, fov_hi, num_fov, device=self.device, dtype=self.dtype
10091009
)
10101010

10111011
ray = self.sample_from_fov(fov_x=0.0, fov_y=fov_samples.tolist(), num_rays=256)
10121012
ray = self.trace2sensor(ray)
10131013

10141014
# Centroid image height per FOV angle, shape [num_fov]
10151015
valid = ray.is_valid > 0 # [num_fov, num_rays]
1016-
masked_y = ray.o[..., 1] * valid
1016+
masked_y = torch.where(valid, ray.o[..., 1], 0.0)
10171017
n_valid = valid.sum(dim=-1).clamp(min=1)
10181018
imgh = (masked_y.sum(dim=-1) / n_valid).abs()
10191019

1020-
# Find the FOV angle whose image height is closest to r_sensor
1020+
# Stop at the first crossing or lost field; a folded outer image must
1021+
# not be mistaken for a larger usable field of view.
10211022
has_valid = valid.sum(dim=-1) > 10
10221023
if has_valid.any():
1023-
imgh[~has_valid] = float("inf")
1024-
diff = (imgh - self.r_sensor).abs()
1025-
best_idx = diff.argmin().item()
1026-
rfov = fov_samples[best_idx].item() * math.pi / 180.0
1024+
beyond = (~has_valid) | (imgh >= self.r_sensor)
1025+
if not beyond.any():
1026+
best_deg = fov_samples[-1]
1027+
else:
1028+
idx = int(beyond.int().argmax())
1029+
if idx == 0:
1030+
best_deg = fov_samples[0]
1031+
elif has_valid[idx]:
1032+
fraction = (self.r_sensor - imgh[idx - 1]) / (
1033+
imgh[idx] - imgh[idx - 1]
1034+
).clamp_min(EPSILON)
1035+
best_deg = fov_samples[idx - 1] + fraction * (
1036+
fov_samples[idx] - fov_samples[idx - 1]
1037+
)
1038+
else:
1039+
best_deg = fov_samples[idx - 1]
1040+
rfov = math.radians(float(best_deg))
10271041
self.rfov = rfov
10281042
self.real_dfov = 2 * rfov
10291043
else:
1030-
self.rfov = self.rfov_eff
1031-
self.real_dfov = self.dfov
1044+
self.rfov = 0.0
1045+
self.real_dfov = 0.0
10321046

10331047
# 3. Compute 35mm equivalent focal length. 35mm sensor: 36mm * 24mm
10341048
self.eqfl = 21.63 / math.tan(self.rfov_eff)
@@ -1472,32 +1486,48 @@ def refocus(self, foc_dist=float("inf")):
14721486

14731487
@torch.no_grad()
14741488
def set_fnum(self, fnum):
1475-
"""Set F-number and aperture radius using binary search.
1489+
"""Set F-number by bracketing the pupil radius, then bisecting.
14761490
14771491
Args:
14781492
fnum (float): target F-number.
1493+
1494+
Raises:
1495+
ValueError: F-number or target pupil radius is not finite and positive.
1496+
RuntimeError: The pupil target cannot be reached. The original
1497+
aperture radius is restored on failure.
14791498
"""
1499+
if not math.isfinite(fnum) or fnum <= 0:
1500+
raise ValueError("F-number must be finite and positive.")
14801501
target_pupil_r = self.foclen / fnum / 2
1481-
aper_r = self.surfaces[self.aper_idx].r
1482-
lo, hi = 0.1 * aper_r, 5.0 * aper_r
1483-
1484-
pupilr = None
1485-
for _ in range(40):
1486-
mid = 0.5 * (lo + hi)
1487-
self.surfaces[self.aper_idx].update_r(float(mid))
1488-
_, pupilr = self.calc_entrance_pupil_rayaiming()
1489-
if abs(pupilr - target_pupil_r) / target_pupil_r < 1e-3:
1490-
break
1491-
if pupilr > target_pupil_r:
1492-
hi = mid
1493-
else:
1494-
lo = mid
1495-
else:
1496-
logging.warning(
1497-
f"set_fnum: did not converge, pupil_r={pupilr:.4f}, target={target_pupil_r:.4f}"
1502+
if not math.isfinite(target_pupil_r) or target_pupil_r <= 0:
1503+
raise ValueError("Target pupil radius must be finite and positive.")
1504+
aperture = self.surfaces[self.aper_idx]
1505+
original_r = aperture.r
1506+
lo, hi = 0.0, None
1507+
radius = original_r
1508+
try:
1509+
for _ in range(80):
1510+
aperture.update_r(float(radius))
1511+
radius = aperture.r # update_r enforces the surface's physical limit.
1512+
_, pupilr = self.calc_entrance_pupil_rayaiming()
1513+
if not math.isfinite(pupilr) or pupilr <= 0:
1514+
break
1515+
if abs(pupilr - target_pupil_r) / target_pupil_r < 1e-3:
1516+
self.calc_pupil()
1517+
return
1518+
if pupilr > target_pupil_r:
1519+
hi = radius
1520+
else:
1521+
if radius <= lo:
1522+
break
1523+
lo = radius
1524+
radius = 2 * radius if hi is None else 0.5 * (lo + hi)
1525+
raise RuntimeError(
1526+
f"Cannot reach F/{fnum}: target pupil radius {target_pupil_r:.6g} mm."
14981527
)
1499-
1500-
self.calc_pupil()
1528+
except Exception:
1529+
aperture.update_r(original_r)
1530+
raise
15011531

15021532
@torch.no_grad()
15031533
def set_target_fov_fnum(self, rfov, fnum):
@@ -1521,12 +1551,8 @@ def set_target_fov_fnum(self, rfov, fnum):
15211551
self.real_dfov = 2 * self.rfov
15221552
self.foclen = self.r_sensor / math.tan(self.rfov_eff)
15231553
self.eqfl = 21.63 / math.tan(self.rfov_eff)
1524-
self.fnum = fnum
1525-
aper_r = self.foclen / fnum / 2
1526-
self.surfaces[self.aper_idx].update_r(float(aper_r))
1527-
1528-
# Update pupil after setting aperture radius
1529-
self.calc_pupil()
1554+
# F-number specifies the entrance pupil, not an internal stop's radius.
1555+
self.set_fnum(fnum)
15301556

15311557
@torch.no_grad()
15321558
def set_fov(self, rfov):

‎deeplens/geolens_pkg/eval.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@
7272
field angles (infinite conjugates), or a traced bundle.
7373
7474
Comprehensive Analysis:
75-
analysis_spot: RMS and geometric spot radii averaged over RGB at
76-
multiple field positions.
75+
analysis_spot: RMS and geometric spot radii and ray throughput over RGB
76+
at multiple field positions.
7777
analysis_rendering: Render a test image through the lens and report
7878
PSNR / SSIM.
7979
analysis: One-call entry point that chains layout drawing, spot
@@ -1549,7 +1549,7 @@ def analysis_rendering(
15491549

15501550
@torch.no_grad()
15511551
def analysis_spot(self, num_field=3, depth=float("inf")):
1552-
"""Compute RMS and geometric spot radii at multiple field positions for RGB.
1552+
"""Compute RGB spot radii and ray throughput at multiple field positions.
15531553
15541554
Traces rays at ``num_field`` evenly-spaced field positions along the
15551555
meridional direction for three wavelengths (R, G, B), and computes
@@ -1581,6 +1581,8 @@ def analysis_spot(self, num_field=3, depth=float("inf")):
15811581
``'fov0.5'``, ``'fov1.0'``). Each value is a dict with:
15821582
- ``'rms'``: Polychromatic RMS spot radius in μm.
15831583
- ``'radius'``: Polychromatic geometric spot radius in μm.
1584+
- ``'throughput'``: Fraction of sampled rays reaching the sensor;
1585+
zero marks a lost field even when its spot radius is zero.
15841586
"""
15851587
# Trace each wavelength and pool rays across wavelengths per field
15861588
xy_list = []
@@ -1624,6 +1626,7 @@ def analysis_spot(self, num_field=3, depth=float("inf")):
16241626
# Convert mm → μm
16251627
avg_rms_radius_um = spot_rms * 1000.0
16261628
avg_geo_radius_um = spot_radius * 1000.0
1629+
throughput = (valid_all > 0).float().mean(-1)
16271630

16281631
# Print results
16291632
print(f"Ray spot analysis results for depth {depth}:")
@@ -1633,6 +1636,9 @@ def analysis_spot(self, num_field=3, depth=float("inf")):
16331636
print(
16341637
f"Geo radius: FoV (0.0) {avg_geo_radius_um[0]:.3f} um, FoV (0.5) {avg_geo_radius_um[num_field // 2]:.3f} um, FoV (1.0) {avg_geo_radius_um[-1]:.3f} um"
16351638
)
1639+
print(
1640+
f"Throughput: FoV (0.0) {throughput[0]:.3f}, FoV (0.5) {throughput[num_field // 2]:.3f}, FoV (1.0) {throughput[-1]:.3f}"
1641+
)
16361642

16371643
# Save to dict
16381644
rms_results = {}
@@ -1642,6 +1648,7 @@ def analysis_spot(self, num_field=3, depth=float("inf")):
16421648
rms_results[f"fov{fov}"] = {
16431649
"rms": round(avg_rms_radius_um[i].item(), 4),
16441650
"radius": round(avg_geo_radius_um[i].item(), 4),
1651+
"throughput": round(throughput[i].item(), 4),
16451652
}
16461653

16471654
return rms_results

‎deeplens/geolens_pkg/optim.py‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -331,9 +331,10 @@ def loss_profile(self):
331331
def loss_bound(self):
332332
"""Penalize geometry-bound violations in a single surface-sampling pass.
333333
334-
Each surface pair is sampled once and its distances feed both the
335-
clearance (min) and envelope (max) relu penalties for air gaps,
336-
glass thickness, BFL, and TTL.
334+
Each surface pair is sampled over its shared physical radius and at
335+
each surface's own rim. These distances feed both the clearance (min)
336+
and envelope (max) relu penalties for air gaps, glass thickness, BFL,
337+
and TTL, without extrapolating beyond either surface.
337338
338339
Returns:
339340
loss_clearance (torch.Tensor): Scalar clearance penalty for parts
@@ -380,7 +381,9 @@ def loss_bound(self):
380381
r_center, 0.0, valid_check=False, d=self.surf_d(i + 1)
381382
)
382383

383-
r_edge = torch.linspace(0.5, 1.0, 16, device=self.device) * current_surf.r
384+
r_edge = torch.linspace(0.0, 1.0, 16, device=self.device) * min(
385+
current_surf.r, next_surf.r
386+
)
384387
z_prev_edge = current_surf.surface_with_offset(
385388
r_edge, 0.0, valid_check=False, d=self.surf_d(i)
386389
)
@@ -390,6 +393,19 @@ def loss_bound(self):
390393

391394
dist_center = z_next_center - z_prev_center
392395
dist_edges = z_next_edge - z_prev_edge
396+
# Keep the same physical-rim sag bracket used by prune_surf.
397+
rim_gap = next_surf.surface_with_offset(
398+
torch.as_tensor(next_surf.r, device=self.device),
399+
0.0,
400+
valid_check=False,
401+
d=self.surf_d(i + 1),
402+
) - current_surf.surface_with_offset(
403+
torch.as_tensor(current_surf.r, device=self.device),
404+
0.0,
405+
valid_check=False,
406+
d=self.surf_d(i),
407+
)
408+
dist_edges = torch.cat((dist_edges, rim_gap.reshape(1)))
393409
dist_edge_lo = torch.min(dist_edges)
394410
dist_edge_hi = torch.max(dist_edges)
395411

0 commit comments

Comments
 (0)