Skip to content

Commit 70c7363

Browse files
Mihouclaude
andcommitted
chore(merge): integrate the deepo-dev bug-fix port into dev
Core fixes ported from deepo-dev (2026-10-02..10-15) plus follow-ups: - io: Zemax glass names with "/" fall back to model nd/Vd; Code V "!" comments and ";" separators no longer drop surfaces - materials: 23 HOYA/Hikari grades that patent lenses name - phase: grating-equation diffraction applied as the generalized Snell's law in one step (Yu 2011 / Aieta 2012), paraxial power for every phase profile, unrounded Binary2 coefficients - optics: code-update/aperture-edge-validity-20261014 (edge entry and prune_surf sizing) The branch was cut from main v2.5.6, so this also back-merges main into dev: release bumps 2.5.2-2.5.6, dropped torch upper bounds, and PR #118 (complex PSF field output). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 parents ba1f104 + f743713 commit 70c7363

18 files changed

Lines changed: 728 additions & 81 deletions

‎deeplens/geolens.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -865,7 +865,7 @@ def calc_foclen(self):
865865

866866
for surf in self.surfaces:
867867
n2 = surf.mat2.ior(wvln).double()
868-
omega = omega - y * surf.paraxial_power(n, n2).double()
868+
omega = omega - y * surf.paraxial_power(n, n2, wvln).double()
869869
y_last = y
870870
y = y + omega * (surf._get_effective_d_next().double() / n2)
871871
n = n2
@@ -1210,7 +1210,7 @@ def calc_pupil_paraxial(self, reverse=False):
12101210
y = y - omega * (d / n)
12111211
z = z - d
12121212
n1 = surfaces[i - 1].mat2.ior(wvln).double() if i > 0 else air
1213-
omega = omega + y * surfaces[i].paraxial_power(n1, n).double()
1213+
omega = omega + y * surfaces[i].paraxial_power(n1, n, wvln).double()
12141214
n = n1
12151215
else:
12161216
n = aper_surf.mat2.ior(wvln).double()
@@ -1220,7 +1220,7 @@ def calc_pupil_paraxial(self, reverse=False):
12201220
z = z + d
12211221
surf = surfaces[i + 1]
12221222
n2 = surf.mat2.ior(wvln).double()
1223-
omega = omega - y * surf.paraxial_power(n, n2).double()
1223+
omega = omega - y * surf.paraxial_power(n, n2, wvln).double()
12241224
n = n2
12251225

12261226
# A stop conjugate to infinity (telecentric side) has no finite pupil.

‎deeplens/geolens_pkg/io.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ def _resolve_zmx_glass(glass_record):
5151
# Preserve catalog dispersion whenever the named glass is known.
5252
Material(normalized_name)
5353
return normalized_name
54-
except NotImplementedError:
54+
except (NotImplementedError, ValueError):
55+
# ValueError: a name with "/" that is not a numeric n/V pair.
5556
pass
5657

5758
# Zemax model-glass records carry nd/Vd in fields four and five. These
@@ -472,7 +473,8 @@ def read_lens_seq(self, filename="./test.seq"):
472473
print("Beginning to parse surface data...\n")
473474

474475
for line_num, line in enumerate(lines, 1):
475-
line = line.strip()
476+
# "!" starts a Code V comment, also after a command.
477+
line = line.split("!", 1)[0].strip()
476478

