Skip to content

Refactor, week of 0414#115

Merged
singer-yang merged 27 commits intodevfrom
refactor-0414
Apr 16, 2026
Merged

Refactor, week of 0414#115
singer-yang merged 27 commits intodevfrom
refactor-0414

Conversation

@singer-yang
Copy link
Copy Markdown
Collaborator

This pull request makes several improvements and cleanups to the optics simulation codebase, particularly in the GeoLens class and related configuration. The main changes include a refactor and enhancement of field-of-view (FoV) calculations, consolidation of ray sampling logic, and improvements to code clarity and maintainability. There are also some import cleanups and documentation improvements.

Field-of-View Calculation and Ray Sampling:

  • Refactored the calc_fov method in GeoLens to use forward ray tracing for real FoV calculation, improving robustness for wide-angle lenses and fixing failures in the previous backward-tracing approach. Now, FoV is determined by tracing rays from object space and matching image height to the sensor, with a fallback to effective FoV if tracing fails. [1] [2]
  • Unified and simplified the ray sampling API by merging the logic of the removed sample_from_points_by_fov method into sample_from_fov, which now handles both collimated and point-source rays depending on the depth argument. [1] [2] [3]
  • Fixed device placement for tensors in several methods (e.g., calc_entrance_pupil, sample_sensor) to ensure compatibility with GPU/CPU execution and prevent device mismatch errors. [1] [2]
  • Corrected the calculation of fov_deg in sample_radial_rays to avoid unnecessary conversion and ensure proper units.

Configuration and Documentation:

  • Improved and reorganized deeplens/optics/config.py by separating tunable experiment constants from physical/numerical constants, adding extensive comments, and clarifying the purpose of each parameter. No functional changes to values, but greatly improved readability and maintainability.
  • Added documentation and logging improvements to deeplens/optics/geolens.py, including tracking of lens design constraints in post_computation. [1] [2] [3] [4] [5] [6]

API and Import Cleanups:

  • Updated imports to reflect module reorganization: functions like diff_float and diff_quantize now imported from .ops instead of .utils, and create_lens is imported directly from geolens_pkg. [1] [2] [3]
  • Removed unused or redundant arguments and code in curriculum_design (e.g., match_mat parameter and related logic). [1] [2] [3]

These changes collectively improve the accuracy, reliability, and maintainability of the optics simulation codebase.

singer-yang and others added 26 commits April 14, 2026 21:13
The old backward-tracing approach sampled rays from the sensor edge
through the on-axis exit pupil, which fails for wide-angle lenses
(0 valid rays due to pupil aberration at full field). Now sweeps 64
FOV angles in parallel via forward tracing and picks the angle whose
centroid image height matches r_sensor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
sample_ring_arm_rays now uses self.rfov (ray-traced) instead of
self.rfov_eff (pinhole) so the full distorted field is covered.
Remove the on-axis ring replacement (fov=0 → 0.01*max) that shifted
the on-axis sample away from the optical axis.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Unify ray sampling into a single function that takes FOV angles and
depth. Infinite depth produces collimated parallel rays, finite depth
produces diverging point-source rays with position determined by FOV
and depth. Remove the intermediate sample_from_points_by_fov function
and the unused prop_to parameter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
prune_surf used rfov_eff (pinhole FOV) to determine the ray tracing
range, missing the outer field of wide-angle lenses with barrel
distortion. Now uses rfov (ray-traced FOV) so the full field is
covered and surface clear apertures are not underestimated.

Also remove now-unused numpy import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
sample_radial_rays(), calc_distortion_radial(), draw_distortion_radial(),
and draw_field_curvature() all used rfov_eff (pinhole FOV) via a direct
self.rfov access that predated the rfov/rfov_eff rename, missing the outer
field of wide-angle lenses with barrel distortion. Now prioritize rfov
(ray-traced FOV) with rfov_eff as fallback so the full sensor is covered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
sample_radial_rays(), calc_distortion_radial(), draw_distortion_radial(),
and draw_field_curvature() all used rfov_eff (pinhole FOV), missing the
outer field of wide-angle lenses with barrel distortion. Now use rfov
(ray-traced FOV) to cover the full sensor. No fallback needed since
self.rfov is always set after post_computation().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… set_fnum bisection

