@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.
- package/CHANGELOG.md +295 -1
- package/CITATION.cff +1 -1
- package/README.md +7 -6
- package/SECURITY.md +1 -1
- package/THIRD_PARTY.md +18 -0
- package/dist/capture/c2pa-embed.d.ts +116 -0
- package/dist/capture/c2pa-embed.js +493 -0
- package/dist/capture/c2pa-embed.js.map +1 -0
- package/dist/capture/dng-import.d.ts +73 -8
- package/dist/capture/dng-import.js +315 -80
- package/dist/capture/dng-import.js.map +1 -1
- package/dist/capture/dng-lens-correction-opcodes.d.ts +2 -2
- package/dist/capture/dng-lens-correction-opcodes.js +2 -2
- package/dist/capture/dng-lens-correction-opcodes.js.map +1 -1
- package/dist/capture/dng-opcodes.d.ts +96 -0
- package/dist/capture/dng-opcodes.js +391 -0
- package/dist/capture/dng-opcodes.js.map +1 -0
- package/dist/capture/dng-profile-maps.d.ts +99 -0
- package/dist/capture/dng-profile-maps.js +381 -0
- package/dist/capture/dng-profile-maps.js.map +1 -0
- package/dist/capture/dng-stage-image.d.ts +23 -0
- package/dist/capture/dng-stage-image.js +65 -0
- package/dist/capture/dng-stage-image.js.map +1 -0
- package/dist/capture/exposure-build-up-stage.d.ts +16 -0
- package/dist/capture/exposure-build-up-stage.js +4 -0
- package/dist/capture/exposure-build-up-stage.js.map +1 -0
- package/dist/capture/jpeg-xl-tiles.d.ts +22 -0
- package/dist/capture/jpeg-xl-tiles.js +151 -0
- package/dist/capture/jpeg-xl-tiles.js.map +1 -0
- package/dist/capture/lossy-jpeg.d.ts +17 -0
- package/dist/capture/lossy-jpeg.js +369 -0
- package/dist/capture/lossy-jpeg.js.map +1 -0
- package/dist/capture/native-environment-lean.d.ts +2 -1
- package/dist/capture/native-environment-lean.js +41 -73
- package/dist/capture/native-environment-lean.js.map +1 -1
- package/dist/capture/native-environment-tabulated.js +4 -3
- package/dist/capture/native-environment-tabulated.js.map +1 -1
- package/dist/capture/native-raw-development.d.ts +2 -1
- package/dist/capture/native-raw-development.js.map +1 -1
- package/dist/capture/native-raw.d.ts +3 -2
- package/dist/capture/native-raw.js +5 -4
- package/dist/capture/native-raw.js.map +1 -1
- package/dist/capture/sensor-raw-producer.d.ts +4 -1
- package/dist/capture/sensor-raw-producer.js +12 -3
- package/dist/capture/sensor-raw-producer.js.map +1 -1
- package/dist/color/neighbor-highlight-reconstruction.d.ts +66 -0
- package/dist/color/neighbor-highlight-reconstruction.js +234 -0
- package/dist/color/neighbor-highlight-reconstruction.js.map +1 -0
- package/dist/color/sensor-color-development.d.ts +5 -0
- package/dist/color/sensor-color-development.js +7 -1
- package/dist/color/sensor-color-development.js.map +1 -1
- package/dist/color/spectral-spikiness.d.ts +11 -0
- package/dist/color/spectral-spikiness.js +23 -0
- package/dist/color/spectral-spikiness.js.map +1 -0
- package/dist/core/execution-error.d.ts +21 -0
- package/dist/core/execution-error.js +12 -0
- package/dist/core/execution-error.js.map +1 -0
- package/dist/core/version.d.ts +1 -1
- package/dist/core/version.js +1 -1
- package/dist/core/version.js.map +1 -1
- package/dist/equipment/generic-lens-catalogue.d.ts +3 -2
- package/dist/equipment/generic-lens-catalogue.js +18 -5
- package/dist/equipment/generic-lens-catalogue.js.map +1 -1
- package/dist/equipment/generic-tier-assets.d.ts +32 -0
- package/dist/equipment/generic-tier-assets.js +16 -11
- package/dist/equipment/generic-tier-assets.js.map +1 -1
- package/dist/equipment/sensor-profile-body.d.ts +78 -0
- package/dist/equipment/sensor-profile-body.js +77 -0
- package/dist/equipment/sensor-profile-body.js.map +1 -0
- package/dist/equipment/sensor-profiles.d.ts +8 -0
- package/dist/equipment/sensor-profiles.js +82 -0
- package/dist/equipment/sensor-profiles.js.map +1 -0
- package/dist/facade/catalogue-lens-signal.d.ts +28 -3
- package/dist/facade/catalogue-lens-signal.js +83 -78
- package/dist/facade/catalogue-lens-signal.js.map +1 -1
- package/dist/facade/chart-radiance-batch.js +2 -1
- package/dist/facade/chart-radiance-batch.js.map +1 -1
- package/dist/facade/defocus-image-space-provider.d.ts +7 -0
- package/dist/facade/defocus-image-space-provider.js +64 -76
- package/dist/facade/defocus-image-space-provider.js.map +1 -1
- package/dist/facade/diffraction-provider.d.ts +1 -1
- package/dist/facade/diffraction-provider.js +4 -4
- package/dist/facade/diffraction-provider.js.map +1 -1
- package/dist/facade/display-view-gpu-session.js +39 -5
- package/dist/facade/display-view-gpu-session.js.map +1 -1
- package/dist/facade/display-view-gpu.d.ts +19 -6
- package/dist/facade/display-view-gpu.js.map +1 -1
- package/dist/facade/display-view-lens-signal.js +25 -23
- package/dist/facade/display-view-lens-signal.js.map +1 -1
- package/dist/facade/display-view-normal.d.ts +15 -0
- package/dist/facade/display-view-normal.js +44 -0
- package/dist/facade/display-view-normal.js.map +1 -0
- package/dist/facade/display-view-rendering.d.ts +97 -0
- package/dist/facade/display-view-rendering.js +598 -0
- package/dist/facade/display-view-rendering.js.map +1 -0
- package/dist/facade/display-view.d.ts +37 -65
- package/dist/facade/display-view.js +152 -499
- package/dist/facade/display-view.js.map +1 -1
- package/dist/facade/expected-signal-setup.d.ts +3 -1
- package/dist/facade/expected-signal-setup.js +3 -3
- package/dist/facade/expected-signal-setup.js.map +1 -1
- package/dist/facade/expected-signal.d.ts +47 -14
- package/dist/facade/expected-signal.js +117 -112
- package/dist/facade/expected-signal.js.map +1 -1
- package/dist/facade/exposure-build-up.d.ts +70 -0
- package/dist/facade/exposure-build-up.js +164 -0
- package/dist/facade/exposure-build-up.js.map +1 -0
- package/dist/facade/fast-methods.d.ts +1 -1
- package/dist/facade/fast-methods.js +46 -9
- package/dist/facade/fast-methods.js.map +1 -1
- package/dist/facade/generic-lens-capture.d.ts +3 -3
- package/dist/facade/generic-lens-capture.js +3 -3
- package/dist/facade/generic-lens-capture.js.map +1 -1
- package/dist/facade/image-plane-enable.d.ts +7 -0
- package/dist/facade/image-plane-enable.js +17 -0
- package/dist/facade/image-plane-enable.js.map +1 -0
- package/dist/facade/image-plane-lattice-data.d.ts +6 -0
- package/dist/facade/image-plane-lattice-data.js +10 -0
- package/dist/facade/image-plane-lattice-data.js.map +1 -0
- package/dist/facade/image-plane-support.d.ts +33 -0
- package/dist/facade/image-plane-support.js +11 -0
- package/dist/facade/image-plane-support.js.map +1 -0
- package/dist/facade/image-plane.d.ts +36 -0
- package/dist/facade/image-plane.js +384 -0
- package/dist/facade/image-plane.js.map +1 -0
- package/dist/facade/mixed-illumination.d.ts +108 -0
- package/dist/facade/mixed-illumination.js +174 -0
- package/dist/facade/mixed-illumination.js.map +1 -0
- package/dist/facade/on-demand-kits.d.ts +14 -0
- package/dist/facade/on-demand-kits.js +36 -0
- package/dist/facade/on-demand-kits.js.map +1 -0
- package/dist/facade/opt-in-kit.d.ts +9 -0
- package/dist/facade/opt-in-kit.js +11 -0
- package/dist/facade/opt-in-kit.js.map +1 -0
- package/dist/facade/quality-kit.d.ts +21 -0
- package/dist/facade/quality-kit.js +23 -0
- package/dist/facade/quality-kit.js.map +1 -0
- package/dist/facade/quality-paths.d.ts +22 -0
- package/dist/facade/quality-paths.js +44 -0
- package/dist/facade/quality-paths.js.map +1 -0
- package/dist/facade/representative-light-sources.d.ts +72 -0
- package/dist/facade/representative-light-sources.js +124 -0
- package/dist/facade/representative-light-sources.js.map +1 -0
- package/dist/facade/scene-radiance.d.ts +145 -4
- package/dist/facade/scene-radiance.js +50 -13
- package/dist/facade/scene-radiance.js.map +1 -1
- package/dist/facade/simulate-capture.d.ts +68 -20
- package/dist/facade/simulate-capture.js +399 -146
- package/dist/facade/simulate-capture.js.map +1 -1
- package/dist/facade/simulate-display-view.d.ts +10 -12
- package/dist/facade/simulate-display-view.js +70 -53
- package/dist/facade/simulate-display-view.js.map +1 -1
- package/dist/gpu/chart-composite-gpu.d.ts +13 -1
- package/dist/gpu/chart-composite-gpu.js +48 -9
- package/dist/gpu/chart-composite-gpu.js.map +1 -1
- package/dist/gpu/chart-radiance-gpu.js +4 -30
- package/dist/gpu/chart-radiance-gpu.js.map +1 -1
- package/dist/gpu/display-view-composite-material-module.d.ts +10 -4
- package/dist/gpu/display-view-composite-material-module.js +39 -13
- package/dist/gpu/display-view-composite-material-module.js.map +1 -1
- package/dist/gpu/display-view-gpu-renderer.d.ts +43 -0
- package/dist/gpu/display-view-gpu-renderer.js +134 -0
- package/dist/gpu/display-view-gpu-renderer.js.map +1 -0
- package/dist/gpu/display-view-image-material-module.d.ts +87 -0
- package/dist/gpu/display-view-image-material-module.js +361 -0
- package/dist/gpu/display-view-image-material-module.js.map +1 -0
- package/dist/gpu/display-view-image-quality.d.ts +87 -0
- package/dist/gpu/display-view-image-quality.js +625 -0
- package/dist/gpu/display-view-image-quality.js.map +1 -0
- package/dist/gpu/display-view-quality-gpu.d.ts +80 -65
- package/dist/gpu/display-view-quality-gpu.js +187 -445
- package/dist/gpu/display-view-quality-gpu.js.map +1 -1
- package/dist/gpu/display-view-speed-gpu.d.ts +67 -19
- package/dist/gpu/display-view-speed-gpu.js +181 -105
- package/dist/gpu/display-view-speed-gpu.js.map +1 -1
- package/dist/gpu/image-kernel-accelerator.js +3 -0
- package/dist/gpu/image-kernel-accelerator.js.map +1 -1
- package/dist/gpu/image-kernel-execution.d.ts +2 -13
- package/dist/gpu/image-kernel-execution.js +2 -10
- package/dist/gpu/image-kernel-execution.js.map +1 -1
- package/dist/gpu/wgsl-display-view-coupled-stub.d.ts +8 -0
- package/dist/gpu/wgsl-display-view-coupled-stub.js +21 -0
- package/dist/gpu/wgsl-display-view-coupled-stub.js.map +1 -0
- package/dist/gpu/wgsl-display-view-coupled.d.ts +88 -0
- package/dist/gpu/wgsl-display-view-coupled.js +353 -0
- package/dist/gpu/wgsl-display-view-coupled.js.map +1 -0
- package/dist/gpu/wgsl-display-view-quality-kernel.d.ts +33 -24
- package/dist/gpu/wgsl-display-view-quality-kernel.js +166 -146
- package/dist/gpu/wgsl-display-view-quality-kernel.js.map +1 -1
- package/dist/gpu/wgsl-display-view-speed-kernel.d.ts +19 -44
- package/dist/gpu/wgsl-display-view-speed-kernel.js +116 -18
- package/dist/gpu/wgsl-display-view-speed-kernel.js.map +1 -1
- package/dist/gpu/wgsl-exact-sum.d.ts +20 -0
- package/dist/gpu/wgsl-exact-sum.js +42 -0
- package/dist/gpu/wgsl-exact-sum.js.map +1 -0
- package/dist/index.d.ts +22 -6
- package/dist/index.js +16 -2
- package/dist/index.js.map +1 -1
- package/dist/motion/subject-motion.d.ts +66 -0
- package/dist/motion/subject-motion.js +52 -0
- package/dist/motion/subject-motion.js.map +1 -0
- package/dist/optics/generic-lens-lateral-color.d.ts +83 -0
- package/dist/optics/generic-lens-lateral-color.js +77 -0
- package/dist/optics/generic-lens-lateral-color.js.map +1 -0
- package/dist/optics/generic-lens-psf.js +54 -46
- package/dist/optics/generic-lens-psf.js.map +1 -1
- package/dist/optics/lens-psf-profile.d.ts +7 -0
- package/dist/optics/lens-psf-profile.js +2 -1
- package/dist/optics/lens-psf-profile.js.map +1 -1
- package/dist/output/camera-channel-response.d.ts +50 -0
- package/dist/output/camera-channel-response.js +69 -0
- package/dist/output/camera-channel-response.js.map +1 -0
- package/dist/output/developed-lens-corrections.d.ts +19 -5
- package/dist/output/developed-lens-corrections.js +28 -9
- package/dist/output/developed-lens-corrections.js.map +1 -1
- package/dist/output/generic-lens-corrections.d.ts +41 -5
- package/dist/output/generic-lens-corrections.js +52 -8
- package/dist/output/generic-lens-corrections.js.map +1 -1
- package/dist/output/native-capture-sdr.js +5 -2
- package/dist/output/native-capture-sdr.js.map +1 -1
- package/dist/output/print-plan.d.ts +41 -5
- package/dist/output/print-plan.js +59 -14
- package/dist/output/print-plan.js.map +1 -1
- package/dist/output/sdr-rendering.d.ts +18 -1
- package/dist/output/sdr-rendering.js +39 -2
- package/dist/output/sdr-rendering.js.map +1 -1
- package/dist/sensor/binomial-sample.d.ts +3 -0
- package/dist/sensor/binomial-sample.js +86 -0
- package/dist/sensor/binomial-sample.js.map +1 -0
- package/dist/sensor/native-sensor-report.d.ts +10 -0
- package/dist/sensor/native-sensor-report.js +16 -0
- package/dist/sensor/native-sensor-report.js.map +1 -0
- package/dist/sensor/sensor-characteristics.d.ts +91 -13
- package/dist/sensor/sensor-characteristics.js +30 -15
- package/dist/sensor/sensor-characteristics.js.map +1 -1
- package/docs/AGENT_GUIDE.md +1 -0
- package/docs/API_REFERENCE.md +214 -4
- package/docs/QUICK_START.md +162 -8
- package/docs/USAGE.md +195 -22
- package/docs/api/capture-c2pa-embed.md +177 -0
- package/docs/api/capture-dng-import.md +86 -10
- package/docs/api/capture-dng-opcodes.md +93 -0
- package/docs/api/capture-dng-profile-maps.md +141 -0
- package/docs/api/capture-dng-stage-image.md +43 -0
- package/docs/api/capture-jpeg-xl-tiles.md +33 -0
- package/docs/api/capture-lossy-jpeg.md +41 -0
- package/docs/api/capture-native-raw-development.md +2 -1
- package/docs/api/core-execution-error.md +43 -0
- package/docs/api/core-version.md +1 -1
- package/docs/api/equipment-generic-lens-catalogue.md +10 -5
- package/docs/api/equipment-sensor-profile-body.md +64 -0
- package/docs/api/equipment-sensor-profiles.md +86 -0
- package/docs/api/exports.json +460 -5
- package/docs/api/facade-display-view-gpu.md +5 -2
- package/docs/api/facade-display-view.md +11 -15
- package/docs/api/facade-exposure-build-up.md +105 -0
- package/docs/api/facade-fast-methods.md +47 -10
- package/docs/api/facade-image-plane-enable.md +16 -0
- package/docs/api/facade-mixed-illumination.md +67 -0
- package/docs/api/facade-quality-paths.md +24 -0
- package/docs/api/facade-representative-light-sources.md +131 -0
- package/docs/api/facade-scene-radiance.md +54 -3
- package/docs/api/facade-simulate-capture.md +48 -11
- package/docs/api/facade-simulate-display-view.md +10 -12
- package/docs/api/gpu-image-kernel-execution.md +0 -36
- package/docs/api/motion-subject-motion.md +23 -0
- package/docs/api/optics-generic-lens-lateral-color.md +79 -0
- package/docs/api/output-camera-channel-response.md +55 -0
- package/docs/api/output-developed-lens-corrections.md +10 -1
- package/docs/api/output-generic-lens-corrections.md +40 -4
- package/docs/api/output-print-plan.md +35 -1
- package/docs/api/output-sdr-rendering.md +6 -1
- package/docs/api/sensor-sensor-characteristics.md +54 -2
- package/llms.txt +3 -0
- package/package.json +6 -5
|
@@ -37,6 +37,8 @@ import { GENERIC_TIER_ASSETS } from "../equipment/generic-tier-assets.js";
|
|
|
37
37
|
import { parseCallerCameraPreset } from "./camera-preset.js";
|
|
38
38
|
import { createHostYield } from "./host-yield.js";
|
|
39
39
|
import { fastFormatDefault, resolveFast } from "./fast-resolution.js";
|
|
40
|
+
import { SPECTRAL_SPIKINESS_THRESHOLD, spectralSpikiness } from "../color/spectral-spikiness.js";
|
|
41
|
+
import { nativeSensorReport } from "../sensor/native-sensor-report.js";
|
|
40
42
|
import { SCENE_TO_SENSOR_BRIDGE_SCOPE } from "../optics/scene-to-sensor-irradiance.js";
|
|
41
43
|
import { resolveGenericEquipmentExposureCapabilities } from "../equipment/exposure-capabilities.js";
|
|
42
44
|
import { resolveGenericIsoSignalChain } from "../sensor/iso-signal-chain.js";
|
|
@@ -60,7 +62,6 @@ import { parseSdrRenderingProfile } from "../output/sdr-rendering.js";
|
|
|
60
62
|
import { createPrintJpegTask, PRINT_JPEG_LIMITS } from "../output/print-jpeg.js";
|
|
61
63
|
import { checkNativeRawIntegrity, prepareNativeRawDevelopment } from "../capture/native-raw-development.js";
|
|
62
64
|
import { createRawPixelDeveloper, normalizedRawSite, rawSiteSaturated } from "../capture/raw-development-kernel.js";
|
|
63
|
-
import { createAhdDemosaicTask } from "../processing/demosaic.js";
|
|
64
65
|
import { PROCESSING_MAX_PIXELS } from "../processing/processing-task.js";
|
|
65
66
|
import { renderSdrRunInto } from "../output/native-capture-sdr.js";
|
|
66
67
|
import { prepareSdrPixelTone } from "../output/sdr-rendering.js";
|
|
@@ -75,14 +76,9 @@ import { prepareEnvironmentLensFieldMapping } from "../optics/sensor-environment
|
|
|
75
76
|
import { createRollingShutterCaptureExposureSchedule, resolveElectronicRollingShutterExposureSchedule } from "../sensor/readout-exposure-linkage.js";
|
|
76
77
|
import { calculateCaptureExposureWindows } from "../sensor/exposure-window.js";
|
|
77
78
|
import { calculateSensorSpectralQuadrature } from "../sensor/spectral-quadrature.js";
|
|
78
|
-
import { createDiffractionLimitedPsfProfile } from "../optics/diffraction-psf-profile.js";
|
|
79
|
-
import { registerDiffractionPsfKey, siteDiffractionKernel } from "../optics/site-diffraction-kernel.js";
|
|
80
79
|
import { convolveSiteKernels, createDefocusDiscSiteKernel, defocusDiscKernelRadius, defocusDiscLimitMessage, requireDefocusDiscKernelRadius, resolveDefocusDisc } from "../optics/defocus-disc-kernel.js";
|
|
81
|
-
import { calculateDefocusFieldVariation, createDefocusImageSpaceProvider, prepareDefocusImageSpaceGeometry } from "./defocus-image-space-provider.js";
|
|
82
80
|
import { createDiffractedPlanarProvider } from "./diffraction-provider.js";
|
|
83
81
|
import { DEFOCUS_DIRECT_KERNEL_MAXIMUM_RADIUS_SITES, siteKernelRuns } from "../optics/site-kernel-runs.js";
|
|
84
|
-
import { createDefocusBokehSiteKernel, resolveDefocusBokehOptions } from "../optics/defocus-bokeh-kernel.js";
|
|
85
|
-
import { planDefocusBokehZones } from "./defocus-bokeh-zones.js";
|
|
86
82
|
import { resolveGenericPrimeLens } from "../equipment/generic-prime-lenses.js";
|
|
87
83
|
import { IDEAL_LENS_LIMITS } from "../equipment/ideal-lens.js";
|
|
88
84
|
import { calculateChannelElectronsPerRadiance } from "./channel-response.js";
|
|
@@ -94,9 +90,11 @@ import { prepareRgbGamutMapping } from "../color/gamut-mapping.js";
|
|
|
94
90
|
import { serializeNativeRawDng } from "../capture/native-raw-dng.js";
|
|
95
91
|
import { correctDiffractionInPlace, defaultDiffractionCorrectionRadiusPixels, designDiffractionCorrectionFilter, parseDiffractionCorrectionSettings, parseUnsharpMaskSettings, renderSdrWithUnsharpMaskInPlace, DEVELOPMENT_DETAIL_MODEL_VERSION } from "../processing/development-detail.js";
|
|
96
92
|
import { resolveSensorReadoutRegime } from "../sensor/raw-readout.js";
|
|
93
|
+
import { loadedKit, optInKit, qualityKit } from "./on-demand-kits.js";
|
|
97
94
|
import { characterizeReadout, parseSensorCharacteristics, sensorReport, SENSOR_CHARACTERISTIC_KEYS } from "../sensor/sensor-characteristics.js";
|
|
98
95
|
import { resolveChartScene, chartMaterialInputs, CHART_SCENE_PRESETS } from "./chart-scenes.js";
|
|
99
96
|
import { resolveChartMaterialLayers } from "./chart-material-layers.js";
|
|
97
|
+
import { imagePlaneSupport } from "./image-plane-support.js";
|
|
100
98
|
import { resolveCaptureIlluminant, calculateIlluminantIrradianceScale, buildPlanarSceneRadianceProvider, requireCallerScene, FACADE_SPECTRAL_RANGE_NANOMETERS, resolveChartSizeOption, PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W, planarProviderMaterialOfDirection } from "./scene-radiance.js";
|
|
101
99
|
/** Version of how the facade builds requests. It changes whenever the same input would build a different request. */
|
|
102
100
|
export const SIMULATE_CAPTURE_FACADE_VERSION = "1.0.0";
|
|
@@ -108,7 +106,7 @@ export const SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES = 4;
|
|
|
108
106
|
* a capture does. `motion.rotation` is a non-zero angular velocity; `sensor.temporalSampleCount` a count above 1.
|
|
109
107
|
*/
|
|
110
108
|
export const SIMULATE_CAPTURE_COST_OPTIONS = /* @__PURE__ */ Object.freeze(["lens.pupilSampling", "lens.projection", "lens.distortion", "lens.vignetting", "lens.psf",
|
|
111
|
-
"lens.diffraction", "lens.defocus", "lens.catalogue", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
|
|
109
|
+
"lens.diffraction", "lens.defocus", "lens.catalogue", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "sensor.spectralStepNanometers", "motion.rotation", "motion.subject", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
|
|
112
110
|
"output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
|
|
113
111
|
"output.diffractionCorrection", "output.sharpening", "output.lensCorrections", "output.format.jpeg", "output.format.png", "output.format.dng"]);
|
|
114
112
|
/**
|
|
@@ -116,7 +114,8 @@ export const SIMULATE_CAPTURE_COST_OPTIONS = /* @__PURE__ */ Object.freeze(["len
|
|
|
116
114
|
* the record form with `fast` and is reported in `diagnostics.fast.applied` when it runs. Every one of them takes the flag,
|
|
117
115
|
* whether or not it has a fast method yet; one without runs exact and reports `false`.
|
|
118
116
|
*/
|
|
119
|
-
export const SIMULATE_CAPTURE_FAST_OPTIONS = /* @__PURE__ */ Object.freeze(["lens.diffraction", "lens.catalogue", "sensor.antiAliasing", "sensor.pixelAperture.box", "output.demosaic.ahd"
|
|
117
|
+
export const SIMULATE_CAPTURE_FAST_OPTIONS = /* @__PURE__ */ Object.freeze(["lens.diffraction", "lens.catalogue", "sensor.antiAliasing", "sensor.pixelAperture.box", "output.demosaic.ahd", "motion.subject",
|
|
118
|
+
"sensor.spectralStepNanometers"]);
|
|
120
119
|
export const FACADE_EVIDENCE = /* @__PURE__ */ Object.freeze([/* @__PURE__ */ Object.freeze({ sourceOrigin: "photivra",
|
|
121
120
|
sourceReference: "photivra:simulate-capture-facade", reuseStatus: "photivra-owned" })]);
|
|
122
121
|
const DEFAULTS = /* @__PURE__ */ Object.freeze({ camera: "professional", fNumber: 8, shutterSeconds: 1 / 125, iso: 100, compensationStops: 0,
|
|
@@ -246,7 +245,7 @@ function cachedDiffractionProfile(input) {
|
|
|
246
245
|
if (kept)
|
|
247
246
|
diffractionProfileCache.delete(key);
|
|
248
247
|
else
|
|
249
|
-
kept = createDiffractionLimitedPsfProfile(input).value;
|
|
248
|
+
kept = loadedKit(qualityKit).createDiffractionLimitedPsfProfile(input).value;
|
|
250
249
|
diffractionProfileCache.set(key, kept);
|
|
251
250
|
let kernelValues = 0;
|
|
252
251
|
for (const profile of diffractionProfileCache.values())
|
|
@@ -416,14 +415,14 @@ function luminanceOf(radiance) {
|
|
|
416
415
|
return PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W * sum;
|
|
417
416
|
}
|
|
418
417
|
/**
|
|
419
|
-
* 2.14: the capabilities with the ISO grid cut to its whole stops (ISO 100·2^k, as the 2.0.0
|
|
418
|
+
* 2.14: the capabilities with the ISO grid cut to its whole stops from the base ISO (ISO 100·2^k on the tiers, as the 2.0.0 bodies list them; #554: a profile's base·2^k), for
|
|
420
419
|
* Auto ISO's default `isoSteps: "whole-stop"`. A whole-stop grid is returned unchanged, so 2.0.0 records resolve as before.
|
|
421
420
|
*/
|
|
422
421
|
function wholeStopIsoCapabilities(capabilities) {
|
|
423
422
|
const grid = capabilities.iso.settingGrid;
|
|
424
423
|
if (grid.kind !== "discrete-values")
|
|
425
424
|
return capabilities;
|
|
426
|
-
const whole = grid.values.filter((iso) => Number.isInteger(Math.log2(iso /
|
|
425
|
+
const whole = grid.values.filter((iso) => Number.isInteger(Math.log2(iso / grid.values[0])));
|
|
427
426
|
if (whole.length === grid.values.length || whole.length === 0)
|
|
428
427
|
return capabilities;
|
|
429
428
|
return { ...capabilities, iso: { ...capabilities.iso, settingGrid: { kind: "discrete-values", values: Object.freeze(whole) } } };
|
|
@@ -577,8 +576,14 @@ function pngColourEncoding(encoding) {
|
|
|
577
576
|
* clipping, white balance, highlight mode and the camera→XYZ→linear-sRGB matrices are all the same. It
|
|
578
577
|
* stops before tone mapping, gamut clipping and quantization. It reads the RAW without taking ownership.
|
|
579
578
|
*/
|
|
580
|
-
async function developLinearSrgb(input, afterRow, ahd) {
|
|
581
|
-
|
|
579
|
+
async function developLinearSrgb(input, afterRow, ahd, neighbors) {
|
|
580
|
+
if (neighbors)
|
|
581
|
+
return developWithNeighborHighlights(input, afterRow, ahd, neighbors.fast, neighbors.module.reconstructHighlightsFromNeighbors);
|
|
582
|
+
return developLinearSrgbWith(prepareNativeRawDevelopment(input), input, afterRow, ahd);
|
|
583
|
+
}
|
|
584
|
+
/** The body of {@link developLinearSrgb} for a prepared development (its operator may be replaced, #602). */
|
|
585
|
+
async function developLinearSrgbWith(v, input, afterRow, ahd) {
|
|
586
|
+
const plan = v.plan, raw = input.raw, layout = plan.frame.colorSamplingProfile.layout;
|
|
582
587
|
if (layout.kind !== "periodic-mosaic")
|
|
583
588
|
fail("Development requires a periodic CFA.");
|
|
584
589
|
const native = plan.exposure.geometry.nativeRaster, g = plan.exposure.resolvedGeometry, active = g.activeCapture.nativeRect;
|
|
@@ -603,6 +608,21 @@ async function developLinearSrgb(input, afterRow, ahd) {
|
|
|
603
608
|
return { rgb, highlight: { mode: v.highlightMode, saturatedPixelCount: c.saturatedPixelCount, whiteBalanceClippedSampleCount: c.whiteBalanceClippedSampleCount,
|
|
604
609
|
reconstructedPixelCount: c.reconstructedPixelCount } };
|
|
605
610
|
}
|
|
611
|
+
/**
|
|
612
|
+
* #602: development with the neighbor highlight reconstruction. The development runs with an operator that stops after the white
|
|
613
|
+
* balance (u = g·c per pixel, unclipped), the whole image is rebuilt (`reconstructHighlightsFromNeighbors`) and the colour step
|
|
614
|
+
* (`developWhiteBalancedInto`) finishes each pixel to unclipped linear sRGB.
|
|
615
|
+
*/
|
|
616
|
+
async function developWithNeighborHighlights(input, afterRow, ahd, fast, reconstructHighlightsFromNeighbors) {
|
|
617
|
+
const v = prepareNativeRawDevelopment(input), real = v.development, [g0, g1, g2] = real.appliedGains;
|
|
618
|
+
const whiteBalanceOnly = { ...real, developInto(c0, c1, c2, out, offset) { out[offset] = c0 * g0; out[offset + 1] = c1 * g1; out[offset + 2] = c2 * g2; } };
|
|
619
|
+
const linear = await developLinearSrgbWith({ ...v, development: whiteBalanceOnly }, input, afterRow, ahd);
|
|
620
|
+
const width = v.raster.pixelWidth, height = v.raster.pixelHeight, rgb = linear.rgb;
|
|
621
|
+
const report = reconstructHighlightsFromNeighbors(rgb, width, height, real.appliedGains, fast);
|
|
622
|
+
for (let i = 0; i < width * height; i++)
|
|
623
|
+
real.developWhiteBalancedInto(rgb[3 * i], rgb[3 * i + 1], rgb[3 * i + 2], rgb, 3 * i);
|
|
624
|
+
return { rgb, highlight: { ...linear.highlight, mode: "reconstruct-neighbors", whiteBalanceClippedSampleCount: 0, reconstructedPixelCount: report.reconstructedPixelCount } };
|
|
625
|
+
}
|
|
606
626
|
/**
|
|
607
627
|
* AHD development (2.1): the same site normalization as the native development kernel (flagged or white-level sites
|
|
608
628
|
* are 1, others (code − black)/(white − black)) on the whole native raster, demosaiced by `createAhdDemosaicTask`
|
|
@@ -626,7 +646,7 @@ async function developAhd(v, raw, repeatWidth, repeatHeight, width, height, at,
|
|
|
626
646
|
const m = v.color.normalizedCameraChannelsToXyz;
|
|
627
647
|
const cameraToXyz = [0, 1, 2].map((r) => [0, 1, 2].map((c) => m[r][c] * gains[c]));
|
|
628
648
|
// A caller-raised pixel limit already bounds the raster, so its working memory is not capped again at the 1 GiB default.
|
|
629
|
-
const task = createAhdDemosaicTask({ plane: { width: W, height: H, captureModeId: v.plan.frame.modeId, colorSamplingProfile: v.plan.frame.colorSamplingProfile, samples },
|
|
649
|
+
const task = (await qualityKit.load()).createAhdDemosaicTask({ plane: { width: W, height: H, captureModeId: v.plan.frame.modeId, colorSamplingProfile: v.plan.frame.colorSamplingProfile, samples },
|
|
630
650
|
channelRoles: { red: "red", green: "green", blue: "blue" }, cameraToXyz, ...(v.plan.maximumPixels === undefined ? {} : { maximumWorkingBytes: Number.MAX_SAFE_INTEGER }) }, { yieldControl: async () => { await host.yieldControl(); host.checkAbort(); } });
|
|
631
651
|
const demosaicStarted = clockMs();
|
|
632
652
|
await task.run();
|
|
@@ -926,11 +946,13 @@ export const DISPLAY_VIEW_WITHOUT_VIEW = Symbol("display view without view");
|
|
|
926
946
|
* Internal: the request path shared by {@link simulateCapture}, {@link simulateIdealReference} and the display view, whose renderer is
|
|
927
947
|
* passed in; for a display view the renderer's value is returned (a result, or a prepared view with DISPLAY_VIEW_WITHOUT_VIEW).
|
|
928
948
|
*/
|
|
929
|
-
export async function runCapture(options, run,
|
|
949
|
+
export async function runCapture(options, run,
|
|
950
|
+
/** The display view option, the parts, or (#534) for a capture an exposure build-up's callback, which sees each native RAW input. */
|
|
951
|
+
viewOption, renderView) {
|
|
930
952
|
const started = Date.now(), idealReference = run === "ideal-reference", displayView = run === "display-view";
|
|
931
953
|
requireInputObject(options, "simulateCapture options");
|
|
932
954
|
const top = allowKeys(options, "simulateCapture options", ["camera", "lens", "exposure", "scene", "subjectDistanceM", "chartSize", "illuminant",
|
|
933
|
-
"illuminanceLux", "output", "sensor", "motion", "fast", "signal", "onProgress"]);
|
|
955
|
+
"illuminanceLux", "illumination", "output", "sensor", "motion", "fast", "signal", "onProgress"]);
|
|
934
956
|
const signal = top.signal, onProgress = top.onProgress;
|
|
935
957
|
if (signal !== undefined && (typeof signal !== "object" || signal === null || typeof signal.aborted !== "boolean"))
|
|
936
958
|
fail("signal must be an AbortSignal.");
|
|
@@ -954,6 +976,38 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
954
976
|
preset = GENERIC_TIER_ASSETS.find((p) => p.tier === cameraOption) ?? fail("Unknown camera tier.");
|
|
955
977
|
else
|
|
956
978
|
preset = parseCallerCameraPreset(cameraOption);
|
|
979
|
+
// #554: a sensor profile record (the catalogue is never imported) replaces the body's sensor assets; its other capabilities and
|
|
980
|
+
// the lens are kept. The parser and body builder are a static import (2.21.0 size pass): as a lazy chunk they made the bundler
|
|
981
|
+
// split the core modules into a shared chunk of their own, which cost more than the 1.7 KB they keep out of the entry.
|
|
982
|
+
const profileRecord = (typeof top.sensor === "object" && top.sensor !== null ? top.sensor : {}).profile;
|
|
983
|
+
let profile, profileSensor, profilePitch = DEFAULTS.pixelPitchMicrometers, profileReadNoiseScale = 1;
|
|
984
|
+
if (profileRecord !== undefined) {
|
|
985
|
+
const kit = await optInKit.load();
|
|
986
|
+
profile = kit.parseSensorProfile(profileRecord);
|
|
987
|
+
profilePitch = profile.pixelPitchMicrometers;
|
|
988
|
+
// #554: with a catalogue lens the capture frames on the profile's sensor: by default the largest centred region of the output's
|
|
989
|
+
// aspect (pitch = min(W/w, H/h)); an explicit pitch gives a centred crop of it. A site of N photosites sums their charge (the
|
|
990
|
+
// full well scales with the area) and their read noise in quadrature (×√N); below one photosite the read noise is kept.
|
|
991
|
+
const o = (typeof top.output === "object" && top.output !== null ? top.output : {}), explicit = top.sensor.pixelPitchMicrometers;
|
|
992
|
+
const W = profile.sensorWidthMm, H = profile.sensorHeightMm, w = positive(o.width ?? DEFAULTS.width, "output.width"), h = positive(o.height ?? DEFAULTS.height, "output.height");
|
|
993
|
+
if (typeof top.lens === "object" && top.lens !== null && top.lens.catalogue !== undefined) {
|
|
994
|
+
profilePitch = typeof explicit === "number" ? explicit : Math.min(W / w, H / h) * 1000;
|
|
995
|
+
if (w * profilePitch > W * 1000 * (1 + 1e-9) || h * profilePitch > H * 1000 * (1 + 1e-9))
|
|
996
|
+
fail("sensor.pixelPitchMicrometers makes the frame larger than the profile's sensor.");
|
|
997
|
+
profileSensor = { widthMm: W, heightMm: H };
|
|
998
|
+
}
|
|
999
|
+
else if (typeof explicit === "number")
|
|
1000
|
+
profilePitch = explicit;
|
|
1001
|
+
const sites = (profilePitch / profile.pixelPitchMicrometers) ** 2;
|
|
1002
|
+
profileReadNoiseScale = Math.sqrt(Math.max(1, sites));
|
|
1003
|
+
preset = { ...preset, body: kit.sensorProfileBody(profile, preset.body, preset.presetVersion, profileReadNoiseScale) };
|
|
1004
|
+
if (profileSensor || sites !== 1)
|
|
1005
|
+
assume("sensor.profileView", "model", { sensorWidthMm: W, sensorHeightMm: H, frameWidthMm: w * profilePitch / 1000, frameHeightMm: h * profilePitch / 1000,
|
|
1006
|
+
pixelPitchMicrometers: profilePitch, croppedSensorAxis: profileSensor ? (w * profilePitch < W * 1000 * (1 - 1e-9) ? "width" : h * profilePitch < H * 1000 * (1 - 1e-9) ? "height" : "none") : null,
|
|
1007
|
+
photositesPerSite: sites, readNoiseScale: profileReadNoiseScale, lensFramedOnSensor: !!profileSensor }, "Approximation (#554; docs/SENSOR_PROFILES.md): the catalogue lens is resolved on the profile's sensor and the capture is its centred frame of the output's aspect (cropped, " +
|
|
1008
|
+
"not stretched); a site sums photositesPerSite photosites (charge by area, read noise ×readNoiseScale). A downsized view, not the sensor's own pixels.");
|
|
1009
|
+
assume("sensor.profile", "model", { ...profile, sensitivityOffsetStops: Math.log2(kit.sensorProfileSaturationIso(profile) / profile.baseIso) }, "Sensor profile (#554; docs/SENSOR_PROFILES.md): published, derived and design values, not a camera calibration; its base ISO is re-rated from full well, QE and pixel area.");
|
|
1010
|
+
}
|
|
957
1011
|
const body = preset.body, lens = preset.lens;
|
|
958
1012
|
// Lens
|
|
959
1013
|
const lensOptions = allowKeys(top.lens, "lens", ["focalLengthMm", "fNumber", "focusDistanceM", "pupilSampling", "projection", "distortion", "vignetting", "psf", "diffraction", "prime", "defocus",
|
|
@@ -963,7 +1017,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
963
1017
|
// The simulated frame in mm (output width × height × pixel pitch), validated again below.
|
|
964
1018
|
const frameMm = () => {
|
|
965
1019
|
const o = (typeof top.output === "object" && top.output !== null ? top.output : {}), sn = (typeof top.sensor === "object" && top.sensor !== null ? top.sensor : {});
|
|
966
|
-
const frameW = positive(o.width ?? DEFAULTS.width, "output.width"), frameH = positive(o.height ?? DEFAULTS.height, "output.height"), framePitch = positive(sn.pixelPitchMicrometers ??
|
|
1020
|
+
const frameW = positive(o.width ?? DEFAULTS.width, "output.width"), frameH = positive(o.height ?? DEFAULTS.height, "output.height"), framePitch = positive(sn.pixelPitchMicrometers ?? profilePitch, "sensor.pixelPitchMicrometers");
|
|
967
1021
|
return { widthMm: frameW * framePitch / 1000, heightMm: frameH * framePitch / 1000 };
|
|
968
1022
|
};
|
|
969
1023
|
if (lensOptions.prime !== undefined) {
|
|
@@ -1013,9 +1067,13 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1013
1067
|
for (const key of ["prime", "focalLengthMm", "projection", "distortion", "vignetting", "psf", "diffraction", "pupilSampling"])
|
|
1014
1068
|
if (lensOptions[key] !== undefined)
|
|
1015
1069
|
fail("lens.catalogue cannot be combined with lens." + key + ": the catalogue lens sets its own focal length, projection, distortion, vignetting and PSF (with diffraction).");
|
|
1016
|
-
|
|
1070
|
+
// #554: with a sensor profile, the lens is resolved on the profile's sensor (sensor.profileView), the capture being its centred frame.
|
|
1071
|
+
catalogueFrame = profileSensor ?? frameMm();
|
|
1017
1072
|
lensKit = await import("./generic-lens-capture.js");
|
|
1018
1073
|
catalogue = lensKit.resolveGenericLens({ lensId: catalogueId, activeImagingArea: catalogueFrame });
|
|
1074
|
+
// #554: the phone lenses are fixed to a phone sensor profile, and it takes only them.
|
|
1075
|
+
if ((catalogue.set === "phone") !== (profile?.fixedLenses === "phone"))
|
|
1076
|
+
fail("lens.catalogue: the phone lenses go only with a phone sensor.profile (fixedLenses \"phone\"), which takes only them.");
|
|
1019
1077
|
assume("lens.catalogue", "model", { lensId: catalogue.lensId, name: catalogue.name, set: catalogue.set, kind: catalogue.kind, equivalentFocalLengthMm: catalogue.equivalentFocalLengthMm,
|
|
1020
1078
|
focalLengthMm: catalogue.focalLengthMm, cropFactor35Mm: catalogue.cropFactor35Mm, widestFNumber: catalogue.widestFNumber, narrowestFNumber: catalogue.narrowestFNumber,
|
|
1021
1079
|
minimumFocusDistanceM: catalogue.minimumFocusDistanceM, maximumMagnification: catalogue.maximumMagnification, projection: catalogue.projection, fisheyeImage: catalogue.fisheyeImage }, "Generic catalogue lens " + catalogue.name + " (#549; Photivra's own design, not a real product): focal length = equivalent ÷ the simulated frame's diagonal crop factor, f/" +
|
|
@@ -1116,6 +1174,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1116
1174
|
diffractionRadiusOption = record.kernelRadiusSamples === undefined ? null : integerIn(record.kernelRadiusSamples, "lens.diffraction.kernelRadiusSamples", 0, 32);
|
|
1117
1175
|
diffractionFast = fastOverride(record, "lens.diffraction");
|
|
1118
1176
|
}
|
|
1177
|
+
// #594: diffraction's code is in the Quality kit, loaded on demand.
|
|
1178
|
+
await qualityKit.load();
|
|
1119
1179
|
if (lensPsf)
|
|
1120
1180
|
fail("lens.diffraction cannot be combined with lens.psf: diffraction would be counted twice.");
|
|
1121
1181
|
if (pupil)
|
|
@@ -1132,7 +1192,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1132
1192
|
const bokeh = { ...record };
|
|
1133
1193
|
delete bokeh.model;
|
|
1134
1194
|
try {
|
|
1135
|
-
defocusBokeh = resolveDefocusBokehOptions(bokeh, "lens.defocus");
|
|
1195
|
+
defocusBokeh = (await qualityKit.load()).resolveDefocusBokehOptions(bokeh, "lens.defocus");
|
|
1136
1196
|
}
|
|
1137
1197
|
catch (error) {
|
|
1138
1198
|
fail(error.message);
|
|
@@ -1153,7 +1213,29 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1153
1213
|
defocusModel = "geometric-disc";
|
|
1154
1214
|
}
|
|
1155
1215
|
// Motion (2.0): constant camera rotation and the shutter type.
|
|
1156
|
-
const motionOptions = allowKeys(top.motion, "motion", ["angularVelocityRadPerSec", "shutter", "rollingShutterSkewSeconds"]);
|
|
1216
|
+
const motionOptions = allowKeys(top.motion, "motion", ["angularVelocityRadPerSec", "shutter", "rollingShutterSkewSeconds", "subject"]);
|
|
1217
|
+
// #551: the moving subject (subject-motion.ts); its plane points depend on time, as camera rotation's rays do.
|
|
1218
|
+
let subject, subjectFast;
|
|
1219
|
+
if (motionOptions.subject !== undefined) {
|
|
1220
|
+
const record = allowKeys(motionOptions.subject, "motion.subject", ["velocityMPerSec", "angularVelocityRadPerSec", "region", "fast"]);
|
|
1221
|
+
const v = allowKeys(record.velocityMPerSec, "motion.subject.velocityMPerSec", ["x", "y"]), r = record.region === undefined ? undefined :
|
|
1222
|
+
allowKeys(record.region, "motion.subject.region", ["centerXM", "centerYM", "widthM", "heightM"]);
|
|
1223
|
+
// Each number finite and within ±10000 (m/s, rad/s, m); a missing velocity component or rate is 0.
|
|
1224
|
+
const finite = (x, name, fallback) => {
|
|
1225
|
+
if (x === undefined && fallback === 0)
|
|
1226
|
+
return 0;
|
|
1227
|
+
if (typeof x !== "number" || !Number.isFinite(x) || Math.abs(x) > 1e4)
|
|
1228
|
+
fail("motion.subject." + name + " must be a finite number within ±10000.");
|
|
1229
|
+
return x;
|
|
1230
|
+
};
|
|
1231
|
+
subject = { vx: finite(v.x, "velocityMPerSec.x", 0), vy: finite(v.y, "velocityMPerSec.y", 0), omega: finite(record.angularVelocityRadPerSec, "angularVelocityRadPerSec", 0),
|
|
1232
|
+
region: r && { cx: finite(r.centerXM, "region.centerXM"), cy: finite(r.centerYM, "region.centerYM"), hw: positive(r.widthM, "motion.subject.region.widthM") / 2,
|
|
1233
|
+
hh: positive(r.heightM, "motion.subject.region.heightM") / 2 } };
|
|
1234
|
+
subjectFast = fastOverride(record, "motion.subject");
|
|
1235
|
+
await optInKit.load();
|
|
1236
|
+
}
|
|
1237
|
+
if (subject && pupil)
|
|
1238
|
+
fail("motion.subject cannot be combined with lens.pupilSampling.");
|
|
1157
1239
|
const angular = { pitch: 0, yaw: 0, roll: 0 };
|
|
1158
1240
|
if (motionOptions.angularVelocityRadPerSec !== undefined) {
|
|
1159
1241
|
const a = allowKeys(motionOptions.angularVelocityRadPerSec, "motion.angularVelocityRadPerSec", ["pitch", "yaw", "roll"]);
|
|
@@ -1174,9 +1256,9 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1174
1256
|
const rollingSkewOption = motionOptions.rollingShutterSkewSeconds === undefined ? undefined : positive(motionOptions.rollingShutterSkewSeconds, "motion.rollingShutterSkewSeconds");
|
|
1175
1257
|
// Diffraction runs as an object-space convolution when every PSF tap's source is another site centre seen
|
|
1176
1258
|
// through one linear map (still camera, rectilinear, no distortion or field throughput); otherwise per tap.
|
|
1177
|
-
const diffractionObjectSpace = diffractionRadiusOption !== undefined && !rotating && !lensFieldMapping && !vignetting;
|
|
1259
|
+
const diffractionObjectSpace = diffractionRadiusOption !== undefined && !rotating && !subject && !lensFieldMapping && !vignetting;
|
|
1178
1260
|
// Output and sensor
|
|
1179
|
-
const outputOptions = allowKeys(top.output, "output", ["width", "height", "format", "toneCurve", "toneCurveParameters", "highlightMode", "exposureCompensationStops", "whiteBalance", "jpegQuantizationStep",
|
|
1261
|
+
const outputOptions = allowKeys(top.output, "output", ["width", "height", "format", "toneCurve", "toneCurveParameters", "highlightMode", "gamutHandling", "exposureCompensationStops", "whiteBalance", "jpegQuantizationStep",
|
|
1180
1262
|
"colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng", "maximumPixels", "region", "metadata", "diffractionCorrection", "sharpening", "lensCorrections"]);
|
|
1181
1263
|
// Opt-in large images: a caller-raised native pixel limit. Without it the 4094-pixel sides are unchanged.
|
|
1182
1264
|
const raisedPixels = outputOptions.maximumPixels === undefined ? undefined : integerIn(outputOptions.maximumPixels, "output.maximumPixels", 1, NATIVE_RAW_LIMITS.ceilingPixels);
|
|
@@ -1263,20 +1345,65 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1263
1345
|
"A site pushed to or past white clips at white and counts as saturated (highlightMode \"reconstruct\" rolls it off). The RAW codes are unchanged" + (format === "dng" ? ", and the DNG holds the unmodified RAW." : "."));
|
|
1264
1346
|
}
|
|
1265
1347
|
const exposureGain = exposureCompensationStops === 0 ? 1 : 2 ** exposureCompensationStops;
|
|
1348
|
+
// #601: the hue-preserving gamut handling. Opt-in: no assumption when omitted.
|
|
1349
|
+
const gamutHandling = outputOptions.gamutHandling ?? "clip-components";
|
|
1350
|
+
if (gamutHandling !== "clip-components" && gamutHandling !== "desaturate-to-luminance")
|
|
1351
|
+
fail("output.gamutHandling must be clip-components or desaturate-to-luminance.");
|
|
1352
|
+
if (outputOptions.gamutHandling !== undefined) {
|
|
1353
|
+
if (linearPath)
|
|
1354
|
+
fail("output.gamutHandling applies to the sRGB rendering only, not with a wide-gamut output.colorEncoding or output.gamutMapping \"perceptual\".");
|
|
1355
|
+
assume("output.gamutHandling", "model", { method: gamutHandling, luminance: "linear-srgb-d65-relative-luminance" }, gamutHandling === "clip-components" ?
|
|
1356
|
+
"Out-of-gamut components are clipped to [0, 1] after the tone curve." :
|
|
1357
|
+
"After the tone curve, a pixel whose largest component exceeds 1 is moved toward the gray of its own luminance until that component is 1: its luminance and hue " +
|
|
1358
|
+
"(the line from the white point through its chromaticity) are kept instead of the component clip's shift toward the secondaries; a pixel whose luminance is at or above 1 is white. " +
|
|
1359
|
+
"Negative components are still clipped to 0. An engine rendering choice, not a camera maker's gamut mapping.");
|
|
1360
|
+
}
|
|
1266
1361
|
const whiteBalanceOption = pick(outputOptions, "whiteBalance", "output.whiteBalance", DEFAULTS.whiteBalance, "White balance measured from a perfect white diffuser under the scene illuminant (a custom/grey-card white balance).");
|
|
1267
1362
|
const jpegStep = format === "jpeg" ? integerIn(pick(outputOptions, "jpegQuantizationStep", "output.jpegQuantizationStep", DEFAULTS.jpegQuantizationStep, "Constant JPEG quantization step 1 (not a manufacturer quality scale)."), "output.jpegQuantizationStep", 1, 255) : undefined;
|
|
1268
1363
|
if (format !== "jpeg" && outputOptions.jpegQuantizationStep !== undefined)
|
|
1269
1364
|
fail("output.jpegQuantizationStep applies only to output.format \"jpeg\".");
|
|
1270
|
-
const sensorOptions = allowKeys(top.sensor, "sensor", ["pixelPitchMicrometers", "spectralStepNanometers", "temporalSampleCount", "noiseSeed", "antiAliasing", "pixelAperture", ...SENSOR_CHARACTERISTIC_KEYS]);
|
|
1271
|
-
const pitch = positive(pick(sensorOptions, "pixelPitchMicrometers", "sensor.pixelPitchMicrometers",
|
|
1365
|
+
const sensorOptions = allowKeys(top.sensor, "sensor", ["pixelPitchMicrometers", "spectralStepNanometers", "temporalSampleCount", "noiseSeed", "antiAliasing", "pixelAperture", "profile", ...SENSOR_CHARACTERISTIC_KEYS]);
|
|
1366
|
+
const pitch = positive(pick(sensorOptions, "pixelPitchMicrometers", "sensor.pixelPitchMicrometers", profilePitch, (profileSensor ? "The profile's sensor across the output (sensor.profileView)" : profile ? "The profile's pitch" : "6 µm pixels (about a 24 MP full-frame sensor)") +
|
|
1367
|
+
". The simulated sensor is width × pitch wide, so small images are a central crop of a large sensor, not a downscaled full frame."), "sensor.pixelPitchMicrometers");
|
|
1272
1368
|
if (pitch < 0.5 || pitch > SIMULATE_CAPTURE_PIXEL_PITCH_MAXIMUM_MICROMETERS)
|
|
1273
1369
|
fail("sensor.pixelPitchMicrometers must be 0.5-" + SIMULATE_CAPTURE_PIXEL_PITCH_MAXIMUM_MICROMETERS + ".");
|
|
1274
|
-
|
|
1370
|
+
// #395: exact or fast per option: the option record's `fast`, else the top-level one, else (2.20.0) the format's default: fast for
|
|
1371
|
+
// sRGB samples and the display view, exact for the exports (DNG, JPEG, PNG) and the ideal reference. Fast methods report true when they run.
|
|
1372
|
+
if (top.fast !== undefined && typeof top.fast !== "boolean")
|
|
1373
|
+
fail("fast must be true or false.");
|
|
1374
|
+
const fastTop = resolveFast(top.fast, fastFormatDefault(format, displayView ? "display-view" : idealReference ? "ideal-reference" : "capture"));
|
|
1375
|
+
// #590 (2.22.0): the default step follows the light and the request's quality. A light is spiky when R(20) = 20²·κ/24 > 0.01 (the
|
|
1376
|
+
// midpoint rule's relative error indicator, color/spectral-spikiness.ts) for any of its sources; an exact request then takes 5 nm,
|
|
1377
|
+
// a fast one 20 nm. Its light is validated with the scene below (a light that fails here is not spiky here).
|
|
1378
|
+
const illumination = top.illumination, lights = illumination ? [illumination.sources, illumination.points]
|
|
1379
|
+
.flatMap((list) => (Array.isArray(list) ? list.map((s) => s?.illuminant) : [])) : [top.illuminant];
|
|
1380
|
+
let kappa = 0;
|
|
1381
|
+
for (const light of lights)
|
|
1382
|
+
if (light !== undefined)
|
|
1383
|
+
try {
|
|
1384
|
+
const shape = resolveCaptureIlluminant(light).shape;
|
|
1385
|
+
kappa = Math.max(kappa, spectralSpikiness((nm) => shape.evaluate(nm)));
|
|
1386
|
+
}
|
|
1387
|
+
catch { /* validated below */ }
|
|
1388
|
+
const r20 = 400 * kappa / 24, spiky = r20 > SPECTRAL_SPIKINESS_THRESHOLD, defaultStep = fastTop || !spiky ? DEFAULTS.spectralStepNanometers : 5;
|
|
1389
|
+
const spectralStep = positive(pick(sensorOptions, "spectralStepNanometers", "sensor.spectralStepNanometers", defaultStep, "Spectral midpoint quadrature over 380-780 nm with subintervals of at most " + defaultStep + " nm" + (spiky ? " (the light is spiky: sensor.spectralSpikiness)." : ".")), "sensor.spectralStepNanometers");
|
|
1390
|
+
if (spiky && sensorOptions.spectralStepNanometers === undefined)
|
|
1391
|
+
assume("sensor.spectralSpikiness", "model", { kappaPerSquareNanometer: kappa, r20, threshold: SPECTRAL_SPIKINESS_THRESHOLD, stepNanometers: defaultStep, fast: fastTop }, "#590: the light's spectrum has narrow bands. The midpoint rule's relative error indicator R(h) = h²·κ/24 (DLMF §3.5(i); κ = Σ|Δ²S| / ΣS on a 1 nm grid) is " + r20 +
|
|
1392
|
+
" at 20 nm, above " + SPECTRAL_SPIKINESS_THRESHOLD + " (a design threshold), so an exact request takes 5 nm. " + (fastTop ? "This fast request keeps 20 nm (4× fewer spectral queries): colors may carry a cast (measured up to " +
|
|
1393
|
+
"about 0.1 Δu′v′ on white under the tri-phosphor fluorescent source). Pass fast: false or sensor.spectralStepNanometers: 5 for the exact colors." : "The step costs 4× the spectral queries of 20 nm."));
|
|
1275
1394
|
if (spectralStep < 1 || spectralStep > 100)
|
|
1276
1395
|
fail("sensor.spectralStepNanometers must be 1-100.");
|
|
1277
|
-
|
|
1396
|
+
let temporalSampleCount = integerIn(rotating || subject ? pick(sensorOptions, "temporalSampleCount", "sensor.temporalSampleCount", ROTATING_TEMPORAL_SAMPLE_COUNT, rotating ? "Eight temporal midpoints across each site's exposure window sample the camera rotation (motion blur); raise it for long exposures or fast rotation." :
|
|
1397
|
+
"Eight temporal midpoints sample the subject's motion; raise it for long streaks.") :
|
|
1278
1398
|
pick(sensorOptions, "temporalSampleCount", "sensor.temporalSampleCount", DEFAULTS.temporalSampleCount, "One temporal sample: the static scene and still camera make more samples unnecessary."), "sensor.temporalSampleCount", 1, 256);
|
|
1279
|
-
const
|
|
1399
|
+
const parsedCharacteristics = parseSensorCharacteristics(sensorOptions), rn = parsedCharacteristics?.readNoise, rs = profileReadNoiseScale;
|
|
1400
|
+
// #554: on a profile's downsized view a site sums photosites: explicit read noises (per photosite) scale as the profile's do; no fixed pattern.
|
|
1401
|
+
const characteristics = rn && rs !== 1 ? { ...parsedCharacteristics, readNoise: { ...rn, ...(rn.lowGainElectrons === undefined ? {} : { lowGainElectrons: rn.lowGainElectrons * rs }),
|
|
1402
|
+
...(rn.highGainElectrons === undefined ? {} : { highGainElectrons: rn.highGainElectrons * rs }) } } : parsedCharacteristics;
|
|
1403
|
+
if (characteristics?.fixedPatternNoise && profile && pitch !== profile.pixelPitchMicrometers)
|
|
1404
|
+
fail("sensor.fixedPatternNoise needs the profile's own pixel pitch: the pattern is per photosite.");
|
|
1405
|
+
// The EQE scale over the facade's QE 0.5 curve: an explicit peak QE, else the profile's (#554), else 1.
|
|
1406
|
+
const bodyQeScale = profile ? profile.peakQuantumEfficiency / 0.5 : 1, qeScale = characteristics?.peakQuantumEfficiency !== undefined ? characteristics.qeScale : bodyQeScale;
|
|
1280
1407
|
const noiseSeed = integerIn(pick(sensorOptions, "noiseSeed", "sensor.noiseSeed", DEFAULTS.noiseSeed, "Fixed noise seed 1 (deterministic output)."), "sensor.noiseSeed", 0, 0xffffffff);
|
|
1281
1408
|
const antiAliasing = sensorOptions.antiAliasing === undefined ? undefined : parseAntiAliasing(sensorOptions.antiAliasing, pitch);
|
|
1282
1409
|
const antiAliasingFast = antiAliasing ? fastOverride(sensorOptions.antiAliasing, "sensor.antiAliasing") : undefined;
|
|
@@ -1309,14 +1436,13 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1309
1436
|
if (rotating && shutterKind === "rolling")
|
|
1310
1437
|
unsupported("camera rotation with motion.shutter \"rolling\"", "every row has its own instants, so the view's " +
|
|
1311
1438
|
"scene points of one instant would mix rows exposed at other times; use motion.shutter \"global\" for the view.");
|
|
1439
|
+
if (subject && (shutterKind === "rolling" || subject.omega !== 0))
|
|
1440
|
+
unsupported("motion.subject with a rolling shutter or angularVelocityRadPerSec", "the view renders sideways motion under a global shutter only; simulateCapture renders both.");
|
|
1312
1441
|
if (outputOptions.region !== undefined)
|
|
1313
1442
|
unsupported("output.region", "pass the rectangle as view.region, in display pixels.");
|
|
1314
1443
|
if (outputOptions.whiteBalance === "auto")
|
|
1315
1444
|
unsupported("output.whiteBalance \"auto\"", "the capture measures it from its noisy full-resolution RAW; use \"scene-illuminant\" or a fixed white balance.");
|
|
1316
|
-
|
|
1317
|
-
unsupported("output.toneCurve " + String(outputOptions.toneCurve), "luminance tone curves couple the channels; the view supports identity and positive-reinhard-per-channel.");
|
|
1318
|
-
if (outputOptions.highlightMode === "reconstruct")
|
|
1319
|
-
unsupported("output.highlightMode \"reconstruct\"", "the view supports the per-channel highlight clip.");
|
|
1445
|
+
// #593 (2.22.0): luminance tone curves, highlightMode "reconstruct" and (#601) the desaturation render (display-view-rendering.ts).
|
|
1320
1446
|
if (outputOptions.diffractionCorrection !== undefined || outputOptions.sharpening !== undefined)
|
|
1321
1447
|
unsupported("output.diffractionCorrection or output.sharpening", "the development detail stages filter the whole developed capture image.");
|
|
1322
1448
|
if (outputOptions.lensCorrections !== undefined)
|
|
@@ -1344,14 +1470,12 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1344
1470
|
if (demosaicOption !== undefined && demosaicOption !== "bilinear" && demosaicOption !== "ahd")
|
|
1345
1471
|
fail("output.demosaic must be bilinear or ahd.");
|
|
1346
1472
|
const ahd = demosaicOption === "ahd";
|
|
1347
|
-
// #395: exact or fast per option: the option record's `fast`, else the top-level one, else (2.20.0) the format's default: fast for
|
|
1348
|
-
// sRGB samples and the display view, exact for the exports (DNG, JPEG, PNG) and the ideal reference. Fast methods report true when they run.
|
|
1349
|
-
if (top.fast !== undefined && typeof top.fast !== "boolean")
|
|
1350
|
-
fail("fast must be true or false.");
|
|
1351
|
-
const fastTop = resolveFast(top.fast, fastFormatDefault(format, displayView ? "display-view" : idealReference ? "ideal-reference" : "capture"));
|
|
1352
1473
|
const fastInUse = [["lens.diffraction", diffractionRadiusOption !== undefined, diffractionFast],
|
|
1353
1474
|
["lens.catalogue", catalogue !== undefined, catalogueFast],
|
|
1354
|
-
["sensor.antiAliasing", antiAliasing !== undefined, antiAliasingFast], ["sensor.pixelAperture.box", boxAperture, boxFast], ["output.demosaic.ahd", ahd, demosaicFast]
|
|
1475
|
+
["sensor.antiAliasing", antiAliasing !== undefined, antiAliasingFast], ["sensor.pixelAperture.box", boxAperture, boxFast], ["output.demosaic.ahd", ahd, demosaicFast],
|
|
1476
|
+
["motion.subject", subject !== undefined, subjectFast],
|
|
1477
|
+
// #590: the default step under a spiky light (an explicit step, or a smooth light, makes no choice and adds no key).
|
|
1478
|
+
["sensor.spectralStepNanometers", spiky && sensorOptions.spectralStepNanometers === undefined, undefined]];
|
|
1355
1479
|
const fastApplied = {};
|
|
1356
1480
|
for (const [option, used] of fastInUse)
|
|
1357
1481
|
if (used)
|
|
@@ -1359,13 +1483,16 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1359
1483
|
// #568: the catalogue's fast method, when requested (its record's flag, else the top-level one); whether it runs is decided with the RAW path.
|
|
1360
1484
|
const catalogueFastRequested = catalogue !== undefined && resolveFast(catalogueFast, fastTop);
|
|
1361
1485
|
let fastReport = Object.freeze({
|
|
1362
|
-
requested: fastTop || [diffractionFast, catalogueFast, antiAliasingFast, boxFast, demosaicFast].some((value) => value === true),
|
|
1486
|
+
requested: fastTop || [diffractionFast, catalogueFast, antiAliasingFast, boxFast, demosaicFast, subjectFast].some((value) => value === true),
|
|
1363
1487
|
defaultedFrom: top.fast === undefined ? "format" : "request", applied: Object.freeze({ ...fastApplied }), assurance: "engine-reference"
|
|
1364
1488
|
});
|
|
1365
1489
|
const markFast = (option) => {
|
|
1366
1490
|
fastApplied[option] = true;
|
|
1367
1491
|
fastReport = Object.freeze({ ...fastReport, applied: Object.freeze({ ...fastApplied }), assurance: "approximation-with-a-priori-bound" });
|
|
1368
1492
|
};
|
|
1493
|
+
// #590: the fast method (20 nm under a spiky light) ran.
|
|
1494
|
+
if (spiky && sensorOptions.spectralStepNanometers === undefined && fastTop)
|
|
1495
|
+
markFast("sensor.spectralStepNanometers");
|
|
1369
1496
|
if (borderOption !== undefined && borderOption !== "margin" && borderOption !== "mirror")
|
|
1370
1497
|
fail("output.borderHandling must be margin or mirror.");
|
|
1371
1498
|
const builtInDemosaic = demosaicOption === undefined ? undefined : createBuiltInBayerDemosaicProfiles({ kernel: "bilinear",
|
|
@@ -1384,7 +1511,9 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1384
1511
|
if (lensCorrectionSelection) {
|
|
1385
1512
|
if (idealReference)
|
|
1386
1513
|
fail("simulateIdealReference does not take output.lensCorrections: a reference has no camera development.");
|
|
1387
|
-
|
|
1514
|
+
// #589: the channel scales follow the capture's own channels, known only once the sensor is built (below); until then they
|
|
1515
|
+
// span every channel's possible dispersion, so the developed rectangle a region reads holds the sources of any of them.
|
|
1516
|
+
lensCorrection = lensKit.prepareDevelopedLensCorrection(catalogue, lensCorrectionSelection, width, height, pitch / 1000);
|
|
1388
1517
|
}
|
|
1389
1518
|
// Region (2.3): a rectangle of the whole frame. Its native sites are the region plus the demosaic reach on each side
|
|
1390
1519
|
// (clipped to the raster, where mirror borders apply exactly as in the whole render), with the origin rounded down to
|
|
@@ -1460,8 +1589,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1460
1589
|
const imageDistanceMm = focalLengthMm * focusDistanceM * 1000 / (focusDistanceM * 1000 - focalLengthMm);
|
|
1461
1590
|
const metresPerFrameUnit = (width * pitch / 2000) * subjectDistanceM / imageDistanceMm;
|
|
1462
1591
|
const aspect = height / width;
|
|
1592
|
+
// #581: an image-plane scene takes the module prepareImagePlaneScenes() loaded, which places its print (chartSize, or fitted in the frame).
|
|
1593
|
+
const imagePlaneModule = top.scene?.kind === "image-plane" ? imagePlaneSupport() ?? fail("Call prepareImagePlaneScenes() first.") : undefined;
|
|
1463
1594
|
// Fixed-size chart (2.3): validated here, before any scene work.
|
|
1464
|
-
const chartSize =
|
|
1595
|
+
const chartSize = imagePlaneModule ? imagePlaneModule.imagePlanePrint(top.scene, top.chartSize, 2 * metresPerFrameUnit, aspect, assume) :
|
|
1596
|
+
top.chartSize === undefined ? undefined : resolveChartSizeOption(top.chartSize, aspect);
|
|
1465
1597
|
assume("scene.framing", "derived", { framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect }, chartSize ? "The scene plane is perpendicular to the optical axis; the frame covers this much of it (thin lens, focused distance), and the chart has the physical size in scene.chartSize." :
|
|
1466
1598
|
"The scene plane is perpendicular to the optical axis and the chart is sized to fill the active frame exactly.");
|
|
1467
1599
|
let chartReport;
|
|
@@ -1498,7 +1630,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1498
1630
|
// Radiance queries per native site: spectral subintervals × temporal midpoints × pupil rays × PSF taps.
|
|
1499
1631
|
const psfTaps = lensPsf ? Math.max(1, ...(lensPsf.configuration.psf.profile.nodes ?? [])
|
|
1500
1632
|
.map((node) => Number(node.kernel?.widthSamples) * Number(node.kernel?.heightSamples)).filter((taps) => Number.isSafeInteger(taps) && taps > 0)) : 1;
|
|
1501
|
-
|
|
1633
|
+
let queriesPerSite = temporalSampleCount * (Math.ceil((FACADE_SPECTRAL_RANGE_NANOMETERS.maximum - FACADE_SPECTRAL_RANGE_NANOMETERS.minimum) / spectralStep) + 1) *
|
|
1502
1634
|
(pupil ? pupil.radialSamples * pupil.angularSamples : 1) * psfTaps * (antiAliasing ? antiAliasing.components.length : 1) * boxSamples * boxSamples;
|
|
1503
1635
|
// Scene and illuminant
|
|
1504
1636
|
// The scene is the one required input: the facade never guesses what is being photographed.
|
|
@@ -1506,10 +1638,23 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1506
1638
|
fail("simulateCapture needs a scene, for example { preset: \"uniform\" } (an 18 % grey card) or { preset: \"slanted-edge\" }.");
|
|
1507
1639
|
const sceneOption = top.scene;
|
|
1508
1640
|
requireInputObject(sceneOption, "scene");
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
const
|
|
1641
|
+
// #550: mixed lighting (loaded only when asked for). Its frame-center light stands in for the single illuminant below: the strongest
|
|
1642
|
+
// source at the center (white balance, the illumination profile) and the center's illuminance (grey card, standard exposure).
|
|
1643
|
+
// A static import (2.21.0 size pass): as a lazy chunk it made the bundler split the core modules into a shared chunk of their own.
|
|
1644
|
+
const mixedKit = top.illumination === undefined ? undefined : await optInKit.load();
|
|
1645
|
+
if (mixedKit && (top.illuminant !== undefined || top.illuminanceLux !== undefined))
|
|
1646
|
+
fail("illumination replaces illuminant and illuminanceLux; omit them.");
|
|
1647
|
+
const lightKit = (resolveIlluminant) => mixedKit.resolveMixedIllumination(top.illumination, resolveIlluminant);
|
|
1648
|
+
const mixed = mixedKit && lightKit(resolveCaptureIlluminant), strongest = mixed?.sources[mixed.center.strongestSource];
|
|
1649
|
+
const illuminantOption = strongest ? strongest.option : pick(top, "illuminant", "illuminant", { kind: "planckian", temperatureKelvin: DEFAULTS.illuminantKelvin }, "Planckian (blackbody) 6500 K as a stand-in for daylight. CIE D65 is not bundled; pass its S0/S1/S2 basis as a daylight illuminant to use it.");
|
|
1650
|
+
const illuminant = strongest ? strongest.illuminant : resolveCaptureIlluminant(illuminantOption);
|
|
1651
|
+
const illuminanceLux = mixed ? mixed.center.illuminanceLux : positive(pick(top, "illuminanceLux", "illuminanceLux", SIMULATE_CAPTURE_DEFAULT_ILLUMINANCE_LUX, "π·10·N²/(0.65·t·S·0.18) lux at f/8, 1/125 s, ISO 100 (CIPA DC-004 standard output sensitivity H = 10/S and camera equation H = 0.65·L·t/N²): an 18 % grey card then gets the reference exposure for the default settings. It does not follow the caller's exposure settings."), "illuminanceLux");
|
|
1652
|
+
const irradianceScale = strongest ? strongest.irradianceScale : calculateIlluminantIrradianceScale(illuminant.shape, illuminanceLux);
|
|
1653
|
+
if (mixed)
|
|
1654
|
+
assume("scene.illumination", "model", { lights: mixed.sources.map((s) => s.report), blend: mixed.blend,
|
|
1655
|
+
coordinates: chartSize ? "chart-local" : "frame", center: mixed.center }, "Mixed lighting (#550): the irradiance at a point is the declared blend Σ w_i·E_i(λ) of the sources (each scaled to its illuminance, weights summing to 1; a crossfade, not a falloff model) " +
|
|
1656
|
+
"plus each point source's I·cos θ/d² (inverse square and Lambert's cosine law; no occlusion). " +
|
|
1657
|
+
"Scene-average metering reads the lit frame; the frame center's light sets the grey card, the standard-exposure illuminance (all sources scaled together) and the grey-card deviation.");
|
|
1513
1658
|
let chart, callerScene, materialLayers;
|
|
1514
1659
|
const sceneRecord = sceneOption;
|
|
1515
1660
|
if ("kind" in sceneRecord) {
|
|
@@ -1517,7 +1662,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1517
1662
|
// the resolved layers are kept for the display view's GPU renderer.
|
|
1518
1663
|
if (sceneRecord.kind === "material-layers")
|
|
1519
1664
|
materialLayers = resolveChartMaterialLayers({ scene: sceneOption, aspect: chartSize ? chartSize.heightM / chartSize.widthM : aspect });
|
|
1520
|
-
callerScene =
|
|
1665
|
+
callerScene = imagePlaneModule ? await imagePlaneModule.resolveImagePlaneScene(sceneOption, chartSize.heightM / chartSize.widthM, assume) :
|
|
1666
|
+
requireCallerScene(materialLayers ? materialLayers.callerScene : sceneOption);
|
|
1521
1667
|
}
|
|
1522
1668
|
else {
|
|
1523
1669
|
chart = resolveChartScene({ scene: sceneOption, aspect: chartSize ? chartSize.heightM / chartSize.widthM : aspect });
|
|
@@ -1539,21 +1685,29 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1539
1685
|
const sceneMaterials = callerScene?.kind === "material-function" ? callerScene.materials.map((m, index) => (m.kind === "neutral" ? m.reflectance :
|
|
1540
1686
|
spectrumDigestForm(callerScene.materialInputs[index], m.spectrum))) : undefined;
|
|
1541
1687
|
const sceneDescription = { facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, scene: chart ? { preset: chart.preset, parameters: chart.parameters } :
|
|
1542
|
-
|
|
1688
|
+
callerScene.digestForm ??
|
|
1689
|
+
{ kind: callerScene.kind, sceneId: callerScene.sceneId }, illuminant: illuminant.id, illuminanceLux, subjectDistanceM, framedWidthM: 2 * metresPerFrameUnit, aspect,
|
|
1543
1690
|
...(chartSize ? { chartSize: { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance } } : {}),
|
|
1544
|
-
...(illuminantSpectrum === undefined ? {} : { illuminantSpectrum }), ...(sceneSpectra.length === 0 ? {} : { sceneSpectra }), ...(sceneMaterials ? { sceneMaterials } : {})
|
|
1691
|
+
...(illuminantSpectrum === undefined ? {} : { illuminantSpectrum }), ...(sceneSpectra.length === 0 ? {} : { sceneSpectra }), ...(sceneMaterials ? { sceneMaterials } : {}),
|
|
1692
|
+
...(mixed ? { illumination: { blend: mixed.blend, lights: mixed.sources.map((s) => [s.report, spectrumDigestForm(s.illuminant.spectrum, s.illuminant.shape)]) } } : {}) };
|
|
1545
1693
|
if (callerScene?.kind === "material-function")
|
|
1546
1694
|
assume("scene.digest", "model", "caller-function-not-hashed", "The source digest covers the caller's sceneId, its materials and the framing, not the body of the caller's materialAt function.");
|
|
1547
|
-
|
|
1695
|
+
// #581: an image-plane scene recorded its own digest assumption when it was resolved.
|
|
1696
|
+
else if (callerScene && callerScene.kind !== "image-plane")
|
|
1548
1697
|
assume("scene.digest", "model", "caller-function-not-hashed", "The source digest covers the caller's sceneId and the framing, not the body of the caller's evaluate function.");
|
|
1549
1698
|
const sourceSha256 = await sha256Hex(JSON.stringify(sceneDescription, (_key, value) => (typeof value === "object" && value !== null && "evaluate" in value ? "caller-spectrum" : value)));
|
|
1550
1699
|
checkAbort();
|
|
1551
|
-
const buildProvider = (sceneIlluminant, scale) => buildPlanarSceneRadianceProvider({ scene: chart ?? callerScene,
|
|
1700
|
+
const buildProvider = (sceneIlluminant, scale, light) => buildPlanarSceneRadianceProvider({ scene: chart ?? callerScene,
|
|
1552
1701
|
sceneId: IDS.scene, providerProfileId: IDS.provider, illuminant: sceneIlluminant, irradianceScale: scale, geometry: { distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
1553
1702
|
...(chartSize ? { chart: { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance } } : {}) }, evidence: FACADE_EVIDENCE,
|
|
1554
|
-
limitations: Object.freeze(["Planar Lambertian scene under uniform illumination; no shadows, inter-reflection, gloss or fluorescence."]),
|
|
1555
|
-
...(catalogue?.kind === "fisheye" ? { zeroBeyondHorizon: true } : {}) });
|
|
1556
|
-
const provider = buildProvider(illuminant, irradianceScale);
|
|
1703
|
+
limitations: Object.freeze(["Planar Lambertian scene under " + (light ? "blended" : "uniform") + " illumination; no shadows, inter-reflection, gloss or fluorescence."]),
|
|
1704
|
+
...(catalogue?.kind === "fisheye" ? { zeroBeyondHorizon: true } : {}), ...(light ? { irradianceAt: light.irradianceAt, illumination: light } : {}) });
|
|
1705
|
+
const provider = buildProvider(illuminant, irradianceScale, mixed);
|
|
1706
|
+
// #551: the moving subject's scene; renderers read it at each query's time. The revealed background is a neutral 18 % reflectance
|
|
1707
|
+
// under the plane's irradiance at the revealed point: the illuminant, or (#550) the blend and point sources there, from the spectrum.
|
|
1708
|
+
const half = chartSize ? chartSize.widthM / 2 / metresPerFrameUnit : 1;
|
|
1709
|
+
const movingScene = subject ? (base, scale, shape, light) => loadedKit(optInKit).createSubjectMotionProvider(base, subject, subjectDistanceM, metresPerFrameUnit, (u, v, nm) => 0.18 * (light ? light.irradianceAt(u / half, v / half, u * metresPerFrameUnit, v * metresPerFrameUnit, nm) : scale * shape(nm)) / Math.PI) : undefined;
|
|
1710
|
+
const sceneProvider = movingScene ? movingScene(provider, irradianceScale, (nm) => illuminant.shape.evaluate(nm), mixed) : provider;
|
|
1557
1711
|
// 2.6: a chart preset's evaluator is engine-built end to end (its results: the schema's exact keys, constant fields,
|
|
1558
1712
|
// the request's identifiers, deep-frozen FACADE_EVIDENCE, UNCERTAINTY and limitations, and an engine-computed
|
|
1559
1713
|
// radiance), so the native task may skip re-parsing the fixed parts. Caller scenes keep the full validation.
|
|
@@ -1576,7 +1730,9 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1576
1730
|
const mode = pick(exposureOptions, "mode", "exposure.mode", "manual", "Manual exposure.");
|
|
1577
1731
|
if (!["manual", "aperture-priority", "shutter-priority", "program"].includes(mode))
|
|
1578
1732
|
fail("Unknown exposure mode.");
|
|
1579
|
-
|
|
1733
|
+
// #554: a profile whose range starts above ISO 100 defaults to its base ISO.
|
|
1734
|
+
const isoOption = profile && profile.baseIso > DEFAULTS.iso ? pick(exposureOptions, "iso", "exposure.iso", profile.baseIso, "The sensor profile's base ISO.") :
|
|
1735
|
+
pick(exposureOptions, "iso", "exposure.iso", DEFAULTS.iso, "ISO 100, the tiers' base ISO.");
|
|
1580
1736
|
// #516: "auto" resolves the ISO with the mode's Auto ISO resolver; NaN marks the ISO as not yet known.
|
|
1581
1737
|
const automaticIso = isoOption === "auto";
|
|
1582
1738
|
if (typeof isoOption === "string" && !automaticIso)
|
|
@@ -1597,7 +1753,9 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1597
1753
|
const clamp = (n) => lensRange ? Math.min(lensRange.narrowestFNumber, Math.max(lensRange.widestFNumber, n)) : n, referenceAperture = clamp(DEFAULTS.fNumber);
|
|
1598
1754
|
// (N0/8)² is exactly 1 at f/8, so f/8 keeps 1/125 s exactly.
|
|
1599
1755
|
const sameShutter = DEFAULTS.shutterSeconds * (referenceAperture / DEFAULTS.fNumber) ** 2;
|
|
1600
|
-
|
|
1756
|
+
// #554: a profile whose range starts above ISO 100 takes the same N²/(t·S) at its base ISO (a shorter t).
|
|
1757
|
+
const referenceIso = profile && profile.baseIso > DEFAULTS.iso ? profile.baseIso : DEFAULTS.iso;
|
|
1758
|
+
const reference = { aperture: referenceAperture, shutterSeconds: Math.max(sameShutter * DEFAULTS.iso / referenceIso, capabilities.shutter.minimumSeconds), iso: referenceIso };
|
|
1601
1759
|
const referenceLuminance = sosReferenceLuminance(reference.aperture, reference.shutterSeconds, reference.iso);
|
|
1602
1760
|
assume("exposure.referenceExposure", "model", { ...reference, meteredLuminanceCdPerSquareMeter: referenceLuminance }, "Exposure anchor: f/8, 1/125 s, ISO 100 gives the CIPA DC-004 standard output sensitivity reference exposure H = 10/S = 0.1 lx·s for an average scene luminance of 10·N²/(0.65·t·S) ≈ 1230.77 cd/m² (camera equation H = 0.65·L·t/N²)" +
|
|
1603
1761
|
(referenceAperture === DEFAULTS.fNumber ? "." : "; the lens excludes f/8: " + (reference.shutterSeconds > sameShutter ? "the shortest shutter" : "the same N²/t") + " at f/" + referenceAperture + "."));
|
|
@@ -1620,6 +1778,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1620
1778
|
const cardLuminance = GREY_CARD_REFLECTANCE * illuminanceLux / Math.PI;
|
|
1621
1779
|
for (const sample of zoneSamples)
|
|
1622
1780
|
sample.relativeLinearSignal = meterProfile.target.targetRelativeSignal * cardLuminance / referenceLuminance;
|
|
1781
|
+
if (mixed)
|
|
1782
|
+
assume("exposure.meteringCard", "model", { position: { u: 0, v: 0 }, illuminanceLux }, "Mixed lighting (#550): the grey card sits at the frame center and reads the light there.");
|
|
1623
1783
|
assume("exposure.meteringSource", "model", { metering, greyCardReflectance: GREY_CARD_REFLECTANCE, luminanceCdPerSquareMeter: cardLuminance }, "Grey-card (incident) metering: every meter zone reads an 18 % Lambertian grey card under the scene illuminance, L = 0.18·E/π, instead of the scene.");
|
|
1624
1784
|
}
|
|
1625
1785
|
const meterResult = meterRelativeExposure({ profile: meterProfile, sampleSet: { measurementId: "facade-meter", sceneStateId: IDS.sceneState,
|
|
@@ -1636,7 +1796,9 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1636
1796
|
fail("lens.fNumber " + fNumber + " is outside the " + lensRange.name + " range f/" + lensRange.widestFNumber + "–f/" + lensRange.narrowestFNumber + ".");
|
|
1637
1797
|
};
|
|
1638
1798
|
if (mode === "manual" || mode === "aperture-priority") {
|
|
1639
|
-
|
|
1799
|
+
// #554: a fixed-aperture lens (the phone lenses) defaults to its one f-number.
|
|
1800
|
+
const fixed = lensRange && lensRange.widestFNumber === lensRange.narrowestFNumber ? lensRange.widestFNumber : undefined;
|
|
1801
|
+
fNumber = positive(pick(lensOptions, "fNumber", "lens.fNumber", fixed ?? DEFAULTS.fNumber, fixed ? "The lens's fixed f/" + fixed + "." : "f/8."), "lens.fNumber");
|
|
1640
1802
|
checkFNumber();
|
|
1641
1803
|
}
|
|
1642
1804
|
else {
|
|
@@ -1747,6 +1909,19 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1747
1909
|
fNumber = resolution.resolvedSettings.aperture ?? fNumber;
|
|
1748
1910
|
shutterSeconds = resolution.resolvedSettings.shutterSeconds ?? shutterSeconds;
|
|
1749
1911
|
const resolvedIso = resolution.resolvedSettings.iso ?? iso;
|
|
1912
|
+
// #551: motion.subject's fast method: with the default count and a still camera, one midpoint per site of the streak, plus one.
|
|
1913
|
+
if (subject) {
|
|
1914
|
+
const streakSites = loadedKit(optInKit).subjectTravelM(subject, shutterSeconds, metresPerFrameUnit * Math.hypot(1, aspect)) * width / (2 * metresPerFrameUnit), fewer = Math.ceil(streakSites) + 1;
|
|
1915
|
+
if (sensorOptions.temporalSampleCount === undefined && !rotating && resolveFast(subjectFast, fastTop) && fewer < temporalSampleCount) {
|
|
1916
|
+
queriesPerSite = queriesPerSite / temporalSampleCount * fewer;
|
|
1917
|
+
temporalSampleCount = fewer;
|
|
1918
|
+
markFast("motion.subject");
|
|
1919
|
+
assume("fast.motion.subject", "model", { temporalSampleCount: fewer, streakSites }, "Fast: temporal midpoints less than one site apart along the subject's streak replace the default 8; exact is fast: false.");
|
|
1920
|
+
}
|
|
1921
|
+
assume("motion.subject", "model", { velocityMPerSec: { x: subject.vx, y: subject.vy }, angularVelocityRadPerSec: subject.omega,
|
|
1922
|
+
region: subject.region ? { centerXM: subject.region.cx, centerYM: subject.region.cy, widthM: 2 * subject.region.hw, heightM: 2 * subject.region.hh } : null,
|
|
1923
|
+
streakSites, timeReference: "first-opening-boundary-phase" }, "Rigid motion on the scene plane, read at each query's time (docs/QUICK_START.md, \"Subject motion\"); streakSites = v·T·m on the sensor.");
|
|
1924
|
+
}
|
|
1750
1925
|
if (!Number.isFinite(resolvedIso) || resolvedIso <= 0)
|
|
1751
1926
|
fail("Exposure could not be resolved: the resolved ISO is not a positive finite number.");
|
|
1752
1927
|
const isoReport = automaticIso ? { isoControl: "automatic", autoIso: autoIsoReport(resolution, resolverCapabilities.iso, autoIsoShutterPolicy, autoIsoLimits, automaticSlowestShutter, autoIsoOptions.isoSteps === undefined ? undefined : autoIsoIsoSteps) } : { isoControl: "manual" };
|
|
@@ -1755,7 +1930,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1755
1930
|
const exposureGreyCardDeviationStops = callerScene?.kind === "radiance-function" ? null : greyCardDeviationStops(fNumber, shutterSeconds, resolvedIso, illuminanceLux);
|
|
1756
1931
|
// #549: the catalogue lens's character at the resolved f-number and focus: focus scatter, the defocus disc folded into its PSF, then
|
|
1757
1932
|
// its distortion and vignetting profiles over the field the PSF and the queries reach, and its PSF profile.
|
|
1758
|
-
let catalogueTaps, catalogueDefocusApplied = false, catalogueKernelRadius = 0;
|
|
1933
|
+
let catalogueTaps, catalogueDefocusApplied = false, catalogueKernelRadius = 0, catalogueCornerStops = 0;
|
|
1759
1934
|
if (catalogue && lensKit) {
|
|
1760
1935
|
const { GENERIC_LENS_EVIDENCE, genericLensDistortionProfile, genericLensVignettingProfile, resolveGenericLens, simulateGenericLensFocusError, createGenericLensPsfProfile, genericLensPsfKernelRadius, GENERIC_LENS_PSF_MAXIMUM_COMBINED_RADIUS_SITES, genericLensBokehKernel } = lensKit;
|
|
1761
1936
|
checkFNumber();
|
|
@@ -1769,6 +1944,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1769
1944
|
focusDistanceM = sample.focusDistanceM;
|
|
1770
1945
|
}
|
|
1771
1946
|
const character = resolveGenericLens({ lensId: catalogue.lensId, activeImagingArea: catalogueFrame, fNumber, focusDistanceM });
|
|
1947
|
+
// The vignetting depends on the f-number only (output.lensCorrections reads it).
|
|
1948
|
+
catalogueCornerStops = character.character.vignetting.cornerStops;
|
|
1772
1949
|
let extraKernel;
|
|
1773
1950
|
if (lensOptions.defocus !== undefined) {
|
|
1774
1951
|
const disc = resolveDefocusDisc({ focalLengthMm, aperture: fNumber, focusDistanceM, subjectDistanceM, pitchMicrometers: pitch });
|
|
@@ -1854,17 +2031,25 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1854
2031
|
// #548: a lens field mapping, vignetting or camera rotation breaks that, so the capture then applies the disc in image space, on the sensor's
|
|
1855
2032
|
// site lattice (defocus-image-space-provider.ts). #563: the display view reads the same geometry in any focus (display-view-lens-signal.ts).
|
|
1856
2033
|
const imageSpaceOptions = [...(lensFieldMapping ? [lensOptions.distortion !== undefined ? (lensOptions.projection !== undefined ? "lens.projection, lens.distortion" : "lens.distortion") : "lens.projection"] : []),
|
|
1857
|
-
...(vignetting ? ["lens.vignetting"] : []), ...(rotating ? ["camera rotation"] : [])];
|
|
1858
|
-
|
|
2034
|
+
...(vignetting ? ["lens.vignetting"] : []), ...(rotating ? ["camera rotation"] : []), ...(subject ? ["motion.subject"] : [])];
|
|
2035
|
+
// #565, #588: the in-focus diffraction PSF's and a catalogue lens's PSF quadrature read each tap's source on this lattice (the two are
|
|
2036
|
+
// never combined). With rotation or a moving subject (#551) under a rolling shutter (each row has its own instants, so a band per
|
|
2037
|
+
// instant, about 1.1–1.2× the time) that is the option's exact method, and each tap's own point its fast one (lens.diffraction,
|
|
2038
|
+
// lens.catalogue).
|
|
2039
|
+
const rollingTaps = !displayView && (rotating || subject !== undefined) && shutterKind === "rolling";
|
|
2040
|
+
const rollingFastTaps = rollingTaps && (diffractionRadiusOption !== undefined && resolveFast(diffractionFast, fastTop) || catalogueTaps !== undefined && catalogueFastRequested);
|
|
2041
|
+
// #594: the image-space provider (lens projection, distortion, vignetting, rotation, a moving subject) is in the Quality kit.
|
|
2042
|
+
const lensGeometryNeeded = (defocusModel !== undefined || displayView || diffractionRadiusOption !== undefined || catalogueTaps !== undefined && !rollingFastTaps) && imageSpaceOptions.length > 0;
|
|
2043
|
+
if (lensGeometryNeeded)
|
|
2044
|
+
await qualityKit.load();
|
|
2045
|
+
const lensGeometry = lensGeometryNeeded ? loadedKit(qualityKit).prepareDefocusImageSpaceGeometry({ focalLengthMm, focusDistanceM,
|
|
1859
2046
|
angularVelocityRadPerSec: angular, ...(lensFieldMapping ? { lensMapping: prepareEnvironmentLensFieldMapping(lensFieldMapping) } : {}), ...(vignetting ? { vignetting } : {}) }) : undefined;
|
|
1860
2047
|
const defocusGeometry = defocusModel !== undefined ? lensGeometry : undefined;
|
|
1861
|
-
// #565: the in-focus PSF quadrature reads each tap's source on this lattice; with rotation under a rolling shutter (each row has its own
|
|
1862
|
-
// instants, so a band per instant, about 1.2× the time) that is lens.diffraction's exact method, and each tap's own point its fast one.
|
|
1863
|
-
const rollingFastTaps = !displayView && rotating && shutterKind === "rolling" && diffractionRadiusOption !== undefined && lensGeometry !== undefined && resolveFast(diffractionFast, fastTop);
|
|
1864
2048
|
if (rollingFastTaps) {
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
2049
|
+
const catalogueTapsFast = catalogueTaps !== undefined;
|
|
2050
|
+
markFast(catalogueTapsFast ? "lens.catalogue" : "lens.diffraction");
|
|
2051
|
+
assume(catalogueTapsFast ? "fast.lens.catalogue" : "fast.lens.diffraction", "model", { method: "per-tap-source-points" }, "Fast: under rolling-shutter rotation each PSF tap reads the scene at its own source point, " +
|
|
2052
|
+
"not at its site-lattice point, so a tap exactly on a chart boundary can resolve to the other material (#565, #588); elsewhere the values agree to rounding. The exact method is fast: false.");
|
|
1868
2053
|
}
|
|
1869
2054
|
const latticeTaps = lensGeometry && !rollingFastTaps;
|
|
1870
2055
|
// #553: the shaped kernel (on axis) and, with a cat's eye, its field zones; built when the disc is resolved out of focus.
|
|
@@ -1873,7 +2058,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1873
2058
|
const options = defocusBokeh, side = subjectDistanceM > focusDistanceM ? 1 : -1;
|
|
1874
2059
|
const build = (field) => {
|
|
1875
2060
|
try {
|
|
1876
|
-
return createDefocusBokehSiteKernel({ diameterSites: disc.diameterSites, side, fNumber, focalLengthMm, pitchMicrometers: pitch, options, ...(field ? { field } : {}) });
|
|
2061
|
+
return loadedKit(qualityKit).createDefocusBokehSiteKernel({ diameterSites: disc.diameterSites, side, fNumber, focalLengthMm, pitchMicrometers: pitch, options, ...(field ? { field } : {}) });
|
|
1877
2062
|
}
|
|
1878
2063
|
catch (error) {
|
|
1879
2064
|
const message = error.message;
|
|
@@ -1894,7 +2079,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1894
2079
|
const look = defocusGeometry.lookOf((x - nativeWidth / 2) * mmPerSite, (y - nativeHeight / 2) * mmPerSite, 0);
|
|
1895
2080
|
return { x: look.x / look.z, y: look.y / look.z };
|
|
1896
2081
|
} : (x, y) => ({ x: (x - nativeWidth / 2) * g * metresPerFrameUnit / subjectDistanceM, y: -(y - nativeHeight / 2) * g * metresPerFrameUnit / subjectDistanceM });
|
|
1897
|
-
bokehZones = planDefocusBokehZones({ options, nativeWidth, nativeHeight, radius: axis.radius, fNumber, focalLengthMm, fieldOf, kernelAt: build });
|
|
2082
|
+
bokehZones = loadedKit(qualityKit).planDefocusBokehZones({ options, nativeWidth, nativeHeight, radius: axis.radius, fNumber, focalLengthMm, fieldOf, kernelAt: build });
|
|
1898
2083
|
if (!(bokehZones.minimumOpenAreaRatio > 0))
|
|
1899
2084
|
fail("lens.defocus.catsEye: the clipping apertures close the pupil in part of the frame; reduce shiftPerTanFieldMm or enlarge radiusMm.");
|
|
1900
2085
|
}
|
|
@@ -1921,12 +2106,12 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1921
2106
|
...(defocusBokeh ? { bokehOptions: defocusBokeh, bokeh: bokehSummary } : {}),
|
|
1922
2107
|
...(defocusGeometry ? { imageSpace: (disc) => {
|
|
1923
2108
|
// Rotation with a rolling shutter: each row's instants differ, so one instant's image on the lattice would mix rows exposed at other times.
|
|
1924
|
-
if (rotating && shutterKind === "rolling")
|
|
1925
|
-
fail("simulateCapture does not support lens.defocus out of focus with camera rotation and motion.shutter \"rolling\": the disc is applied " +
|
|
2109
|
+
if ((rotating || subject) && shutterKind === "rolling")
|
|
2110
|
+
fail("simulateCapture does not support lens.defocus out of focus with camera rotation and motion.shutter \"rolling\" (or motion.subject with it): the disc is applied " +
|
|
1926
2111
|
"to each temporal midpoint's image on the sensor lattice, and under a rolling shutter every row has its own instants, so each row would need its own image of the rows the disc " +
|
|
1927
2112
|
"reaches (about 2R + 1 rows per row, R the kernel radius). Use motion.shutter \"global\", set lens.focusDistanceM equal to subjectDistanceM, or omit one of the options.");
|
|
1928
2113
|
const temporalMidpointsSeconds = rotating ? Array.from({ length: temporalSampleCount }, (_, i) => (i + 0.5) * (shutterSeconds / temporalSampleCount)) : [];
|
|
1929
|
-
return { options: imageSpaceOptions, fieldVariation: calculateDefocusFieldVariation({ geometry: defocusGeometry, ...(lensFieldMapping ? { lensMapping: prepareEnvironmentLensFieldMapping(lensFieldMapping) } : {}),
|
|
2114
|
+
return { options: imageSpaceOptions, fieldVariation: loadedKit(qualityKit).calculateDefocusFieldVariation({ geometry: defocusGeometry, ...(lensFieldMapping ? { lensMapping: prepareEnvironmentLensFieldMapping(lensFieldMapping) } : {}),
|
|
1930
2115
|
focalLengthMm, fNumber, focusDistanceM, subjectDistanceM, pitchMicrometers: pitch, nativeWidth, nativeHeight, angularVelocityRadPerSec: angular, temporalMidpointsSeconds,
|
|
1931
2116
|
diameterSites: disc.diameterSites, discAt: (distanceM) => resolveDefocusDisc({ focalLengthMm, aperture: fNumber, focusDistanceM, subjectDistanceM: distanceM, pitchMicrometers: pitch }).diameterSites }) };
|
|
1932
2117
|
} } : {}) });
|
|
@@ -1940,36 +2125,50 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1940
2125
|
// output, independent of pixel size, while the collected charge is proportional to the sensitive-aperture area
|
|
1941
2126
|
// (EMVA 1288 Release 4.0, Eq. 3). The tier's signal chain is anchored to the 6 µm aperture; at another area the
|
|
1942
2127
|
// charge-domain readout scales with the area so the same H gives the same code. At 6 µm nothing is changed.
|
|
1943
|
-
|
|
2128
|
+
// #554: a profile's readout is declared at its own photosite area.
|
|
2129
|
+
const referenceArea = profile ? profile.pixelPitchMicrometers ** 2 : ISO_REFERENCE_APERTURE_AREA_SQUARE_MICROMETERS, areaScale = pitch * pitch / referenceArea;
|
|
1944
2130
|
// #555: a full well replaces the area-scaled base-ISO saturation; the charge-domain transfer scales by the same rule.
|
|
1945
|
-
|
|
2131
|
+
// #595: the charge scale at a pitch p (the capture's, and the native photosite's).
|
|
2132
|
+
const fullWell = characteristics?.fullWell, wellAt = (p) => fullWell && (fullWell.link === "none" ? fullWell.electrons : fullWell.electrons * (p / (fullWell.referencePixelPitchMicrometers ?? 6)) ** 2);
|
|
2133
|
+
const fullWellElectrons = wellAt(pitch), chargeScaleAt = (p) => { const well = wellAt(p); return well === undefined ? p * p / referenceArea : well / body.readout[0].profile.regimes[0].preAdcSaturationElectronEquivalent.value; };
|
|
1946
2134
|
if (fullWellElectrons !== undefined && !(fullWellElectrons >= 100 && fullWellElectrons <= 1e10))
|
|
1947
2135
|
fail("sensor.fullWell must give 100 to 1e10 electrons at the pixel pitch (" + fullWellElectrons + ").");
|
|
1948
|
-
const chargeScale =
|
|
2136
|
+
const chargeScale = chargeScaleAt(pitch);
|
|
2137
|
+
// #599 (2.22.0): a tier's site that sums N = (pitch / 6 µm)² photosites sums their read noise in quadrature (×√N), the body's or
|
|
2138
|
+
// sensor.readNoise's (per photosite), as a sensor profile's does (#554, applied in its body); below one photosite it is kept.
|
|
2139
|
+
const tierReadNoiseScale = profile ? 1 : Math.sqrt(Math.max(1, areaScale));
|
|
1949
2140
|
const regimeByChannel = {};
|
|
1950
2141
|
for (const channel of CHANNELS) {
|
|
1951
2142
|
const asset = body.readout.find((a) => a.profile.channelId === channel) ?? fail("Camera readout lacks channel " + channel + ".");
|
|
1952
2143
|
const chain = resolveGenericIsoSignalChain({ profile: body.signalChain, isoCapability: body.iso, requestedIsoSetting: { kind: "standard", exposureIndex: resolvedIso },
|
|
1953
2144
|
captureModeId: IDS.mode, readoutProfile: asset.profile });
|
|
1954
|
-
regimeByChannel[channel] = { readoutProfile: characterizeReadout(asset.profile, chargeScale, characteristics), regimeId: chain.readout.regime.regimeId };
|
|
2145
|
+
regimeByChannel[channel] = { readoutProfile: characterizeReadout(asset.profile, chargeScale, characteristics, tierReadNoiseScale), regimeId: chain.readout.regime.regimeId };
|
|
1955
2146
|
}
|
|
1956
|
-
const
|
|
1957
|
-
|
|
1958
|
-
|
|
2147
|
+
const capacityOf = (scale) => {
|
|
2148
|
+
const regimes = characterizeReadout(body.readout[0].profile, scale, characteristics).regimes;
|
|
2149
|
+
return Math.max(...regimes.map((r) => r.preAdcSaturationElectronEquivalent.value), regimes[0].preAdcSaturationElectronEquivalent.value);
|
|
2150
|
+
};
|
|
2151
|
+
const capacityElectrons = capacityOf(chargeScale);
|
|
2152
|
+
// #595: the same readout at the native photosite (a tier's 6 µm reference, a profile's own pitch): the area rule (or the full well's link) at
|
|
2153
|
+
// that pitch, and the read noise of one photosite (a profile's body carries the view's √N, removed here).
|
|
2154
|
+
const nativePitch = profile ? profile.pixelPitchMicrometers : DEFAULTS.pixelPitchMicrometers, nativeScale = chargeScaleAt(nativePitch);
|
|
2155
|
+
// sensor.readNoise is per photosite on a tier and a profile alike (owner decision 2026-11-11): the native report takes it as given.
|
|
2156
|
+
const nativeSensor = { profile: characterizeReadout(body.readout.find((a) => a.profile.channelId === "green").profile, nativeScale, characteristics, 1 / profileReadNoiseScale),
|
|
2157
|
+
capacityElectrons: capacityOf(nativeScale), pixelPitchMicrometers: nativePitch, framePhotosites: width * height * pitch * pitch / (nativePitch * nativePitch) };
|
|
1959
2158
|
assume("sensor.capacityElectrons", "derived", capacityElectrons, "Physical charge capacity set to the body's largest declared pre-ADC saturation (its base-ISO saturation signal), so storage never clips before the declared readout white.");
|
|
1960
2159
|
if (areaScale !== 1 && !fullWell) {
|
|
1961
|
-
assume("sensor.isoAreaNormalization", "model", { referenceApertureAreaSquareMicrometers:
|
|
2160
|
+
assume("sensor.isoAreaNormalization", "model", { referenceApertureAreaSquareMicrometers: referenceArea,
|
|
1962
2161
|
apertureAreaSquareMicrometers: pitch * pitch, areaScale }, "Approximation. The tier's ISO signal chain is declared for a 6 µm square aperture. Standard output sensitivity S = 10/H (CIPA DC-004-2020 §3.2) is defined at the output, " +
|
|
1963
2162
|
"independent of pixel size, and the collected charge is proportional to the aperture area (EMVA 1288 Release 4.0, Eq. 3), so the system conversion gain (e⁻ per code) and the " +
|
|
1964
2163
|
"pre-ADC saturation of every ISO regime are multiplied by area / 36 µm² = " + areaScale + ". The same exposure gives the same output code at any pitch; per-pixel shot-noise SNR " +
|
|
1965
|
-
"falls with smaller pixels. Read noise
|
|
2164
|
+
"falls with smaller pixels. Read noise is the 6 µm photosite's ×√max(1, area / 36 µm²) = " + tierReadNoiseScale + " (#599: the photosites a site sums); dark current stays per pixel. The charge capacity follows the scaled base-ISO saturation, so it is proportional to " +
|
|
1966
2165
|
"the aperture area: a design consequence of holding the tier's ISO rating and highlight headroom fixed, not a measured full-well model (published sensor data grow less than " +
|
|
1967
2166
|
"proportionally with area across technologies).");
|
|
1968
2167
|
}
|
|
1969
2168
|
if (characteristics) {
|
|
1970
2169
|
const density = capacityElectrons / (pitch * pitch);
|
|
1971
2170
|
assume("sensor.characteristics", "model", { ...Object.fromEntries(SENSOR_CHARACTERISTIC_KEYS.filter((k) => sensorOptions[k] !== undefined).map((k) => [k, sensorOptions[k]])),
|
|
1972
|
-
chargeScale, sensitivityOffsetStops: Math.log2(
|
|
2171
|
+
chargeScale, sensitivityOffsetStops: Math.log2(qeScale / bodyQeScale * areaScale / chargeScale) }, "Approximation (#555), not a measured sensor: these replace the body's values. The full well is the base-ISO charge at the RAW white level (EMVA 1288 Release 4.0 saturation capacity); " +
|
|
1973
2172
|
"every ISO regime's conversion gain and pre-ADC saturation are multiplied by chargeScale. Each ISO keeps its regime, so the output per unit exposure changes by 2^sensitivityOffsetStops: " +
|
|
1974
2173
|
"the rating S = 10/H (CIPA DC-004-2020 §3.2) holds only if peak QE × pixel area / full well is the body's. Read noise replaces each regime's; the high gain's smaller storage is not modelled. " +
|
|
1975
2174
|
"A bit depth scales the black and saturation codes by 2^Δbits." + (density < 200 || density > 1e4 ? " Unusual for the pitch: " + density +
|
|
@@ -1980,8 +2179,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1980
2179
|
assume("sensor.wholeFrameView", "model", { pixelPitchMicrometers: pitch, frameWidthMm: width * pitch / 1000, frameHeightMm: height * pitch / 1000 }, "A pixel pitch above 100 µm is larger than any camera photosite, so each output pixel stands for a " + pitch + " µm square of a " + width * pitch / 1000 + " × " + height * pitch / 1000 +
|
|
1981
2180
|
" mm frame: a downsized view of the whole frame, with the framing, crop factor, generic prime and minimum focus of that frame. Its signal is the charge of the whole square " +
|
|
1982
2181
|
"(sensor.isoAreaNormalization), so its shot noise is that of all the real photosites in the square summed or averaged (Poisson counts add), and the image brightness is the " +
|
|
1983
|
-
"same as at any other pitch.
|
|
1984
|
-
"sensor's binned output. This is the noise of a binned, downsized view, not the real sensor's per-pixel noise. A point pixel aperture samples one point of each square; " +
|
|
2182
|
+
"same as at any other pitch. Its read noise is the photosites' summed (×√N, #599), its dark current once per output pixel; result.sensor.native has a photosite's figures. This is the noise of a binned, downsized view, not the real sensor's per-pixel noise. A point pixel aperture samples one point of each square; " +
|
|
1985
2183
|
"use sensor.pixelAperture box (or sensor.antiAliasing) to average scene detail finer than the pitch.");
|
|
1986
2184
|
}
|
|
1987
2185
|
assume("sensor.darkCurrent", "model", { electronsPerSecond: DARK_CURRENT_ELECTRONS_PER_SECOND, temperatureC: OPERATING_TEMPERATURE_C }, "Synthetic uniform dark current of 1 e⁻/s per site at 20 °C (the tier presets declare none); dark shot noise follows from it.");
|
|
@@ -2058,8 +2256,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2058
2256
|
fail("output.region cannot be combined with output.whiteBalance \"auto\": grey-world statistics of a region differ from the whole frame's.");
|
|
2059
2257
|
needsAuto = true;
|
|
2060
2258
|
}
|
|
2061
|
-
else if (whiteBalanceOption === "scene-illuminant")
|
|
2259
|
+
else if (whiteBalanceOption === "scene-illuminant") {
|
|
2062
2260
|
whiteBalanceState = sceneIlluminantWhiteBalance(illuminant);
|
|
2261
|
+
if (mixed)
|
|
2262
|
+
assume("output.whiteBalanceSource", "model", { source: mixed.center.strongestSource, illuminant: illuminant.id }, "Mixed lighting (#550): measured under the light strongest at the frame center (the largest illuminance there, w_i·E_i or a point's I·cos θ/d²; the first on a tie). An explicit white balance is used as given.");
|
|
2263
|
+
}
|
|
2063
2264
|
else if (typeof whiteBalanceOption === "object" && whiteBalanceOption !== null && "kelvin" in whiteBalanceOption) {
|
|
2064
2265
|
const wb = allowKeys(whiteBalanceOption, "output.whiteBalance", ["kelvin", "tint"]);
|
|
2065
2266
|
const intent = createColorTemperatureWhiteBalanceIntent({ intentId: "facade-wb-cct", colorTemperatureKelvin: positive(wb.kelvin, "output.whiteBalance.kelvin"),
|
|
@@ -2080,16 +2281,29 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2080
2281
|
const intentOf = (state) => (state ? createCaptureWhiteBalanceIntent({ state }) : null);
|
|
2081
2282
|
const rendering = { schemaVersion: "0.1.0", profileId: "facade-sdr", profileVersion: "1", renderingExposureEv: 0, toneCurve,
|
|
2082
2283
|
...(outputOptions.toneCurveParameters === undefined ? {} : { toneCurveParameters: outputOptions.toneCurveParameters }),
|
|
2083
|
-
gamutHandling
|
|
2284
|
+
gamutHandling, outputDynamicRange: "sdr", transferFunction: "srgb", bitDepth: format === "srgb-16" || (format === "png" && !wideGamut && pngBitDepth === 16) ? 16 : 8, rounding: "nearest-ties-up", dither: "none" };
|
|
2084
2285
|
if (!linearPath)
|
|
2085
|
-
assume("output.rendering", "model", { renderingExposureEv: 0, gamutHandling
|
|
2286
|
+
assume("output.rendering", "model", { renderingExposureEv: 0, gamutHandling }, exposureGain === 1 ? "Developed linear sRGB/D65 with the RAW white level mapped to display white, no exposure push, out-of-gamut components clipped." :
|
|
2086
2287
|
"Developed linear sRGB/D65 with the RAW white level mapped to display white, no further exposure push after development (output.exposureCompensationStops acts on the RAW), out-of-gamut components clipped.");
|
|
2087
2288
|
else
|
|
2088
2289
|
assume("output.rendering", "model", { renderingExposureEv: 0, gamutHandling: "clip-components-in-target-gamut", sdrRendererUsed: false }, "The sRGB SDR renderer is not used: the linear development (RAW white level = relative 1.0, " + (exposureGain === 1 ? "no exposure push" : "after the development exposure push on the RAW") + ") is converted to the target encoding; request.development.rendering is validated but only its exposure and tone curve (identity) apply.");
|
|
2089
2290
|
// Rendering choices are validated before any radiance work, so a bad tone curve fails fast.
|
|
2090
2291
|
parseSdrRenderingProfile(rendering);
|
|
2091
|
-
if (highlightMode !== "clip" && highlightMode !== "reconstruct")
|
|
2092
|
-
fail("output.highlightMode must be clip or reconstruct.");
|
|
2292
|
+
if (highlightMode !== "clip" && highlightMode !== "reconstruct" && highlightMode !== "reconstruct-neighbors")
|
|
2293
|
+
fail("output.highlightMode must be clip, reconstruct or reconstruct-neighbors.");
|
|
2294
|
+
// #602: the neighbor reconstruction reads whole clipped regions, so a region of the frame cannot render it alone.
|
|
2295
|
+
const neighborHighlights = highlightMode === "reconstruct-neighbors", developmentHighlightMode = neighborHighlights ? "clip" : highlightMode;
|
|
2296
|
+
if (neighborHighlights && outputOptions.region !== undefined)
|
|
2297
|
+
fail("output.highlightMode \"reconstruct-neighbors\" reads whole clipped regions; it is not available with output.region.");
|
|
2298
|
+
const neighborFast = fastTop;
|
|
2299
|
+
// #602: loaded on demand (the Quality kit, #594), only for a request that asks for it.
|
|
2300
|
+
const neighborModule = neighborHighlights ? (await qualityKit.load()).neighborHighlights : null;
|
|
2301
|
+
const { NEIGHBOR_HIGHLIGHT_RING, NEIGHBOR_HIGHLIGHT_THRESHOLD } = neighborModule ?? { NEIGHBOR_HIGHLIGHT_RING: 0, NEIGHBOR_HIGHLIGHT_THRESHOLD: 0 };
|
|
2302
|
+
if (neighborHighlights && !displayView)
|
|
2303
|
+
assume("output.highlightMode.reconstructNeighbors", "model", { method: neighborFast ? "ring-average" : "harmonic-interpolation", threshold: NEIGHBOR_HIGHLIGHT_THRESHOLD,
|
|
2304
|
+
ringPixels: NEIGHBOR_HIGHLIGHT_RING, fast: neighborFast, scientificStatus: "estimate" }, "Clipped highlights (a channel at " + NEIGHBOR_HIGHLIGHT_THRESHOLD + " of white or above) are rebuilt from the color ratios of the unclipped pixels around each clipped region: " +
|
|
2305
|
+
(neighborFast ? "the mean ratios of the unclipped pixels within " + NEIGHBOR_HIGHLIGHT_RING + " pixels of the region (fast)" : "their harmonic (Laplace) interpolation inward from the region's unclipped neighbors (full)") +
|
|
2306
|
+
"; each clipped channel becomes the larger of its recorded value and the ratio times the scale of the pixel's unclipped channels. An estimate of the clipped signal, not a recovery; the values are not clipped at white before the tone curve.");
|
|
2093
2307
|
// Frame profiles
|
|
2094
2308
|
const evidence = FACADE_EVIDENCE;
|
|
2095
2309
|
const nativeRaster = { pixelWidth: nativeWidth, pixelHeight: nativeHeight };
|
|
@@ -2101,7 +2315,13 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2101
2315
|
const exposureWindow = rollingSchedule ? createRollingShutterCaptureExposureSchedule(rollingSchedule) :
|
|
2102
2316
|
{ shutterMechanism: "electronic", nominalExposureDurationSeconds: { value: shutterSeconds, unit: "s", evidence },
|
|
2103
2317
|
opening: { kind: "simultaneous" }, closing: { kind: "simultaneous" } };
|
|
2104
|
-
|
|
2318
|
+
// #534: an exposure build-up render sees each native RAW input it renders (to attach its sensor stage).
|
|
2319
|
+
const buildRawInput = (intent) => {
|
|
2320
|
+
const input = buildRawInputFor(intent, geometry, captureModeProfile, bindingProfile, nativeWidth * nativeHeight);
|
|
2321
|
+
if (run === "capture")
|
|
2322
|
+
viewOption?.(input);
|
|
2323
|
+
return input;
|
|
2324
|
+
};
|
|
2105
2325
|
const buildRawInputFor = (intent, rasterGeometry, modeProfile, binding, sites) => ({
|
|
2106
2326
|
exposure: { captureId: IDS.capture, sceneStateId: IDS.sceneState, sceneTimeSeconds: 0, geometry: rasterGeometry,
|
|
2107
2327
|
exposure: { focalLengthMm, aperture: fNumber, shutterSeconds, iso: resolvedIso }, focus: { kind: "finite", distanceM: focusDistanceM },
|
|
@@ -2120,7 +2340,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2120
2340
|
const eqeSamples = (channel) => {
|
|
2121
2341
|
const out = [];
|
|
2122
2342
|
for (let nm = range.minimum; nm <= range.maximum + 1e-9; nm += eqeSpacing)
|
|
2123
|
-
out.push({ nm, value: EQE_SCALE[channel] * estimateCie1931ColorMatchingFunctions(nm)[CMF_KEY[channel]] * 555 / nm *
|
|
2343
|
+
out.push({ nm, value: EQE_SCALE[channel] * estimateCie1931ColorMatchingFunctions(nm)[CMF_KEY[channel]] * 555 / nm * qeScale });
|
|
2124
2344
|
return out;
|
|
2125
2345
|
};
|
|
2126
2346
|
const operatingConditions = { temperatureC: 25, incidenceAngleDegreesFromNormal: 0, polarization: "unpolarized" };
|
|
@@ -2218,7 +2438,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2218
2438
|
apertureFNumber: fNumber, signedDefocusImagePlaneMicrometers: 0 }, psfWavelengthBasis: { value: "vacuum", evidence },
|
|
2219
2439
|
spatialModel: { kind: "destination-local-shift-invariant-approximation", evidence, limitation: "Diffraction-limited Airy PSF, the same at every field point." } } };
|
|
2220
2440
|
// Requests with the same profile input and PSF fields share their site diffraction kernels (optics/site-diffraction-kernel.ts).
|
|
2221
|
-
registerDiffractionPsfKey(diffractionPsf.configuration.psf, profileInput);
|
|
2441
|
+
loadedKit(qualityKit).registerDiffractionPsfKey(diffractionPsf.configuration.psf, profileInput);
|
|
2222
2442
|
assume("lens.diffraction", "model", { model: diffraction.model, aperture: "circular-aperture", fNumber, workingFNumber: diffraction.workingFNumber,
|
|
2223
2443
|
kernelRadiusSamples, samplePitchMicrometers: pitch, maximumTruncatedEnergyFraction: diffraction.maximumTruncatedEnergyFraction,
|
|
2224
2444
|
truncatedEnergyByWavelength: diffraction.wavelengths.map((w) => ({ wavelengthNm: w.wavelengthNm, truncatedEnergyFraction: w.truncatedEnergyFraction })),
|
|
@@ -2236,6 +2456,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2236
2456
|
// with the disc, radius R_diffraction + R_defocus (built on first use, kept for the request). Object space, or image space (#548).
|
|
2237
2457
|
let defocusSiteKernels;
|
|
2238
2458
|
if (defocusKernel && defocusKernel.radius > DEFOCUS_DIRECT_KERNEL_MAXIMUM_RADIUS_SITES) {
|
|
2459
|
+
// #594: the run lattice of a disc above the direct radius is in the Quality kit.
|
|
2460
|
+
await qualityKit.load();
|
|
2239
2461
|
// #564: above the direct radius the disc is convolved by runs (site-kernel-runs.ts) and each node's diffraction kernel applied
|
|
2240
2462
|
// after it, instead of one combined kernel per node: the same sums regrouped. `at` is never read on this path.
|
|
2241
2463
|
const runs = siteKernelRuns(defocusKernel.weights, defocusKernel.radius), diffractionRadius = diffraction ? diffraction.kernelRadiusSamples : 0;
|
|
@@ -2247,7 +2469,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2247
2469
|
if (!kernel) {
|
|
2248
2470
|
if (!profileWavelengths.includes(wavelengthNm))
|
|
2249
2471
|
fail("simulateCapture: a spectral node has no diffraction kernel.");
|
|
2250
|
-
kernel = siteDiffractionKernel(psfConfiguration, wavelengthNm, pitch, diffractionRadius, "simulateCapture: diffraction kernels must sample the site pitch exactly.");
|
|
2472
|
+
kernel = loadedKit(qualityKit).siteDiffractionKernel(psfConfiguration, wavelengthNm, pitch, diffractionRadius, "simulateCapture: diffraction kernels must sample the site pitch exactly.");
|
|
2251
2473
|
diffractionKernels.set(wavelengthNm, kernel);
|
|
2252
2474
|
}
|
|
2253
2475
|
return kernel;
|
|
@@ -2266,7 +2488,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2266
2488
|
if (!kernel) {
|
|
2267
2489
|
if (!profileWavelengths.includes(wavelengthNm))
|
|
2268
2490
|
fail("simulateCapture: a spectral node has no diffraction kernel.");
|
|
2269
|
-
kernel = convolveSiteKernels(siteDiffractionKernel(psfConfiguration, wavelengthNm, pitch, diffractionRadius, "simulateCapture: diffraction kernels must sample the site pitch exactly."), diffractionRadius, defocusKernel.weights, defocusKernel.radius);
|
|
2491
|
+
kernel = convolveSiteKernels(loadedKit(qualityKit).siteDiffractionKernel(psfConfiguration, wavelengthNm, pitch, diffractionRadius, "simulateCapture: diffraction kernels must sample the site pitch exactly."), diffractionRadius, defocusKernel.weights, defocusKernel.radius);
|
|
2270
2492
|
combined.set(wavelengthNm, kernel);
|
|
2271
2493
|
}
|
|
2272
2494
|
return kernel;
|
|
@@ -2341,10 +2563,10 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2341
2563
|
let diffractionMs = 0, defocusMs = 0, demosaicMs, encodeMs;
|
|
2342
2564
|
const diffractedProvider = (window) =>
|
|
2343
2565
|
// #548: the disc (or diffraction ⊛ disc) in image space, on the sensor's site lattice, through the mapping, field throughput and rotation.
|
|
2344
|
-
defocusSiteKernels && defocusGeometry && defocusImageSpace ? createDefocusImageSpaceProvider({ base:
|
|
2566
|
+
defocusSiteKernels && defocusGeometry && defocusImageSpace ? loadedKit(qualityKit).createDefocusImageSpaceProvider({ base: sceneProvider, geometry: defocusGeometry, siteKernels: defocusSiteKernels.at,
|
|
2345
2567
|
...(defocusSiteKernels.large ? { largeKernel: defocusSiteKernels.large } : {}),
|
|
2346
2568
|
wavelengthsNm: defocusSiteKernels.wavelengthsNm, kernelRadius: defocusSiteKernels.radius, nativeWidth, nativeHeight, sitePitchMicrometers: pitch, distanceM: subjectDistanceM,
|
|
2347
|
-
metresPerFrameUnit, queryOffsetsSites, timesPerBand: temporalSampleCount, onConvolution: (ms) => { defocusMs += ms; }, ...(window ? { window } : {}),
|
|
2569
|
+
metresPerFrameUnit, queryOffsetsSites, timesPerBand: temporalSampleCount, onConvolution: (ms) => { defocusMs += ms; }, ...(window ? { window } : {}), ...(subject ? { timeDependentScene: true } : {}),
|
|
2348
2570
|
...(bokehZones ? { fieldZones: bokehZones.zones } : {}) }) :
|
|
2349
2571
|
defocusSiteKernels ? createDiffractedPlanarProvider({ base: provider, siteKernels: defocusSiteKernels.at, ...(defocusSiteKernels.large ? { largeKernel: defocusSiteKernels.large } : {}),
|
|
2350
2572
|
wavelengthsNm: defocusSiteKernels.wavelengthsNm, kernelRadius: defocusSiteKernels.radius, nativeWidth, nativeHeight, outputWidth: width,
|
|
@@ -2358,11 +2580,12 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2358
2580
|
...(window ? { window } : {}),
|
|
2359
2581
|
// The environment queries each site at each aperture node minus each split offset (sites, +x right, +y down).
|
|
2360
2582
|
...(antiAliasing || boxSamples > 1 ? { queryOffsetsSites } : {}) }) :
|
|
2361
|
-
// #565: the PSF quadrature reads each tap's source at its lattice point (a unit kernel on #548's lattice), so ties resolve as on the lattice.
|
|
2362
|
-
diffraction && latticeTaps ? createDefocusImageSpaceProvider({ base:
|
|
2363
|
-
kernelRadius: 0, wavelengthsNm: diffraction.profile.axes.wavelengthNm, nativeWidth, nativeHeight, sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
2364
|
-
queryOffsetsSites: [-1, 0, 1].flatMap((k) => queryOffsetsSites.map((o) => ({ x: o.x +
|
|
2365
|
-
timesPerBand: temporalSampleCount, ...(window ? { window } : {})
|
|
2583
|
+
// #565, #588: the PSF quadrature reads each tap's source at its lattice point (a unit kernel on #548's lattice), so ties resolve as on the lattice.
|
|
2584
|
+
(diffraction || catalogueTaps !== undefined) && latticeTaps ? loadedKit(qualityKit).createDefocusImageSpaceProvider({ base: sceneProvider, geometry: lensGeometry, siteKernels: () => UNIT_SITE_KERNEL,
|
|
2585
|
+
kernelRadius: 0, wavelengthsNm: diffraction ? diffraction.profile.axes.wavelengthNm : quadratureWavelengths(), nativeWidth, nativeHeight, sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
2586
|
+
queryOffsetsSites: [-1, 0, 1].flatMap((k) => { const r = k * (diffraction ? diffraction.kernelRadiusSamples : catalogueKernelRadius); return queryOffsetsSites.map((o) => ({ x: o.x + r, y: o.y + r })); }),
|
|
2587
|
+
timesPerBand: temporalSampleCount, zeroBeyondHorizon: catalogue?.kind === "fisheye", ...(window ? { window } : {}),
|
|
2588
|
+
...(subject ? { timeDependentScene: true } : {}) }) : sceneProvider;
|
|
2366
2589
|
// Built on first use: a capture that takes the tabulated path (#394 step 2) never queries it, and its kernels cost a setup.
|
|
2367
2590
|
let rawProviderBuilt;
|
|
2368
2591
|
const rawProvider = () => (rawProviderBuilt ??= diffractedProvider(regionNative));
|
|
@@ -2445,13 +2668,10 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2445
2668
|
viewLensKit.createDisplayViewLensPsf(lensPsf.configuration.psf, catalogueKernelRadius, nativeWidth, nativeHeight, pitch, catalogueFastRequested ? undefined : Math.max(nativeWidth, nativeHeight)) : undefined;
|
|
2446
2669
|
if (displayViewLensPsf && displayViewLensPsf.zoneSites > 1)
|
|
2447
2670
|
markFast("lens.catalogue");
|
|
2448
|
-
const viewAhdFast = ahd && resolveFast(demosaicFast, fastTop);
|
|
2449
|
-
if (displayView && viewAhdFast)
|
|
2450
|
-
markFast("output.demosaic.ahd");
|
|
2451
2671
|
if (displayViewLensPsf)
|
|
2452
2672
|
assume("view.lensPsf", "model", { method: "piecewise-constant-field-zones", zoneSites: displayViewLensPsf.zoneSites, kernelRadiusSites: catalogueKernelRadius, scientificStatus: "approximation" }, "Each site takes the lens PSF at its field zone's centre, where the capture resolves it at each query point (docs/DISPLAY_VIEW.md, \"Catalogue lenses\").");
|
|
2453
2673
|
const lensSignal = viewLensKit ? viewLensKit.prepareDisplayViewLensSignal({ geometry: lensGeometry,
|
|
2454
|
-
times: rotating ? Array.from({ length: temporalSampleCount }, (_, i) => (i + 0.5) * (shutterSeconds / temporalSampleCount)) : [0], distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
2674
|
+
times: rotating || subject ? Array.from({ length: temporalSampleCount }, (_, i) => (i + 0.5) * (shutterSeconds / temporalSampleCount)) : [0], distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
2455
2675
|
pitch, imagingArea, boxSamples, spots: antiAliasing ? antiAliasing.components : [{ offsetMicrometers: { x: 0, y: 0 } }], psf: displayViewLensPsf, zeroBeyondHorizon: catalogue?.kind === "fisheye" }, nativeWidth, nativeHeight, (diffraction ? diffraction.kernelRadiusSamples : 0) + (displayViewDefocus ? (bokehAxisKernel?.radius ?? defocusDiscKernelRadius(displayViewDefocus.diameterSites)) : 0) + catalogueKernelRadius +
|
|
2456
2676
|
Math.max(0, ...queryOffsetsSites.map((o) => Math.max(Math.abs(o.x), Math.abs(o.y)))) - 0.5, catalogue ? "lens.catalogue" : imageSpaceOptions.join(", ")) : undefined;
|
|
2457
2677
|
// #535: the green channel's expected charge from an 18 % Lambertian grey patch (radiance 0.18·E·S(λ)/π) next to the axis, and the sensor report;
|
|
@@ -2459,11 +2679,15 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2459
2679
|
let greyElectrons = fastTop ? undefined : 0;
|
|
2460
2680
|
if (!idealReference && greyElectrons !== undefined)
|
|
2461
2681
|
for (const [nm, a] of axisChannelResponse(displayView ? "simulateDisplayView" : "simulateCapture").electronsPerRadiance.green)
|
|
2462
|
-
greyElectrons += a * GREY_CARD_REFLECTANCE *
|
|
2463
|
-
|
|
2682
|
+
greyElectrons += mixed ? a * GREY_CARD_REFLECTANCE * mixed.irradianceAt(0, 0, 0, 0, nm) / Math.PI :
|
|
2683
|
+
a * GREY_CARD_REFLECTANCE * irradianceScale * illuminant.shape.evaluate(nm) / Math.PI;
|
|
2684
|
+
const sensor = sensorReport(regimeByChannel.green.readoutProfile, regimeByChannel.green.regimeId, capacityElectrons, greyElectrons, DARK_CURRENT_ELECTRONS_PER_SECOND * shutterSeconds, { pixelPitchMicrometers: pitch, peakQuantumEfficiency: 0.5 * qeScale,
|
|
2464
2685
|
readoutSeconds: characteristics?.readoutSeconds ?? body.rollingReadoutTiming?.profile.spatialSamplingSkewSeconds?.value ?? null }, fixedPattern);
|
|
2686
|
+
// #595: the native photosite's figures (their own module, so a bundler can split them off the default path).
|
|
2687
|
+
sensor.native = nativeSensorReport(sensor, regimeByChannel.green.regimeId, greyElectrons, DARK_CURRENT_ELECTRONS_PER_SECOND * shutterSeconds, nativeSensor, fixedPattern);
|
|
2465
2688
|
if (displayView)
|
|
2466
|
-
return await renderView({ sensor, viewFast: {
|
|
2689
|
+
return await renderView({ sensor, viewFast: { uniformSampling: fastTop, rendering: fastTop }, gamutHandling, lens: lensSignal, lensPsf: displayViewLensPsf, movingScene, ahd, fastReport, antiAliasing, aspect, assume, assumptions, axisChannelResponse, boxSamples, buildProvider, callerScene, chart, materialLayers, chartReport, chartSize, diffraction, defocus: displayViewDefocus, diffractionPsf, fNumber, focalLengthMm, focusDistanceM, height, highlightMode, exposureGain, illuminanceLux, illuminant, illuminantOption, imagingArea, intentOf, metresPerFrameUnit, noiseSeed, outputOptions, parsedView, pitch, preset, provider, regimeByChannel, resolvedIso, isoReport, standardExposureDeviationStops: exposureDeviationStops, standardExposureIlluminanceLux: exposureIlluminanceLux, greyCardDeviationStops: exposureGreyCardDeviationStops, metering, sceneOption, scenePreset, shutterSeconds, started, subjectDistanceM, toneCurve, whiteBalanceOption, whiteBalanceState, width, checkAbort, margin, mode, nativeHeight, nativeWidth, sourceSha256, yieldControl, kelvinWhiteBalanceInput,
|
|
2690
|
+
mixedIllumination: mixedKit && lightKit });
|
|
2467
2691
|
// Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
|
|
2468
2692
|
if (idealReference) {
|
|
2469
2693
|
const referenceStarted = clockMs();
|
|
@@ -2472,19 +2696,22 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2472
2696
|
if (needsAuto)
|
|
2473
2697
|
fail("simulateIdealReference cannot reproduce output.whiteBalance \"auto\": the capture measures it from its noisy RAW. Use \"scene-illuminant\" or a fixed white balance.");
|
|
2474
2698
|
const { electronsPerRadiance, wavelengths, relativePerElectron } = axisChannelResponse("simulateIdealReference");
|
|
2475
|
-
// 3.
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
};
|
|
2699
|
+
// 3. Each sample's signals, integrated from its spectral radiance at the nodes against each channel's response when it is read
|
|
2700
|
+
// (2.21.0): chart materials keep their spectra (and the fixed-size chart's surround), caller functions are read per sample.
|
|
2701
|
+
const responses = CHANNELS.map((c) => [[...electronsPerRadiance[c]].map(([nm, a]) => [wavelengths.indexOf(nm), a]), relativePerElectron[c]]);
|
|
2702
|
+
const signalOf = (spectrum) => responses.map(([terms, scale]) => { let e = 0; for (const [i, a] of terms)
|
|
2703
|
+
e += a * spectrum[i]; return e * scale; });
|
|
2481
2704
|
const materialCount = chart ? chart.materials.length : callerScene?.kind === "material-function" ? callerScene.materials.length : 0;
|
|
2482
|
-
const
|
|
2705
|
+
const spectra = provider.chartMaterialAt ? Array.from({ length: materialCount + (chartSize ? 1 : 0) }, (_, m) => wavelengths.map((nm) => provider.materialRadiance(m, nm))) : undefined;
|
|
2483
2706
|
// 4. Pixels: the native sites of the output (or region) pixels, each centre-sampled or box-integrated like the capture.
|
|
2484
2707
|
const x0 = (region ? region.x : 0) + margin, y0 = (region ? region.y : 0) + margin, n = boxSamples, unit = 2 / width;
|
|
2485
2708
|
const referenceIntent = intentOf(whiteBalanceState), referenceGains = referenceIntent ? [referenceIntent.channelGains.red, referenceIntent.channelGains.green, referenceIntent.channelGains.blue] : null;
|
|
2486
|
-
const
|
|
2709
|
+
const realDevelopment = prepareSensorColorDevelopment(CAMERA_TO_XYZ, referenceGains, developmentHighlightMode), [rg0, rg1, rg2] = realDevelopment.appliedGains;
|
|
2710
|
+
// #602: the neighbor reconstruction develops to the white balance first, rebuilds the whole image, then finishes the colour.
|
|
2711
|
+
const development = neighborHighlights ? { ...realDevelopment, developInto(c0, c1, c2, out, offset) { out[offset] = c0 * rg0; out[offset + 1] = c1 * rg1; out[offset + 2] = c2 * rg2; } } : realDevelopment;
|
|
2487
2712
|
const rgb = new Float64Array(outputWidth * outputHeight * 3), counts = { whiteBalanceClippedSampleCount: 0, reconstructedPixelCount: 0 }, sum = [0, 0, 0];
|
|
2713
|
+
// A sample that reads the previous sample's material reuses that sample's integral (nothing is kept per material).
|
|
2714
|
+
let last = -1, s = [];
|
|
2488
2715
|
for (let py = 0; py < outputHeight; py++) {
|
|
2489
2716
|
for (let px = 0; px < outputWidth; px++) {
|
|
2490
2717
|
sum[0] = 0;
|
|
@@ -2492,8 +2719,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2492
2719
|
sum[2] = 0;
|
|
2493
2720
|
for (let l = 0; l < n; l++)
|
|
2494
2721
|
for (let k = 0; k < n; k++) {
|
|
2495
|
-
const u = (x0 + px + (k + 0.5) / n - nativeWidth / 2) * unit, v = -(y0 + py + (l + 0.5) / n - nativeHeight / 2) * unit;
|
|
2496
|
-
|
|
2722
|
+
const u = (x0 + px + (k + 0.5) / n - nativeWidth / 2) * unit, v = -(y0 + py + (l + 0.5) / n - nativeHeight / 2) * unit, m = spectra ? provider.chartMaterialAt(u, v) : -1;
|
|
2723
|
+
if (m < 0 || m !== last) {
|
|
2724
|
+
s = signalOf(m < 0 ? wavelengths.map((nm) => provider.radianceAt(u, v, nm)) : spectra[m]);
|
|
2725
|
+
last = m;
|
|
2726
|
+
}
|
|
2497
2727
|
sum[0] += s[0];
|
|
2498
2728
|
sum[1] += s[1];
|
|
2499
2729
|
sum[2] += s[2];
|
|
@@ -2508,6 +2738,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2508
2738
|
checkAbort();
|
|
2509
2739
|
}
|
|
2510
2740
|
}
|
|
2741
|
+
if (neighborHighlights) {
|
|
2742
|
+
neighborModule.reconstructHighlightsFromNeighbors(rgb, outputWidth, outputHeight, realDevelopment.appliedGains, neighborFast);
|
|
2743
|
+
for (let i = 0; i < outputWidth * outputHeight; i++)
|
|
2744
|
+
realDevelopment.developWhiteBalancedInto(rgb[3 * i], rgb[3 * i + 1], rgb[3 * i + 2], rgb, 3 * i);
|
|
2745
|
+
}
|
|
2511
2746
|
let samples;
|
|
2512
2747
|
if (!linearPath) {
|
|
2513
2748
|
const maximum = 2 ** rendering.bitDepth - 1, tone = prepareSdrPixelTone(rendering), scratch = new Float64Array(3);
|
|
@@ -2528,7 +2763,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2528
2763
|
const referenceMs = clockMs() - referenceStarted;
|
|
2529
2764
|
assume("reference.kind", "model", "ideal-reference", "An ideal reference, not a capture: the same scene, framing, chart size, output size, region and pixel grid as the capture request, with no camera effects.");
|
|
2530
2765
|
assume("reference.excludes", "model", ["lens PSF, diffraction, defocus and pupil sampling", "projection and distortion (ideal rectilinear mapping)", "vignetting", "anti-aliasing filter",
|
|
2531
|
-
"colour filter mosaic and demosaicing (every pixel has all three channels)", "photon, dark and read noise", "dark signal", "ADC rounding and non-linearity", "camera motion and rolling shutter"], "None of these is applied; each pixel's channels come from the scene radiance at the pixel alone.");
|
|
2766
|
+
"colour filter mosaic and demosaicing (every pixel has all three channels)", "photon, dark and read noise", "dark signal", "ADC rounding and non-linearity", "camera motion and rolling shutter", ...(subject ? ["subject motion"] : [])], "None of these is applied; each pixel's channels come from the scene radiance at the pixel alone.");
|
|
2532
2767
|
assume("reference.exposure", "model", { convention: "exposure-matched", fNumber, shutterSeconds, iso: resolvedIso, illuminanceLux,
|
|
2533
2768
|
normalizedSignalPerElectron: { ...relativePerElectron } }, "Exposure-matched: each channel is the expected (noise-free) signal the capture's own sensor would record at the resolved exposure (f-number, shutter, ISO, illuminance and lens transmission; the facade's environment chain at the optical axis), " +
|
|
2534
2769
|
"normalized like the RAW development ((code − black)/(white − black) = e·d/(G·(W − B)) for a linear readout), clipped at white, then developed with the capture's white balance, camera → XYZ → sRGB matrices, highlight mode, tone curve and encoding. " +
|
|
@@ -2538,7 +2773,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2538
2773
|
return Object.freeze({ kind: "ideal-reference", label: "Ideal reference (no camera effects); not a capture.", facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, engineApiVersion: ENGINE_API_VERSION,
|
|
2539
2774
|
image: { width: outputWidth, height: outputHeight, format, samples, ...(png ? { png, colorEncoding: colorEncoding } : {}) },
|
|
2540
2775
|
exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds), ...isoReport },
|
|
2541
|
-
scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: chart
|
|
2776
|
+
scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: (chart ?? callerScene).parameters ?? {},
|
|
2542
2777
|
illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
|
|
2543
2778
|
framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },
|
|
2544
2779
|
...(region && regionNative ? { region: { ...region, frameWidth: width, frameHeight: height } } : {}),
|
|
@@ -2579,24 +2814,26 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2579
2814
|
evaluateRadiance: rawProvider().evaluateRadiance, ...(pupil ? { evaluateApertureRadiance: provider.evaluateApertureRadiance } : {}), yieldControl });
|
|
2580
2815
|
// #394 step 2: an engine-built planar scene (chart preset or caller material scene; still, rectilinear, no distortion, vignetting,
|
|
2581
2816
|
// pupil rays or caller PSF) takes its expected electrons from the tabulated signal, verified per CFA phase against the chain.
|
|
2582
|
-
const tabulatedScene = chart !== undefined || callerScene?.kind === "material-function";
|
|
2817
|
+
const tabulatedScene = chart !== undefined || callerScene?.kind === "material-function" || callerScene?.kind === "image-plane";
|
|
2583
2818
|
// #391: a DNG from region parts takes the whole frame's RAW from the parts instead of rendering it.
|
|
2584
2819
|
// #571: simulateCaptureFromParts takes the RAW from the parts in the same way, then develops it as a capture does.
|
|
2585
2820
|
const dngParts = run === "dng-from-parts" || run === "develop-from-parts" ? viewOption : undefined, partsCaller = run === "dng-from-parts" ? "serializeSimulatedCaptureDngFromParts" : "simulateCaptureFromParts";
|
|
2586
2821
|
if (dngParts !== undefined && regionNative)
|
|
2587
2822
|
fail(partsCaller + " takes the whole frame: the options take no output.region.");
|
|
2588
2823
|
let tabulated;
|
|
2589
|
-
// #568: lens.catalogue's fast method:
|
|
2824
|
+
// #568: lens.catalogue's fast method: expected electrons integrated per site at use, with the PSF blended over a field grid (catalogue-lens-signal.ts), for a
|
|
2590
2825
|
// still camera, site-centre queries and a chart or material scene; verified per CFA phase against the exact chain within its tolerance.
|
|
2591
2826
|
let catalogueFastRun;
|
|
2592
2827
|
const catalogueFastEligible = catalogueFastRequested && catalogueTaps !== undefined && lensKit !== undefined && lensPsf !== undefined && dngParts === undefined && tabulatedScene &&
|
|
2593
|
-
!pupil && !rotating && boxSamples === 1 && !antiAliasing && distortion?.kind === "radial" && provider.chartMaterialAt !== undefined && provider.materialRadiance !== undefined;
|
|
2828
|
+
!pupil && !rotating && !subject && boxSamples === 1 && !antiAliasing && distortion?.kind === "radial" && provider.chartMaterialAt !== undefined && provider.materialRadiance !== undefined;
|
|
2594
2829
|
// The tabulated paths' channel response per spectral node and material tables (the fast catalogue path and the expected-signal path).
|
|
2830
|
+
// #550: under mixed lighting a point's radiance is not its material's, so the tables read the provider's radiance instead (materials undefined).
|
|
2595
2831
|
const tabulatedTables = () => {
|
|
2596
2832
|
const { electronsPerRadiance, wavelengths } = axisChannelResponse("simulateCapture");
|
|
2597
|
-
|
|
2833
|
+
// #581: an image-plane print has no materials (its spectral nodes are the expected signal's, `image`).
|
|
2834
|
+
const sceneMaterials = chart ? chart.materials : callerScene.materials ?? [], materialRadiance = provider.materialRadiance;
|
|
2598
2835
|
return { wavelengths, perNode: CHANNELS.map((c) => Float64Array.from(wavelengths, (nm) => electronsPerRadiance[c].get(nm) ?? 0)),
|
|
2599
|
-
materials: { materialAt: provider.chartMaterialAt, whiteRadiance: (nm) => irradianceScale * illuminant.shape.evaluate(nm) / Math.PI,
|
|
2836
|
+
materials: mixed ? undefined : { materialAt: provider.chartMaterialAt, whiteRadiance: (nm) => irradianceScale * illuminant.shape.evaluate(nm) / Math.PI,
|
|
2600
2837
|
list: [...sceneMaterials.map((m, index) => (m.kind === "neutral" ? { neutral: m.reflectance } : { radiance: (nm) => materialRadiance(index, nm) })),
|
|
2601
2838
|
...(chartSize ? [{ neutral: chartSize.surroundReflectance }] : [])] } };
|
|
2602
2839
|
};
|
|
@@ -2605,7 +2842,10 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2605
2842
|
const { signalInput } = buildExpectedSignalInput({ nativeWidth, nativeHeight, width, wavelengths, perNode, apertureNodes: apertureNodeOffsets(1), boxSamples: 1, antiAliasing: undefined, pitch,
|
|
2606
2843
|
materials, radianceAt: provider.radianceAt, imagingArea, focalLengthMm, focusDistanceM, subjectDistanceM, metresPerFrameUnit, caller: "simulateCapture" });
|
|
2607
2844
|
const radial = distortion.profile;
|
|
2608
|
-
|
|
2845
|
+
// #603: the grid follows the request's quality: 2.20.0's faster grid on a fast request (but for the measured lenses that keep the
|
|
2846
|
+
// full one), the full one on an exact request.
|
|
2847
|
+
const gridSubdivisions = lensKit.catalogueFastGridSubdivisions(catalogue.lensId, fastTop);
|
|
2848
|
+
const signal = lensKit.createCatalogueLensSignal({ nativeWidth, nativeHeight, pitch, psf: lensPsf.configuration.psf, wavelengthsNm: wavelengths, electronsPerRadiance: perNode, gridSubdivisions,
|
|
2609
2849
|
scene: signalInput.scene, channelAt: signalInput.channelAt,
|
|
2610
2850
|
distortion: { normalizationRadiusMm: radial.normalizationRadiusMm, coefficients: { k1: radial.coefficients.k1, k2: radial.coefficients.k2 ?? 0, k3: radial.coefficients.k3 ?? 0 } },
|
|
2611
2851
|
...(vignetting ? { vignetting } : {}), projection: projection === "fisheye-equisolid-angle" ? projection : "rectilinear", outsideImageCircleSeesNothing: catalogue.kind === "fisheye",
|
|
@@ -2613,23 +2853,24 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2613
2853
|
tabulated = await renderTabulatedNativeRaw({ input: environmentRaw, region: regionNative, siteInput, evaluateRadiance: rawProvider().evaluateRadiance,
|
|
2614
2854
|
electrons: signal, relativeTolerance: lensKit.CATALOGUE_FAST_VERIFICATION_TOLERANCE, onTile: rawTick, tileWidth, yieldControl: () => yieldControl(), checkAbort });
|
|
2615
2855
|
if (tabulated)
|
|
2616
|
-
catalogueFastRun = { ...signal.grid, verificationRelativeDifference: tabulated.verificationRelativeDifference };
|
|
2856
|
+
catalogueFastRun = { ...signal.grid, verificationRelativeDifference: tabulated.verificationRelativeDifference, subdivisions: gridSubdivisions };
|
|
2617
2857
|
}
|
|
2618
2858
|
// #553: a cat's eye varies the kernel over the field, which the expected-signal tables (one kernel per node) do not take.
|
|
2619
|
-
else if (dngParts === undefined && tabulatedScene && !pupil && !lensFieldMapping && !vignetting && !lensPsf && !rotating && !bokehZones && (!diffraction || diffractionObjectSpace) &&
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2859
|
+
else if (dngParts === undefined && tabulatedScene && !pupil && !lensFieldMapping && !vignetting && !lensPsf && !rotating && !subject && !bokehZones && (!diffraction || diffractionObjectSpace) &&
|
|
2860
|
+
(mixed || provider.materialRadiance || provider.imageSignalScene)) {
|
|
2861
|
+
// #581: an image-plane print takes the radiance path, its signals integrated per pixel from the reconstructed spectrum, instead of material tables.
|
|
2862
|
+
const { wavelengths, perNode, materials } = tabulatedTables(), tabulatedMaterials = provider.imageSignalScene ? undefined : materials;
|
|
2863
|
+
// The input both expected signals share (the scene, the responses, the aperture and the geometry).
|
|
2864
|
+
const shared = { nativeWidth, nativeHeight, width, wavelengths, perNode, apertureNodes: apertureNodeOffsets(boxSamples), boxSamples, antiAliasing, pitch, materials: tabulatedMaterials,
|
|
2865
|
+
imageSignalScene: provider.imageSignalScene, radianceAt: provider.radianceAt, imagingArea, focalLengthMm, focusDistanceM, subjectDistanceM, metresPerFrameUnit, caller: "simulateCapture" };
|
|
2866
|
+
const diffractionKernels = diffraction ? { radius: diffraction.kernelRadiusSamples, psf: diffractionPsf.configuration.psf, profileWavelengths: diffraction.profile.axes.wavelengthNm } : undefined;
|
|
2867
|
+
const { signalInput: builtSignalInput } = buildExpectedSignalInput({ ...shared, diffraction: defocusSiteKernels ? undefined : diffractionKernels });
|
|
2624
2868
|
// #492: with lens.defocus the table convolves the lattice radiance with each node's defocus (or combined) kernel, on the
|
|
2625
2869
|
// lattice formula, exactly as the object-space provider does.
|
|
2626
2870
|
let signalInput = builtSignalInput;
|
|
2627
2871
|
if (defocusSiteKernels?.large) {
|
|
2628
2872
|
// #564: the in-focus signal on the lattice formula (diffraction kernels, or a unit tap per node), then the disc by runs.
|
|
2629
|
-
signalInput = buildExpectedSignalInput({
|
|
2630
|
-
diffraction: diffraction ? { radius: diffraction.kernelRadiusSamples, psf: diffractionPsf.configuration.psf, profileWavelengths: diffraction.profile.axes.wavelengthNm } : undefined,
|
|
2631
|
-
largeDefocus: defocusSiteKernels.large.runs, materials: tabulatedMaterials,
|
|
2632
|
-
radianceAt: provider.radianceAt, imagingArea, focalLengthMm, focusDistanceM, subjectDistanceM, metresPerFrameUnit, caller: "simulateCapture" }).signalInput;
|
|
2873
|
+
signalInput = buildExpectedSignalInput({ ...shared, diffraction: diffractionKernels, largeDefocus: defocusSiteKernels.large.runs }).signalInput;
|
|
2633
2874
|
}
|
|
2634
2875
|
else if (defocusSiteKernels) {
|
|
2635
2876
|
const lattice = { ...builtSignalInput };
|
|
@@ -2658,7 +2899,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2658
2899
|
rawOutput = { raw: tabulated.raw, providerEvaluationCount: tabulated.providerEvaluationCount };
|
|
2659
2900
|
// The verification's relative difference itself is not reported: it is a last-bits quantity, and captures use the JavaScript
|
|
2660
2901
|
// engine's own Math functions, so it differs between engines while the results are the same.
|
|
2661
|
-
assume("raw.expectedSignal", "model", { method: "
|
|
2902
|
+
assume("raw.expectedSignal", "model", { method: "per-site-integrated-electrons", verificationSites: 4, tolerance: TABULATED_RELATIVE_TOLERANCE }, "Each native site's expected photo electrons are integrated per site when the site is read: the sum over the pixel-aperture nodes, anti-aliasing spots, diffraction taps and spectral nodes " +
|
|
2903
|
+
"of each tap's material spectrum against the site's channel response (the display view's expected signal), " +
|
|
2662
2904
|
"the same quantity the environment chain computes for this still, rectilinear, distortion-free planar scene, formed in another order, so it can differ in the last bits. The sensor stage (dark charge, Poisson draws, " +
|
|
2663
2905
|
"read noise, ADC) is the native one with each site's own seeds. One site of each CFA phase ran the full chain: its readout was reproduced bit for bit and its electrons agreed within the tolerance. " +
|
|
2664
2906
|
"diagnostics.providerEvaluationCount counts those sites' evaluator calls and the table's scene lookups.");
|
|
@@ -2726,6 +2968,15 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2726
2968
|
evidence, limitations: ["Diagonal camera→XYZ matrix of the idealized colorimetric (Luther–Ives) sensor; exact up to spectral quadrature."] };
|
|
2727
2969
|
const phaseProfiles = builtInDemosaic ? builtInDemosaic.phaseProfiles : bilinearPhaseProfiles();
|
|
2728
2970
|
const developmentWhiteBalance = whiteBalanceState ? "apply-resolved-sensor-gains" : "not-required";
|
|
2971
|
+
// #589: each channel's effective dispersion (its own response × the illuminant at the capture's spectral nodes) for the catalogue
|
|
2972
|
+
// lens's lateral-colour corrections: the developed ones and, in a DNG, the opcodes (#587), so both use the same channel scales.
|
|
2973
|
+
let channelDispersion;
|
|
2974
|
+
const captureChannelDispersion = () => {
|
|
2975
|
+
if (channelDispersion)
|
|
2976
|
+
return channelDispersion;
|
|
2977
|
+
const { electronsPerRadiance, wavelengths } = axisChannelResponse("simulateCapture");
|
|
2978
|
+
return channelDispersion = lensKit.channelEffectiveDispersion(wavelengths, CHANNELS.map((c) => wavelengths.map((nm) => electronsPerRadiance[c].get(nm) ?? 0)), wavelengths.map((nm) => illuminant.shape.evaluate(nm)));
|
|
2979
|
+
};
|
|
2729
2980
|
// DNG (2.0): the packed native RAW itself, before development takes ownership of it.
|
|
2730
2981
|
let dng;
|
|
2731
2982
|
if (format === "dng") {
|
|
@@ -2734,11 +2985,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2734
2985
|
return [channel, { conversionGainElectronsPerCode: resolved.regime.systemConversionGainElectronsPerCode.value / (resolved.regime.adc.digitalGain ?? 1),
|
|
2735
2986
|
readNoiseRmsElectrons: resolved.combinedReadNoiseRmsElectrons }];
|
|
2736
2987
|
}));
|
|
2737
|
-
const dngStarted = clockMs(), opcodes = dngOpcodeSelection && lensKit.createDngLensCorrectionOpcodes(lensKit.resolveGenericLens({ lensId: catalogue.lensId, activeImagingArea: catalogueFrame, fNumber }), dngOpcodeSelection, width, height, pitch / 1000,
|
|
2988
|
+
const dngStarted = clockMs(), opcodes = dngOpcodeSelection && lensKit.createDngLensCorrectionOpcodes(lensKit.resolveGenericLens({ lensId: catalogue.lensId, activeImagingArea: catalogueFrame, fNumber }), dngOpcodeSelection, width, height, pitch / 1000, captureChannelDispersion());
|
|
2738
2989
|
dng = await serializeNativeRawDng({ raw: rawOutput.raw, colorProfile, noise, ...(dngCapturedAtUtc === undefined ? {} : { capturedAtUtc: dngCapturedAtUtc }),
|
|
2739
2990
|
...(textMetadata ? { textMetadata } : {}), ...(opcodes ? { opcodeList3: opcodes.opcodeList3 } : {}) });
|
|
2740
2991
|
if (opcodes)
|
|
2741
|
-
assume("output.dng.lensCorrectionOpcodes", "model", { tag: 51022, opcodes: opcodes.opcodes }, "output.lensCorrections' profile as optional DNG opcodes, in order (docs/NATIVE_RAW_AND_PRINT.md, \"Lens-correction opcodes\"); the RAW is unchanged.");
|
|
2992
|
+
assume("output.dng.lensCorrectionOpcodes", "model", { tag: 51022, channelDispersion: captureChannelDispersion(), opcodes: opcodes.opcodes }, "output.lensCorrections' profile as optional DNG opcodes, in order (docs/NATIVE_RAW_AND_PRINT.md, \"Lens-correction opcodes\"); the RAW is unchanged.");
|
|
2742
2993
|
encodeMs = clockMs() - dngStarted;
|
|
2743
2994
|
assumeTextMetadata(TEXT_METADATA_LAYOUT.dng);
|
|
2744
2995
|
checkAbort();
|
|
@@ -2749,7 +3000,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2749
3000
|
return dng;
|
|
2750
3001
|
// Development
|
|
2751
3002
|
const development = { phaseProfiles, colorProfile,
|
|
2752
|
-
whiteBalance: developmentWhiteBalance, rendering, highlightMode, ...(mirrorBorder ? { borderHandling: "mirror" } : {}),
|
|
3003
|
+
whiteBalance: developmentWhiteBalance, rendering, highlightMode: developmentHighlightMode, ...(mirrorBorder ? { borderHandling: "mirror" } : {}),
|
|
2753
3004
|
...(exposureCompensationStops === 0 ? {} : { exposureCompensationStops }),
|
|
2754
3005
|
maximumRetainedPayloadBytes: developmentPlan.outputBytes + outputWidth * outputHeight * 3 * (rendering.bitDepth / 8) };
|
|
2755
3006
|
let developed;
|
|
@@ -2781,10 +3032,12 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2781
3032
|
// #570: the lens corrections after the diffraction correction (which acts on the sensor's own lattice), before the tone curve.
|
|
2782
3033
|
let cornerStops = null;
|
|
2783
3034
|
if (lensCorrection) {
|
|
3035
|
+
const channelDispersion = captureChannelDispersion();
|
|
3036
|
+
lensCorrection = lensKit.prepareDevelopedLensCorrection(catalogue, lensCorrectionSelection, width, height, pitch / 1000, channelDispersion);
|
|
2784
3037
|
const s = lensCorrectionSelection, c = lensCorrection, k = (channel) => c.scales[channel];
|
|
2785
3038
|
if (s.vignetting)
|
|
2786
|
-
cornerStops =
|
|
2787
|
-
assume("output.lensCorrections", "model", { profileId: "photivra-" + catalogue.lensId + ":corrections", ...s, k1: c.k1, channelScales: { red: k(0), green: k(1), blue: k(2) },
|
|
3039
|
+
cornerStops = catalogueCornerStops;
|
|
3040
|
+
assume("output.lensCorrections", "model", { profileId: "photivra-" + catalogue.lensId + ":corrections", ...s, k1: c.k1, channelDispersion, channelScales: { red: k(0), green: k(1), blue: k(2) },
|
|
2788
3041
|
cornerStops, cornerGain: cornerStops === null ? 1 : 2 ** cornerStops, magnification: 1 / c.fill, ...(imageRegion ? { developedRect: region } : {}), dng: "uncorrected" }, "The catalogue lens's correction profile (createGenericLensCorrectionProfile; an approximation) on the developed linear colour: pixel p reads channel c bilinearly at q(1 + k1|q|²/R²), q = a_c·p/magnification " +
|
|
2789
3042
|
"(the smallest magnification ≥ 1 that keeps every source in the frame), times det J/E at the source, so a flat field stays flat (E = (1 − bρ²)², 1/det J the distortion's area factor). " +
|
|
2790
3043
|
"Corners lose resolution, corner noise rises with the gain, axial colour stays. The DNG is not corrected.");
|
|
@@ -2842,8 +3095,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2842
3095
|
adjustedPixelCount: encoded.adjustedPixelCount, lightnessClippedPixelCount: encoded.lightnessClippedPixelCount };
|
|
2843
3096
|
return { raw: rawOutput.raw, integerSamples: encoded.samples, highlight, diagnostics: encoded.diagnostics };
|
|
2844
3097
|
};
|
|
2845
|
-
if (ahd || ((detailRequested || lensCorrection) && !linearPath)) {
|
|
2846
|
-
const linear = await developLinearSrgb({ ...development, raw: rawOutput.raw }, rowProgress("development"), ahd ? { yieldControl: () => yieldControl(), checkAbort, onDemosaicMs: (ms) => { demosaicMs = ms; } } : undefined);
|
|
3098
|
+
if (ahd || neighborHighlights || ((detailRequested || lensCorrection) && !linearPath)) {
|
|
3099
|
+
const linear = await developLinearSrgb({ ...development, raw: rawOutput.raw }, rowProgress("development"), ahd ? { yieldControl: () => yieldControl(), checkAbort, onDemosaicMs: (ms) => { demosaicMs = ms; } } : undefined, neighborModule ? { fast: neighborFast, module: neighborModule } : undefined);
|
|
2847
3100
|
const rgb = await correctLinear(linear.rgb);
|
|
2848
3101
|
if (!linearPath) {
|
|
2849
3102
|
let codes, counts;
|
|
@@ -2875,7 +3128,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2875
3128
|
}
|
|
2876
3129
|
else {
|
|
2877
3130
|
// Wide gamut: the native development operator up to linear sRGB/D65 (unclipped), then XYZ → target primaries.
|
|
2878
|
-
const linear = await developLinearSrgb({ ...development, raw: rawOutput.raw }, rowProgress("development"));
|
|
3131
|
+
const linear = await developLinearSrgb({ ...development, raw: rawOutput.raw }, rowProgress("development"), undefined, neighborModule ? { fast: neighborFast, module: neighborModule } : undefined);
|
|
2879
3132
|
{
|
|
2880
3133
|
developed = encodeLinear(await correctLinear(linear.rgb), linear.highlight);
|
|
2881
3134
|
if (perceptual) {
|
|
@@ -2932,10 +3185,10 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2932
3185
|
...(distortion ? ["lens.distortion"] : []), ...(vignetting ? ["lens.vignetting"] : []), ...(lensPsf ? ["lens.psf"] : []),
|
|
2933
3186
|
...(catalogue ? ["lens.catalogue"] : []),
|
|
2934
3187
|
...(diffraction ? ["lens.diffraction"] : []), ...(defocusSiteKernels || catalogueDefocusApplied ? ["lens.defocus"] : []), ...(antiAliasing ? ["sensor.antiAliasing"] : []), ...(boxAperture ? ["sensor.pixelAperture.box"] : []),
|
|
2935
|
-
...(temporalSampleCount > 1 ? ["sensor.temporalSampleCount"] : []), ...(rotating ? ["motion.rotation"] : []),
|
|
3188
|
+
...(temporalSampleCount > 1 ? ["sensor.temporalSampleCount"] : []), ...(rotating ? ["motion.rotation"] : []), ...(subject ? ["motion.subject"] : []),
|
|
2936
3189
|
...(rollingSchedule ? ["motion.shutter.rolling"] : []), ...(mirrorBorder ? ["output.borderHandling.mirror"] : []), ...(region ? ["output.region"] : []),
|
|
2937
3190
|
...(needsAuto ? ["output.whiteBalance.auto"] : []), ...(ahd ? ["output.demosaic.ahd"] : []),
|
|
2938
|
-
...(highlightMode === "reconstruct" ? ["output.highlightMode.reconstruct"] : []), ...(wideGamut ? ["output.colorEncoding.wide-gamut"] : []),
|
|
3191
|
+
...(highlightMode === "reconstruct" || neighborHighlights ? ["output.highlightMode.reconstruct"] : []), ...(wideGamut ? ["output.colorEncoding.wide-gamut"] : []),
|
|
2939
3192
|
...(perceptual ? ["output.gamutMapping.perceptual"] : []), ...(diffractionFilter ? ["output.diffractionCorrection"] : []),
|
|
2940
3193
|
...(sharpening ? ["output.sharpening"] : []), ...(lensCorrection ? ["output.lensCorrections"] : []), ...(format === "jpeg" || format === "png" || format === "dng" ? ["output.format." + format] : [])]);
|
|
2941
3194
|
// #552: the detail stages' contracts, in execution order inside the development.
|
|
@@ -2952,7 +3205,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2952
3205
|
exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds), ...isoReport,
|
|
2953
3206
|
metering, meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, standardExposureDeviationStops: exposureDeviationStops,
|
|
2954
3207
|
standardExposureIlluminanceLux: exposureIlluminanceLux, greyCardDeviationStops: exposureGreyCardDeviationStops, resolution },
|
|
2955
|
-
scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: chart
|
|
3208
|
+
scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: (chart ?? callerScene).parameters ?? {},
|
|
2956
3209
|
illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
|
|
2957
3210
|
framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },
|
|
2958
3211
|
diagnostics: { providerEvaluationCount, highlight: developed.highlight, rendering: developed.diagnostics, ...(gamutMappingDiagnostics ? { gamutMapping: gamutMappingDiagnostics } : {}),
|