@photivra/engine 2.15.0 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +102 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/THIRD_PARTY.md +38 -0
  6. package/dist/camera/lens-projection.d.ts +5 -0
  7. package/dist/camera/lens-projection.js +10 -0
  8. package/dist/camera/lens-projection.js.map +1 -1
  9. package/dist/capture/native-environment-lean.js +1 -1
  10. package/dist/capture/native-environment-lean.js.map +1 -1
  11. package/dist/capture/native-raw-development.d.ts +15 -0
  12. package/dist/capture/native-raw-development.js +12 -6
  13. package/dist/capture/native-raw-development.js.map +1 -1
  14. package/dist/capture/raw-development-kernel.d.ts +10 -3
  15. package/dist/capture/raw-development-kernel.js +25 -6
  16. package/dist/capture/raw-development-kernel.js.map +1 -1
  17. package/dist/core/internal-radiance-evaluator.d.ts +5 -2
  18. package/dist/core/internal-radiance-evaluator.js.map +1 -1
  19. package/dist/core/version.d.ts +1 -1
  20. package/dist/core/version.js +1 -1
  21. package/dist/core/version.js.map +1 -1
  22. package/dist/equipment/generic-lens-catalogue.d.ts +227 -0
  23. package/dist/equipment/generic-lens-catalogue.js +243 -0
  24. package/dist/equipment/generic-lens-catalogue.js.map +1 -0
  25. package/dist/facade/defocus-bokeh-zones.d.ts +33 -0
  26. package/dist/facade/defocus-bokeh-zones.js +53 -0
  27. package/dist/facade/defocus-bokeh-zones.js.map +1 -0
  28. package/dist/facade/defocus-image-space-provider.d.ts +118 -0
  29. package/dist/facade/defocus-image-space-provider.js +448 -0
  30. package/dist/facade/defocus-image-space-provider.js.map +1 -0
  31. package/dist/facade/diffraction-provider.d.ts +45 -0
  32. package/dist/facade/diffraction-provider.js +120 -14
  33. package/dist/facade/diffraction-provider.js.map +1 -1
  34. package/dist/facade/display-view-gpu-session.js +4 -0
  35. package/dist/facade/display-view-gpu-session.js.map +1 -1
  36. package/dist/facade/display-view-gpu.d.ts +10 -0
  37. package/dist/facade/display-view-gpu.js.map +1 -1
  38. package/dist/facade/display-view-lens-signal.d.ts +30 -0
  39. package/dist/facade/display-view-lens-signal.js +427 -0
  40. package/dist/facade/display-view-lens-signal.js.map +1 -0
  41. package/dist/facade/display-view.d.ts +21 -0
  42. package/dist/facade/display-view.js +66 -8
  43. package/dist/facade/display-view.js.map +1 -1
  44. package/dist/facade/expected-signal-setup.d.ts +7 -0
  45. package/dist/facade/expected-signal-setup.js +12 -2
  46. package/dist/facade/expected-signal-setup.js.map +1 -1
  47. package/dist/facade/expected-signal.d.ts +8 -0
  48. package/dist/facade/expected-signal.js +150 -0
  49. package/dist/facade/expected-signal.js.map +1 -1
  50. package/dist/facade/generic-lens-capture.d.ts +6 -0
  51. package/dist/facade/generic-lens-capture.js +8 -0
  52. package/dist/facade/generic-lens-capture.js.map +1 -0
  53. package/dist/facade/large-defocus-lattice.d.ts +39 -0
  54. package/dist/facade/large-defocus-lattice.js +64 -0
  55. package/dist/facade/large-defocus-lattice.js.map +1 -0
  56. package/dist/facade/scene-radiance.d.ts +5 -0
  57. package/dist/facade/scene-radiance.js +5 -2
  58. package/dist/facade/scene-radiance.js.map +1 -1
  59. package/dist/facade/simulate-capture.d.ts +148 -6
  60. package/dist/facade/simulate-capture.js +521 -88
  61. package/dist/facade/simulate-capture.js.map +1 -1
  62. package/dist/facade/simulate-display-view.js +29 -19
  63. package/dist/facade/simulate-display-view.js.map +1 -1
  64. package/dist/gpu/display-view-quality-gpu.d.ts +2 -9
  65. package/dist/gpu/display-view-quality-gpu.js +63 -13
  66. package/dist/gpu/display-view-quality-gpu.js.map +1 -1
  67. package/dist/gpu/display-view-speed-gpu.d.ts +2 -0
  68. package/dist/gpu/display-view-speed-gpu.js +47 -16
  69. package/dist/gpu/display-view-speed-gpu.js.map +1 -1
  70. package/dist/gpu/image-kernel-accelerator.js +3 -0
  71. package/dist/gpu/image-kernel-accelerator.js.map +1 -1
  72. package/dist/gpu/wgsl-display-view-quality-kernel.d.ts +4 -0
  73. package/dist/gpu/wgsl-display-view-quality-kernel.js +53 -2
  74. package/dist/gpu/wgsl-display-view-quality-kernel.js.map +1 -1
  75. package/dist/gpu/wgsl-display-view-speed-kernel.d.ts +15 -0
  76. package/dist/gpu/wgsl-display-view-speed-kernel.js +42 -1
  77. package/dist/gpu/wgsl-display-view-speed-kernel.js.map +1 -1
  78. package/dist/index.d.ts +5 -2
  79. package/dist/index.js +4 -1
  80. package/dist/index.js.map +1 -1
  81. package/dist/optics/defocus-bokeh-kernel.d.ts +207 -0
  82. package/dist/optics/defocus-bokeh-kernel.js +380 -0
  83. package/dist/optics/defocus-bokeh-kernel.js.map +1 -0
  84. package/dist/optics/defocus-disc-kernel.d.ts +44 -21
  85. package/dist/optics/defocus-disc-kernel.js +47 -25
  86. package/dist/optics/defocus-disc-kernel.js.map +1 -1
  87. package/dist/optics/generic-lens-psf.d.ts +68 -0
  88. package/dist/optics/generic-lens-psf.js +337 -0
  89. package/dist/optics/generic-lens-psf.js.map +1 -0
  90. package/dist/optics/illumination-vignetting.d.ts +6 -0
  91. package/dist/optics/illumination-vignetting.js +24 -7
  92. package/dist/optics/illumination-vignetting.js.map +1 -1
  93. package/dist/optics/sensor-environment-query.d.ts +37 -0
  94. package/dist/optics/sensor-environment-query.js +58 -19
  95. package/dist/optics/sensor-environment-query.js.map +1 -1
  96. package/dist/optics/site-kernel-runs.d.ts +77 -0
  97. package/dist/optics/site-kernel-runs.js +200 -0
  98. package/dist/optics/site-kernel-runs.js.map +1 -0
  99. package/dist/output/generic-lens-corrections.d.ts +24 -0
  100. package/dist/output/generic-lens-corrections.js +51 -0
  101. package/dist/output/generic-lens-corrections.js.map +1 -0
  102. package/dist/processing/development-detail.d.ts +132 -0
  103. package/dist/processing/development-detail.js +325 -0
  104. package/dist/processing/development-detail.js.map +1 -0
  105. package/docs/AGENT_GUIDE.md +1 -0
  106. package/docs/API_REFERENCE.md +54 -1
  107. package/docs/QUICK_START.md +444 -26
  108. package/docs/USAGE.md +71 -8
  109. package/docs/api/capture-native-raw-development.md +16 -0
  110. package/docs/api/core-version.md +1 -1
  111. package/docs/api/equipment-generic-lens-catalogue.md +281 -0
  112. package/docs/api/exports.json +162 -2
  113. package/docs/api/facade-simulate-capture.md +151 -5
  114. package/docs/api/optics-defocus-bokeh-kernel.md +145 -0
  115. package/docs/api/optics-defocus-disc-kernel.md +25 -17
  116. package/docs/api/optics-sensor-environment-query.md +6 -0
  117. package/docs/api/output-generic-lens-corrections.md +39 -0
  118. package/llms.txt +1 -0
  119. package/package.json +1 -1