Replace Python branch on tensor k in Aspheric.is_within_data_range with
torch.where so the function is traceable through torch.compile without
graph breaks.

Replace GeoLens.set_fnum with true midpoint bisection: wider bracket
[0.1r, 5r], relative tolerance 1e-3, 40 iterations, and a
logging.warning on non-convergence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…from layout title

Clamp the radicand of the conic sqrt in Aspheric._sag and _dfdxy to
>= EPSILON so out-of-range rays produce finite sag instead of NaN,
allowing the optimizer to recover gracefully.

Also drop the equivalent focal length segment from the GeoLens layout
title in GeoLensVis.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace "Sensor Diagonal" with IMGH (semi-diagonal), append RGB
wavelengths (nm) to the first title line, and add a second line with
per-FoV RMS spot sizes from analysis_spot(). Set suptitle font to
Nimbus Sans on both single and multi plot paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the green-centroid reference with a combined polychromatic
centroid pooled from R, G, B ray intercepts per field point, matching
Zemax's "RMS Spot Radius w.r.t. Centroid" definition. The previous
approach averaged per-wavelength RMS values, which underweighted lateral
chromatic aberration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pdate_r; fix io WAVL

Drop match_mat flag and the conditional match_materials() call inside
the curriculum loop in 2_autolens_rms.py; material snapping is now
always done once after the curriculum stage.

Add Phase.update_r() for flat phase surfaces: updates r/w/h without
rescaling coefficients since norm_radii is fixed.

Emit WAVL from WAVE_RGB in write_lens_zmx instead of hardcoded
Fraunhofer triplet, keeping the output consistent with the project
wavelength config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add two clearly labelled sections in config.py:
- Tunable per-experiment: DEPTH, SPP_*, PSF_KS, GEO_GRID, DEFAULT_WAVE
- Physical/numerical (do not modify): EPSILON/DELTA thresholds, WAVE_RGB,
  Fraunhofer lines, narrow-band spectra, hyperspectral constants

Improve inline comments throughout to explain units and intent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add sample_more_off_axis=False parameter to optimize() and thread it
through to sample_ring_arm_rays, allowing callers to concentrate ray
samples toward the field edge for better off-axis correction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewrite loss_rms to mirror optimize()'s per-FOV reweighting: green
wavelength anchors a detached normalized weight mask so FOV points with
larger spot error receive proportionally more gradient signal. Invalid
rays are zeroed before squaring to avoid Inf*0 = NaN.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…straints to post_computation

Rename geolens_pkg/utils.py to optim_init.py so the three-file
optimization workflow is discoverable as a group: optim_init (build
starting point) → optim (gradient loop) → optim_ops (discrete edits).
Also resolves naming collision with optics/utils.py.

Move init_constraints() call from get_optimizer() into
post_computation() so constraints are always refreshed when lens
geometry is loaded or modified, not only when an optimizer is created.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- post_computation: mention constraint initialization in Calculates list
- get_optimizer: correct Returns type (Adam, not list); improve Args wording
- draw_layout: remove stale entrance_pupil arg; document auto-title behavior
  (IMGH, RGB wavelengths, RMS spot second line)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…misc cleanup

- deeplens/optics/utils.py → ops.py: file only contains pure differentiable
  torch ops (interp1d, grid_sample_xy, foc_dist_balanced, diff_float,
  diff_quantize); added module docstring to prevent future drift; dropped
  numpy import (only wave_rgb needed it)
- Removed wave_rgb() from ops.py — callers can compose from WAVE_RED/GREEN/BLUE
  constants already in config.py; removed TestWaveRgb class from test_basics.py
- geolens_pkg/optim.py: replaced `from transformers import
  get_cosine_schedule_with_warmup` with a local implementation using only
  math + torch; dropped transformers dependency
