Skip to content

[Feat] Huygens PSF#93

Merged
singer-yang merged 6 commits intomainfrom
huygens_psf
Dec 30, 2025
Merged

[Feat] Huygens PSF#93
singer-yang merged 6 commits intomainfrom
huygens_psf

Conversation

@singer-yang
Copy link
Copy Markdown
Collaborator

@singer-yang singer-yang commented Dec 30, 2025

  1. Fix Huygens PSF to use the standard definition (same as TOG 2021).
  2. Result alignment with exit-pupil propagation method (SIGGRAPH Asia 2025).

Note

Implements coherent Huygens PSF and streamlines PSF APIs while improving pupil and aperture handling.

  • Adds GeoLens.psf_huygens (coherent exit‑pupil tracing + Huygens–Fresnel integration), normalizes/flip outputs; psf_coherent now delegates to psf_pupil_prop; psf is geometric-only with recenter support; introduces trace2exit_pupil
  • Updates pupil_field to use trace2exit_pupil, supports recenter, and returns psf_center; standardizes intensity normalization across PSF paths
  • Aperture/geometry improvements: Aperture.init_from_dict accepts is_square/pos_xy/vec_local/device; Plane.intersect enforces square/round aperture masks; entrance‑pupil radius accounts for square apertures; Lens.set_sensor_res avoids rounding for precise sensor_size/pixel_size
  • Example 5_pupil_field.py now computes and compares psf (geometric), psf_huygens, and psf_coherent/psf_pupil_prop, and plots center‑line profiles
  • Tests updated: new Huygens PSF coverage, adjusted shapes/API calls, and consistency checks with geometric PSF

Written by Cursor Bugbot for commit 6ac0358. This will update automatically on new commits. Configure here.

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the final PR Bugbot will review for you during this billing cycle

Your free Bugbot reviews will reset on January 7

Details

You are on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle.

To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

Comment thread deeplens/geolens.py
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors the PSF (Point Spread Function) calculation methods to align with standard definitions and introduces a new Huygens PSF implementation based on the TOG 2021 paper. The changes separate the previously combined PSF methods into distinct implementations for geometric, exit-pupil propagation, and Huygens approaches.

Key changes:

  • Removed the mode parameter from the psf() method and created separate psf_huygens() and psf_pupil_prop() methods
  • Implemented a new Huygens-Fresnel integration approach for coherent PSF calculation
  • Added trace2exit_pupil() helper method and improved numerical precision in sensor size calculations

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
deeplens/optics/geometric_surface/plane.py Added clarifying parentheses to aperture mask calculation and a comment label
deeplens/optics/geometric_surface/aperture.py Extended init_from_dict to handle additional optional parameters (is_square, pos_xy, vec_local, device)
deeplens/lens.py Removed round() calls from sensor size and pixel size calculations to preserve numerical precision
deeplens/geolens.py Major refactoring: split PSF methods into separate functions, added trace2exit_pupil(), implemented new Huygens PSF calculation, reorganized code and improved documentation
5_pupil_field.py Updated example to demonstrate and compare all three PSF calculation methods with visualization improvements
Comments suppressed due to low confidence (1)

deeplens/lens.py:122

  • Overridden method signature does not match call, where it is passed too many arguments. Overriding method method GeoLens.psf matches the call.
    Overridden method signature does not match call, where it is passed an argument named 'spp'. Overriding method method GeoLens.psf matches the call.
    Overridden method signature does not match call, where it is passed an argument named 'recenter'. Overriding method method GeoLens.psf matches the call.
    Overridden method signature does not match call, where it is passed an argument named 'psf_type'. Overriding method method ParaxialLens.psf matches the call.
    Overridden method signature does not match call, where it is passed an argument named 'psf_type'. Overriding method method ParaxialLens.psf matches the call.
    Overridden method signature does not match call, where it is passed an argument named 'psf_type'. Overriding method method ParaxialLens.psf matches the call.
    Overridden method signature does not match call, where it is passed an argument named 'psf_type'. Overriding method method ParaxialLens.psf matches the call.
    Overridden method signature does not match call, where it is passed an argument named 'psf_type'. Overriding method method ParaxialLens.psf matches the call.
    Overridden method signature does not match call, where it is passed an argument named 'psf_type'. Overriding method method ParaxialLens.psf matches the call.
    Overridden method signature does not match call, where it is passed an argument named 'psf_type'. Overriding method method ParaxialLens.psf matches the call.
    Overridden method signature does not match call, where it is passed an argument named 'psf_type'. Overriding method method ParaxialLens.psf matches the call.
    def psf(self, points, wvln=0.589, ks=51, **kwargs):

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread deeplens/geolens.py
opl_min = valid_opl.min()

# Compute distance from each secondary source to each pixel
batch_size = min(num_valid, 10_000) # Process rays in batches
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The batch processing in the Huygens integration could be inefficient. The batch_size is hardcoded to 10,000, which may be too small for modern GPUs with large memory. Consider making this configurable or dynamically computing it based on available memory and ks size to maximize GPU utilization.

