@photivra/engine 0.2.0 → 0.4.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 (89) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/CITATION.cff +1 -1
  3. package/README.md +58 -10
  4. package/dist/camera/equivalent-focal-length.d.ts +34 -0
  5. package/dist/camera/equivalent-focal-length.d.ts.map +1 -0
  6. package/dist/camera/equivalent-focal-length.js +33 -0
  7. package/dist/camera/equivalent-focal-length.js.map +1 -0
  8. package/dist/camera/field-of-view.d.ts +40 -3
  9. package/dist/camera/field-of-view.d.ts.map +1 -1
  10. package/dist/camera/field-of-view.js +66 -24
  11. package/dist/camera/field-of-view.js.map +1 -1
  12. package/dist/core/calculation-result.d.ts.map +1 -1
  13. package/dist/core/calculation-result.js +14 -2
  14. package/dist/core/calculation-result.js.map +1 -1
  15. package/dist/core/configuration-error.d.ts +9 -0
  16. package/dist/core/configuration-error.d.ts.map +1 -0
  17. package/dist/core/configuration-error.js +13 -0
  18. package/dist/core/configuration-error.js.map +1 -0
  19. package/dist/core/evidence-provenance.d.ts +40 -0
  20. package/dist/core/evidence-provenance.d.ts.map +1 -0
  21. package/dist/core/evidence-provenance.js +79 -0
  22. package/dist/core/evidence-provenance.js.map +1 -0
  23. package/dist/core/image-formation.d.ts +70 -0
  24. package/dist/core/image-formation.d.ts.map +1 -0
  25. package/dist/core/image-formation.js +335 -0
  26. package/dist/core/image-formation.js.map +1 -0
  27. package/dist/index.d.ts +15 -3
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +15 -3
  30. package/dist/index.js.map +1 -1
  31. package/dist/motion/camera-rotation.d.ts +91 -0
  32. package/dist/motion/camera-rotation.d.ts.map +1 -0
  33. package/dist/motion/camera-rotation.js +157 -0
  34. package/dist/motion/camera-rotation.js.map +1 -0
  35. package/dist/optics/focus-breathing.d.ts +73 -0
  36. package/dist/optics/focus-breathing.d.ts.map +1 -0
  37. package/dist/optics/focus-breathing.js +77 -0
  38. package/dist/optics/focus-breathing.js.map +1 -0
  39. package/dist/optics/illumination-vignetting.d.ts +59 -0
  40. package/dist/optics/illumination-vignetting.d.ts.map +1 -0
  41. package/dist/optics/illumination-vignetting.js +107 -0
  42. package/dist/optics/illumination-vignetting.js.map +1 -0
  43. package/dist/optics/lateral-chromatic-aberration.d.ts +93 -0
  44. package/dist/optics/lateral-chromatic-aberration.d.ts.map +1 -0
  45. package/dist/optics/lateral-chromatic-aberration.js +174 -0
  46. package/dist/optics/lateral-chromatic-aberration.js.map +1 -0
  47. package/dist/optics/psf-foundation.d.ts +102 -0
  48. package/dist/optics/psf-foundation.d.ts.map +1 -0
  49. package/dist/optics/psf-foundation.js +164 -0
  50. package/dist/optics/psf-foundation.js.map +1 -0
  51. package/dist/optics/radial-distortion.d.ts +91 -0
  52. package/dist/optics/radial-distortion.d.ts.map +1 -0
  53. package/dist/optics/radial-distortion.js +198 -0
  54. package/dist/optics/radial-distortion.js.map +1 -0
  55. package/dist/output/capture-geometry.d.ts +225 -0
  56. package/dist/output/capture-geometry.d.ts.map +1 -0
  57. package/dist/output/capture-geometry.js +478 -0
  58. package/dist/output/capture-geometry.js.map +1 -0
  59. package/dist/schema/validation.d.ts +1 -8
  60. package/dist/schema/validation.d.ts.map +1 -1
  61. package/dist/schema/validation.js +2 -11
  62. package/dist/schema/validation.js.map +1 -1
  63. package/dist/sensor/architecture.d.ts +40 -0
  64. package/dist/sensor/architecture.d.ts.map +1 -0
  65. package/dist/sensor/architecture.js +98 -0
  66. package/dist/sensor/architecture.js.map +1 -0
  67. package/dist/sensor/radiometry-readiness.d.ts +111 -0
  68. package/dist/sensor/radiometry-readiness.d.ts.map +1 -0
  69. package/dist/sensor/radiometry-readiness.js +294 -0
  70. package/dist/sensor/radiometry-readiness.js.map +1 -0
  71. package/dist/sensor/sensor-geometry.d.ts +99 -0
  72. package/dist/sensor/sensor-geometry.d.ts.map +1 -0
  73. package/dist/sensor/sensor-geometry.js +71 -0
  74. package/dist/sensor/sensor-geometry.js.map +1 -0
  75. package/dist/simulation/poc-simulation.d.ts +93 -1
  76. package/dist/simulation/poc-simulation.d.ts.map +1 -1
  77. package/dist/simulation/poc-simulation.js +308 -24
  78. package/dist/simulation/poc-simulation.js.map +1 -1
  79. package/docs/API_STYLE.md +35 -2
  80. package/docs/ARCHITECTURE.md +179 -1
  81. package/docs/IMAGE_FORMATION.md +142 -0
  82. package/docs/MOTION_AND_SIGNAL.md +52 -1
  83. package/docs/PHYSICS_FOUNDATION.md +190 -3
  84. package/docs/POC_API.md +73 -7
  85. package/docs/PROVENANCE.md +24 -0
  86. package/docs/PSF_FOUNDATION.md +133 -0
  87. package/docs/STABILIZATION.md +17 -4
  88. package/docs/USAGE.md +657 -3
  89. package/package.json +1 -1