- geolens_pkg/io.py: removed redundant self.post_computation() call from
  read_lens_json() and updated docstring accordingly
- Updated all import sites (hybridlens, diffraclens, psf_compute, diffractive,
  test_utils, test_basics) to import from ops instead of utils

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
foc_dist_balanced is experiment/EDoF plumbing, not a differentiable tensor
op, so it does not belong in ops.py. Moved to deeplens/utils.py under a new
EDoF section; updated test_utils.py import accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…GPU moves

Replace torch.tensor/zeros/linspace().to(device) with device= at construction
across six files. Eliminates implicit CPU→GPU syncs in hot paths:

- geolens.py: calc_entrance_pupil ray_o + phi (already done in prior commit)
- geolens_pkg/eval.py: linspace in chief_rays(), zeros for chief_ray_o/d,
  and zeros for o1/o2 in the spot-diagram branch
- geolens_pkg/optim_init.py: d_sensor tensor in init_constraints
- geometric_surface/base.py: surface_with_offset and surface_sag scalar→tensor
  conversion guards
- geometric_surface/thinlens.py: __init__ and set_f
- imgsim/psf.py: psf_map_crop and psf_map allocation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move heavy optional imports off the module-level critical path so that
GeoLens load/inspect workflows avoid paying the cost of unused deps:

- light/wave.py: save_image → save_npz, show; tqdm → RayleighSommerfeldIntegral
- lens.py: make_grid/save_image → draw_psf_radial; tqdm → render psf_map branch
- geolens_pkg/eval.py: skimage.metrics + save_image → analysis_rendering
- deeplens/utils.py: peak_signal_noise_ratio → batch_PSNR;
  structural_similarity → batch_ssim; tqdm → create_video_from_images

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…cumulation with softplus

ray.obliq was a multiplicative product of cos(bend) values across surfaces,
making it hard to interpret and sensitive to surface count. The new
bend_penalty is an additive softplus accumulator:

  per_surf = softplus(cos(30°) - cos_bend, beta=50)
  ray.bend_penalty += per_surf * valid

This rises smoothly once a surface bend exceeds 30° and stays near zero
for mild refractions, giving an uncoupled per-surface signal.

Changes:
- ray.py: obliq → bend_penalty; init zeros instead of ones (additive identity)
- geometric_surface/base.py: replace multiplicative obliq update with
  softplus accumulation using 30° gate
- geolens_pkg/optim.py: obliq_min → bend_penalty_max (cellphone: 0.6,
  other: 1.0); loss_ray_angle now penalizes rays exceeding the max
- test/test_ray.py: update clone attribute check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…code

Rule 2 (fix aperture distance when aper_idx == 0) is no longer needed.
Removed it along with the now-unused aper_idx, optim_surf_range, and
shape_changed variables, the dead `if shape_changed` print, and the
bool return value. Function now contains only Rule 1 (shift first
surface to z=0) and prune_surf.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ce_pupil=False

When not using the entrance pupil, pupilz was hardcoded to 0, which
assumed the first surface sits at z=0. Replace with
`surfaces[0].d.item()` so ray sampling starts at the actual axial
position of the first surface. Applies to `geolens.py` (forward_rays)
and both visualization methods in `geolens_pkg/vis.py`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ized softplus

- `init_constraints`: rename bound attrs to `air_edge_min/max` style;
  replace `bend_penalty_max` with `obliq_min`; add `ttl_min`; fix
  cellphone `thick_center_min` (0.4→0.25) and `ttl_max` (15→20).
- `loss_reg`: new signature `(w_clearance, w_envelope, w_profile)`;
  delegates to `loss_bound()` + `loss_profile()`.
- `loss_infocus`: add `wvln` param; switch to `softplus(rms-target)`.
- `loss_surface` → `loss_profile`: softplus-based sag/grad penalties,
  range-normalized by constraint value (beta=10).
- `loss_intersec` + `loss_thickness` → `loss_bound`: single sampling
  pass returning `(loss_clearance, loss_envelope)`; violations normalized
  by (max-min) band so gradient magnitudes are balanced.
