@photivra/engine 2.20.0 → 2.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/CHANGELOG.md +295 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +7 -6
  4. package/SECURITY.md +1 -1
  5. package/THIRD_PARTY.md +18 -0
  6. package/dist/capture/c2pa-embed.d.ts +116 -0
  7. package/dist/capture/c2pa-embed.js +493 -0
  8. package/dist/capture/c2pa-embed.js.map +1 -0
  9. package/dist/capture/dng-import.d.ts +73 -8
  10. package/dist/capture/dng-import.js +315 -80
  11. package/dist/capture/dng-import.js.map +1 -1
  12. package/dist/capture/dng-lens-correction-opcodes.d.ts +2 -2
  13. package/dist/capture/dng-lens-correction-opcodes.js +2 -2
  14. package/dist/capture/dng-lens-correction-opcodes.js.map +1 -1
  15. package/dist/capture/dng-opcodes.d.ts +96 -0
  16. package/dist/capture/dng-opcodes.js +391 -0
  17. package/dist/capture/dng-opcodes.js.map +1 -0
  18. package/dist/capture/dng-profile-maps.d.ts +99 -0
  19. package/dist/capture/dng-profile-maps.js +381 -0
  20. package/dist/capture/dng-profile-maps.js.map +1 -0
  21. package/dist/capture/dng-stage-image.d.ts +23 -0
  22. package/dist/capture/dng-stage-image.js +65 -0
  23. package/dist/capture/dng-stage-image.js.map +1 -0
  24. package/dist/capture/exposure-build-up-stage.d.ts +16 -0
  25. package/dist/capture/exposure-build-up-stage.js +4 -0
  26. package/dist/capture/exposure-build-up-stage.js.map +1 -0
  27. package/dist/capture/jpeg-xl-tiles.d.ts +22 -0
  28. package/dist/capture/jpeg-xl-tiles.js +151 -0
  29. package/dist/capture/jpeg-xl-tiles.js.map +1 -0
  30. package/dist/capture/lossy-jpeg.d.ts +17 -0
  31. package/dist/capture/lossy-jpeg.js +369 -0
  32. package/dist/capture/lossy-jpeg.js.map +1 -0
  33. package/dist/capture/native-environment-lean.d.ts +2 -1
  34. package/dist/capture/native-environment-lean.js +41 -73
  35. package/dist/capture/native-environment-lean.js.map +1 -1
  36. package/dist/capture/native-environment-tabulated.js +4 -3
  37. package/dist/capture/native-environment-tabulated.js.map +1 -1
  38. package/dist/capture/native-raw-development.d.ts +2 -1
  39. package/dist/capture/native-raw-development.js.map +1 -1
  40. package/dist/capture/native-raw.d.ts +3 -2
  41. package/dist/capture/native-raw.js +5 -4
  42. package/dist/capture/native-raw.js.map +1 -1
  43. package/dist/capture/sensor-raw-producer.d.ts +4 -1
  44. package/dist/capture/sensor-raw-producer.js +12 -3
  45. package/dist/capture/sensor-raw-producer.js.map +1 -1
  46. package/dist/color/neighbor-highlight-reconstruction.d.ts +66 -0
  47. package/dist/color/neighbor-highlight-reconstruction.js +234 -0
  48. package/dist/color/neighbor-highlight-reconstruction.js.map +1 -0
  49. package/dist/color/sensor-color-development.d.ts +5 -0
  50. package/dist/color/sensor-color-development.js +7 -1
  51. package/dist/color/sensor-color-development.js.map +1 -1
  52. package/dist/color/spectral-spikiness.d.ts +11 -0
  53. package/dist/color/spectral-spikiness.js +23 -0
  54. package/dist/color/spectral-spikiness.js.map +1 -0
  55. package/dist/core/execution-error.d.ts +21 -0
  56. package/dist/core/execution-error.js +12 -0
  57. package/dist/core/execution-error.js.map +1 -0
  58. package/dist/core/version.d.ts +1 -1
  59. package/dist/core/version.js +1 -1
  60. package/dist/core/version.js.map +1 -1
  61. package/dist/equipment/generic-lens-catalogue.d.ts +3 -2
  62. package/dist/equipment/generic-lens-catalogue.js +18 -5
  63. package/dist/equipment/generic-lens-catalogue.js.map +1 -1
  64. package/dist/equipment/generic-tier-assets.d.ts +32 -0
  65. package/dist/equipment/generic-tier-assets.js +16 -11
  66. package/dist/equipment/generic-tier-assets.js.map +1 -1
  67. package/dist/equipment/sensor-profile-body.d.ts +78 -0
  68. package/dist/equipment/sensor-profile-body.js +77 -0
  69. package/dist/equipment/sensor-profile-body.js.map +1 -0
  70. package/dist/equipment/sensor-profiles.d.ts +8 -0
  71. package/dist/equipment/sensor-profiles.js +82 -0
  72. package/dist/equipment/sensor-profiles.js.map +1 -0
  73. package/dist/facade/catalogue-lens-signal.d.ts +28 -3
  74. package/dist/facade/catalogue-lens-signal.js +83 -78
  75. package/dist/facade/catalogue-lens-signal.js.map +1 -1
  76. package/dist/facade/chart-radiance-batch.js +2 -1
  77. package/dist/facade/chart-radiance-batch.js.map +1 -1
  78. package/dist/facade/defocus-image-space-provider.d.ts +7 -0
  79. package/dist/facade/defocus-image-space-provider.js +64 -76
  80. package/dist/facade/defocus-image-space-provider.js.map +1 -1
  81. package/dist/facade/diffraction-provider.d.ts +1 -1
  82. package/dist/facade/diffraction-provider.js +4 -4
  83. package/dist/facade/diffraction-provider.js.map +1 -1
  84. package/dist/facade/display-view-gpu-session.js +39 -5
  85. package/dist/facade/display-view-gpu-session.js.map +1 -1
  86. package/dist/facade/display-view-gpu.d.ts +19 -6
  87. package/dist/facade/display-view-gpu.js.map +1 -1
  88. package/dist/facade/display-view-lens-signal.js +25 -23
  89. package/dist/facade/display-view-lens-signal.js.map +1 -1
  90. package/dist/facade/display-view-normal.d.ts +15 -0
  91. package/dist/facade/display-view-normal.js +44 -0
  92. package/dist/facade/display-view-normal.js.map +1 -0
  93. package/dist/facade/display-view-rendering.d.ts +97 -0
  94. package/dist/facade/display-view-rendering.js +598 -0
  95. package/dist/facade/display-view-rendering.js.map +1 -0
  96. package/dist/facade/display-view.d.ts +37 -65
  97. package/dist/facade/display-view.js +152 -499
  98. package/dist/facade/display-view.js.map +1 -1
  99. package/dist/facade/expected-signal-setup.d.ts +3 -1
  100. package/dist/facade/expected-signal-setup.js +3 -3
  101. package/dist/facade/expected-signal-setup.js.map +1 -1
  102. package/dist/facade/expected-signal.d.ts +47 -14
  103. package/dist/facade/expected-signal.js +117 -112
  104. package/dist/facade/expected-signal.js.map +1 -1
  105. package/dist/facade/exposure-build-up.d.ts +70 -0
  106. package/dist/facade/exposure-build-up.js +164 -0
  107. package/dist/facade/exposure-build-up.js.map +1 -0
  108. package/dist/facade/fast-methods.d.ts +1 -1
  109. package/dist/facade/fast-methods.js +46 -9
  110. package/dist/facade/fast-methods.js.map +1 -1
  111. package/dist/facade/generic-lens-capture.d.ts +3 -3
  112. package/dist/facade/generic-lens-capture.js +3 -3
  113. package/dist/facade/generic-lens-capture.js.map +1 -1
  114. package/dist/facade/image-plane-enable.d.ts +7 -0
  115. package/dist/facade/image-plane-enable.js +17 -0
  116. package/dist/facade/image-plane-enable.js.map +1 -0
  117. package/dist/facade/image-plane-lattice-data.d.ts +6 -0
  118. package/dist/facade/image-plane-lattice-data.js +10 -0
  119. package/dist/facade/image-plane-lattice-data.js.map +1 -0
  120. package/dist/facade/image-plane-support.d.ts +33 -0
  121. package/dist/facade/image-plane-support.js +11 -0
  122. package/dist/facade/image-plane-support.js.map +1 -0
  123. package/dist/facade/image-plane.d.ts +36 -0
  124. package/dist/facade/image-plane.js +384 -0
  125. package/dist/facade/image-plane.js.map +1 -0
  126. package/dist/facade/mixed-illumination.d.ts +108 -0
  127. package/dist/facade/mixed-illumination.js +174 -0
  128. package/dist/facade/mixed-illumination.js.map +1 -0
  129. package/dist/facade/on-demand-kits.d.ts +14 -0
  130. package/dist/facade/on-demand-kits.js +36 -0
  131. package/dist/facade/on-demand-kits.js.map +1 -0
  132. package/dist/facade/opt-in-kit.d.ts +9 -0
  133. package/dist/facade/opt-in-kit.js +11 -0
  134. package/dist/facade/opt-in-kit.js.map +1 -0
  135. package/dist/facade/quality-kit.d.ts +21 -0
  136. package/dist/facade/quality-kit.js +23 -0
  137. package/dist/facade/quality-kit.js.map +1 -0
  138. package/dist/facade/quality-paths.d.ts +22 -0
  139. package/dist/facade/quality-paths.js +44 -0
  140. package/dist/facade/quality-paths.js.map +1 -0
  141. package/dist/facade/representative-light-sources.d.ts +72 -0
  142. package/dist/facade/representative-light-sources.js +124 -0
  143. package/dist/facade/representative-light-sources.js.map +1 -0
  144. package/dist/facade/scene-radiance.d.ts +145 -4
  145. package/dist/facade/scene-radiance.js +50 -13
  146. package/dist/facade/scene-radiance.js.map +1 -1
  147. package/dist/facade/simulate-capture.d.ts +68 -20
  148. package/dist/facade/simulate-capture.js +399 -146
  149. package/dist/facade/simulate-capture.js.map +1 -1
  150. package/dist/facade/simulate-display-view.d.ts +10 -12
  151. package/dist/facade/simulate-display-view.js +70 -53
  152. package/dist/facade/simulate-display-view.js.map +1 -1
  153. package/dist/gpu/chart-composite-gpu.d.ts +13 -1
  154. package/dist/gpu/chart-composite-gpu.js +48 -9
  155. package/dist/gpu/chart-composite-gpu.js.map +1 -1
  156. package/dist/gpu/chart-radiance-gpu.js +4 -30
  157. package/dist/gpu/chart-radiance-gpu.js.map +1 -1
  158. package/dist/gpu/display-view-composite-material-module.d.ts +10 -4
  159. package/dist/gpu/display-view-composite-material-module.js +39 -13
  160. package/dist/gpu/display-view-composite-material-module.js.map +1 -1
  161. package/dist/gpu/display-view-gpu-renderer.d.ts +43 -0
  162. package/dist/gpu/display-view-gpu-renderer.js +134 -0
  163. package/dist/gpu/display-view-gpu-renderer.js.map +1 -0
  164. package/dist/gpu/display-view-image-material-module.d.ts +87 -0
  165. package/dist/gpu/display-view-image-material-module.js +361 -0
  166. package/dist/gpu/display-view-image-material-module.js.map +1 -0
  167. package/dist/gpu/display-view-image-quality.d.ts +87 -0
  168. package/dist/gpu/display-view-image-quality.js +625 -0
  169. package/dist/gpu/display-view-image-quality.js.map +1 -0
  170. package/dist/gpu/display-view-quality-gpu.d.ts +80 -65
  171. package/dist/gpu/display-view-quality-gpu.js +187 -445
  172. package/dist/gpu/display-view-quality-gpu.js.map +1 -1
  173. package/dist/gpu/display-view-speed-gpu.d.ts +67 -19
  174. package/dist/gpu/display-view-speed-gpu.js +181 -105
  175. package/dist/gpu/display-view-speed-gpu.js.map +1 -1
  176. package/dist/gpu/image-kernel-accelerator.js +3 -0
  177. package/dist/gpu/image-kernel-accelerator.js.map +1 -1
  178. package/dist/gpu/image-kernel-execution.d.ts +2 -13
  179. package/dist/gpu/image-kernel-execution.js +2 -10
  180. package/dist/gpu/image-kernel-execution.js.map +1 -1
  181. package/dist/gpu/wgsl-display-view-coupled-stub.d.ts +8 -0
  182. package/dist/gpu/wgsl-display-view-coupled-stub.js +21 -0
  183. package/dist/gpu/wgsl-display-view-coupled-stub.js.map +1 -0
  184. package/dist/gpu/wgsl-display-view-coupled.d.ts +88 -0
  185. package/dist/gpu/wgsl-display-view-coupled.js +353 -0
  186. package/dist/gpu/wgsl-display-view-coupled.js.map +1 -0
  187. package/dist/gpu/wgsl-display-view-quality-kernel.d.ts +33 -24
  188. package/dist/gpu/wgsl-display-view-quality-kernel.js +166 -146
  189. package/dist/gpu/wgsl-display-view-quality-kernel.js.map +1 -1
  190. package/dist/gpu/wgsl-display-view-speed-kernel.d.ts +19 -44
  191. package/dist/gpu/wgsl-display-view-speed-kernel.js +116 -18
  192. package/dist/gpu/wgsl-display-view-speed-kernel.js.map +1 -1
  193. package/dist/gpu/wgsl-exact-sum.d.ts +20 -0
  194. package/dist/gpu/wgsl-exact-sum.js +42 -0
  195. package/dist/gpu/wgsl-exact-sum.js.map +1 -0
  196. package/dist/index.d.ts +22 -6
  197. package/dist/index.js +16 -2
  198. package/dist/index.js.map +1 -1
  199. package/dist/motion/subject-motion.d.ts +66 -0
  200. package/dist/motion/subject-motion.js +52 -0
  201. package/dist/motion/subject-motion.js.map +1 -0
  202. package/dist/optics/generic-lens-lateral-color.d.ts +83 -0
  203. package/dist/optics/generic-lens-lateral-color.js +77 -0
  204. package/dist/optics/generic-lens-lateral-color.js.map +1 -0
  205. package/dist/optics/generic-lens-psf.js +54 -46
  206. package/dist/optics/generic-lens-psf.js.map +1 -1
  207. package/dist/optics/lens-psf-profile.d.ts +7 -0
  208. package/dist/optics/lens-psf-profile.js +2 -1
  209. package/dist/optics/lens-psf-profile.js.map +1 -1
  210. package/dist/output/camera-channel-response.d.ts +50 -0
  211. package/dist/output/camera-channel-response.js +69 -0
  212. package/dist/output/camera-channel-response.js.map +1 -0
  213. package/dist/output/developed-lens-corrections.d.ts +19 -5
  214. package/dist/output/developed-lens-corrections.js +28 -9
  215. package/dist/output/developed-lens-corrections.js.map +1 -1
  216. package/dist/output/generic-lens-corrections.d.ts +41 -5
  217. package/dist/output/generic-lens-corrections.js +52 -8
  218. package/dist/output/generic-lens-corrections.js.map +1 -1
  219. package/dist/output/native-capture-sdr.js +5 -2
  220. package/dist/output/native-capture-sdr.js.map +1 -1
  221. package/dist/output/print-plan.d.ts +41 -5
  222. package/dist/output/print-plan.js +59 -14
  223. package/dist/output/print-plan.js.map +1 -1
  224. package/dist/output/sdr-rendering.d.ts +18 -1
  225. package/dist/output/sdr-rendering.js +39 -2
  226. package/dist/output/sdr-rendering.js.map +1 -1
  227. package/dist/sensor/binomial-sample.d.ts +3 -0
  228. package/dist/sensor/binomial-sample.js +86 -0
  229. package/dist/sensor/binomial-sample.js.map +1 -0
  230. package/dist/sensor/native-sensor-report.d.ts +10 -0
  231. package/dist/sensor/native-sensor-report.js +16 -0
  232. package/dist/sensor/native-sensor-report.js.map +1 -0
  233. package/dist/sensor/sensor-characteristics.d.ts +91 -13
  234. package/dist/sensor/sensor-characteristics.js +30 -15
  235. package/dist/sensor/sensor-characteristics.js.map +1 -1
  236. package/docs/AGENT_GUIDE.md +1 -0
  237. package/docs/API_REFERENCE.md +214 -4
  238. package/docs/QUICK_START.md +162 -8
  239. package/docs/USAGE.md +195 -22
  240. package/docs/api/capture-c2pa-embed.md +177 -0
  241. package/docs/api/capture-dng-import.md +86 -10
  242. package/docs/api/capture-dng-opcodes.md +93 -0
  243. package/docs/api/capture-dng-profile-maps.md +141 -0
  244. package/docs/api/capture-dng-stage-image.md +43 -0
  245. package/docs/api/capture-jpeg-xl-tiles.md +33 -0
  246. package/docs/api/capture-lossy-jpeg.md +41 -0
  247. package/docs/api/capture-native-raw-development.md +2 -1
  248. package/docs/api/core-execution-error.md +43 -0
  249. package/docs/api/core-version.md +1 -1
  250. package/docs/api/equipment-generic-lens-catalogue.md +10 -5
  251. package/docs/api/equipment-sensor-profile-body.md +64 -0
  252. package/docs/api/equipment-sensor-profiles.md +86 -0
  253. package/docs/api/exports.json +460 -5
  254. package/docs/api/facade-display-view-gpu.md +5 -2
  255. package/docs/api/facade-display-view.md +11 -15
  256. package/docs/api/facade-exposure-build-up.md +105 -0
  257. package/docs/api/facade-fast-methods.md +47 -10
  258. package/docs/api/facade-image-plane-enable.md +16 -0
  259. package/docs/api/facade-mixed-illumination.md +67 -0
  260. package/docs/api/facade-quality-paths.md +24 -0
  261. package/docs/api/facade-representative-light-sources.md +131 -0
  262. package/docs/api/facade-scene-radiance.md +54 -3
  263. package/docs/api/facade-simulate-capture.md +48 -11
  264. package/docs/api/facade-simulate-display-view.md +10 -12
  265. package/docs/api/gpu-image-kernel-execution.md +0 -36
  266. package/docs/api/motion-subject-motion.md +23 -0
  267. package/docs/api/optics-generic-lens-lateral-color.md +79 -0
  268. package/docs/api/output-camera-channel-response.md +55 -0
  269. package/docs/api/output-developed-lens-corrections.md +10 -1
  270. package/docs/api/output-generic-lens-corrections.md +40 -4
  271. package/docs/api/output-print-plan.md +35 -1
  272. package/docs/api/output-sdr-rendering.md +6 -1
  273. package/docs/api/sensor-sensor-characteristics.md +54 -2
  274. package/llms.txt +3 -0
  275. package/package.json +6 -5
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subject-motion.js","sourceRoot":"","sources":["../../src/motion/subject-motion.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Subject motion (#551, 2.22.0): the whole scene, or one declared rectangle of it, moving on the scene plane during the\n * exposure, over a still background. Translation in m/s on the plane and, optionally, a constant rotation about an axis\n * parallel to the optical axis (through the plane's origin for the whole scene, through the rectangle's centre for a\n * region). Times are seconds from the first opening reference, the camera rotation's time reference, so each site's own\n * exposure window (shifted by its rolling-shutter offset) sees the subject where it is during that window.\n *\n * The scene at plane point p and time t: a point of the subject at rest point q is at R(θ)(q − c) + c + d at time t\n * (d = v·t, θ = ω·t, c the centre), so p shows the subject's q = R(−θ)(p − c − d) + c when q lies in the subject; else\n * a region's rest rectangle shows a neutral 18 % background (what the subject revealed), and anywhere else the still scene at p. Exact for this\n * model (rigid motion of a planar layer); the blur is the capture's temporal midpoint quadrature.\n * @see docs/QUICK_START.md, \"Subject motion\".\n */\n\nimport type { PlanarSceneRadianceProvider } from \"../facade/scene-radiance.js\";\nimport type { SceneRadianceEvaluationRequest, SceneRadianceEvaluationResult } from \"../schema/scene-radiance.js\";\n\n/** `motion.subject` of `simulateCapture`: a moving subject on the scene plane (#551). */\nexport interface SimulateCaptureSubjectMotion {\n /** Velocity on the scene plane, m/s: +x right, +y up as seen from the camera. Missing components are 0. */\n velocityMPerSec?: { x?: number; y?: number };\n /** Rotation about the optical axis direction, rad/s, counter-clockwise as seen from the camera (+x toward +y). Default 0. */\n angularVelocityRadPerSec?: number;\n /**\n * The moving part: a rectangle on the scene plane at rest (at the first opening reference), its centre and size in metres\n * (+x right, +y up from the optical axis). Omitted: the whole scene moves.\n */\n region?: { centerXM: number; centerYM: number; widthM: number; heightM: number };\n /** Exact or fast (2.20.0 convention): fast takes fewer temporal midpoints when the subject's streak is short (see the docs). */\n fast?: boolean;\n}\n\n/** Internal: a validated subject motion (simulate-capture.ts parses it). */\nexport interface SubjectMotion {\n vx: number; vy: number; omega: number;\n /** The region's centre and half sizes in metres, or undefined for the whole scene. */\n region: { cx: number; cy: number; hw: number; hh: number } | undefined;\n}\n\n/**\n * The farthest any subject point travels on the plane during `seconds`, in metres (translation plus rotation at the subject's\n * farthest point from its centre; `reachM` is the whole scene's reach from the axis when no region is declared).\n */\nexport function subjectTravelM(motion: SubjectMotion, seconds: number, reachM: number): number {\n const r = motion.region, arm = r ? Math.hypot(r.hw, r.hh) : reachM;\n return (Math.hypot(motion.vx, motion.vy) + Math.abs(motion.omega) * arm) * seconds;\n}\n\n/**\n * The plane point that shows at (x, y) at time t (in the motion's length unit), written to out[0], out[1]; returns false where a region's rest\n * rectangle shows the revealed background. Without motion at t (d = 0, θ = 0) the point is (x, y) unchanged, bit for bit.\n */\nexport function subjectSourcePoint(motion: SubjectMotion, x: number, y: number, t: number, out: Float64Array): boolean {\n const r = motion.region, th = motion.omega * t;\n let qx = x - motion.vx * t, qy = y - motion.vy * t;\n if (th !== 0) {\n const cx = r ? r.cx : 0, cy = r ? r.cy : 0, c = Math.cos(th), s = Math.sin(th), dx = qx - cx, dy = qy - cy;\n qx = c * dx + s * dy + cx; qy = c * dy - s * dx + cy;\n }\n if (r && !(Math.abs(qx - r.cx) <= r.hw && Math.abs(qy - r.cy) <= r.hh)) {\n out[0] = x; out[1] = y;\n return !(Math.abs(x - r.cx) <= r.hw && Math.abs(y - r.cy) <= r.hh);\n }\n out[0] = qx; out[1] = qy;\n return true;\n}\n\n/**\n * The scene of `base` with the subject moving: `evaluateRadiance` reads each query at its request time (the base result, with the\n * radiance read where the subject shows), and `radianceAt` takes the time as a fourth argument (default 0) for the image-space\n * lattices and the display view. It has no chart material access, so renderers read radiance. `revealedRadiance` is the revealed\n * background's spectral radiance at a plane point in frame units (an 18 % neutral reflectance under the irradiance there).\n */\nexport function createSubjectMotionProvider(base: PlanarSceneRadianceProvider, motion: SubjectMotion, distanceM: number, scale: number,\n revealedRadiance: (u: number, v: number, nm: number) => number): PlanarSceneRadianceProvider {\n // The motion in frame units, so a still instant reads the base at the queried point itself.\n const r = motion.region, framed: SubjectMotion = { vx: motion.vx / scale, vy: motion.vy / scale, omega: motion.omega,\n region: r && { cx: r.cx / scale, cy: r.cy / scale, hw: r.hw / scale, hh: r.hh / scale } }, point = new Float64Array(2);\n const radianceAt = (u: number, v: number, nm: number, t = 0): number =>\n subjectSourcePoint(framed, u, v, t, point) ? base.radianceAt(point[0]!, point[1]!, nm) : revealedRadiance(u, v, nm);\n return { sceneId: base.sceneId, providerProfileId: base.providerProfileId, radianceAt, evaluateApertureRadiance: base.evaluateApertureRadiance,\n evaluateRadiance(request: Readonly<SceneRadianceEvaluationRequest>): SceneRadianceEvaluationResult {\n const result = base.evaluateRadiance(request), o = (request.target as { outgoingDirectionUnitVector: { x: number; y: number; z: number } }).outgoingDirectionUnitVector;\n if (!(o.z < 0)) return result;\n // The base provider's own plane-point arithmetic for this direction.\n const t = distanceM / -o.z;\n return { ...result, spectralRadianceWattsPerSquareMeterSteradianNanometer: radianceAt(-o.x * t / scale, -o.y * t / scale, request.wavelengthNanometers, request.timeSecondsFromExposureStart) };\n } };\n}\n"]}
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Lateral colour of a catalogue lens as a fringe width (#580): the red–blue image displacement in micrometres and in pixels at
3
+ * one field position, for the app's Guide page on color fringing.
4
+ *
5
+ * It reads the lens's own lateral-colour model (`docs/LENS_CATALOGUE.md`, "Models"), the one `simulateCapture` renders and
6
+ * `createGenericLensCorrectionProfile` corrects: a radial image displacement Δr(λ) = L·R·H·D(λ), with L the lens's
7
+ * `lateralColor.fraction`, R the sensor's half-diagonal, H the field height (fraction of R) and D(λ) the normalised Cauchy
8
+ * dispersion (0 at the Fraunhofer d line, F minus C = 1). The red and blue channels are taken at the effective wavelengths of
9
+ * the lens corrections, `GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM` (610 and 465 nm), so the blue-minus-red displacement
10
+ * is L·R·H·(D(465) − D(610)). With `channelDispersion` (2.21.0, #589's `calculateChannelEffectiveDispersion` for the camera's
11
+ * channels and the light) it is L·R·H·(D̄_blue − D̄_red), the displacement `simulateCapture` renders in RAW. The lens's lateral
12
+ * colour does not change with the f-number or the focus distance.
13
+ *
14
+ * Every value is an approximation: the lens is a generic Photivra design, not a measured product, and a real channel spans a
15
+ * band of wavelengths, not one.
16
+ */
17
+ import { type CalculationResult } from "../core/calculation-result.js";
18
+ import { type ChannelDispersion } from "../output/developed-lens-corrections.js";
19
+ export interface CalculateLateralColorFringeInput {
20
+ /** A catalogue id (`GENERIC_LENS_IDS`). */
21
+ lensId: string;
22
+ /** The sensor's active capture area, mm (the lens is scaled to it as in `resolveGenericLens`). */
23
+ activeImagingArea: {
24
+ widthMm: number;
25
+ heightMm: number;
26
+ };
27
+ /** The sensor's pixel pitch, µm. */
28
+ pixelPitchMicrometers: number;
29
+ /**
30
+ * The field position as fractions of the frame's half-width (x, +right) and half-height (y, +up) from the optical axis, each
31
+ * in [−1, 1]. Default `{ x: 1, y: 1 }`: the top-right corner.
32
+ */
33
+ fieldPosition?: {
34
+ x: number;
35
+ y: number;
36
+ };
37
+ /**
38
+ * 2.21.0: each channel's effective dispersion (`calculateChannelEffectiveDispersion` for the camera's channels and the light;
39
+ * only `red` and `blue` are read). Default: D at the single wavelengths 610 and 465 nm.
40
+ */
41
+ channelDispersion?: ChannelDispersion;
42
+ }
43
+ /** A signed displacement of the blue image from the red one: radial (positive: blue farther from the axis) and its x and y parts. */
44
+ export interface LateralColorDisplacement {
45
+ radial: number;
46
+ x: number;
47
+ y: number;
48
+ }
49
+ export interface LateralColorFringe {
50
+ lensId: string;
51
+ /** The image-plane point, mm from the optical axis (+x right, +y up). */
52
+ imagePointMm: {
53
+ x: number;
54
+ y: number;
55
+ };
56
+ /** Field height H: the point's distance from the axis as a fraction of the half-diagonal (1 at the corner). */
57
+ fieldHeight: number;
58
+ /** The channel wavelengths the displacement is taken at, nm; null with a given `channelDispersion`. */
59
+ channelWavelengthsNm: {
60
+ red: number;
61
+ blue: number;
62
+ } | null;
63
+ /** The red and blue dispersions D used (normalized: 0 at the d line, F minus C = 1). */
64
+ channelDispersion: {
65
+ red: number;
66
+ blue: number;
67
+ };
68
+ /** Blue-minus-red image displacement, µm. */
69
+ blueMinusRedMicrometers: LateralColorDisplacement;
70
+ /** The same in pixels of the given pitch. */
71
+ blueMinusRedPixels: LateralColorDisplacement;
72
+ /** The fringe width: |radial| displacement, µm and pixels. */
73
+ fringeWidthMicrometers: number;
74
+ fringeWidthPixels: number;
75
+ /** The lens's own F-to-C (486 to 656 nm) displacement at this point, L·R·H, µm. */
76
+ fToCMicrometers: number;
77
+ }
78
+ /**
79
+ * The red–blue image displacement of a catalogue lens at a field position on a sensor, in micrometres and pixels, from the
80
+ * lens's own lateral-colour model Δr(λ) = L·R·H·D(λ) at the lens corrections' channel wavelengths (610 and 465 nm), or at the
81
+ * given `channelDispersion`. Exact for the model (no fast mode); an approximation of a generic lens, not a measured product.
82
+ */
83
+ export declare function calculateLateralColorFringe(input: CalculateLateralColorFringeInput): CalculationResult<LateralColorFringe>;
@@ -0,0 +1,77 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Lateral colour of a catalogue lens as a fringe width (#580): the red–blue image displacement in micrometres and in pixels at
4
+ * one field position, for the app's Guide page on color fringing.
5
+ *
6
+ * It reads the lens's own lateral-colour model (`docs/LENS_CATALOGUE.md`, "Models"), the one `simulateCapture` renders and
7
+ * `createGenericLensCorrectionProfile` corrects: a radial image displacement Δr(λ) = L·R·H·D(λ), with L the lens's
8
+ * `lateralColor.fraction`, R the sensor's half-diagonal, H the field height (fraction of R) and D(λ) the normalised Cauchy
9
+ * dispersion (0 at the Fraunhofer d line, F minus C = 1). The red and blue channels are taken at the effective wavelengths of
10
+ * the lens corrections, `GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM` (610 and 465 nm), so the blue-minus-red displacement
11
+ * is L·R·H·(D(465) − D(610)). With `channelDispersion` (2.21.0, #589's `calculateChannelEffectiveDispersion` for the camera's
12
+ * channels and the light) it is L·R·H·(D̄_blue − D̄_red), the displacement `simulateCapture` renders in RAW. The lens's lateral
13
+ * colour does not change with the f-number or the focus distance.
14
+ *
15
+ * Every value is an approximation: the lens is a generic Photivra design, not a measured product, and a real channel spans a
16
+ * band of wavelengths, not one.
17
+ */
18
+ import { approximationResult } from "../core/calculation-result.js";
19
+ import { InvalidScientificInputError } from "../core/validation.js";
20
+ import { requireInputKeys, requireInputObject } from "../core/record-validation.js";
21
+ import { genericLensDispersion, resolveGenericLens } from "../equipment/generic-lens-catalogue.js";
22
+ import { GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM } from "../output/developed-lens-corrections.js";
23
+ function positiveFinite(value, name) {
24
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
25
+ throw new InvalidScientificInputError(name + " must be a positive finite number.");
26
+ return value;
27
+ }
28
+ function fieldCoordinate(value, name) {
29
+ if (typeof value !== "number" || !Number.isFinite(value) || value < -1 || value > 1)
30
+ throw new InvalidScientificInputError(name + " must be a finite number in [-1, 1].");
31
+ return value;
32
+ }
33
+ /**
34
+ * The red–blue image displacement of a catalogue lens at a field position on a sensor, in micrometres and pixels, from the
35
+ * lens's own lateral-colour model Δr(λ) = L·R·H·D(λ) at the lens corrections' channel wavelengths (610 and 465 nm), or at the
36
+ * given `channelDispersion`. Exact for the model (no fast mode); an approximation of a generic lens, not a measured product.
37
+ */
38
+ export function calculateLateralColorFringe(input) {
39
+ requireInputObject(input, "calculateLateralColorFringe input");
40
+ requireInputKeys(input, "calculateLateralColorFringe", ["lensId", "activeImagingArea", "pixelPitchMicrometers", "fieldPosition", "channelDispersion"]);
41
+ const lens = resolveGenericLens({ lensId: input.lensId, activeImagingArea: input.activeImagingArea });
42
+ const pitch = positiveFinite(input.pixelPitchMicrometers, "pixelPitchMicrometers");
43
+ let u = 1, v = 1;
44
+ if (input.fieldPosition !== undefined) {
45
+ requireInputObject(input.fieldPosition, "fieldPosition");
46
+ requireInputKeys(input.fieldPosition, "fieldPosition", ["x", "y"]);
47
+ u = fieldCoordinate(input.fieldPosition.x, "fieldPosition.x");
48
+ v = fieldCoordinate(input.fieldPosition.y, "fieldPosition.y");
49
+ }
50
+ const x = u * input.activeImagingArea.widthMm / 2, y = v * input.activeImagingArea.heightMm / 2, r = Math.hypot(x, y);
51
+ const fieldHeight = r / lens.halfDiagonalMm, { red, blue } = GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM, given = input.channelDispersion;
52
+ let dispersion = { red: genericLensDispersion(red), blue: genericLensDispersion(blue) };
53
+ if (given !== undefined) {
54
+ requireInputObject(given, "channelDispersion");
55
+ requireInputKeys(given, "channelDispersion", ["red", "green", "blue"]);
56
+ // D spans −1.08 (1100 nm) to 4.30 (300 nm), as createGenericLensCorrectionProfile checks.
57
+ for (const c of ["red", "blue"])
58
+ if (typeof given[c] !== "number" || !(given[c] >= -1.1 && given[c] <= 4.4))
59
+ throw new InvalidScientificInputError("channelDispersion." + c + " must be a number in [−1.1, 4.4], D between 1100 and 300 nm.");
60
+ dispersion = { red: given.red, blue: given.blue };
61
+ }
62
+ const fToCMicrometers = lens.character.lateralColor.fraction * lens.halfDiagonalMm * 1000 * fieldHeight;
63
+ const radial = fToCMicrometers * (dispersion.blue - dispersion.red);
64
+ const cos = r > 0 ? x / r : 0, sin = r > 0 ? y / r : 0;
65
+ // `+ 0` turns a −0 (from a negative zero cosine or sine) into 0.
66
+ const um = { radial, x: radial * cos + 0, y: radial * sin + 0 };
67
+ return approximationResult({ lensId: lens.lensId, imagePointMm: { x, y }, fieldHeight, channelWavelengthsNm: given ? null : { red, blue }, channelDispersion: dispersion,
68
+ blueMinusRedMicrometers: um, blueMinusRedPixels: { radial: um.radial / pitch, x: um.x / pitch, y: um.y / pitch },
69
+ fringeWidthMicrometers: Math.abs(radial), fringeWidthPixels: Math.abs(radial) / pitch, fToCMicrometers }, "generic-lens-lateral-color-fringe", "1.0.0", [
70
+ "The catalogue lens's lateral-colour model: radial displacement L·R·H·D(λ), D the normalised Cauchy dispersion (Fraunhofer d, F and C lines).",
71
+ given ? "Red and blue at the given channel dispersions (a neutral surface's weighting of each channel's band)." :
72
+ "Red and blue at single effective wavelengths of 610 and 465 nm (GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM); a real channel spans a band.",
73
+ "A generic Photivra lens, not a measured product; lateral colour does not change with the f-number or the focus distance in this model.",
74
+ "Geometric displacement only: the rendered fringe also depends on the lens's blur, the demosaic and the colour conversion."
75
+ ]);
76
+ }
77
+ //# sourceMappingURL=generic-lens-lateral-color.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"generic-lens-lateral-color.js","sourceRoot":"","sources":["../../src/optics/generic-lens-lateral-color.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Lateral colour of a catalogue lens as a fringe width (#580): the red–blue image displacement in micrometres and in pixels at\n * one field position, for the app's Guide page on color fringing.\n *\n * It reads the lens's own lateral-colour model (`docs/LENS_CATALOGUE.md`, \"Models\"), the one `simulateCapture` renders and\n * `createGenericLensCorrectionProfile` corrects: a radial image displacement Δr(λ) = L·R·H·D(λ), with L the lens's\n * `lateralColor.fraction`, R the sensor's half-diagonal, H the field height (fraction of R) and D(λ) the normalised Cauchy\n * dispersion (0 at the Fraunhofer d line, F minus C = 1). The red and blue channels are taken at the effective wavelengths of\n * the lens corrections, `GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM` (610 and 465 nm), so the blue-minus-red displacement\n * is L·R·H·(D(465) − D(610)). With `channelDispersion` (2.21.0, #589's `calculateChannelEffectiveDispersion` for the camera's\n * channels and the light) it is L·R·H·(D̄_blue − D̄_red), the displacement `simulateCapture` renders in RAW. The lens's lateral\n * colour does not change with the f-number or the focus distance.\n *\n * Every value is an approximation: the lens is a generic Photivra design, not a measured product, and a real channel spans a\n * band of wavelengths, not one.\n */\n\nimport { approximationResult, type CalculationResult } from \"../core/calculation-result.js\";\nimport { InvalidScientificInputError } from \"../core/validation.js\";\nimport { requireInputKeys, requireInputObject } from \"../core/record-validation.js\";\nimport { genericLensDispersion, resolveGenericLens } from \"../equipment/generic-lens-catalogue.js\";\nimport { GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM, type ChannelDispersion } from \"../output/developed-lens-corrections.js\";\n\nexport interface CalculateLateralColorFringeInput {\n /** A catalogue id (`GENERIC_LENS_IDS`). */\n lensId: string;\n /** The sensor's active capture area, mm (the lens is scaled to it as in `resolveGenericLens`). */\n activeImagingArea: { widthMm: number; heightMm: number };\n /** The sensor's pixel pitch, µm. */\n pixelPitchMicrometers: number;\n /**\n * The field position as fractions of the frame's half-width (x, +right) and half-height (y, +up) from the optical axis, each\n * in [−1, 1]. Default `{ x: 1, y: 1 }`: the top-right corner.\n */\n fieldPosition?: { x: number; y: number };\n /**\n * 2.21.0: each channel's effective dispersion (`calculateChannelEffectiveDispersion` for the camera's channels and the light;\n * only `red` and `blue` are read). Default: D at the single wavelengths 610 and 465 nm.\n */\n channelDispersion?: ChannelDispersion;\n}\n\n/** A signed displacement of the blue image from the red one: radial (positive: blue farther from the axis) and its x and y parts. */\nexport interface LateralColorDisplacement {\n radial: number;\n x: number;\n y: number;\n}\n\nexport interface LateralColorFringe {\n lensId: string;\n /** The image-plane point, mm from the optical axis (+x right, +y up). */\n imagePointMm: { x: number; y: number };\n /** Field height H: the point's distance from the axis as a fraction of the half-diagonal (1 at the corner). */\n fieldHeight: number;\n /** The channel wavelengths the displacement is taken at, nm; null with a given `channelDispersion`. */\n channelWavelengthsNm: { red: number; blue: number } | null;\n /** The red and blue dispersions D used (normalized: 0 at the d line, F minus C = 1). */\n channelDispersion: { red: number; blue: number };\n /** Blue-minus-red image displacement, µm. */\n blueMinusRedMicrometers: LateralColorDisplacement;\n /** The same in pixels of the given pitch. */\n blueMinusRedPixels: LateralColorDisplacement;\n /** The fringe width: |radial| displacement, µm and pixels. */\n fringeWidthMicrometers: number;\n fringeWidthPixels: number;\n /** The lens's own F-to-C (486 to 656 nm) displacement at this point, L·R·H, µm. */\n fToCMicrometers: number;\n}\n\nfunction positiveFinite(value: unknown, name: string): number {\n if (typeof value !== \"number\" || !Number.isFinite(value) || value <= 0) throw new InvalidScientificInputError(name + \" must be a positive finite number.\");\n return value;\n}\nfunction fieldCoordinate(value: unknown, name: string): number {\n if (typeof value !== \"number\" || !Number.isFinite(value) || value < -1 || value > 1) throw new InvalidScientificInputError(name + \" must be a finite number in [-1, 1].\");\n return value;\n}\n\n/**\n * The red–blue image displacement of a catalogue lens at a field position on a sensor, in micrometres and pixels, from the\n * lens's own lateral-colour model Δr(λ) = L·R·H·D(λ) at the lens corrections' channel wavelengths (610 and 465 nm), or at the\n * given `channelDispersion`. Exact for the model (no fast mode); an approximation of a generic lens, not a measured product.\n */\nexport function calculateLateralColorFringe(input: CalculateLateralColorFringeInput): CalculationResult<LateralColorFringe> {\n requireInputObject(input, \"calculateLateralColorFringe input\");\n requireInputKeys(input, \"calculateLateralColorFringe\", [\"lensId\", \"activeImagingArea\", \"pixelPitchMicrometers\", \"fieldPosition\", \"channelDispersion\"]);\n const lens = resolveGenericLens({ lensId: input.lensId, activeImagingArea: input.activeImagingArea });\n const pitch = positiveFinite(input.pixelPitchMicrometers, \"pixelPitchMicrometers\");\n let u = 1, v = 1;\n if (input.fieldPosition !== undefined) {\n requireInputObject(input.fieldPosition, \"fieldPosition\");\n requireInputKeys(input.fieldPosition, \"fieldPosition\", [\"x\", \"y\"]);\n u = fieldCoordinate(input.fieldPosition.x, \"fieldPosition.x\"); v = fieldCoordinate(input.fieldPosition.y, \"fieldPosition.y\");\n }\n const x = u * input.activeImagingArea.widthMm / 2, y = v * input.activeImagingArea.heightMm / 2, r = Math.hypot(x, y);\n const fieldHeight = r / lens.halfDiagonalMm, { red, blue } = GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM, given = input.channelDispersion;\n let dispersion = { red: genericLensDispersion(red), blue: genericLensDispersion(blue) };\n if (given !== undefined) {\n requireInputObject(given, \"channelDispersion\");\n requireInputKeys(given, \"channelDispersion\", [\"red\", \"green\", \"blue\"]);\n // D spans −1.08 (1100 nm) to 4.30 (300 nm), as createGenericLensCorrectionProfile checks.\n for (const c of [\"red\", \"blue\"] as const) if (typeof given[c] !== \"number\" || !(given[c] >= -1.1 && given[c] <= 4.4))\n throw new InvalidScientificInputError(\"channelDispersion.\" + c + \" must be a number in [−1.1, 4.4], D between 1100 and 300 nm.\");\n dispersion = { red: given.red, blue: given.blue };\n }\n const fToCMicrometers = lens.character.lateralColor.fraction * lens.halfDiagonalMm * 1000 * fieldHeight;\n const radial = fToCMicrometers * (dispersion.blue - dispersion.red);\n const cos = r > 0 ? x / r : 0, sin = r > 0 ? y / r : 0;\n // `+ 0` turns a −0 (from a negative zero cosine or sine) into 0.\n const um: LateralColorDisplacement = { radial, x: radial * cos + 0, y: radial * sin + 0 };\n return approximationResult({ lensId: lens.lensId, imagePointMm: { x, y }, fieldHeight, channelWavelengthsNm: given ? null : { red, blue }, channelDispersion: dispersion,\n blueMinusRedMicrometers: um, blueMinusRedPixels: { radial: um.radial / pitch, x: um.x / pitch, y: um.y / pitch },\n fringeWidthMicrometers: Math.abs(radial), fringeWidthPixels: Math.abs(radial) / pitch, fToCMicrometers },\n \"generic-lens-lateral-color-fringe\", \"1.0.0\", [\n \"The catalogue lens's lateral-colour model: radial displacement L·R·H·D(λ), D the normalised Cauchy dispersion (Fraunhofer d, F and C lines).\",\n given ? \"Red and blue at the given channel dispersions (a neutral surface's weighting of each channel's band).\" :\n \"Red and blue at single effective wavelengths of 610 and 465 nm (GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM); a real channel spans a band.\",\n \"A generic Photivra lens, not a measured product; lateral colour does not change with the f-number or the focus distance in this model.\",\n \"Geometric displacement only: the rendered fringe also depends on the lens's blur, the demosaic and the colour conversion.\"]);\n}\n"]}
@@ -9,7 +9,8 @@
9
9
  * U(x) = Σ_pupil exp(i·2π·W(u)/λ)·exp(−i·2π·x·u/(λ·z)) is evaluated with a zero-padded radix-2 FFT (Cooley & Tukey 1965) on
10
10
  * an image grid of pitch δ = min(λ·N_w/2, p/4) (p the site pitch), and |U|² is integrated over each site's square cell (exact
11
11
  * overlap of each δ-square with the cell). Lateral colour displaces the PSF radially by Δr(λ). The kernel window is (2R + 1)²
12
- * sites; the energy outside it is reported and put back by unit-sum renormalisation, as for `lens.diffraction`.
12
+ * sites; the energy outside it is reported and put back by unit-sum renormalisation, as for `lens.diffraction`. Each kernel's
13
+ * centroid is then moved onto the PSF's own first moment (#589, `alignKernelCentroid`): the lateral colour and coma's mean tilt.
13
14
  *
14
15
  * Nodes: a 5 × 5 regular grid of field points (0, ±half the frame, ±the frame edge plus the kernel reach) × 5 wavelengths
15
16
  * (380, 450, 550, 650, 780 nm), each computed at its own field azimuth (no rotation of a radial node), interpolated by the
@@ -21,7 +22,7 @@
21
22
  */
22
23
  import { InvalidScientificInputError } from "../core/validation.js";
23
24
  import { genericLensDispersion } from "../equipment/generic-lens-catalogue.js";
24
- import { markEngineOwnedLensSampledPsfProfile } from "./lens-psf-profile.js";
25
+ import { kernelCentroid, markEngineOwnedLensSampledPsfProfile } from "./lens-psf-profile.js";
25
26
  import { convolveSiteKernels } from "./defocus-disc-kernel.js";
26
27
  import { createDefocusBokehSiteKernel } from "./defocus-bokeh-kernel.js";
27
28
  /** Wavelength nodes of a catalogue PSF profile, nm (they span the facade's 380–780 nm quadrature). */
@@ -106,12 +107,35 @@ function geometricRadiusMicrometers(lens, H, lambdaUm, workingF) {
106
107
  const px = -1 + 2 * i / (n - 1), py = -1 + 2 * j / (n - 1);
107
108
  if (px * px + py * py > 1)
108
109
  continue;
109
- const gx = (opdMicrometers(lens, H, 1, 0, lambdaUm, px + h, py) - opdMicrometers(lens, H, 1, 0, lambdaUm, px - h, py)) / (2 * h);
110
- const gy = (opdMicrometers(lens, H, 1, 0, lambdaUm, px, py + h) - opdMicrometers(lens, H, 1, 0, lambdaUm, px, py - h)) / (2 * h);
111
- largest = Math.max(largest, Math.hypot(gx, gy));
110
+ const slope = (dx, dy) => (opdMicrometers(lens, H, 1, 0, lambdaUm, px + dx, py + dy) - opdMicrometers(lens, H, 1, 0, lambdaUm, px - dx, py - dy)) / (2 * h);
111
+ largest = Math.max(largest, Math.hypot(slope(h, 0), slope(0, h)));
112
112
  }
113
113
  return 2 * workingF * largest;
114
114
  }
115
+ /**
116
+ * #589: moves a unit-sum kernel's intensity centroid to `target` (sites from the centre tap, +x right, +y up = the row index), each
117
+ * axis by a linear-interpolation shift of the whole kernel; energy that would leave the window stays on its edge tap, so the shift
118
+ * repeats (at most 8 passes per axis) while that leaves a residual. Cell integration alone quantises the centroid of a PSF narrower than a
119
+ * site, and the window's truncation pulls a wide one inwards: either moves the rendered lateral colour and coma off the lens's
120
+ * model. The shift keeps the sum and the other axis's moment, and widens the kernel by f·(1 − f) site² of variance for a move of f.
121
+ */
122
+ function alignKernelCentroid(kernel, radius, target) {
123
+ const w = 2 * radius + 1;
124
+ const shape = { widthSamples: w, heightSamples: w, centerSampleX: radius, centerSampleY: radius, normalizedIntensity: kernel };
125
+ for (let axis = 0; axis < 2; axis++)
126
+ for (let pass = 0; pass < 8; pass++) {
127
+ const c = kernelCentroid(shape), r = axis ? target.y - c.y : target.x - c.x, whole = Math.floor(r), f = r - whole, moved = new Float64Array(kernel.length);
128
+ if (Math.abs(r) < 1e-9)
129
+ break;
130
+ kernel.forEach((v, k) => {
131
+ // Tap k's index t along the axis moves to t + whole and t + whole + 1, held at the window's edges.
132
+ const t = axis ? Math.floor(k / w) : k % w, step = axis ? w : 1;
133
+ moved[k + (Math.min(w - 1, Math.max(0, t + whole)) - t) * step] += v * (1 - f);
134
+ moved[k + (Math.min(w - 1, Math.max(0, t + whole + 1)) - t) * step] += v * f;
135
+ });
136
+ kernel.set(moved);
137
+ }
138
+ }
115
139
  function nodeKernel(lens, xMm, yMm, lambdaNm, workingF, pupilDiameterMm, zMm, pitch, radius) {
116
140
  const lambdaUm = lambdaNm / 1000, r = Math.hypot(xMm, yMm), H = Math.min(1, r / lens.halfDiagonalMm);
117
141
  const cosPhi = r > 0 ? xMm / r : 1, sinPhi = r > 0 ? yMm / r : 0;
@@ -145,17 +169,14 @@ function nodeKernel(lens, xMm, yMm, lambdaNm, workingF, pupilDiameterMm, zMm, pi
145
169
  rowUsed[(v + size) % size] = 1;
146
170
  open++;
147
171
  }
172
+ // The largest step to the next sample in a row and in a column (outside the pupil the OPD is NaN, and so is the step: skipped).
148
173
  const width = 2 * reach + 1;
149
- for (let j = 0; j < width; j++)
150
- for (let i = 0; i < width; i++) {
151
- const w = opd[j * width + i];
152
- if (Number.isNaN(w))
153
- continue;
154
- if (i + 1 < width && !Number.isNaN(opd[j * width + i + 1]))
155
- phaseStep = Math.max(phaseStep, Math.abs(opd[j * width + i + 1] - w) / lambdaUm);
156
- if (j + 1 < width && !Number.isNaN(opd[(j + 1) * width + i]))
157
- phaseStep = Math.max(phaseStep, Math.abs(opd[(j + 1) * width + i] - w) / lambdaUm);
158
- }
174
+ for (let k = 0; k < opd.length; k++) {
175
+ if ((k + 1) % width)
176
+ phaseStep = Math.max(phaseStep, Math.abs(opd[k + 1] - opd[k]) / lambdaUm || 0);
177
+ if (k + width < opd.length)
178
+ phaseStep = Math.max(phaseStep, Math.abs(opd[k + width] - opd[k]) / lambdaUm || 0);
179
+ }
159
180
  if (open < 9)
160
181
  throw new InvalidScientificInputError("The catalogue lens PSF pupil has fewer than 9 samples.");
161
182
  if (phaseStep > MAXIMUM_PHASE_STEP_WAVES) {
@@ -167,20 +188,13 @@ function nodeKernel(lens, xMm, yMm, lambdaNm, workingF, pupilDiameterMm, zMm, pi
167
188
  cos[k] = Math.cos(2 * Math.PI * k / size);
168
189
  sin[k] = Math.sin(2 * Math.PI * k / size);
169
190
  }
170
- const rowRe = new Float64Array(size), rowIm = new Float64Array(size);
171
- for (let y = 0; y < size; y++) {
172
- if (!rowUsed[y])
173
- continue;
174
- rowRe.set(re.subarray(y * size, (y + 1) * size));
175
- rowIm.set(im.subarray(y * size, (y + 1) * size));
176
- radix2Fft(rowRe, rowIm, size, cos, sin);
177
- re.set(rowRe, y * size);
178
- im.set(rowIm, y * size);
179
- }
191
+ for (let y = 0; y < size; y++)
192
+ if (rowUsed[y])
193
+ radix2Fft(re.subarray(y * size, (y + 1) * size), im.subarray(y * size, (y + 1) * size), size, cos, sin);
180
194
  const colRe = new Float64Array(size), colIm = new Float64Array(size);
181
195
  const ox = overlaps(size, delta, shift * cosPhi, pitch, radius), oy = overlaps(size, delta, shift * sinPhi, pitch, radius);
182
196
  const w2 = 2 * radius + 1, kernel = new Float64Array(w2 * w2);
183
- let total = 0, inside = 0;
197
+ let total = 0;
184
198
  for (let x = 0; x < size; x++) {
185
199
  for (let y = 0; y < size; y++) {
186
200
  colRe[y] = re[y * size + x];
@@ -199,33 +213,24 @@ function nodeKernel(lens, xMm, yMm, lambdaNm, workingF, pupilDiameterMm, zMm, pi
199
213
  const cy2 = oy.cell2[y], wy = oy.weight[y], wy2 = oy.weight2[y];
200
214
  // Row index increases with image +Y (the quadrature's orientation).
201
215
  kernel[cy * w2 + cx] += intensity * wx * wy;
202
- inside += intensity * wx * wy;
203
- if (cx2 >= 0) {
216
+ if (cx2 >= 0)
204
217
  kernel[cy * w2 + cx2] += intensity * wx2 * wy;
205
- inside += intensity * wx2 * wy;
206
- }
207
218
  if (cy2 >= 0) {
208
219
  kernel[cy2 * w2 + cx] += intensity * wx * wy2;
209
- inside += intensity * wx * wy2;
210
- if (cx2 >= 0) {
220
+ if (cx2 >= 0)
211
221
  kernel[cy2 * w2 + cx2] += intensity * wx2 * wy2;
212
- inside += intensity * wx2 * wy2;
213
- }
214
222
  }
215
223
  }
216
224
  }
217
- if (!(total > 0) || !(inside > 0))
225
+ if (!(total > 0) || !kernel.some((v) => v > 0))
218
226
  throw new InvalidScientificInputError("The catalogue lens PSF has no energy in its window.");
219
- return { kernel, total, transform: size, phaseStep };
227
+ // #589: the PSF's first moment is the pupil-mean wavefront tilt 2·N_w·⟨∇W⟩ (the Fourier transform's moment theorem; Goodman,
228
+ // Introduction to Fourier Optics, ch. 4). Of the Seidel terms only coma has a mean tilt, ⟨∇(W131·H·ρ²·ρcos(θ − φ))⟩ = W131·H
229
+ // along the field azimuth (⟨ρ²⟩ = 1/2, ⟨x²⟩ = 1/4 on the unit disc); the lateral colour adds its displacement. Sites.
230
+ const moment = (shift + 2 * workingF * DESIGN_WAVELENGTH_UM * lens.character.aberrations.comaWaves * H) / pitch;
231
+ return { kernel, total, transform: size, phaseStep, centroid: { x: moment * cosPhi, y: moment * sinPhi } };
220
232
  }
221
233
  }
222
- function mirror(kernel, radius, flipX, flipY) {
223
- const w = 2 * radius + 1, out = new Float64Array(kernel.length);
224
- for (let j = 0; j < w; j++)
225
- for (let i = 0; i < w; i++)
226
- out[(flipY ? w - 1 - j : j) * w + (flipX ? w - 1 - i : i)] = kernel[j * w + i];
227
- return out;
228
- }
229
234
  /** Thin-lens image distance z (mm), pupil diameter D = f/N (mm) and working f-number z/D at a finite focus. */
230
235
  function pupilGeometry(lens, focusDistanceM) {
231
236
  const f = lens.focalLengthMm, d = focusDistanceM * 1000;
@@ -290,17 +295,20 @@ export function createGenericLensPsfProfile(input) {
290
295
  while (radius < bound && outside(radius) > GENERIC_LENS_PSF_WINDOW_TRUNCATION)
291
296
  radius++;
292
297
  const truncated = Math.max(0, outside(radius));
293
- const crop = (node) => {
298
+ /** The node's kernel cropped to the window, unit-sum, mirrored for a field point with x or y < 0. */
299
+ const crop = (node, flipX, flipY) => {
294
300
  const w0 = 2 * bound + 1, w1 = 2 * radius + 1, out = new Float64Array(w1 * w1);
295
301
  let sum = 0;
296
302
  for (let j = 0; j < w1; j++)
297
303
  for (let i = 0; i < w1; i++) {
298
304
  const v = node.kernel[(j + bound - radius) * w0 + i + bound - radius];
299
- out[j * w1 + i] = v;
305
+ out[(flipY ? w1 - 1 - j : j) * w1 + (flipX ? w1 - 1 - i : i)] = v;
300
306
  sum += v;
301
307
  }
302
308
  for (let k = 0; k < out.length; k++)
303
309
  out[k] = out[k] / sum;
310
+ // #589: the kernel's first moment is the PSF's (lateral colour and coma), not the cells' quantised or truncated one.
311
+ alignKernelCentroid(out, radius, { x: flipX ? -node.centroid.x : node.centroid.x, y: flipY ? -node.centroid.y : node.centroid.y });
304
312
  return out;
305
313
  };
306
314
  const extra = input.extraKernel, kernelRadius = radius + (extra ? extra.radius : 0), w = 2 * kernelRadius + 1;
@@ -312,7 +320,7 @@ export function createGenericLensPsfProfile(input) {
312
320
  for (const y of fieldY)
313
321
  for (const nm of GENERIC_LENS_PSF_WAVELENGTHS_NM) {
314
322
  const base = quadrant.get(Math.abs(x) + ":" + Math.abs(y) + ":" + nm);
315
- let kernel = mirror(crop(base), radius, x < 0, y < 0);
323
+ let kernel = crop(base, x < 0, y < 0);
316
324
  if (extra)
317
325
  kernel = convolveSiteKernels(kernel, radius, extra.at(x, y), extra.radius);
318
326
  nodes.push({ nodeId: "pv-" + x.toFixed(6) + "-" + y.toFixed(6) + "-" + nm,