@@ -0,0 +1,24 @@
1
+ import { type OpticalProfileState } from "../optics/profile-contract.js";
2
+ import { type GenericLensCorrectionProfile, type LensCorrectionChannel } from "./lens-corrections.js";
3
+ /**
4
+ * Effective wavelengths of the red, green and blue channels for the lateral-colour map, nm: a generic approximation of where
5
+ * a camera's channel responses peak, not a measured camera.
6
+ */
7
+ export declare const GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM: Readonly<Record<LensCorrectionChannel, number>>;
8
+ export interface CreateGenericLensCorrectionProfileInput {
9
+ /** A catalogue id (`GENERIC_LENS_IDS`); `state.lensId` must be the same id. */
10
+ lensId: string;
11
+ /** The sensor's active capture area, mm (the lens is scaled to it as in `resolveGenericLens`). */
12
+ activeImagingArea: {
13
+ widthMm: number;
14
+ heightMm: number;
15
+ };
16
+ /** The exact camera state the profile applies to: its focal length must be the lens's on this sensor, its aperture in range. */
17
+ state: OpticalProfileState;
18
+ }
19
+ /**
20
+ * Builds the catalogue lens's generic correction profile for one camera state: a radial distortion map (the lens's own
21
+ * r_d = r(1 + k1ρ²), read at each ideal destination point), per-channel lateral-colour scales 1 + L·D(λ_channel), and the
22
+ * relative-illumination gain at the state's aperture (strength 1). The result passes `parseGenericLensCorrectionProfile`.
23
+ */
24
+ export declare function createGenericLensCorrectionProfile(input: CreateGenericLensCorrectionProfileInput): GenericLensCorrectionProfile;
@@ -0,0 +1,51 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * The generic correction profile of a catalogue lens (#549), for the post-capture lens corrections
4
+ * (`resolveLensCorrectionPlan`, `calculateLensCorrectedCapture`): a distortion map, a lateral-colour map per channel and a
5
+ * peripheral-illumination gain, each from the lens's own character (`resolveGenericLens`). Consumer lenses rely on it more:
6
+ * their uncorrected distortion and vignetting are larger, so the same profile corrects more. Like every catalogue number it is
7
+ * a labelled approximation, bound to one exact camera state (`OpticalProfileState`).
8
+ */
9
+ import { InvalidScientificInputError } from "../core/validation.js";
10
+ import { requireInputKeys, requireInputObject } from "../core/record-validation.js";
11
+ import { parseOpticalProfileState } from "../optics/profile-contract.js";
12
+ import { GENERIC_LENS_EVIDENCE, genericLensDispersion, resolveGenericLens } from "../equipment/generic-lens-catalogue.js";
13
+ import { parseGenericLensCorrectionProfile } from "./lens-corrections.js";
14
+ /**
15
+ * Effective wavelengths of the red, green and blue channels for the lateral-colour map, nm: a generic approximation of where
16
+ * a camera's channel responses peak, not a measured camera.
17
+ */
18
+ export const GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM = /* @__PURE__ */ Object.freeze({ red: 610, green: 540, blue: 465 });
19
+ /**
20
+ * Builds the catalogue lens's generic correction profile for one camera state: a radial distortion map (the lens's own
21
+ * r_d = r(1 + k1ρ²), read at each ideal destination point), per-channel lateral-colour scales 1 + L·D(λ_channel), and the
22
+ * relative-illumination gain at the state's aperture (strength 1). The result passes `parseGenericLensCorrectionProfile`.
23
+ */
24
+ export function createGenericLensCorrectionProfile(input) {
25
+ requireInputObject(input, "createGenericLensCorrectionProfile input");
26
+ requireInputKeys(input, "createGenericLensCorrectionProfile", ["lensId", "activeImagingArea", "state"]);
27
+ const state = parseOpticalProfileState(input.state);
28
+ const lens = resolveGenericLens({ lensId: input.lensId, activeImagingArea: input.activeImagingArea, fNumber: state.aperture });
29
+ if (state.lensId !== lens.lensId)
30
+ throw new InvalidScientificInputError("state.lensId must be the catalogue id " + lens.lensId + ".");
31
+ if (Math.abs(state.focalLengthMm - lens.focalLengthMm) > 1e-9 * lens.focalLengthMm)
32
+ throw new InvalidScientificInputError("state.focalLengthMm must be the " + lens.name + "'s focal length on this sensor, " + lens.focalLengthMm + " mm.");
33
+ const c = lens.character, domain = "reconstructed-linear";
34
+ const identity = { version: "1.0.0", domain, availability: "toggle", defaultEnabled: true, dependencies: [], requiredByStabilizationModes: [] };
35
+ const scale = (channel) => 1 + c.lateralColor.fraction * genericLensDispersion(GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM[channel]);
36
+ const affine = (channel) => ({ id: lens.lensId + ":lateral-ca:" + channel, version: "1.0.0", domain, purpose: "lateral-ca", kind: "affine",
37
+ matrix: [scale(channel), 0, 0, scale(channel)], offsetMm: { x: 0, y: 0 } });
38
+ return parseGenericLensCorrectionProfile({ schemaVersion: "0.1.0", id: "photivra-" + lens.lensId + ":corrections", version: "1.0.0", state,
39
+ evidence: { kind: "generic-parametric", basis: "Generic catalogue lens " + lens.name + " (#549): the inverse of its own distortion, lateral colour and vignetting.",
40
+ residualNote: "Generic approximation, not a measured lens; axial colour, coma and the other aberrations stay.", sources: [...GENERIC_LENS_EVIDENCE] },
41
+ components: [
42
+ { ...identity, id: "distortion", kind: "geometry", residualNote: "Corrects the lens's radial distortion r_d = r(1 + k1ρ²); the stretched corners lose resolution.",
43
+ transform: { id: lens.lensId + ":distortion", version: "1.0.0", domain, purpose: "distortion", kind: "radial",
44
+ profile: { normalizationRadiusMm: lens.halfDiagonalMm, maximumNormalizedRadius: 1.05, coefficients: { k1: c.distortion.corner, k2: 0, k3: 0 } } } },
45
+ { ...identity, id: "lateral-ca", kind: "lateral-ca", residualNote: "Registers the channels at one effective wavelength each; colour inside a channel's band and axial colour stay.",
46
+ transforms: { red: affine("red"), green: affine("green"), blue: affine("blue") } },
47
+ { ...identity, id: "vignetting", kind: "peripheral-illumination", residualNote: "Gain 1/E at the profile's aperture; it raises corner noise with the signal.",
48
+ profile: { ...c.vignetting.profile, maximumNormalizedRadius: 1.05 }, strength: 1 }
49
+ ] });
50
+ }
51
+ //# sourceMappingURL=generic-lens-corrections.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"generic-lens-corrections.js","sourceRoot":"","sources":["../../src/output/generic-lens-corrections.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * The generic correction profile of a catalogue lens (#549), for the post-capture lens corrections\n * (`resolveLensCorrectionPlan`, `calculateLensCorrectedCapture`): a distortion map, a lateral-colour map per channel and a\n * peripheral-illumination gain, each from the lens's own character (`resolveGenericLens`). Consumer lenses rely on it more:\n * their uncorrected distortion and vignetting are larger, so the same profile corrects more. Like every catalogue number it is\n * a labelled approximation, bound to one exact camera state (`OpticalProfileState`).\n */\n\nimport { InvalidScientificInputError } from \"../core/validation.js\";\nimport { requireInputKeys, requireInputObject } from \"../core/record-validation.js\";\nimport { parseOpticalProfileState, type OpticalProfileState } from \"../optics/profile-contract.js\";\nimport { GENERIC_LENS_EVIDENCE, genericLensDispersion, resolveGenericLens } from \"../equipment/generic-lens-catalogue.js\";\nimport { parseGenericLensCorrectionProfile, type GenericLensCorrectionProfile, type LensCorrectionChannel } from \"./lens-corrections.js\";\nimport type { AffineGeometricTransform } from \"./geometric-transforms.js\";\n\n/**\n * Effective wavelengths of the red, green and blue channels for the lateral-colour map, nm: a generic approximation of where\n * a camera's channel responses peak, not a measured camera.\n */\nexport const GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM: Readonly<Record<LensCorrectionChannel, number>> = /* @__PURE__ */ Object.freeze({ red: 610, green: 540, blue: 465 });\n\nexport interface CreateGenericLensCorrectionProfileInput {\n /** A catalogue id (`GENERIC_LENS_IDS`); `state.lensId` must be the same id. */\n lensId: string;\n /** The sensor's active capture area, mm (the lens is scaled to it as in `resolveGenericLens`). */\n activeImagingArea: { widthMm: number; heightMm: number };\n /** The exact camera state the profile applies to: its focal length must be the lens's on this sensor, its aperture in range. */\n state: OpticalProfileState;\n}\n\n/**\n * Builds the catalogue lens's generic correction profile for one camera state: a radial distortion map (the lens's own\n * r_d = r(1 + k1ρ²), read at each ideal destination point), per-channel lateral-colour scales 1 + L·D(λ_channel), and the\n * relative-illumination gain at the state's aperture (strength 1). The result passes `parseGenericLensCorrectionProfile`.\n */\nexport function createGenericLensCorrectionProfile(input: CreateGenericLensCorrectionProfileInput): GenericLensCorrectionProfile {\n requireInputObject(input, \"createGenericLensCorrectionProfile input\");\n requireInputKeys(input, \"createGenericLensCorrectionProfile\", [\"lensId\", \"activeImagingArea\", \"state\"]);\n const state = parseOpticalProfileState(input.state);\n const lens = resolveGenericLens({ lensId: input.lensId, activeImagingArea: input.activeImagingArea, fNumber: state.aperture });\n if (state.lensId !== lens.lensId) throw new InvalidScientificInputError(\"state.lensId must be the catalogue id \" + lens.lensId + \".\");\n if (Math.abs(state.focalLengthMm - lens.focalLengthMm) > 1e-9 * lens.focalLengthMm)\n throw new InvalidScientificInputError(\"state.focalLengthMm must be the \" + lens.name + \"'s focal length on this sensor, \" + lens.focalLengthMm + \" mm.\");\n const c = lens.character, domain = \"reconstructed-linear\" as const;\n const identity = { version: \"1.0.0\", domain, availability: \"toggle\" as const, defaultEnabled: true, dependencies: [] as string[], requiredByStabilizationModes: [] as string[] };\n const scale = (channel: LensCorrectionChannel): number => 1 + c.lateralColor.fraction * genericLensDispersion(GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM[channel]);\n const affine = (channel: LensCorrectionChannel): AffineGeometricTransform => ({ id: lens.lensId + \":lateral-ca:\" + channel, version: \"1.0.0\", domain, purpose: \"lateral-ca\" as const, kind: \"affine\" as const,\n matrix: [scale(channel), 0, 0, scale(channel)] as [number, number, number, number], offsetMm: { x: 0, y: 0 } });\n return parseGenericLensCorrectionProfile({ schemaVersion: \"0.1.0\", id: \"photivra-\" + lens.lensId + \":corrections\", version: \"1.0.0\", state,\n evidence: { kind: \"generic-parametric\", basis: \"Generic catalogue lens \" + lens.name + \" (#549): the inverse of its own distortion, lateral colour and vignetting.\",\n residualNote: \"Generic approximation, not a measured lens; axial colour, coma and the other aberrations stay.\", sources: [...GENERIC_LENS_EVIDENCE] },\n components: [\n { ...identity, id: \"distortion\", kind: \"geometry\", residualNote: \"Corrects the lens's radial distortion r_d = r(1 + k1ρ²); the stretched corners lose resolution.\",\n transform: { id: lens.lensId + \":distortion\", version: \"1.0.0\", domain, purpose: \"distortion\", kind: \"radial\",\n profile: { normalizationRadiusMm: lens.halfDiagonalMm, maximumNormalizedRadius: 1.05, coefficients: { k1: c.distortion.corner, k2: 0, k3: 0 } } } },\n { ...identity, id: \"lateral-ca\", kind: \"lateral-ca\", residualNote: \"Registers the channels at one effective wavelength each; colour inside a channel's band and axial colour stay.\",\n transforms: { red: affine(\"red\"), green: affine(\"green\"), blue: affine(\"blue\") } },\n { ...identity, id: \"vignetting\", kind: \"peripheral-illumination\", residualNote: \"Gain 1/E at the profile's aperture; it raises corner noise with the signal.\",\n profile: { ...c.vignetting.profile, maximumNormalizedRadius: 1.05 }, strength: 1 }\n ] });\n}\n"]}
@@ -0,0 +1,132 @@
1
+ import { type SdrRenderingProfile } from "../output/sdr-rendering.js";
2
+ /** Version of the `development-detail` model (diffraction correction and unsharp mask). */
3
+ export declare const DEVELOPMENT_DETAIL_MODEL_VERSION: "0.1.0";
4
+ /** Unsharp mask settings: Gaussian standard deviation in output pixels, gain on the detail signal, and threshold in encoded units [0, 1]. */
5
+ export interface UnsharpMaskSettings {
6
+ radiusPixels: number;
7
+ amount: number;
8
+ threshold: number;
9
+ }
10
+ /** Diffraction correction settings: design wavelength, Wiener noise-to-signal regularization, gain bound and FIR radius in pixels. */
11
+ export interface DiffractionCorrectionSettings {
12
+ wavelengthNm: number;
13
+ regularization: number;
14
+ maximumGain: number;
15
+ kernelRadiusPixels: number;
16
+ }
17
+ /** Accepted ranges. Bounds are engineering limits on cost and on noise gain, not quality recommendations. */
18
+ export declare const DEVELOPMENT_DETAIL_LIMITS: Readonly<{
19
+ maximumUnsharpRadiusPixels: 8;
20
+ maximumUnsharpAmount: 5;
21
+ minimumWavelengthNm: 380;
22
+ maximumWavelengthNm: 780;
23
+ minimumRegularization: 0.0001;
24
+ maximumRegularization: 1;
25
+ maximumGain: 10;
26
+ maximumKernelRadiusPixels: 16;
27
+ }>;
28
+ /** Validates unsharp mask settings. */
29
+ export declare function parseUnsharpMaskSettings(value: UnsharpMaskSettings, path?: string): UnsharpMaskSettings;
30
+ /** Validates diffraction correction settings. */
31
+ export declare function parseDiffractionCorrectionSettings(value: DiffractionCorrectionSettings, path?: string): DiffractionCorrectionSettings;
32
+ /** Default FIR radius: the Airy pattern's third dark ring (3.2383·λ·N_w) in pixels, rounded up, within 1..16. */
33
+ export declare function defaultDiffractionCorrectionRadiusPixels(workingFNumber: number, wavelengthNm: number, pixelPitchMicrometers: number): number;
34
+ /**
35
+ * Incoherent OTF of an ideal, aberration-free circular pupil at normalized frequency s = f/f_c:
36
+ * H(s) = (2/π)(acos s − s·√(1 − s²)) for s < 1, and 0 for s ≥ 1 (MIT OCW 2.71, lectures 21–22; Goodman, §7.3).
37
+ */
38
+ export declare function circularPupilDiffractionMtf(normalizedFrequency: number): number;
39
+ /** A designed diffraction correction filter and what its response does. Frequencies are in cycles per pixel. */
40
+ export interface DiffractionCorrectionFilter {
41
+ settings: DiffractionCorrectionSettings;
42
+ workingFNumber: number;
43
+ pixelPitchMicrometers: number;
44
+ /** f_c · pitch = pitch/(λ·N_w): the diffraction cut-off. The sampling Nyquist frequency is 0.5. */
45
+ cutoffCyclesPerPixel: number;
46
+ /** Fraction of the Nyquist square |fx|, |fy| ≤ 0.5 lying beyond the cut-off, where the lens transmitted nothing. */
47
+ nyquistAreaBeyondCutoffFraction: number;
48
+ /** Largest gain of the designed (untruncated) response: min(maximumGain, max over s of H(1+α)/(H² + α)). */
49
+ designPeakGain: number;
50
+ /** Largest |response| of the windowed, truncated FIR over a 1/64-cycle grid of the Nyquist square. */
51
+ responsePeakGain: number;
52
+ /**
53
+ * Largest |response| of the FIR at grid frequencies beyond the cut-off (null when the whole Nyquist square is inside it).
54
+ * The design is 0 there; a non-zero value is truncation leakage that passes noise and aliased signal, never lost detail,
55
+ * because the optics delivered no signal at those frequencies.
56
+ */
57
+ responseMaximumBeyondCutoff: number | null;
58
+ /** Row-major (2R+1)² weights summing to 1; weights[(dy + R)(2R + 1) + dx + R]. */
59
+ weights: Float64Array;
60
+ }
61
+ /**
62
+ * Designs the bounded Wiener–Tikhonov correction. In frequency (normalized s = ρ/f_c, ρ the radial frequency):
63
+ * G(s) = min(maximumGain, H(s)·(1 + α)/(H(s)² + α)), G(0) = 1, and G(s) = 0 for s ≥ 1,
64
+ * where α is the regularization (an assumed noise-to-signal power ratio, Wiener's K). The response is sampled on an M × M
65
+ * grid (M a power of two ≥ max(64, 4(2R + 1))), inverse transformed, truncated to the (2R + 1)² window with a radial raised
66
+ * cosine taper and renormalized to unit sum. The achieved response is then measured, not assumed.
67
+ */
68
+ export declare function designDiffractionCorrectionFilter(input: {
69
+ workingFNumber: number;
70
+ pixelPitchMicrometers: number;
71
+ settings: DiffractionCorrectionSettings;
72
+ }): DiffractionCorrectionFilter;
73
+ /** Frequency response Σ w(x, y)·cos(2π(fx·x + fy·y)) of a centred (2R + 1)² FIR at (fx, fy) cycles per pixel. */
74
+ export declare function evaluateDiffractionCorrectionResponse(weights: Float64Array, radius: number, fx: number, fy: number): number;
75
+ /** What the correction did to the linear samples. */
76
+ export interface DiffractionCorrectionDiagnostics {
77
+ /** Corrected samples below 0 (ringing below black). They are clipped by the later gamut handling. */
78
+ negativeSampleCount: number;
79
+ /** Corrected samples above 1 (above the RAW white level). */
80
+ aboveWhiteSampleCount: number;
81
+ }
82
+ /**
83
+ * Applies the FIR to interleaved linear RGB (width × height × 3), each channel separately, edges clamped. Returns new samples.
84
+ */
85
+ export declare function applyDiffractionCorrection(rgb: Float64Array, width: number, height: number, filter: Pick<DiffractionCorrectionFilter, "weights" | "settings">): {
86
+ samples: Float64Array;
87
+ diagnostics: DiffractionCorrectionDiagnostics;
88
+ };
89
+ /** Normalized Gaussian weights exp(−i²/(2σ²)), i = −r..r with r = ⌈3σ⌉. */
90
+ export declare function unsharpMaskKernel(radiusPixels: number): Float64Array;
91
+ /** What the unsharp mask did. Counts are samples (one per channel). */
92
+ export interface UnsharpMaskDiagnostics {
93
+ /** Half-width of the separable Gaussian, ⌈3σ⌉ pixels. */
94
+ kernelRadiusPixels: number;
95
+ /** Samples changed by the mask (|detail| ≥ threshold and detail ≠ 0). */
96
+ sharpenedSampleCount: number;
97
+ /** Samples left unchanged because 0 < |detail| < threshold. */
98
+ thresholdHeldSampleCount: number;
99
+ /** Sharpened samples clipped at 0 or 1 (the halo did not fit in the code range). */
100
+ clippedLowSampleCount: number;
101
+ clippedHighSampleCount: number;
102
+ /** Largest increase and decrease amount·detail before clipping, in encoded units: the halo strength. */
103
+ maximumIncrease: number;
104
+ maximumDecrease: number;
105
+ }
106
+ /**
107
+ * Unsharp mask on interleaved encoded RGB in [0, 1] (each channel separately, edges clamped):
108
+ * d = x − (G_σ ∗ x); y = x + amount·d where |d| ≥ threshold, else y = x; then y is clipped to [0, 1].
109
+ * Returns new samples.
110
+ */
111
+ export declare function applyUnsharpMask(encoded: Float64Array, width: number, height: number, settings: UnsharpMaskSettings): {
112
+ samples: Float64Array;
113
+ diagnostics: UnsharpMaskDiagnostics;
114
+ };
115
+ /** SDR counters in the shape of the SDR rendering diagnostics. */
116
+ export interface DetailSdrCounts {
117
+ negative: number;
118
+ above: number;
119
+ changed: number;
120
+ low: number;
121
+ high: number;
122
+ }
123
+ /**
124
+ * SDR rendering with an unsharp mask between the sRGB transfer and quantization. Per pixel it runs the reference renderer's
125
+ * operations in its order (−0 → +0, rendering exposure 2^EV with reference white 1, the shared tone operator, gamut
126
+ * handling, sRGB transfer); the whole encoded image is then sharpened and rounded to nearest (ties up).
127
+ */
128
+ export declare function renderSdrWithUnsharpMask(rgb: Float64Array, width: number, height: number, profile: SdrRenderingProfile, settings: UnsharpMaskSettings): {
129
+ codes: Uint8Array | Uint16Array;
130
+ counts: DetailSdrCounts;
131
+ sharpening: UnsharpMaskDiagnostics;
132
+ };
@@ -0,0 +1,325 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Development detail stages (#552), both opt-in and off by default:
4
+ *
5
+ * - **Diffraction correction**: a bounded, regularized (Wiener–Tikhonov) deconvolution of the developed linear RGB with the
6
+ * diffraction OTF of an ideal circular pupil at the capture's working f-number. The OTF is zero beyond the cut-off
7
+ * f_c = 1/(λ·N_w), so detail beyond it never reached the sensor; the filter's design gain there is 0 and it cannot, and
8
+ * does not claim to, restore it.
9
+ * - **Sharpening**: an unsharp mask (radius, amount, threshold) on the display-encoded values, after the tone curve and
10
+ * gamut handling and before quantization. It raises edge contrast and creates halos; it adds no optical detail.
11
+ *
12
+ * Development order with these stages: (exposure gain) → demosaic → colour and white balance → diffraction correction
13
+ * (linear) → rendering exposure and tone curve → gamut handling → sRGB transfer → unsharp mask → quantization (encode).
14
+ *
15
+ * Image edges are handled by clamping (edge pixels repeated). Both stages work on a whole in-memory image.
16
+ * @see docs/DIGITAL_OPTICS_FOUNDATION.md for the equations, sources, trade-offs and limits.
17
+ */
18
+ import { encodeSrgbComponent } from "../color/srgb-transfer.js";
19
+ import { InvalidConfigurationError } from "../core/configuration-error.js";
20
+ import { InvalidScientificResultError } from "../core/validation.js";
21
+ import { prepareSdrPixelTone } from "../output/sdr-rendering.js";
22
+ /** Version of the `development-detail` model (diffraction correction and unsharp mask). */
23
+ export const DEVELOPMENT_DETAIL_MODEL_VERSION = "0.1.0";
24
+ /** Accepted ranges. Bounds are engineering limits on cost and on noise gain, not quality recommendations. */
25
+ export const DEVELOPMENT_DETAIL_LIMITS = /* @__PURE__ */ Object.freeze({
26
+ maximumUnsharpRadiusPixels: 8, maximumUnsharpAmount: 5,
27
+ minimumWavelengthNm: 380, maximumWavelengthNm: 780,
28
+ minimumRegularization: 1e-4, maximumRegularization: 1,
29
+ maximumGain: 10, maximumKernelRadiusPixels: 16
30
+ });
31
+ /** Third dark ring of the Airy pattern, in units of λ·N (j₁,₃/π, j₁,₃ = 10.1734681…). */
32
+ const AIRY_THIRD_ZERO_PER_LAMBDA_N = 10.173468135062722 / Math.PI;
33
+ function finiteIn(value, name, minimum, maximum, minimumExclusive = false) {
34
+ if (typeof value !== "number" || !Number.isFinite(value) || (minimumExclusive ? value <= minimum : value < minimum) || value > maximum) {
35
+ throw new InvalidConfigurationError(name + " must be a finite number in " + (minimumExclusive ? "(" : "[") + minimum + ", " + maximum + "].");
36
+ }
37
+ return value;
38
+ }
39
+ /** Validates unsharp mask settings. */
40
+ export function parseUnsharpMaskSettings(value, path = "sharpening") {
41
+ return { radiusPixels: finiteIn(value.radiusPixels, path + ".radiusPixels", 0, DEVELOPMENT_DETAIL_LIMITS.maximumUnsharpRadiusPixels, true),
42
+ amount: finiteIn(value.amount, path + ".amount", 0, DEVELOPMENT_DETAIL_LIMITS.maximumUnsharpAmount, true),
43
+ threshold: finiteIn(value.threshold, path + ".threshold", 0, 1) };
44
+ }
45
+ /** Validates diffraction correction settings. */
46
+ export function parseDiffractionCorrectionSettings(value, path = "diffractionCorrection") {
47
+ const radius = value.kernelRadiusPixels;
48
+ if (typeof radius !== "number" || !Number.isSafeInteger(radius) || radius < 1 || radius > DEVELOPMENT_DETAIL_LIMITS.maximumKernelRadiusPixels) {
49
+ throw new InvalidConfigurationError(path + ".kernelRadiusPixels must be an integer from 1 to " + DEVELOPMENT_DETAIL_LIMITS.maximumKernelRadiusPixels + ".");
50
+ }
51
+ return { wavelengthNm: finiteIn(value.wavelengthNm, path + ".wavelengthNm", DEVELOPMENT_DETAIL_LIMITS.minimumWavelengthNm, DEVELOPMENT_DETAIL_LIMITS.maximumWavelengthNm),
52
+ regularization: finiteIn(value.regularization, path + ".regularization", DEVELOPMENT_DETAIL_LIMITS.minimumRegularization, DEVELOPMENT_DETAIL_LIMITS.maximumRegularization),
53
+ maximumGain: finiteIn(value.maximumGain, path + ".maximumGain", 1, DEVELOPMENT_DETAIL_LIMITS.maximumGain), kernelRadiusPixels: radius };
54
+ }
55
+ /** Default FIR radius: the Airy pattern's third dark ring (3.2383·λ·N_w) in pixels, rounded up, within 1..16. */
56
+ export function defaultDiffractionCorrectionRadiusPixels(workingFNumber, wavelengthNm, pixelPitchMicrometers) {
57
+ const sites = Math.ceil(AIRY_THIRD_ZERO_PER_LAMBDA_N * wavelengthNm / 1000 * workingFNumber / pixelPitchMicrometers);
58
+ return Math.min(DEVELOPMENT_DETAIL_LIMITS.maximumKernelRadiusPixels, Math.max(1, sites));
59
+ }
60
+ /**
61
+ * Incoherent OTF of an ideal, aberration-free circular pupil at normalized frequency s = f/f_c:
62
+ * H(s) = (2/π)(acos s − s·√(1 − s²)) for s < 1, and 0 for s ≥ 1 (MIT OCW 2.71, lectures 21–22; Goodman, §7.3).
63
+ */
64
+ export function circularPupilDiffractionMtf(normalizedFrequency) {
65
+ const s = Math.abs(normalizedFrequency);
66
+ if (s >= 1)
67
+ return 0;
68
+ return 2 / Math.PI * (Math.acos(s) - s * Math.sqrt(1 - s * s));
69
+ }
70
+ /**
71
+ * Designs the bounded Wiener–Tikhonov correction. In frequency (normalized s = ρ/f_c, ρ the radial frequency):
72
+ * G(s) = min(maximumGain, H(s)·(1 + α)/(H(s)² + α)), G(0) = 1, and G(s) = 0 for s ≥ 1,
73
+ * where α is the regularization (an assumed noise-to-signal power ratio, Wiener's K). The response is sampled on an M × M
74
+ * grid (M a power of two ≥ max(64, 4(2R + 1))), inverse transformed, truncated to the (2R + 1)² window with a radial raised
75
+ * cosine taper and renormalized to unit sum. The achieved response is then measured, not assumed.
76
+ */
77
+ export function designDiffractionCorrectionFilter(input) {
78
+ const settings = parseDiffractionCorrectionSettings(input.settings);
79
+ const workingFNumber = finiteIn(input.workingFNumber, "workingFNumber", 0, 1e4, true), pitch = finiteIn(input.pixelPitchMicrometers, "pixelPitchMicrometers", 0, 1e4, true);
80
+ const cutoff = pitch / (settings.wavelengthNm / 1000 * workingFNumber), alpha = settings.regularization, bound = settings.maximumGain;
81
+ const gainAt = (rho) => {
82
+ const h = circularPupilDiffractionMtf(rho / cutoff);
83
+ return h === 0 ? 0 : Math.min(bound, h * (1 + alpha) / (h * h + alpha));
84
+ };
85
+ const R = settings.kernelRadiusPixels, size = 2 * R + 1;
86
+ let M = 64;
87
+ while (M < 4 * size)
88
+ M *= 2;
89
+ // A(ky, x) = Σ_kx G cos(2π kx x/M); h(x, y) = (1/M²) Σ_ky cos(2π ky y/M) A(ky, x). G is even in each axis, so h is real and even.
90
+ const cosTable = new Float64Array(M * (R + 1));
91
+ for (let k = 0; k < M; k++)
92
+ for (let x = 0; x <= R; x++)
93
+ cosTable[k * (R + 1) + x] = Math.cos(2 * Math.PI * k * x / M);
94
+ const A = new Float64Array(M * (R + 1));
95
+ for (let ky = 0; ky < M; ky++) {
96
+ const fy = (ky < M / 2 ? ky : ky - M) / M;
97
+ for (let kx = 0; kx < M; kx++) {
98
+ const fx = (kx < M / 2 ? kx : kx - M) / M, g = gainAt(Math.hypot(fx, fy));
99
+ if (g === 0)
100
+ continue;
101
+ for (let x = 0; x <= R; x++)
102
+ A[ky * (R + 1) + x] += g * cosTable[kx * (R + 1) + x];
103
+ }
104
+ }
105
+ const quarter = new Float64Array((R + 1) * (R + 1));
106
+ for (let y = 0; y <= R; y++)
107
+ for (let x = 0; x <= R; x++) {
108
+ let sum = 0;
109
+ for (let ky = 0; ky < M; ky++)
110
+ sum += cosTable[ky * (R + 1) + y] * A[ky * (R + 1) + x];
111
+ const r = Math.hypot(x, y), taper = r < R + 1 ? 0.5 * (1 + Math.cos(Math.PI * r / (R + 1))) : 0;
112
+ quarter[y * (R + 1) + x] = sum / (M * M) * taper;
113
+ }
114
+ const weights = new Float64Array(size * size);
115
+ let total = 0;
116
+ for (let dy = -R; dy <= R; dy++)
117
+ for (let dx = -R; dx <= R; dx++) {
118
+ const w = quarter[Math.abs(dy) * (R + 1) + Math.abs(dx)];
119
+ weights[(dy + R) * size + dx + R] = w;
120
+ total += w;
121
+ }
122
+ if (!(total > 0) || !Number.isFinite(total))
123
+ throw new InvalidScientificResultError("Diffraction correction filter has no finite DC response.");
124
+ for (let i = 0; i < weights.length; i++)
125
+ weights[i] = weights[i] / total;
126
+ let designPeakGain = 0;
127
+ for (let i = 0; i <= 4096; i++)
128
+ designPeakGain = Math.max(designPeakGain, gainAt(i / 4096 * cutoff));
129
+ let responsePeakGain = 0, beyond = null, beyondCount = 0;
130
+ const GRID = 64;
131
+ for (let iy = 0; iy <= GRID / 2; iy++)
132
+ for (let ix = 0; ix <= GRID / 2; ix++) {
133
+ const fx = ix / GRID, fy = iy / GRID, response = Math.abs(evaluateDiffractionCorrectionResponse(weights, R, fx, fy));
134
+ responsePeakGain = Math.max(responsePeakGain, response);
135
+ if (Math.hypot(fx, fy) >= cutoff)
136
+ beyond = Math.max(beyond ?? 0, response);
137
+ }
138
+ const AREA = 256;
139
+ for (let iy = 0; iy < AREA; iy++)
140
+ for (let ix = 0; ix < AREA; ix++)
141
+ if (Math.hypot((ix + 0.5) / (2 * AREA), (iy + 0.5) / (2 * AREA)) >= cutoff)
142
+ beyondCount++;
143
+ return { settings, workingFNumber, pixelPitchMicrometers: pitch, cutoffCyclesPerPixel: cutoff, nyquistAreaBeyondCutoffFraction: beyondCount / (AREA * AREA),
144
+ designPeakGain, responsePeakGain, responseMaximumBeyondCutoff: beyond, weights };
145
+ }
146
+ /** Frequency response Σ w(x, y)·cos(2π(fx·x + fy·y)) of a centred (2R + 1)² FIR at (fx, fy) cycles per pixel. */
147
+ export function evaluateDiffractionCorrectionResponse(weights, radius, fx, fy) {
148
+ const size = 2 * radius + 1;
149
+ let sum = 0;
150
+ for (let dy = -radius; dy <= radius; dy++)
151
+ for (let dx = -radius; dx <= radius; dx++)
152
+ sum += weights[(dy + radius) * size + dx + radius] * Math.cos(2 * Math.PI * (fx * dx + fy * dy));
153
+ return sum;
154
+ }
155
+ /**
156
+ * Applies the FIR to interleaved linear RGB (width × height × 3), each channel separately, edges clamped. Returns new samples.
157
+ */
158
+ export function applyDiffractionCorrection(rgb, width, height, filter) {
159
+ if (rgb.length !== width * height * 3)
160
+ throw new InvalidConfigurationError("Diffraction correction raster size mismatch.");
161
+ const R = filter.settings.kernelRadiusPixels, size = 2 * R + 1, w = filter.weights, out = new Float64Array(rgb.length);
162
+ const xs = new Int32Array(width + 2 * R);
163
+ for (let i = 0; i < xs.length; i++)
164
+ xs[i] = Math.min(width - 1, Math.max(0, i - R)) * 3;
165
+ let negative = 0, above = 0;
166
+ for (let y = 0; y < height; y++) {
167
+ const o = y * width * 3;
168
+ for (let dy = -R; dy <= R; dy++) {
169
+ const row = Math.min(height - 1, Math.max(0, y + dy)) * width * 3, wr = (dy + R) * size;
170
+ for (let dx = 0; dx < size; dx++) {
171
+ const weight = w[wr + dx];
172
+ if (weight === 0)
173
+ continue;
174
+ for (let x = 0; x < width; x++) {
175
+ const s = row + xs[x + dx], d = o + 3 * x;
176
+ out[d] = out[d] + weight * rgb[s];
177
+ out[d + 1] = out[d + 1] + weight * rgb[s + 1];
178
+ out[d + 2] = out[d + 2] + weight * rgb[s + 2];
179
+ }
180
+ }
181
+ }
182
+ }
183
+ for (let i = 0; i < out.length; i++) {
184
+ const v = out[i];
185
+ if (!Number.isFinite(v))
186
+ throw new InvalidScientificResultError("Diffraction correction produced a non-finite sample.");
187
+ if (v < 0)
188
+ negative++;
189
+ else if (v > 1)
190
+ above++;
191
+ }
192
+ return { samples: out, diagnostics: { negativeSampleCount: negative, aboveWhiteSampleCount: above } };
193
+ }
194
+ /** Normalized Gaussian weights exp(−i²/(2σ²)), i = −r..r with r = ⌈3σ⌉. */
195
+ export function unsharpMaskKernel(radiusPixels) {
196
+ const r = Math.ceil(3 * radiusPixels), k = new Float64Array(2 * r + 1);
197
+ let total = 0;
198
+ for (let i = -r; i <= r; i++) {
199
+ const v = Math.exp(-i * i / (2 * radiusPixels * radiusPixels));
200
+ k[i + r] = v;
201
+ total += v;
202
+ }
203
+ for (let i = 0; i < k.length; i++)
204
+ k[i] = k[i] / total;
205
+ return k;
206
+ }
207
+ /**
208
+ * Unsharp mask on interleaved encoded RGB in [0, 1] (each channel separately, edges clamped):
209
+ * d = x − (G_σ ∗ x); y = x + amount·d where |d| ≥ threshold, else y = x; then y is clipped to [0, 1].
210
+ * Returns new samples.
211
+ */
212
+ export function applyUnsharpMask(encoded, width, height, settings) {
213
+ const s = parseUnsharpMaskSettings(settings);
214
+ if (encoded.length !== width * height * 3)
215
+ throw new InvalidConfigurationError("Unsharp mask raster size mismatch.");
216
+ const k = unsharpMaskKernel(s.radiusPixels), r = (k.length - 1) / 2, horizontal = new Float64Array(encoded.length), blurred = new Float64Array(encoded.length);
217
+ for (let y = 0; y < height; y++)
218
+ for (let x = 0; x < width; x++) {
219
+ const d = (y * width + x) * 3;
220
+ let a = 0, b = 0, c = 0;
221
+ for (let i = -r; i <= r; i++) {
222
+ const sx = (y * width + Math.min(width - 1, Math.max(0, x + i))) * 3, w = k[i + r];
223
+ a += w * encoded[sx];
224
+ b += w * encoded[sx + 1];
225
+ c += w * encoded[sx + 2];
226
+ }
227
+ horizontal[d] = a;
228
+ horizontal[d + 1] = b;
229
+ horizontal[d + 2] = c;
230
+ }
231
+ for (let y = 0; y < height; y++)
232
+ for (let x = 0; x < width; x++) {
233
+ const d = (y * width + x) * 3;
234
+ let a = 0, b = 0, c = 0;
235
+ for (let i = -r; i <= r; i++) {
236
+ const sy = (Math.min(height - 1, Math.max(0, y + i)) * width + x) * 3, w = k[i + r];
237
+ a += w * horizontal[sy];
238
+ b += w * horizontal[sy + 1];
239
+ c += w * horizontal[sy + 2];
240
+ }
241
+ blurred[d] = a;
242
+ blurred[d + 1] = b;
243
+ blurred[d + 2] = c;
244
+ }
245
+ const out = new Float64Array(encoded.length);
246
+ let sharpened = 0, held = 0, low = 0, high = 0, increase = 0, decrease = 0;
247
+ for (let i = 0; i < encoded.length; i++) {
248
+ const x = encoded[i], detail = x - blurred[i];
249
+ if (detail === 0 || Math.abs(detail) < s.threshold) {
250
+ if (detail !== 0)
251
+ held++;
252
+ out[i] = x;
253
+ continue;
254
+ }
255
+ const change = s.amount * detail;
256
+ let v = x + change;
257
+ sharpened++;
258
+ if (change > increase)
259
+ increase = change;
260
+ if (-change > decrease)
261
+ decrease = -change;
262
+ if (v < 0) {
263
+ v = 0;
264
+ low++;
265
+ }
266
+ else if (v > 1) {
267
+ v = 1;
268
+ high++;
269
+ }
270
+ out[i] = v;
271
+ }
272
+ return { samples: out, diagnostics: { kernelRadiusPixels: r, sharpenedSampleCount: sharpened, thresholdHeldSampleCount: held, clippedLowSampleCount: low,
273
+ clippedHighSampleCount: high, maximumIncrease: increase, maximumDecrease: decrease } };
274
+ }
275
+ /**
276
+ * SDR rendering with an unsharp mask between the sRGB transfer and quantization. Per pixel it runs the reference renderer's
277
+ * operations in its order (−0 → +0, rendering exposure 2^EV with reference white 1, the shared tone operator, gamut
278
+ * handling, sRGB transfer); the whole encoded image is then sharpened and rounded to nearest (ties up).
279
+ */
280
+ export function renderSdrWithUnsharpMask(rgb, width, height, profile, settings) {
281
+ if (rgb.length !== width * height * 3)
282
+ throw new InvalidConfigurationError("SDR raster size mismatch.");
283
+ const scale = 2 ** profile.renderingExposureEv, tone = prepareSdrPixelTone(profile), scratch = new Float64Array(3), encoded = new Float64Array(rgb.length);
284
+ if (!Number.isFinite(scale) || scale <= 0)
285
+ throw new InvalidConfigurationError("Rendering scale is unrepresentable.");
286
+ const reject = profile.gamutHandling === "reject-out-of-range", counts = { negative: 0, above: 0, changed: 0, low: 0, high: 0 };
287
+ for (let i = 0; i < rgb.length; i += 3) {
288
+ const r = rgb[i], g = rgb[i + 1], b = rgb[i + 2];
289
+ const er = (r === 0 ? 0 : r) * scale, eg = (g === 0 ? 0 : g) * scale, eb = (b === 0 ? 0 : b) * scale;
290
+ if (!Number.isFinite(er) || !Number.isFinite(eg) || !Number.isFinite(eb))
291
+ throw new InvalidScientificResultError("Rendering exposure produced a non-finite sample.");
292
+ tone(er, eg, eb, scratch);
293
+ for (let c = 0; c < 3; c++) {
294
+ const exposed = c === 0 ? er : c === 1 ? eg : eb;
295
+ if (exposed < 0)
296
+ counts.negative++;
297
+ if (exposed > 1)
298
+ counts.above++;
299
+ let mapped = scratch[c];
300
+ if (mapped !== exposed)
301
+ counts.changed++;
302
+ if (mapped === 0)
303
+ mapped = 0;
304
+ if (mapped < 0 || mapped > 1) {
305
+ if (reject)
306
+ throw new InvalidConfigurationError("Tone output exceeds the declared SDR gamut range.");
307
+ if (mapped < 0) {
308
+ counts.low++;
309
+ mapped = 0;
310
+ }
311
+ else {
312
+ counts.high++;
313
+ mapped = 1;
314
+ }
315
+ }
316
+ encoded[i + c] = encodeSrgbComponent(mapped);
317
+ }
318
+ }
319
+ const sharpened = applyUnsharpMask(encoded, width, height, settings), maximum = 2 ** profile.bitDepth - 1;
320
+ const codes = profile.bitDepth === 8 ? new Uint8Array(rgb.length) : new Uint16Array(rgb.length);
321
+ for (let i = 0; i < codes.length; i++)
322
+ codes[i] = Math.round(sharpened.samples[i] * maximum);
323
+ return { codes, counts, sharpening: sharpened.diagnostics };
324
+ }
325
+ //# sourceMappingURL=development-detail.js.map