- `loss_ray_angle`: softplus CRA + bend penalties, `num_ring` 8→4;
  both terms use `cra_scale = 1 - cos_ref` normalization.
- `base.py` bend penalty: add `bend_scale = 1 - cos_gate`; normalize
  softplus arg by `bend_scale`; lower beta 50→10 to match optim.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ss functions

Reflect the refactor in the previous commit:
- `air_min_edge` → `air_edge_min`, `thick_min_center` → `thick_center_min`
- Check new attrs `ttl_min` and `obliq_min` exist after init_constraints
- `loss_reg` dict keys: `loss_intersec/thickness/surf` → `loss_clearance/envelope/profile`
- `test_loss_surface_scalar` → `test_loss_profile_scalar` (calls loss_profile)
- `test_loss_intersec_scalar` + `test_loss_thickness_scalar` → `test_loss_bound_returns_tuple`
  (verifies loss_bound returns two scalar tensors, both >= 0)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- geolens.py sample_sensor: call .item() on self.d_sensor before
  passing to torch.full_like; d_sensor is a Tensor and full_like
  requires a scalar fill_value (TypeError in script 1).
- 2_autolens_rms.py: fix stale import path
  deeplens.optics.geolens_pkg.utils → deeplens.optics.geolens_pkg
  (create_lens was moved to optim_init and re-exported from __init__).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 core optics simulation utilities and the GeoLens pipeline, with a focus on more robust field-of-view (FoV) estimation, consolidated ray sampling APIs, and improved device-safe tensor creation. It also reorganizes differentiable ops/imports and updates tests accordingly.

Changes:

  • Refactor GeoLens.calc_fov() to a forward-tracing sweep-based FoV estimate with fallback to effective FoV, plus additional constraints initialization in post_computation().
  • Consolidate ray sampling by folding point-source-by-FoV sampling into sample_from_fov(), and replace the legacy Ray.obliq with an accumulated bend_penalty.
  • Import/organization cleanup: move differentiable ops to deeplens/optics/ops.py, move EDoF helper to deeplens/utils.py, and reduce heavy top-level imports by lazy-importing (skimage/torchvision/tqdm).

Reviewed changes

Copilot reviewed 28 out of 28 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/test_utils.py Updates imports to reflect ops/util split.
test/test_ray.py Updates Ray cloning test for bend_penalty attribute.
test/test_geolens_optim.py Aligns tests with renamed constraints and new loss breakdown.
test/test_basics.py Removes wave_rgb() tests and keeps constants validation.
deeplens/utils.py Moves heavy imports to function scope; adds foc_dist_balanced().
deeplens/optics/phase_surface/phase.py Adds update_r() convenience method for phase surface sizing.
deeplens/optics/ops.py Narrows module to differentiable tensor ops; removes non-core helpers.
deeplens/optics/light/wave.py Lazy-imports torchvision/tqdm for IO-heavy methods.
deeplens/optics/light/ray.py Replaces obliq with bend_penalty and updates cloning/squeeze behavior.
deeplens/optics/lens.py Lazy-imports torchvision/tqdm in visualization/render loops.
deeplens/optics/imgsim/psf.py Fixes device placement for new tensors via device=.
deeplens/optics/hybridlens.py Updates diff_float import to come from ops.
deeplens/optics/geometric_surface/thinlens.py Ensures f tensor is created on the correct device.
deeplens/optics/geometric_surface/base.py Adds per-surface bend penalty accumulation in refraction; device-safe tensor creation.
deeplens/optics/geometric_surface/aspheric.py Improves numerical safety and tensorizes validity checks for compile/tracing.
deeplens/optics/geolens_pkg/vis.py Improves titles/docs and adjusts default pupil z-reference; adds spot RMS to title.
deeplens/optics/geolens_pkg/psf_compute.py Updates diff_float import to come from ops.
deeplens/optics/geolens_pkg/optim_ops.py Refines pruning/shape correction flow and removes numpy dependency in pruning.
deeplens/optics/geolens_pkg/optim_init.py Fixes device placement when creating d_sensor.
deeplens/optics/geolens_pkg/optim.py Replaces legacy geometry losses with loss_profile/loss_bound; adds local cosine LR scheduler.
deeplens/optics/geolens_pkg/io.py Uses WAVE_RGB for Zemax export and removes redundant post_computation() call on JSON read.
deeplens/optics/geolens_pkg/eval.py Device-safe tensor creation and updated spot computation to pooled polychromatic centroid.
deeplens/optics/geolens_pkg/__init__.py Exports create_lens from optim_init.
deeplens/optics/geolens.py Forward-traced FoV calculation; unified sample_from_fov; adds init_constraints() to post_computation().
deeplens/optics/diffractive_surface/diffractive.py Updates diff_quantize import to come from ops.
deeplens/optics/diffraclens.py Updates diff_float import to come from ops.
deeplens/optics/config.py Reorganizes constants into “tunable” vs “physical/numerical” sections with clearer comments.
2_autolens_rms.py Updates create_lens import path and removes unused curriculum args/logic.

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