package/docs/USAGE.md CHANGED
@@ -14,6 +14,30 @@ console.log(result.quality);
14
14
 
15
15
  `quality` is optional and is omitted when the engine does not have defensible quantified uncertainty/accuracy metadata to report.
16
16
 
17
+ ## Image-formation contract
18
+
19
+ Use the public image-formation contract when coordinating effects that cross optics, motion, sensor, and output domains:
20
+
21
+ ```ts
22
+ import { getImageFormationContract } from "@photivra/engine";
23
+
24
+ const contract = getImageFormationContract();
25
+
26
+ console.log(contract.domains);
27
+ console.log(contract.temporal.authoritativeTimeUnit);
28
+ console.log(
29
+ contract.effectPlacements.find(
30
+ (effect) => effect.id === "mechanical-vignetting"
31
+ )
32
+ );
33
+ ```
34
+
35
+ The contract is descriptive metadata. It does not imply that every reserved stage is implemented, and it does not change the composed POC request/response contract.
36
+
37
+ Use `requiredUpstreamStages` for hard scientific dependencies and `coupledStages` for shared state/interactions that must not be treated as independent renderer filters.
38
+
39
+ See [Image-Formation Contract](IMAGE_FORMATION.md) for coordinate, temporal, renderer, and reserved sensor-stage semantics.
40
+
17
41
  ## Field of view
18
42
 
19
43
  Use `calculateFieldOfView()` for one sensor dimension at a time.
@@ -44,8 +68,260 @@ console.log(focused.value.degrees);
44
68
 
45
69
  Without `focusDistanceM`, nominal focal length is used as the infinity-focus/pinhole projection distance. Neither mode models real-lens distortion or focus breathing.
46
70
 