477479
# Skip irrelevant lines
478480
if not line or line.startswith(
@@ -535,7 +537,8 @@ def read_lens_seq(self, filename="./test.seq"):
535537
surfaces.append(current_surface)
536538
surface_index += 1
537539

538-
parts = line.split()
540+
# ";" ends the S command; another command may follow it.
541+
parts = line.split(";", 1)[0].split()
539542
radius_value = float(parts[1]) if len(parts) > 1 else 0.0
540543
thickness = float(parts[2]) if len(parts) > 2 else 0.0
541544
material = parts[3].upper() if len(parts) > 3 else "AIR"

‎deeplens/geolens_pkg/ops.py‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,12 @@ def prune_surf(self, mounting_margin=None):
4040
"""Prune surface radii so all valid rays pass through, then enforce manufacturability.
4141
4242
Traces 16 meridional fields from 0 to the full FoV to find the maximum
43-
ray height [mm] on each surface, expands it by a mounting margin, then
44-
caps the proposed radii to satisfy an edge-sag limit and edge-clearance
45-
(air-gap / edge-thickness) constraints with neighbouring surfaces.
43+
sensor-reaching ray height [mm] on each surface, expands it by a mounting
44+
margin, then caps the proposed radii to satisfy an edge-sag limit and
45+
edge-clearance (air-gap / edge-thickness) constraints with neighbouring surfaces.
4646
Aperture surfaces are not resized. The capped radii are committed via
47-
each surface's `update_r`.
47+
each surface's `update_r`. With no sensor-reaching ray height, the saved
48+
radius is used without a mounting margin; geometry caps still apply.
4849
4950
Args:
5051
mounting_margin (float or None, optional): Absolute mounting margin
@@ -75,8 +76,11 @@ def prune_surf(self, mounting_margin=None):
7576

7677
# Ray record, shape [num_rays, num_surfaces + 2, 3]
7778
ray_o_record = torch.stack(ray_o_record, dim=-2)
78-
ray_o_record = torch.nan_to_num(ray_o_record, nan=0.0, posinf=0.0, neginf=0.0)
7979
ray_o_record = ray_o_record.reshape(-1, ray_o_record.shape[-2], 3)
80+
# A ray lost later must not inflate an earlier surface's aperture.
81+
reaches_sensor = torch.isfinite(ray_o_record[:, -1, :]).all(-1)
82+
ray_o_record = torch.where(reaches_sensor[:, None, None], ray_o_record, 0.0)
83+
ray_o_record = torch.nan_to_num(ray_o_record, nan=0.0, posinf=0.0, neginf=0.0)
8084

8185
# Compute the maximum ray height for each surface
8286
ray_r_record = (ray_o_record[..., :2] ** 2).sum(-1).sqrt()
@@ -91,7 +95,9 @@ def prune_surf(self, mounting_margin=None):
9195
if surf_r_max[i] > 0:
9296
base = float(surf_r_max[i].item())
9397
else:
94-
base = float(self.surfaces[i].r)
98+
# No ray evidence: repeated pruning must not add another margin.
99+
proposed_r[i] = float(saved_radii[i])
100+
continue
95101

96102
# Expand the ray-traced radius by a mounting margin
97103
if mounting_margin is None:

‎deeplens/geolens_pkg/psf_compute.py‎

Lines changed: 89 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -81,27 +81,33 @@ def psf(self, points, wvln=None, ks=PSF_KS, **kwargs):
8181
recenter (bool): If True (default), center the PSF on the chief
8282
ray; otherwise on the pinhole projection.
8383
model (str): One of 'geometric' (default), 'coherent', 'huygens'.
84+
return_field (bool): For the coherent and Huygens models, return
85+
the energy-normalized complex sensor-plane field instead of
86+
intensity. Defaults to False.
8487
8588
Returns:
86-
psf (torch.Tensor): PSF normalized to sum to 1. Shape [ks, ks] for a
87-
single point, or [N, ks, ks] for the geometric model with N points.
89+
torch.Tensor: Intensity PSF normalized to sum to 1, or an
90+
energy-normalized complex field when ``return_field=True``.
91+
Shape [ks, ks] for a single point, or [N, ks, ks] for the
92+
geometric model with N points.
8893
8994
Raises:
9095
ValueError: If `model` is not one of the supported names.
9196
"""
9297
wvln = self.primary_wvln if wvln is None else wvln
9398
spp = kwargs.get("spp", None)
9499
recenter = kwargs.get("recenter", True)
100+
return_field = kwargs.get("return_field", False)
95101
model = kwargs.get("model", "geometric")
96102
if model == "geometric":
97103
spp = SPP_PSF if spp is None else spp
98104
return self.psf_geometric(points, ks, wvln, spp, recenter)
99105
elif model == "coherent":
100106
spp = SPP_COHERENT if spp is None else spp
101-
return self.psf_coherent(points, ks, wvln, spp, recenter)
107+
return self.psf_coherent(points, ks, wvln, spp, recenter, return_field)
102108
elif model == "huygens":
103109
spp = SPP_COHERENT if spp is None else spp
104-
return self.psf_huygens(points, ks, wvln, spp, recenter)
110+
return self.psf_huygens(points, ks, wvln, spp, recenter, return_field)
105111
else:
106112
raise ValueError(f"Unknown PSF model: {model}")
107113

@@ -182,7 +188,13 @@ def psf_geometric(self, points, ks=PSF_KS, wvln=None, spp=SPP_PSF, recenter=True
182188
return diff_float(psf)
183189

184190
def psf_coherent(
185-
self, points, ks=PSF_KS, wvln=None, spp=SPP_COHERENT, recenter=True
191+
self,
192+
points,
193+
ks=PSF_KS,
194+
wvln=None,
195+
spp=SPP_COHERENT,
196+
recenter=True,
197+
return_field=False,
186198
):
187199
"""Compute the coherent exit-pupil PSF (alias for `psf_pupil_prop`).
188200
@@ -198,22 +210,43 @@ def psf_coherent(
198210
back to `self.primary_wvln`.
199211
spp (int, optional): Rays sampled. Defaults to SPP_COHERENT.
200212
recenter (bool, optional): If True (default), center on the chief ray.
213+
return_field (bool, optional): Return the energy-normalized complex
214+
sensor-plane field instead of intensity. Defaults to False.
201215
202216
Returns:
203-
psf (torch.Tensor): PSF normalized to sum to 1. Shape [ks, ks].
217+
torch.Tensor: Intensity PSF normalized to sum to 1, or an
218+
energy-normalized complex field when ``return_field=True``.
219+
Shape [ks, ks].
220+
221+
Note:
222+
The complex field has an arbitrary global phase because optical path
223+
length is measured relative to a reference; relative phase is meaningful.
204224
"""
205225
wvln = self.primary_wvln if wvln is None else wvln
206-
return self.psf_pupil_prop(points, ks=ks, wvln=wvln, spp=spp, recenter=recenter)
226+
return self.psf_pupil_prop(
227+
points,
228+
ks=ks,
229+
wvln=wvln,
230+
spp=spp,
231+
recenter=recenter,
232+
return_field=return_field,
233+
)
207234

208235
def psf_pupil_prop(
209-
self, points, ks=PSF_KS, wvln=None, spp=SPP_COHERENT, recenter=True
236+
self,
237+
points,
238+
ks=PSF_KS,
239+
wvln=None,
240+
spp=SPP_COHERENT,
241+
recenter=True,
242+
return_field=False,
210243
):
211244
"""Compute the single-point monochromatic PSF via the exit-pupil diffraction model.
212245
213246
Steps:
214247
1. Compute the complex wavefront at the exit-pupil plane by coherent ray tracing.
215248
2. Propagate to the sensor plane with the Angular Spectrum Method (ASM)
216-
and take the intensity as the PSF. This function is differentiable.
249+
and return either its field or intensity. This function is differentiable.
217250
218251
Args:
219252
points (torch.Tensor or list): Single normalized point source [3] or
@@ -226,12 +259,14 @@ def psf_pupil_prop(
226259
spp (int, optional): Number of rays to sample. Defaults to SPP_COHERENT.
227260
recenter (bool, optional): If True (default), center on the chief ray;
228261
otherwise on the pinhole projection.
262+
return_field (bool, optional): Return the energy-normalized complex
263+
sensor-plane field instead of intensity. Defaults to False.
229264
230265
Returns:
231-
psf (torch.Tensor): PSF normalized to sum to 1. Shape [ks, ks] when
232-
`ks` is given, or the full uncropped intensity field of shape
233-
[1, 1, 2H, 2H] (twice the exit-pupil grid after zero-padding)
234-
when `ks` is None.
266+
torch.Tensor: Intensity PSF normalized to sum to 1, or an
267+
energy-normalized complex field when ``return_field=True``.
268+
Shape [ks, ks] when `ks` is given, or [1, 1, 2H, 2H] when
269+
`ks` is None (twice the exit-pupil grid after zero-padding).
235270
236271
Reference:
237272
[1] "End-to-End Hybrid Refractive-Diffractive Lens Design with Differentiable Ray-Wave Model", SIGGRAPH Asia 2024.
@@ -241,6 +276,8 @@ def psf_pupil_prop(
241276
Angular Spectrum Method (ASM) instead of a single FFT. ASM is more
242277
accurate because the FFT approach assumes a far-field condition
243278
(e.g., chief ray perpendicular to the image plane).
279+
The complex field has an arbitrary global phase because optical path
280+
length is measured relative to a reference; relative phase is meaningful.
244281
"""
245282
wvln = self.primary_wvln if wvln is None else wvln
246283
# Pupil field by coherent ray tracing
@@ -275,6 +312,26 @@ def psf_pupil_prop(
275312
psfc_idx_i = ((2 - psfc[1]) * h / 4).round().long()
276313
psfc_idx_j = ((2 + psfc[0]) * w / 4).round().long()
277314

315+
if return_field:
316+
field = sensor_field
317+
if ks is not None:
318+
field = (
319+
F.pad(
320+
field,
321+
[ks // 2, ks // 2, ks // 2, ks // 2],
322+
mode="constant",
323+
value=0,
324+
)
325+
.squeeze(0)
326+
.squeeze(0)
327+
)
328+
field = field[
329+
psfc_idx_i : psfc_idx_i + ks,
330+
psfc_idx_j : psfc_idx_j + ks,
331+
]
332+
energy = field.abs().square().sum(dim=(-2, -1), keepdim=True)
333+
return field / torch.sqrt(energy + EPSILON)
334+
278335
# Crop valid PSF region and normalize
279336
if ks is not None:
280337
psf_inten_pad = (
@@ -392,7 +449,13 @@ def pupil_field(self, points, wvln=None, spp=SPP_COHERENT, recenter=True):
392449
return wavefront, psf_center
393450

394451
def psf_huygens(
395-
self, points, ks=PSF_KS, wvln=None, spp=SPP_COHERENT, recenter=True
452+
self,
453+
points,
454+
ks=PSF_KS,
455+
wvln=None,
456+
spp=SPP_COHERENT,
457+
recenter=True,
458+
return_field=False,
396459
):
397460
"""Compute the single-wavelength Huygens PSF by spherical-wave integration.
398461
@@ -414,16 +477,22 @@ def psf_huygens(
414477
spp (int, optional): Rays sampled. Defaults to SPP_COHERENT.
415478
recenter (bool, optional): If True (default), center on the chief ray;
416479
otherwise on the pinhole projection.
480+
return_field (bool, optional): Return the energy-normalized complex
481+
sensor-plane field instead of intensity. Defaults to False.
417482
418483
Returns:
419-
psf (torch.Tensor): PSF normalized to sum to 1. Shape [ks, ks].
484+
torch.Tensor: Intensity PSF normalized to sum to 1, or an
485+
energy-normalized complex field when ``return_field=True``.
486+
Shape [ks, ks].
420487
421488
Reference:
422489
[1] "Optical Aberrations Correction in Postprocessing Using Imaging Simulation", TOG 2021.
423490
424491
Note:
425492
Different from the ZEMAX Huygens PSF, which traces rays to the image
426493
plane and performs plane-wave integration.
494+
The complex field has an arbitrary global phase because optical path
495+
length is measured relative to a reference; relative phase is meaningful.
427496
"""
428497
wvln = self.primary_wvln if wvln is None else wvln
429498
if self.dtype != torch.float64:
@@ -541,6 +610,11 @@ def psf_huygens(
541610
# Sum contributions from this batch
542611
psf_complex += complex_amp.sum(dim=-1) # [ks, ks]
543612

613+
if return_field:
614+
field = torch.flip(psf_complex, [-2, -1])
615+
energy = field.abs().square().sum(dim=(-2, -1), keepdim=True)
616+
return field / torch.sqrt(energy + EPSILON)
617+
544618
# Convert complex field to intensity
545619
psf = psf_complex.abs() ** 2
546620

‎deeplens/geometric_surface/aspheric.py‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,69 @@ def _get_curvature_params(self):
178178
"""
179179
return self.c, self.k
180180

181-
def paraxial_power(self, n1, n2):
181+
def newtons_method(self, ray):
182+
"""Find an intersection, rejecting rays entering behind the aperture rim."""
183+
t, valid = super().newtons_method(ray)
184+
return t, valid & ~self._enters_through_edge(ray)
185+
186+
@torch.no_grad()
187+
def _enters_through_edge(self, ray):
188+
"""Identify rays entering the aperture or conic domain behind the face.
189+
190+
The entry point is pulled radially inside the boundary before evaluating
191+
sag, so a grazing ray is not sampled past its intersection. Rays starting
192+
inside the valid region retain the usual Newton validity checks.
193+
194+
Args:
195+
ray (Ray): Input ray bundle in local surface coordinates.
196+
197+
Returns:
198+
mask (torch.Tensor): Boolean edge-entry mask, shape [...].
199+
"""
200+
o, d = ray.o, ray.d
201+
c, k = self._get_curvature_params()
202+
aperture_sq = (self.w**2 + self.h**2) / 4 if self.is_square else self.r**2
203+
conic_power = (1 + k) * c**2
204+
domain_sq = torch.where(
205+
conic_power > 0,
206+
1 / torch.where(conic_power > 0, conic_power, 1.0),
207+
torch.full_like(conic_power, float("inf")),
208+
)
209+
radius_sq = domain_sq.clamp(max=aperture_sq).to(o)
210+
a = d[..., :2].square().sum(-1)
211+
b = (o[..., :2] * d[..., :2]).sum(-1)
212+
outside = o[..., :2].square().sum(-1) - radius_sq
213+
disc = b.square() - a * outside
214+
axial = a == 0
215+
denominator = torch.where(axial, 1.0, a)
216+
root = disc.clamp(min=0).sqrt()
217+
t_in = torch.where(axial, -float("inf"), (-b - root) / denominator)
218+
t_out = torch.where(axial, float("inf"), (-b + root) / denominator)
219+
reaches = torch.where(axial, outside <= 0, disc > 0)
220+
starts_inside = outside <= 0
221+
222+
if self.is_square:
223+
# Square apertures use half-side lengths, not a circular substitute.
224+
half_size = o.new_tensor([self.w / 2, self.h / 2])
225+
parallel = d[..., :2] == 0
226+
safe_d = torch.where(parallel, 1.0, d[..., :2])
227+
t1 = (-half_size - o[..., :2]) / safe_d
228+
t2 = (half_size - o[..., :2]) / safe_d
229+
near = torch.where(parallel, -float("inf"), torch.minimum(t1, t2))
230+
far = torch.where(parallel, float("inf"), torch.maximum(t1, t2))
231+
t_in = torch.maximum(t_in, near.amax(-1))
232+
t_out = torch.minimum(t_out, far.amin(-1))
233+
inside_square = o[..., :2].abs() <= half_size
234+
reaches = reaches & (~parallel | inside_square).all(-1)
235+
starts_inside = starts_inside & inside_square.all(-1)
236+
237+
enters = reaches & ~starts_inside & (t_in > 0) & (t_out > t_in)
238+
entry = o + d * torch.where(enters, t_in, 0.0).unsqueeze(-1)
239+
xy = torch.where(enters.unsqueeze(-1), entry[..., :2], 0.0) * (1 - 1e-6)
240+
behind = (self._sag(xy[..., 0], xy[..., 1]) - entry[..., 2]) * d[..., 2] < 0
241+
return enters & behind
242+
243+
def paraxial_power(self, n1, n2, wvln=None):
182244
"""Return the paraxial optical power of this surface [1/mm].
183245
184246
Only the vertex curvature contributes, so the conic constant and the
@@ -190,6 +252,8 @@ def paraxial_power(self, n1, n2):
190252
Args:
191253
n1 (torch.Tensor): Refractive index of the incident medium.
192254
n2 (torch.Tensor): Refractive index of the transmission medium.
255+
wvln (float, optional): Wavelength [µm]. Unused by refractive
256+
surfaces, which see it only through `n1`/`n2`.
193257
194258
Returns:
195259
power (torch.Tensor): Surface power `(n2 - n1) * (c + 2 * a2)`

0 commit comments

Comments
 (0)