Comment thread deeplens/optics/geolens_pkg/eval.py Outdated
Comment on lines 1661 to 1664
if isinstance(rfov, float) and rfov > 0:
rfov = torch.linspace(0, rfov, 2)
rfov = torch.linspace(0, rfov, 2, device=self.device)
rfov = rfov.to(self.device)

Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

calc_chief_ray_infinite() converts scalar rfov to a tensor only when rfov > 0. If a caller passes rfov=0.0 (which is a valid/useful case per the docstring), the next line rfov = rfov.to(self.device) will fail because a Python float has no .to(). Handle all scalar rfov values (e.g., int/float, including 0) by converting via torch.as_tensor/torch.tensor before calling .to().

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@copilot apply changes based on this feedback

fov_deg = float(fov) * 180 / torch.pi
print(f"Using fov_deg: {fov_deg} during surface pruning.")
elif self.rfov_eff is not None:
fov_deg = self.rfov_eff * 180 / torch.pi
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

prune_surf() can reference fov_deg before assignment when both self.rfov and self.rfov_eff are None (the previous fallback based on atan(r_sensor/foclen) was removed). This will raise at runtime in the fov_y = ... line. Consider restoring a safe fallback (e.g., compute from r_sensor/foclen) or explicitly raising a clear error if FoV has not been computed yet.

Suggested change
fov_deg = self.rfov_eff * 180 / torch.pi
fov_deg = self.rfov_eff * 180 / torch.pi
elif getattr(self, "r_sensor", None) is not None and getattr(self, "foclen", None) is not None:
fov_deg = torch.atan(self.r_sensor / self.foclen) * 180 / torch.pi
else:
raise ValueError(
"Cannot prune surfaces because field of view is unavailable: "
"expected self.rfov or self.rfov_eff to be set, or both "
"self.r_sensor and self.foclen to be available for fallback computation."
)

Copilot uses AI. Check for mistakes.
When rfov=0.0 or rfov=0 (int), the old code skipped the tensor
conversion (condition was `isinstance(rfov, float) and rfov > 0`),
causing `rfov.to(self.device)` to fail with AttributeError on a
Python float/int.

Now all scalar rfov values (int or float, including 0) are converted
to tensors before the `.to()` call:
- rfov > 0: linspace([0, rfov]) two-element tensor (previous behavior)
- rfov <= 0: single-element tensor [rfov]

Agent-Logs-Url: https://github.com/vccimaging/DeepLens/sessions/daf7625d-9fb7-45c3-b490-b41fcef1662f

Co-authored-by: singer-yang <25293821+singer-yang@users.noreply.github.com>
@singer-yang singer-yang merged commit f129c59 into dev Apr 16, 2026
Copilot stopped work on behalf of singer-yang due to an error April 16, 2026 09:27
@singer-yang singer-yang deleted the refactor-0414 branch April 16, 2026 12:00
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.

3 participants