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
1413import math
1514
1615import 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 ):
0 commit comments