71
+ For off-center sensor regions, use `calculateFieldOfViewBounds()` with signed sensor-plane coordinates relative to the optical axis:
72
+
73
+ ```ts
74
+ import { calculateFieldOfViewBounds } from "@photivra/engine";
75
+
76
+ const leftHalf = calculateFieldOfViewBounds({
77
+ focalLengthMm: 50,
78
+ minimumSensorCoordinateMm: -18,
79
+ maximumSensorCoordinateMm: 0
80
+ });
81
+
82
+ console.log(leftHalf.value.minimumDegrees);
83
+ console.log(leftHalf.value.maximumDegrees);
84
+ console.log(leftHalf.value.degrees);
85
+ ```
86
+
87
+ Unlike the centered `2 × atan(size / 2d)` form, the bounds API preserves asymmetric angular limits and is used by active-capture geometry for off-center crops.
88
+
47
89
  See [Physics Foundation](PHYSICS_FOUNDATION.md#rectilinear-field-of-view).
48
90
 
91
+ ## Actual and 35 mm-equivalent focal length
92
+
93
+ Use `calculateEquivalentFocalLength35Mm()` when you need the conventional diagonal-based 35 mm-equivalent value while preserving the physical optical focal length:
94
+
95
+ ```ts
96
+ import { calculateEquivalentFocalLength35Mm } from "@photivra/engine";
97
+
98
+ const equivalent = calculateEquivalentFocalLength35Mm({
99
+ focalLengthMm: 4.5,
100
+ activeImagingArea: {
101
+ widthMm: 6.17,
102
+ heightMm: 4.55
103
+ }
104
+ });
105
+
106
+ console.log(equivalent.value.actualFocalLengthMm); // 4.5
107
+ console.log(equivalent.value.equivalentFocalLength35Mm);
108
+ console.log(equivalent.value.basis); // "diagonal"
109
+ ```
110
+
111
+ The actual focal length remains the physical optical quantity used by projection and depth-of-field calculations. The equivalent value is derived from the active physical capture diagonal relative to a 36 × 24 mm reference frame.
112
+
113
+ If an active sensor crop is in use, pass the physical active area resolved by `resolveCaptureGeometry()`. Physical camera orientation does not change the equivalent value because the active diagonal is unchanged.
114
+
115
+ Focus distance is not part of conventional 35 mm-equivalent focal length. Later digital/output crop and output resolution are also excluded. If a product wants to describe final digital framing, it should label that as output framing rather than silently replacing the optical focal length or capture-equivalent focal length.
116
+
117
+ `calculateImagingAreaMetrics()` exposes the same canonical diagonal crop-factor calculation independently of raster density so other engine modules do not need to duplicate that formula.
118
+
119
+ ## Focus-breathing projection
120
+
121
+ Use `calculateFocusBreathingProjection()` when a caller has an explicit projection-scale value for the selected focus state.
122
+
123
+ ```ts
124
+ import {
125
+ calculateFocusBreathingFieldOfView,
126
+ calculateFocusBreathingProjection
127
+ } from "@photivra/engine";
128
+
129
+ const projection = calculateFocusBreathingProjection({
130
+ focalLengthMm: 50,
131
+ focusDistanceM: 1.5,
132
+ breathingProjectionScale: 1.04
133
+ });
134
+
135
+ const horizontalFov = calculateFocusBreathingFieldOfView({
136
+ focalLengthMm: 50,
137
+ focusDistanceM: 1.5,
138
+ sensorDimensionMm: 36,
139
+ breathingProjectionScale: 1.04
140
+ });
141
+
142
+ console.log(projection.value.physicalFocalLengthMm); // still 50
143
+ console.log(projection.value.effectiveProjectionDistanceMm);
144
+ console.log(horizontalFov.value.effectiveDegrees);
145
+ ```
146
+
147
+ `breathingProjectionScale` is dimensionless and applies to the ideal thin-lens projection distance for that one focus state:
148
+
149
+ - `1` means no additional breathing relative to the current thin-lens model;
150
+ - values above `1` narrow framing / increase magnification;
151
+ - values below `1` widen framing / reduce magnification.
152
+
153
+ Photivra does **not** infer this scale from focal length, focus distance, lens identity, sensor format, or marketing specifications. Supplying the same scale at two focus distances means exactly that: the caller explicitly declared the same relative projection scale at both states.
154
+
155
+ The physical focal length remains authoritative. The effective projection distance is not a renamed focal length and later digital/output crop does not redefine the breathing scale.
156
+
157
+ This generic model is returned as an `approximation`. A future calibrated lens profile would need defensible provenance, reuse rights, and uncertainty/limitation metadata.
158
+
159
+ ## Radial lens-distortion mapping
160
+
161
+ Use `calculateRadialDistortionMapping()` for a generic rotationally symmetric ideal-to-distorted image-plane mapping and `calculateInverseRadialDistortionMapping()` for the destination-to-source mapping a renderer needs for inverse sampling.
162
+
163
+ ```ts
164
+ import {
165
+ calculateInverseRadialDistortionMapping,
166
+ calculateRadialDistortionMapping
167
+ } from "@photivra/engine";
168
+
169
+ const profile = {
170
+ normalizationRadiusMm: 21.63,
171
+ maximumNormalizedRadius: 1,
172
+ coefficients: {
173
+ k1: -0.08,
174
+ k2: 0.025,
175
+ k3: -0.004
176
+ }
177
+ };
178
+
179
+ const forward = calculateRadialDistortionMapping({
180
+ imagePointMm: { x: 14, y: 8 },
181
+ profile
182
+ });
183
+
184
+ const inverse = calculateInverseRadialDistortionMapping({
185
+ distortedImagePointMm: forward.value.mappedImagePointMm,
186
+ profile
187
+ });
188
+
189
+ console.log(forward.value.radialScale);
190
+ console.log(inverse.value.sourceImagePointMm);
191
+ ```
192
+
193
+ The model is:
194
+
195
+ ```text
196
+ r = image-plane radius / normalizationRadiusMm
197
+ scale = 1 + k1 r² + k2 r⁴ + k3 r⁶
198
+ p_distorted = p_ideal × scale
199
+ ```
200
+
201
+ The coefficients are dimensionless **only together with the declared physical normalization radius**. Reusing coefficients with a different normalization changes the model.
202
+
203
+ `maximumNormalizedRadius` is the caller-declared valid operating envelope. Photivra analytically checks that radial distance remains strictly monotonic over that interval; profiles that fold over are rejected so inverse mapping is unique.
204
+
205
+ This first field-mapping slice is radial-only and centered on the optical axis. It does not model tangential/decentered distortion, anamorphic mapping, wavelength dependence, or a calibrated named lens.
206
+
207
+ The engine returns an `approximation` because the polynomial is a generic caller-parameterized lens model. Test Fixture grid/fiducials can provide renderer regression evidence, but they do not calibrate real-lens coefficients.
208
+
209
+ ## Lateral chromatic-aberration mapping
210
+
211
+ Use `calculateLateralChromaticAberrationMapping()` to map one ideal image-plane point through a shared **green-reference base distortion** plus red/blue radial coefficient offsets.
212
+
213
+ Use `calculateInverseLateralChromaticAberrationMapping()` when a renderer needs the ideal source coordinate for each output channel at one distorted destination.
214
+
215
+ ```ts
216
+ import {
217
+ calculateInverseLateralChromaticAberrationMapping,
218
+ calculateLateralChromaticAberrationMapping
219
+ } from "@photivra/engine";
220
+
221
+ const profile = {
222
+ normalizationRadiusMm: 21.63,
223
+ maximumNormalizedRadius: 1,
224
+ baseDistortionCoefficients: {
225
+ k1: -0.06,
226
+ k2: 0.018,
227
+ k3: 0
228
+ },
229
+ redCoefficientOffset: {
230
+ k1: 0.02,
231
+ k2: -0.006,
232
+ k3: 0
233
+ },
234
+ blueCoefficientOffset: {
235
+ k1: -0.02,
236
+ k2: 0.006,
237
+ k3: 0
238
+ }
239
+ };
240
+
241
+ const forward = calculateLateralChromaticAberrationMapping({
242
+ imagePointMm: { x: 14, y: 8 },
243
+ profile
244
+ });
245
+
246
+ const inverse = calculateInverseLateralChromaticAberrationMapping({
247
+ distortedImagePointMm: { x: 14, y: 8 },
248
+ profile
249
+ });
250
+
251
+ console.log(forward.value.separation);
252
+ console.log(inverse.value.channels.red.sourceImagePointMm);
253
+ console.log(inverse.value.channels.green.sourceImagePointMm);
254
+ console.log(inverse.value.channels.blue.sourceImagePointMm);
255
+ ```
256
+
257
+ This is **field mapping, not a blur kernel**.
258
+
259
+ The green channel carries the common base geometric distortion. Red and blue offsets are added to that base before mapping. Therefore:
260
+
261
+ - zero red/blue offsets mean no lateral CA, while base distortion can still exist;
262
+ - callers should not apply the same base distortion as a second renderer warp;
263
+ - all three combined channel profiles share one physical normalization radius and operating envelope;
264
+ - every combined channel profile must remain individually invertible over that envelope.
265
+
266
+ The channel labels are representative renderer RGB channels. They are not wavelength samples, a spectral lens model, sensor CFA primaries, or a camera colorimetric profile. Longitudinal chromatic aberration and wavelength-dependent PSF behavior remain separate future work.
267
+
268
+ A renderer should inverse-map each destination channel to the source coordinate returned by the engine instead of adding a backend-specific RGB offset or finished-image fringe blur.
269
+
270
+ ## Illumination vignetting
271
+
272
+ Use `calculateIlluminationVignetting()` when a generic virtual lens needs field-dependent **relative linear throughput** without changing geometry or pupil/PSF shape.
273
+
274
+ ```ts
275
+ import { calculateIlluminationVignetting } from "@photivra/engine";
276
+
277
+ const illumination = calculateIlluminationVignetting({
278
+ imagePointMm: { x: 16, y: 0 },
279
+ profile: {
280
+ normalizationRadiusMm: 20,
281
+ maximumNormalizedRadius: 1,
282
+ coefficients: {
283
+ r2: -0.5,
284
+ r4: 0.1,
285
+ r6: 0
286
+ }
287
+ }
288
+ });
289
+
290
+ console.log(illumination.value.linearThroughputFactor);
291
+ console.log(illumination.value.attenuationStops);
292
+ ```
293
+
294
+ The generic radial model is:
295
+
296
+ ```text
297
+ rho = image-plane radius / normalizationRadiusMm
298
+ T(rho) = 1 + r2*rho² + r4*rho⁴ + r6*rho⁶
299
+ ```
300
+
301
+ `T` is a multiplicative **scene-linear/channel-linear** throughput factor. Apply it before display transfer/gamma encoding.
302
+
303
+ The profile declares `maximumNormalizedRadius`. Photivra analytically checks the complete interval, including internal extrema, and rejects profiles that:
304
+
305
+ - reach zero or negative throughput; or
306
+ - amplify above the optical-axis normalization of `1`.
307
+
308
+ The output also reports positive attenuation in stops:
309
+
310
+ ```text
311
+ attenuationStops = -log2(T)
312
+ ```
313
+
314
+ This model changes throughput only. It does not change:
315
+
316
+ - image-plane coordinates;
317
+ - focus;
318
+ - pupil shape;
319
+ - PSF shape;
320
+ - bokeh;
321
+ - channel geometry.
322
+
323
+ Mechanical/pupil vignetting and cat's-eye bokeh belong to the later pupil/PSF foundation. This generic profile is also not calibrated radiometry or a named-lens measurement.
324
+
49
325
  ## Thin-lens image distance and magnification
50
326
 
51
327
  Use `calculateThinLensImageDistance()` to calculate ideal Gaussian thin-lens image distance for an object/focus plane.
@@ -133,6 +409,45 @@ The circle-of-confusion criterion used for depth-of-field is caller supplied. Th
133
409
 
134
410
  See [Physics Foundation](PHYSICS_FOUNDATION.md#thin-lens-focus-and-depth-of-field).
135
411
 
412
+ ## PSF and pupil foundation
413
+
414
+ Use `getPsfFoundationContract()` to inspect current/reserved PSF contribution ownership and `calculatePsfFoundationComponents()` to evaluate the currently implemented diagnostics in one explicit field/depth/spectral/pupil context.
415
+
416
+ ```ts
417
+ import {
418
+ calculatePsfFoundationComponents,
419
+ getPsfFoundationContract
420
+ } from "@photivra/engine";
421
+
422
+ const contract = getPsfFoundationContract();
423
+
424
+ const components = calculatePsfFoundationComponents({
425
+ focalLengthMm: 85,
426
+ aperture: 2.8,
427
+ focusDistanceM: 10,
428
+ subjectDistanceM: 20,
429
+ fieldPointMm: { x: 12, y: 8 },
430
+ fieldNormalizationRadiusMm: 21.6,
431
+ spectralBasis: {
432
+ kind: "monochromatic",
433
+ wavelengthNm: 550
434
+ }
435
+ });
436
+
437
+ console.log(contract.compositionPolicy);
438
+ console.log(components.value.contributions.geometricDefocus);
439
+ console.log(components.value.contributions.circularDiffraction);
440
+ console.log(components.value.composition.status); // "not-composed"
441
+ ```
442
+
443
+ The current foundation intentionally **does not combine** geometric defocus and circular diffraction into a synthetic PSF, MTF, convolution kernel, combined blur radius, or “lens sharpness” score.
444
+
445
+ It records field position now even though the current defocus/Airy diagnostics are field invariant. Future field-curvature, pupil-clipping, bokeh, and aberration contributions can therefore consume the same context without reinterpreting existing outputs.
446
+
447
+ The current spectral basis is explicitly monochromatic for the Airy diagnostic. This does not create a spectral lens or sensor-color model.
448
+
449
+ See [PSF and Pupil Foundation](PSF_FOUNDATION.md).
450
+
136
451
  ## Circular-aperture diffraction
137
452
 
138
453
  Use `calculateAiryDisk()` for the ideal monochromatic circular-pupil first-zero Airy diameter:
@@ -191,7 +506,165 @@ console.log(pitch.value.millimeters);
191
506
 
192
507
  The current primitive calculates horizontal pixel pitch. It assumes the supplied sensor width and pixel count describe the same active dimension.
193
508
 
194
- See [Physics Foundation](PHYSICS_FOUNDATION.md#pixel-pitch-and-crop).
509
+ See [Physics Foundation](PHYSICS_FOUNDATION.md#sensor-geometry-sampling-capture-and-crop).
510
+
511
+ ## Sensor imaging area and native raster
512
+
513
+ Use `calculateSensorGeometryMetrics()` when physical imaging size and native image resolution need to remain independent:
514
+
515
+ ```ts
516
+ import { calculateSensorGeometryMetrics } from "@photivra/engine";
517
+
518
+ const metrics = calculateSensorGeometryMetrics({
519
+ imagingArea: {
520
+ widthMm: 36,
521
+ heightMm: 24
522
+ },
523
+ nativeRaster: {
524
+ pixelWidth: 6000,
525
+ pixelHeight: 4000
526
+ }
527
+ });
528
+
529
+ console.log(metrics.value.imagingArea.cropFactor35Mm); // 1
530
+ console.log(metrics.value.nativeRaster.megapixels); // 24
531
+ console.log(metrics.value.sampling.pitchXMicrometers); // 6
532
+ console.log(metrics.value.sampling.pitchYMicrometers); // 6
533
+ ```
534
+
535
+ `SensorImagingArea` means the physical photosensitive imaging area used for image formation, not sensor package or die dimensions. `NativeImageRaster` is the effective native image-sampling grid; it does not assert a one-to-one relationship between image samples and physical photodiodes/photosites.
536
+
537
+ The 35 mm crop factor is diagonal-based relative to a 36 × 24 mm reference frame. Changing native raster density does not change the physical crop factor. Changing physical imaging area does not inherently change native megapixels.
538
+
539
+ The returned X/Y sampling pitches are geometric image-sample spacing. They are not photodiode active area, fill factor, or a photon-collection model and must not be used as those quantities.
540
+
541
+ The existing `calculatePixelPitch()` API remains available for callers that only need horizontal pitch from sensor width and horizontal pixel count.
542
+
543
+ ## Sensor architecture metadata
544
+
545
+ Use `parseSensorArchitectureProfile()` for descriptive hardware/capability metadata that crosses an untrusted JSON boundary:
546
+
547
+ ```ts
548
+ import { parseSensorArchitectureProfile } from "@photivra/engine";
549
+
550
+ const architecture = parseSensorArchitectureProfile({
551
+ schemaVersion: "0.2.0",
552
+ illumination: {
553
+ value: "bsi",
554
+ evidence: [
555
+ {
556
+ sourceOrigin: "manufacturer",
557
+ sourceReference: "manufacturer-spec:example",
558
+ reuseStatus: "factual-reference-only"
559
+ }
560
+ ]
561
+ },
562
+ integration: {
563
+ value: "stacked",
564
+ evidence: [
565
+ {
566
+ sourceOrigin: "manufacturer",
567
+ sourceReference: "manufacturer-spec:example",
568
+ reuseStatus: "factual-reference-only"
569
+ }
570
+ ]
571
+ },
572
+ readoutCapabilities: [
573
+ {
574
+ value: "rolling",
575
+ evidence: [
576
+ {
577
+ sourceOrigin: "manufacturer",
578
+ sourceReference: "manufacturer-spec:example",
579
+ reuseStatus: "factual-reference-only"
580
+ }
581
+ ]
582
+ }
583
+ ],
584
+ colorSamplingFamily: {
585
+ value: "bayer",
586
+ evidence: [
587
+ {
588
+ sourceOrigin: "manufacturer",
589
+ sourceReference: "manufacturer-spec:example",
590
+ reuseStatus: "factual-reference-only"
591
+ }
592
+ ]
593
+ }
594
+ });
595
+ ```
596
+
597
+ The axes are independent. BSI may be stacked or monolithic; stacking does not imply global shutter; and global readout capability does not imply a particular stacking architecture.
598
+
599
+ Source origin and reuse rights are also independent. Manufacturer or third-party material may be factual-reference-only or explicitly reusable when an appropriate license is present. `photivra-owned` evidence must originate from Photivra. Reusable-data evidence requires an explicit license.
600
+
601
+ Scalar facts may cite multiple evidence records. Multi-valued capabilities such as rolling/global readout carry evidence independently for each value so one source does not silently support another capability.
602
+
603
+ `readoutCapabilities` describes hardware capabilities, not the mode selected for one exposure. Capture-specific readout selection and timing belong to later readout/capture-mode models.
604
+
605
+ Unknown facts should be omitted instead of inferred. Architecture metadata remains descriptive only: BSI, stacking, readout family, and CFA family do not directly change FOV, crop factor, pixel pitch, exposure, noise, or dynamic range. A separate documented downstream physical/calibration model is required before any such effect can be claimed.
606
+
607
+ ## Capture orientation, active area, and output geometry
608
+
609
+ Use `resolveCaptureGeometry()` to keep physical sensor identity, active capture, physical camera orientation, and final digital output geometry separate:
610
+
611
+ ```ts
612
+ import { resolveCaptureGeometry } from "@photivra/engine";
613
+
614
+ const geometry = resolveCaptureGeometry({
615
+ imagingArea: { widthMm: 36, heightMm: 24 },
616
+ nativeRaster: { pixelWidth: 6000, pixelHeight: 4000 },
617
+ orientation: "portrait-clockwise",
618
+ activeCaptureRect: {
619
+ x: 1000,
620
+ y: 800,
621
+ width: 4000,
622
+ height: 2400
623
+ },
624
+ outputCropRect: {
625
+ x: 0,
626
+ y: 875,
627
+ width: 2400,
628
+ height: 2250
629
+ },
630
+ outputRaster: {
631
+ pixelWidth: 2160,
632
+ pixelHeight: 2025
633
+ }
634
+ });
635
+
636
+ console.log(geometry.value.activeCapture.centerOffsetFromOpticalAxisMm);
637
+ console.log(geometry.value.orientedCapture.raster);
638
+ console.log(geometry.value.output.raster);
639
+ ```
640
+
641
+ Native sensor raster coordinates are invariant under physical camera rotation:
642
+
643
+ - origin: top-left;
644
+ - +X: right;
645
+ - +Y: down;
646
+ - rectangles: integer, half-open extents `[x, x + width) × [y, y + height)`.
647
+
648
+ The engine exports exact native↔oriented point, vector, and rectangle transforms for all four physical rotations:
649
+
650
+ ```ts
651
+ import {
652
+ transformNativeRasterPointToOriented,
653
+ transformOrientedRasterPointToNative
654
+ } from "@photivra/engine";
655
+ ```
656
+
657
+ Point transforms use continuous raster-edge coordinates; pixel centers may be represented with +0.5 offsets. Vector transforms rotate direction only. Rectangle transforms preserve integer half-open semantics. Clockwise and counter-clockwise portrait transforms are distinct and round-trip to native coordinates.
658
+
659
+ `activeCaptureRect` is expressed in native coordinates. The generic model derives physical active-area bounds by assuming native image samples uniformly span the declared physical imaging area. The result exposes physical bounds and center offset relative to the optical axis so off-center crops remain optically asymmetric.
660
+
661
+ `outputCropRect` is expressed in oriented active-capture coordinates. `outputRaster` may resample that crop but must preserve its aspect ratio within integer-rounding tolerance; implicit geometric stretching is rejected.
662
+
663
+ Display/file transforms such as EXIF mirroring remain separate from physical `CaptureOrientation`.
664
+
665
+ Use `calculateActiveCaptureFieldOfView()` for active physical capture FOV. It preserves signed horizontal/vertical bounds for off-center crops and reports both opposite-corner diagonal spans. `diagonalDegrees` is the larger of those spans; centered crops produce equal diagonal spans.
666
+
667
+ Final digital/output crop is deliberately excluded from active-capture FOV.
195
668
 
196
669
  ## Centered crop and subject framing crop
197
670
 
@@ -231,7 +704,7 @@ console.log(framing.value.subjectClipped);
231
704
 
232
705
  Subject framing assumes the crop can be positioned around the subject; it does not check the subject's actual position against image edges.
233
706
 
234
- See [Physics Foundation](PHYSICS_FOUNDATION.md#pixel-pitch-and-crop).
707
+ See [Physics Foundation](PHYSICS_FOUNDATION.md#sensor-geometry-sampling-capture-and-crop).
235
708
 
236
709
  ## Projected subject motion
237
710
 
@@ -332,6 +805,47 @@ These relations do not model scene radiance, lens T-stop/transmission, vignettin
332
805
 
333
806
  See [Motion and Signal Foundation](MOTION_AND_SIGNAL.md#exposure-relations).
334
807
 
808
+ ## Spatial camera-rotation mapping
809
+
810
+ Use `calculateCameraRotationImageMapping()` when you need the image-plane location of a stationary world ray after **pure camera rotation** at an arbitrary physical time from exposure start.
811
+
812
+ ```ts
813
+ import { calculateCameraRotationImageMapping } from "@photivra/engine";
814
+
815
+ const mapped = calculateCameraRotationImageMapping({
816
+ focalLengthMm: 50,
817
+ imagePointMm: { x: 18, y: 12 },
818
+ timeSecondsFromExposureStart: 1 / 60,
819
+ angularVelocityRadPerSec: {
820
+ pitch: 0.01,
821
+ yaw: 0.02,
822
+ roll: 0.005
823
+ },
824
+ samplingPitchMicrometers: {
825
+ x: 6,
826
+ y: 6
827
+ }
828
+ });
829
+
830
+ console.log(mapped.value.mappedImagePointMm);
831
+ console.log(mapped.value.deltaMm);
832
+ console.log(mapped.value.deltaImagePlaneSamples);
833
+ ```
834
+
835
+ Coordinate/sign convention:
836
+
837
+ - camera axes at exposure start are +X right, +Y up, +Z forward;
838
+ - positive pitch/yaw/roll follow the right-hand rule about +X/+Y/+Z;
839
+ - the returned image-plane basis is +X right, +Y up;
840
+ - a stationary world ray is transformed by the **inverse** camera rotation, so positive physical camera rotation generally moves scene imagery in the opposite screen direction;
841
+ - optional `deltaImagePlaneSamples` retains image-plane +Y-up semantics and is **not** a native-raster (+Y-down) vector.
842
+
843
+ The angular-velocity vector is assumed constant and is integrated as one axis-angle rotation. This avoids arbitrary Euler ordering for simultaneous pitch/yaw/roll.
844
+
845
+ The function is intentionally rotation-only. Camera translation is excluded because translational optical flow depends on scene depth/parallax.
846
+
847
+ It is also separate from stabilization. `estimateCameraShakeBlur()` remains the older educational stabilization-equivalent approximation and keeps its existing global-vector semantics for compatibility.
848
+
335
849
  ## Camera shake and stabilization-equivalent approximation
336
850
 
337
851
  Use `estimateCameraShakeBlur()` with a controlled yaw/pitch angular-velocity profile:
@@ -361,6 +875,74 @@ The `2^-stops` attenuation is an educational approximation, not a real IBIS/OIS
361
875
 
362
876
  See [Camera Shake and Stabilization](STABILIZATION.md).
363
877
 
878
+ ## Radiometry readiness
879
+
880
+ Use `parseRadiometryReadinessProfile()` and `assessRadiometryReadiness()` to validate whether a declared radiometric calibration package contains the prerequisites needed for a future photon estimate.
881
+
882
+ The gate covers six independently declared requirements:
883
+
884
+ 1. scene spectral radiance or a documented spectral approximation;
885
+ 2. optical transmission, either spectral data or an explicit T-stop approximation;
886
+ 3. pupil/vignetting behavior;
887
+ 4. photosite collection-area semantics;
888
+ 5. exposure integration;
889
+ 6. sensor spectral response / quantum efficiency.
890
+
891
+ Example:
892
+
893
+ ```ts
894
+ import {
895
+ assessRadiometryReadiness,
896
+ parseRadiometryReadinessProfile
897
+ } from "@photivra/engine";
898
+
899
+ const profile = parseRadiometryReadinessProfile({
900
+ schemaVersion: "0.1.0",
901
+ components: [
902
+ {
903
+ requirement: "photosite-collection-area",
904
+ scientificStatus: "approximation",
905
+ modelId: "example-collection-area",
906
+ modelVersion: "1.0.0",
907
+ evidence: [
908
+ {
909
+ sourceOrigin: "photivra",
910
+ sourceReference: "photivra:example",
911
+ reuseStatus: "photivra-owned"
912
+ }
913
+ ],
914
+ uncertainty: {
915
+ kind: "not-quantified",
916
+ limitation: "Example only."
917
+ },
918
+ areaModel: "geometric-area-times-fill-factor",
919
+ geometricCellAreaSquareMicrometers: 36,
920
+ fillFactor: 0.8
921
+ }
922
+ ]
923
+ });
924
+
925
+ const readiness = assessRadiometryReadiness(profile);
926
+
927
+ console.log(readiness.status); // "not-ready"
928
+ console.log(readiness.missingRequirements);
929
+ console.log(readiness.composedPhotonOutputEnabled); // always false
930
+ ```
931
+
932
+ Readiness states are:
933
+
934
+ - `not-ready`: one or more required prerequisite categories are missing;
935
+ - `approximate-only`: every category is present, but at least one component is approximate or has unquantified uncertainty;
936
+ - `calibrated-ready`: every category is declared calibrated and carries quantified uncertainty.
937
+
938
+ A readiness assessment validates the declared structure and provenance metadata. It does not verify that a source or calibration is scientifically correct.
939
+
940
+ Geometric sample pitch is **not** accepted as photon-collection area by itself. The profile must declare either an effective collection area or a geometric cell area plus explicit fill factor.
941
+
942
+ Data-bearing spectral/spatial calibration inputs are represented by an artifact ID plus SHA-256 checksum. Photivra does not infer reuse rights from public availability; reusable calibration data requires explicit licensing or Photivra ownership.
943
+
944
+ The readiness API does not calculate photons, does not change the existing `calculatePhotoelectrons()` primitive, and does not add photon/noise output to `simulatePocCamera()`. Enabling any composed photon model remains a separate future scientific/integration step.
945
+
364
946
  ## Photoelectron and SNR primitives
365
947
 
366
948
  Use `calculatePhotoelectrons()` only when you already have a defensible mean incident photon count and quantum efficiency:
@@ -493,6 +1075,19 @@ See [Scientific and Source Provenance](PROVENANCE.md) and [Public API Style](API
493
1075
 
494
1076
  Use `simulatePocCamera()` when you want one composed response containing the current POC calculations.
495
1077
 
1078
+ The root engine and composed POC are versioned independently. `ENGINE_API_VERSION` describes the root library surface; `POC_SIMULATION_API_VERSION` describes this composed request/response contract.
1079
+
1080
+ The current composed POC reports X/Y geometric sample pitch but still uses one backwards-compatible representative horizontal pitch internally for blur/sampling calculations. It therefore rejects sensor geometry whose X/Y pitch differs by more than 1%. Axis-aware lower-level geometry remains available for more general sensor layouts.
1081
+
1082
+ POC API 0.20 composes the capture-geometry foundation through final output/viewing semantics. Existing requests remain valid. New callers may supply an optional `capture` object with physical orientation, an optional native active-capture rectangle, an optional oriented output crop, and an optional final output raster.
1083
+
1084
+ Capture mode deliberately keeps legacy `crop.factor` separate: it must remain `1` when `capture` is present. Staged capture can use the existing `subjectCrop` request, but the result appears under `capture.subjectFraming` because it is a post-output framing stage rather than a legacy total-crop factor.
1085
+
1086
+ Equivalent-viewing CoC in capture mode uses the final retained physical image region, including subject framing when present; changing only output pixel resolution does not change the criterion. Explicit `circleOfConfusionMm` remains unchanged.
1087
+
1088
+ The optional response `capture` block exposes resolved geometry, active/output FOV, active-capture diagonal-based 35 mm-equivalent focal length, output sampling scale, optional subject framing, and explicit native-raster/oriented/output motion diagnostics. Legacy motion Y is image-plane +Y-up; capture raster Y is +Y-down, so the conversion is reported rather than hidden. Physical focal length remains authoritative. Sensor-architecture metadata and radiometry-readiness profiles are still standalone.
1089
+
1090
+
496
1091
  ```ts
497
1092
  import { simulatePocCamera } from "@photivra/engine";
498
1093
 
@@ -553,7 +1148,66 @@ console.log(simulation.motion);
553
1148
  console.log(simulation.provenance);
554
1149
  ```
555
1150
 
556
- The focus request must supply exactly one circle-of-confusion criterion: either `circleOfConfusionMm` or `equivalentViewingCircleOfConfusion`.
1151
+ To opt into staged capture geometry:
1152
+
1153
+ ```ts
1154
+ const portraitCrop = simulatePocCamera({
1155
+ sensor: {
1156
+ widthMm: 36,
1157
+ heightMm: 24,
1158
+ pixelWidth: 6000,
1159
+ pixelHeight: 4000
1160
+ },
1161
+ lens: {
1162
+ focalLengthMm: 50,
1163
+ aperture: 4
1164
+ },
1165
+ exposure: {
1166
+ shutterSeconds: 1 / 250,
1167
+ iso: 100
1168
+ },
1169
+ focus: {
1170
+ focusDistanceM: 10,
1171
+ circleOfConfusionMm: 0.03
1172
+ },
1173
+ crop: {
1174
+ factor: 1
1175
+ },
1176
+ capture: {
1177
+ orientation: "portrait-clockwise",
1178
+ activeCaptureRect: {
1179
+ x: 1500,
1180
+ y: 1000,
1181
+ width: 3000,
1182
+ height: 2000
1183
+ },
1184
+ outputCropRect: {
1185
+ x: 500,
1186
+ y: 750,
1187
+ width: 1000,
1188
+ height: 1500
1189
+ },
1190
+ outputRaster: {
1191
+ pixelWidth: 2000,
1192
+ pixelHeight: 3000
1193
+ }
1194
+ },
1195
+ diffraction: {
1196
+ wavelengthNm: 550
1197
+ },
1198
+ motion: {
1199
+ positionM: { x: 0, y: 0, z: 10 },
1200
+ velocityMps: { x: 1, y: 0, z: 0 }
1201
+ }
1202
+ });
1203
+
1204
+ console.log(portraitCrop.capture?.geometry);
1205
+ console.log(portraitCrop.capture?.activeFieldOfView);
1206
+ console.log(portraitCrop.capture?.focalLength);
1207
+ console.log(portraitCrop.capture?.motion.outputDeltaPixels);
1208
+ ```
1209
+
1210
+ The focus request must supply exactly one circle-of-confusion criterion: either `circleOfConfusionMm` or `equivalentViewingCircleOfConfusion`. In staged capture mode, the equivalent-viewing approximation uses the final retained physical image region; an explicit `circleOfConfusionMm` remains unchanged by crop/output geometry.
557
1211
 
558
1212
  Additional named defocus, sampling, and motion samples can be supplied when a renderer or analysis client needs per-object outputs.
559
1213