Copilot uses AI. Check for mistakes.
Comment thread deeplens/geolens.py Outdated
self.real_rfov = self.rfov
self.real_dfov = self.dfov
print(f"Ray tracing to compute fov failed, set real_rfov to {self.rfov}.")
print(f"Failed to calculate distored FoV by ray tracing, use effective FoV {self.rfov} rad.")
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message has been improved to clarify that it's the "distorted FoV" calculation that failed, but the message uses "distored" which appears to be a typo. The correct spelling should be "distorted".

Suggested change
print(f"Failed to calculate distored FoV by ray tracing, use effective FoV {self.rfov} rad.")
print(f"Failed to calculate distorted FoV by ray tracing, use effective FoV {self.rfov} rad.")

Copilot uses AI. Check for mistakes.
Comment thread deeplens/geolens.py
ray (Ray object): Ray object.
"""
ray = self.trace2sensor(ray)
pupil_z, _ = self.get_exit_pupil()
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trace2exit_pupil method propagates rays to the exit pupil z-position using ray.prop_to(pupil_z), but it doesn't validate that pupil_z is valid or that the propagation succeeds. Consider adding validation or documentation about what happens when the exit pupil calculation fails or returns an unexpected value.

Suggested change
pupil_z, _ = self.get_exit_pupil()
pupil_z, _ = self.get_exit_pupil()
# Validate exit pupil position before propagation to avoid using
# an invalid or non-finite z value, which could cause numerical
# issues or unexpected behavior in ray propagation.
if pupil_z is None:
raise ValueError(
"Exit pupil z-position is None. Exit pupil calculation failed."
)
# Handle scalar and array-like values (e.g., numpy arrays)
try:
is_finite = bool(np.all(np.isfinite(pupil_z)))
except TypeError:
# Fallback for non-numpy types; for simple scalars math.isfinite works.
is_finite = isinstance(pupil_z, (int, float)) and math.isfinite(pupil_z)
if not is_finite:
raise ValueError(
f"Exit pupil z-position is not finite: {pupil_z!r}. "
"Exit pupil calculation returned an unexpected value."
)

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +56
(torch.abs(new_o[..., 0]) < (self.w / 2))
& (torch.abs(new_o[..., 1]) < (self.h / 2))
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The added parentheses around self.w / 2 and self.h / 2 are redundant and don't change the behavior since division already has higher precedence than comparison operators. While this doesn't introduce a bug, it's unnecessary and could be misleading.

Suggested change
(torch.abs(new_o[..., 0]) < (self.w / 2))
& (torch.abs(new_o[..., 1]) < (self.h / 2))
(torch.abs(new_o[..., 0]) < self.w / 2)
& (torch.abs(new_o[..., 1]) < self.h / 2)

Copilot uses AI. Check for mistakes.
Comment thread deeplens/lens.py
Comment on lines +95 to +98
2 * self.r_sensor * self.sensor_res[0] / diam_res,
2 * self.r_sensor * self.sensor_res[1] / diam_res,
)
self.pixel_size = round(self.sensor_size[0] / self.sensor_res[0], 4)
self.pixel_size = self.sensor_size[0] / self.sensor_res[0]
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing round() from sensor size and pixel size calculations increases numerical precision, which is beneficial. However, this could introduce minor backward compatibility issues if downstream code or tests depend on the exact rounded values. Ensure that this change doesn't break any existing functionality that may rely on these specific values.

Copilot uses AI. Check for mistakes.
Comment thread deeplens/geolens.py
Comment on lines +1047 to +1050
pointc[0, 0] + x_coords,
pointc[0, 1] + y_coords,
indexing="xy"
) # [ks, ks] each
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The psf_huygens function only supports a single point at a time (indexing pointc[0, 0] and pointc[0, 1]), but the function signature and documentation suggest it should support batches of points. Either add batch support by iterating over points, or document that only single points are supported and add validation to reject batched inputs.

Copilot uses AI. Check for mistakes.
Comment thread deeplens/geolens.py Outdated
Comment on lines +916 to +918
# 1. Geometric PSF: incoherent intensity ray tracing
# 2. Exit-pupil PSF: coherent ray tracing to exit pupil, then free-space propagation with ASM
# 3. Huygens PSF: coherent ray tracing to exit pupil, then Huygens-Fresnel integration
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation describes three PSF methods but only provides accurate information for the third one (exit-pupil PSF). The comment should clarify that "Exit-pupil PSF" refers to psf_pupil_prop or psf_coherent, and "Huygens PSF" refers to psf_huygens. Consider adding cross-references to the actual method names.

Suggested change
# 1. Geometric PSF: incoherent intensity ray tracing
# 2. Exit-pupil PSF: coherent ray tracing to exit pupil, then free-space propagation with ASM
# 3. Huygens PSF: coherent ray tracing to exit pupil, then Huygens-Fresnel integration
# 1. Geometric PSF (`psf`): incoherent intensity ray tracing
# 2. Exit-pupil PSF (`psf_pupil_prop` / `psf_coherent`): coherent ray tracing to exit pupil, then free-space propagation with ASM
# 3. Huygens PSF (`psf_huygens`): coherent ray tracing to exit pupil, then Huygens-Fresnel integration

Copilot uses AI. Check for mistakes.
Comment thread 5_pupil_field.py
Comment on lines +39 to +46
psf_coherent = lens.psf_coherent(torch.tensor([0.0, 0.4, -10000.0]), ks=64)
save_image(psf_coherent, "./psf_coherent.png", normalize=True)
psf_incoherent = lens.psf(torch.tensor([0.0, 0.0, -10000.0]), ks=64)

psf_incoherent = lens.psf(torch.tensor([0.0, 0.4, -10000.0]), ks=64)
save_image(psf_incoherent, "./psf_incoherent.png", normalize=True)

psf_huygens = lens.psf_huygens(torch.tensor([0.0, 0.4, -10000.0]), ks=64)
save_image(psf_huygens, "./psf_huygens.png", normalize=True)
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example now uses different point sources for geometric, Huygens, and coherent PSFs (changed from [0.0, 0.0, -10000.0] to [0.0, 0.4, -10000.0]). While this makes the comparison more interesting for off-axis points, it would be clearer to compare all three methods using the same point source first, then optionally show off-axis behavior separately.

Copilot uses AI. Check for mistakes.
Comment thread deeplens/geolens.py
Comment on lines +1204 to +1205
assert spp >= 1_000_000, (
f"Ray sampling {spp} is too small for coherent ray tracing, which may lead to inaccurate simulation."
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertion message uses an f-string to include the actual spp value, which is helpful. However, the minimum required spp is hardcoded as 1,000,000 in the assertion. This should match the constant SPP_COHERENT (16,777,216) or use a named constant for clarity and consistency.

Suggested change
assert spp >= 1_000_000, (
f"Ray sampling {spp} is too small for coherent ray tracing, which may lead to inaccurate simulation."
assert spp >= SPP_COHERENT, (
f"Ray sampling {spp} is too small for coherent ray tracing (minimum {SPP_COHERENT}), which may lead to inaccurate simulation."

Copilot uses AI. Check for mistakes.
Comment thread deeplens/geolens.py Outdated
[1] "End-to-End Hybrid Refractive-Diffractive Lens Design with Differentiable Ray-Wave Model", SIGGRAPH Asia 2024.

Note:
[1] This function is similar to ZEMAX FFT_PSF but implement free-space propagation with Angular Spectrum Method (ASM) rathar than FFT transform. Free-space propagation using ASM is more accurate than doing FFT, because FFT (as used in ZEMAX) assumes far-field condition (e.g., chief ray perpendicular to image plane).
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Note section states "This function is similar to ZEMAX FFT_PSF but implement free-space propagation with Angular Spectrum Method (ASM) rathar than FFT transform." The word "rathar" should be "rather".

Suggested change
[1] This function is similar to ZEMAX FFT_PSF but implement free-space propagation with Angular Spectrum Method (ASM) rathar than FFT transform. Free-space propagation using ASM is more accurate than doing FFT, because FFT (as used in ZEMAX) assumes far-field condition (e.g., chief ray perpendicular to image plane).
[1] This function is similar to ZEMAX FFT_PSF but implement free-space propagation with Angular Spectrum Method (ASM) rather than FFT transform. Free-space propagation using ASM is more accurate than doing FFT, because FFT (as used in ZEMAX) assumes far-field condition (e.g., chief ray perpendicular to image plane).

Copilot uses AI. Check for mistakes.
Comment thread deeplens/geolens.py
# Shape of [N, 2], un-normalized physical coordinates
pointc_chief_ray = self.psf_center(point_obj, method="chief_ray")
raise ValueError("Points must be of shape [1, 3].")
single_point = False
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect shape validation with unreachable dead code

The shape validation in psf_huygens is contradictory and contains unreachable code. The error message states "Points must be of shape [1, 3]" but the code actually rejects tensors of shape [1, 3] since len(points.shape) == 2 routes to the else branch which raises ValueError. Only 1D tensors of shape [3] are accepted. Additionally, the line single_point = False after the raise statement is unreachable dead code. This prevents users from passing valid 2D single-point tensors like [[0.0, 0.0, -10000.0]].

Fix in Cursor Fix in Web

Comment thread deeplens/geolens.py

# Compute distance from each secondary source to each pixel
batch_size = min(num_valid, 10_000) # Process rays in batches
for batch_start in range(0, num_valid, batch_size):
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Crash on empty valid rays in Huygens PSF

In psf_huygens, when all rays are blocked/vignetted during tracing (resulting in zero valid rays), the function crashes with unhelpful errors. Specifically, valid_opl.min() on an empty tensor raises RuntimeError, and if that were bypassed, range(0, num_valid, batch_size) where batch_size=0 raises ValueError: range() arg 3 must not be zero. This edge case can occur with extreme field positions or unusual lens configurations. When recenter=True, the error is caught earlier in psf_center, but when recenter=False the crash occurs here without a clear error message.

Fix in Cursor Fix in Web

@singer-yang singer-yang merged commit a671634 into main Dec 30, 2025
2 checks passed
@singer-yang singer-yang deleted the huygens_psf branch February 16, 2026 12:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants