diff --git a/.gitignore b/.gitignore index 9ed1d40..ba2478f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,8 @@ Cargo.lock.bak /packaging/src/ /packaging/*.pkg.tar.* /packaging/*.log + +# Cached upstream film profiles, re-fetchable with +# tools/film-profiles/convert.py --fetch. Not source: the converted +# profiles in core/dr-film/profiles are. +tools/film-profiles/upstream/ diff --git a/Cargo.lock b/Cargo.lock index 00f0c76..170d148 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1447,6 +1447,15 @@ dependencies = [ "zune-jpeg 0.4.21", ] +[[package]] +name = "dr-film" +version = "0.6.0" +dependencies = [ + "log", + "serde", + "serde_norway", +] + [[package]] name = "dr-gpu" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index 9c1e690..6ffabd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "core/dr-thumbs", "core/dr-decode", "core/dr-export", + "core/dr-film", "core/dr-ingest", "core/dr-gpu", "core/dr-lens", @@ -34,6 +35,7 @@ dr-catalog = { path = "core/dr-catalog" } dr-thumbs = { path = "core/dr-thumbs" } dr-decode = { path = "core/dr-decode" } dr-export = { path = "core/dr-export" } +dr-film = { path = "core/dr-film" } dr-ingest = { path = "core/dr-ingest" } dr-gpu = { path = "core/dr-gpu" } dr-lens = { path = "core/dr-lens" } diff --git a/core/dr-film/Cargo.toml b/core/dr-film/Cargo.toml new file mode 100644 index 0000000..e6f150e --- /dev/null +++ b/core/dr-film/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dr-film" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +# Isolated from dr-pipeline for the same reason dr-lens is: that crate has no +# dependencies so its codegen stays testable without a device (ARCH §6.5a), and +# a YAML parser plus the stock profiles do not belong in it. The pipeline +# consumes the baked tables this crate produces and never links the profiles. +# +# No wgpu dependency either, deliberately. What comes out of here is plain +# `f32` data with a documented layout; deciding it is a 3D texture is dr-gpu's +# job, and keeping that decision out of here is what lets the whole spectral +# model be tested on the CPU. +[dependencies] +log.workspace = true +serde.workspace = true +serde_norway.workspace = true diff --git a/core/dr-film/README.md b/core/dr-film/README.md new file mode 100644 index 0000000..cc746bf --- /dev/null +++ b/core/dr-film/README.md @@ -0,0 +1,76 @@ +# Film stocks + +One file per stock in [`profiles/`](profiles/). Adding a stock is adding a +file — no code change, no shader, no new operation — for the same reason +`dr-decode`'s base curves work that way: under the GPLv3 a stock should be +contributable without a release. + +## What a profile is + +Three measured tables, all of them published in the manufacturer's datasheet: + +| Field | What it decides | +|---|---| +| `log_sensitivity` | what each emulsion layer *sees*, per wavelength | +| `density_curves` | contrast, latitude, and where the stock clips | +| `dye_density` | what the developed stock *looks* like, per wavelength | +| `base_density` | the support: film base, and a colour negative's orange mask | + +Plus `kind` (negative or positive), `support` (film or paper), and the two +illuminants the data is referenced to. A print paper is a stock like any +other; `support` exists so an interface can offer papers separately, not +because the renderer treats them differently. + +## Why it is not a LUT + +Because the parameters stay physical. Opening up a stop moves the picture along +the film's own characteristic curve — toe, shoulder and all — instead of +scaling a number somebody baked at one exposure. A scanned negative comes out +orange and inverted because that is what a negative *is*, and it becomes a +photograph when a paper profile prints it, exactly as it would in a darkroom. + +The data cost runs the other way from a LUT collection too: a stock is about +17 kB of measurements, where one HaldCLUT is roughly 800 kB of one person's +grade. + +## How it runs + +The spectral chain reduces to three tables, and the reduction is exact where it +matters — see [`src/bake.rs`](src/bake.rs) for the argument: + +1. **A 3×3 matrix**, linear sRGB to the three layers' exposure. Exact, not an + approximation: the reconstructed scene spectrum is linear in the sRGB + triple, so the integral collapses into nine numbers. +2. **Three 1D curves**, log exposure to density, sampled at 256 points. +3. **One 32³ lookup**, density to linear sRGB — dye absorption, the print + through the negative, the paper, the viewing illuminant and the chromatic + adaptation, all of which take exactly three numbers in. + +Per pixel that is a matrix multiply, three curve taps and one texture fetch. +Splitting 2 from 3, rather than baking one LUT over exposure, is measured +rather than assumed: the curve carries all the sharp shape and the dye mixing +is smooth, so folding the curve into the 3D lookup would need it three times +larger for the same error. At 32³ the worst interpolation error is about 0.003 +in linear sRGB, below one 8-bit code value, and there is a test that says so. + +## Adding a stock + +If spektrafilm has it, add its name to `STOCKS` in +[`tools/film-profiles/convert.py`](../../tools/film-profiles/convert.py) and +re-run it. Otherwise write the YAML by hand from the datasheet; the loader +validates the table lengths and says which file and field is wrong. + +Either way, list it in `BUILT_IN` in [`src/lib.rs`](src/lib.rs) to compile it +in — or drop it in the profile directory at runtime, which is the path meant +for stocks that ship separately from the binary. + +## Provenance + +The shipped profiles are converted from +[spektrafilm](https://github.com/andreavolpato/spektrafilm) by Andrea Volpato, +licensed CC BY-SA 4.0. See [`profiles/LICENSE-PROFILES.txt`](profiles/LICENSE-PROFILES.txt) +for the licence and [`profiles/CHANGELOG.txt`](profiles/CHANGELOG.txt) for what +the conversion changed and what it deliberately did not. + +The sRGB reflectance basis is Mallett & Yuksel (2019); the observer is the CIE +1931 2°. diff --git a/core/dr-film/profiles/CHANGELOG.txt b/core/dr-film/profiles/CHANGELOG.txt new file mode 100644 index 0000000..729835e --- /dev/null +++ b/core/dr-film/profiles/CHANGELOG.txt @@ -0,0 +1,74 @@ +Changes made to the spektrafilm profiles shipped in this directory +================================================================= + +The profiles here are derived from spektrafilm by Andrea Volpato +(https://github.com/andreavolpato/spektrafilm), licensed CC BY-SA 4.0. The +full licence is in LICENSE-PROFILES.txt and is reproduced unchanged. + +CC BY-SA 4.0 section 3(a)(1)(B) requires that a modified copy say it was +modified. It was. This file says how, and tools/film-profiles/convert.py +performs the modification, so it can be re-run against upstream and the result +compared rather than taken on trust. + + +What was changed +---------------- + +1. Format. Upstream ships JSON; these are YAML, so that adding or correcting a + stock is editing a legible file rather than a minified one. No value is + altered by the reformat. + +2. Trimmed to the fields this renderer reads: + + kept info.*, data.wavelengths (implicitly, as the fixed grid), + data.log_sensitivity, data.channel_density (renamed + dye_density), data.base_density, data.log_exposure (kept as its + two endpoints, since it is uniformly sampled), + data.density_curves + + dropped data.density_curves_model - a 3-CDF fit of the curves; the + sampled curves are shipped + instead, and reproduce it to + 0.004 density + data.density_curves_layers - per-sublayer curves, used for + grain, which is not implemented + yet. Worth restoring when it is: + real grain is per sublayer. + data.hanatos2025_adaptation_* - parameters for a spectral + upsampling method this renderer + does not use; see below + data.midscale_neutral_density - null in every profile shipped + + Dropping fields loses nothing for the stocks shipped, but it does mean a + re-run of the converter is needed to pick up an upstream field later. + +3. Numbers are written at 6 significant figures (5 for the density curves). + The inputs are digitised datasheet curves, so this is well inside their + measurement error; it is what takes a profile from 207 kB to 17 kB. + +4. Nulls made explicit. Upstream uses null where a datasheet has no reading. + In log_sensitivity that means the layer is blind there, written here as the + sentinel -9; in the density tables it means no absorption, written as 0. + + +What was NOT changed +-------------------- + +No measured value has been rescaled, shifted, smoothed or refitted. The +renderer's own calibration conventions - mid-grey at 0.184, exposure +normalised on the green layer - are taken from spektrafilm's reference +implementation rather than invented, because the profile data is calibrated +against them. + + +Known deviation from upstream's rendering +----------------------------------------- + +Upstream reconstructs a spectrum from an RGB triple with Hanatos (2025), which +needs a 4 MB coefficient table. This renderer uses the Mallett & Yuksel (2019) +sRGB basis instead, which is three curves and about 1 kB, at some cost in how +faithfully very saturated and out-of-gamut colours are handled. The +hanatos2025_adaptation_* parameters in the upstream profiles are therefore +unused here. This is a deliberate trade of accuracy at the gamut edge against +shipping four megabytes, and it is the first thing to revisit if saturated +colours look wrong. diff --git a/core/dr-film/profiles/LICENSE-PROFILES.txt b/core/dr-film/profiles/LICENSE-PROFILES.txt new file mode 100644 index 0000000..303ba46 --- /dev/null +++ b/core/dr-film/profiles/LICENSE-PROFILES.txt @@ -0,0 +1,520 @@ +================================================================================ + License for spektrafilm profiles, LUTs, and direct derivatives + by Andrea Volpato + License: CC BY-SA 4.0 | Preamble v1.1 (2026-05-28) +================================================================================ + + +This license applies to the original spektrafilm profiles that can be found at +https://github.com/andreavolpato/spektrafilm, in the subfolder +src/spektrafilm/data/profiles, and to all direct derivatives of the profiles, +such as copies in other projects, LUTs, or any other format that encodes the +same content. + +LUTs and similar artifacts are interpreted as direct encodings of the +information in the original profiles. I chose not to release them under GPLv3 +because GPL's software-shaped "derivative work" model fits LUTs poorly and would +have made adoption more difficult. CC BY-SA 4.0 preserves the same share-alike +spirit in a form that matches how creative-grading assets are actually handled. + +Please do not modify this file. Changes to profiles and LUTs should be tracked +in a separate CHANGELOG.txt file shipped with them. A copy of this license is +available at: +https://github.com/andreavolpato/spektrafilm/blob/main/SPEKTRAFILM_LICENSE.txt + +--- + +A note before the legal text. + +spektrafilm is a film-emulation project, designed in the open, given away +freely, and made to stay that way. Use it on anything. Share it widely. Build +on it. The works you make are yours, completely. + +What follows is the full text of the Creative Commons Attribution-ShareAlike +4.0 International Public License (CC BY-SA 4.0). It is the legal floor. The +spirit it exists to protect, in three lines: + + - Free use forever, on any production, of any size. + - Full credit to the source, on every copy and every derivative. + - No parasitism. + + +-------------------------------------------------------------------------------- +WHAT YOU CAN DO +-------------------------------------------------------------------------------- + +Use these profiles and LUTs freely to grade anything - personal, educational, +or commercial; small project or feature film; any platform, any deliverable. +The works you make are yours. No royalties. No copyleft on your finished film +or photograph. No obligation flowing to your distributor, broadcaster, or +audience. + +Share the files freely with friends, collaborators, vendors, and post houses. + +Modify, remix, regrade, recombine. Modified profiles or LUTs must stay +CC BY-SA 4.0 and credit spektrafilm. + + +-------------------------------------------------------------------------------- +ATTRIBUTION - ALWAYS, EVERYWHERE, FOREVER +-------------------------------------------------------------------------------- + +Every copy, redistribution, modification, or derivative of these files - in +any format, in any context, across any chain of hands - must preserve: + + - Andrea Volpato, named as the original author. + - https://github.com/andreavolpato/spektrafilm, as the canonical source. + - The CC BY-SA 4.0 license notice (this file, or a link to it). + - A note that the file has been modified, if it has, and by whom. + +This applies whether the LUT is shipped inside a .cube header, an ICC profile +field, an app's About screen, a paid plugin, a derived .3dl, training-data +documentation, or anywhere else. The form can vary; the information cannot +disappear. + +Removing or hiding this information ends the license grant immediately +(CC BY-SA 4.0, Section 6(a)). + + +-------------------------------------------------------------------------------- +SUGGESTED ATTRIBUTION TEXT +-------------------------------------------------------------------------------- + +For unmodified files: + + spektrafilm by Andrea Volpato + https://github.com/andreavolpato/spektrafilm + Licensed CC BY-SA 4.0 + +For modified files: + + Derived from spektrafilm by Andrea Volpato + https://github.com/andreavolpato/spektrafilm + Licensed CC BY-SA 4.0 + Modified by [your name]. + + +-------------------------------------------------------------------------------- +WHAT I ASK, BEYOND THE LEGAL FLOOR +-------------------------------------------------------------------------------- + +These are not legal requirements. They are the spirit of the project, stated +plainly, so nobody has to guess. + + - Keep this file unchanged when sharing profiles/LUTs, and if changes were + made describe them in a file CHANGELOG.txt shipped together with this + license. + + - Credit spektrafilm in the human layer too - project docs, about pages, + credits, breakdowns. Not required on a finished film or photograph, but + appreciated wherever it's natural. + + - Don't use "spektrafilm" or my name in product branding without asking. + The license covers the files; the name is not part of that grant. + Factual reference is welcome and even encouraged, for example "graded with + spektrafilm", or "this app uses spektrafilm LUTs". + + - Paid apps and tools that add real value are welcome. Charge for what you + built - UI, performance, support, a service. Please don't repackage the + LUTs themselves as a paid LUT pack: CC BY-SA technically allows it, but + that's not the spirit of the project, and any buyer can legally + redistribute for free anyway. + + - Please don't train commercial AI models on the LUTs or their outputs. + CC BY-SA doesn't forbid this, but it is asked. Models that approximate + or replace the spektrafilm look defeat the purpose of giving these away + freely. Non-commercial research training is fine. + + +-------------------------------------------------------------------------------- +CONTACT +-------------------------------------------------------------------------------- + +GitHub Issues or Discussions on the spektrafilm repository. + +For private inquiries about commercial licensing, name use, or custom work, +write to andrea.volpato@outlook.com. + + +-------------------------------------------------------------------------------- +A small thank you +-------------------------------------------------------------------------------- + +If spektrafilm finds its way into your work, that is already the reward. If +you want to credit it generously, share what you made, or just say hi, please do +not hesitate. Make beautiful images. + + Andrea Volpato + + +================================================================================ +END OF SPEKTRAFILM PREAMBLE - BEGIN CC BY-SA 4.0 LICENSE TEXT (verbatim) +================================================================================ + + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-ShareAlike 4.0 International Public License ("Public +License"). To the extent this Public License may be interpreted as a +contract, You are granted the Licensed Rights in consideration of Your +acceptance of these terms and conditions, and the Licensor grants You +such rights in consideration of benefits the Licensor receives from +making the Licensed Material available under these terms and +conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. BY-SA Compatible License means a license listed at + creativecommons.org/compatiblelicenses, approved by Creative + Commons as essentially the equivalent of this Public License. + + d. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + e. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + f. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + g. License Elements means the license attributes listed in the name + of a Creative Commons Public License. The License Elements of this + Public License are Attribution and ShareAlike. + + h. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + i. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + j. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + k. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + l. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + m. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. Additional offer from the Licensor -- Adapted Material. + Every recipient of Adapted Material from You + automatically receives an offer from the Licensor to + exercise the Licensed Rights in the Adapted Material + under the conditions of the Adapter's License You apply. + + c. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + b. ShareAlike. + + In addition to the conditions in Section 3(a), if You Share + Adapted Material You produce, the following conditions also apply. + + 1. The Adapter's License You apply must be a Creative Commons + license with the same License Elements, this version or + later, or a BY-SA Compatible License. + + 2. You must include the text of, or the URI or hyperlink to, the + Adapter's License You apply. You may satisfy this condition + in any reasonable manner based on the medium, means, and + context in which You Share Adapted Material. + + 3. You may not offer or impose any additional or different terms + or conditions on, or apply any Effective Technological + Measures to, Adapted Material that restrict exercise of the + rights granted under the Adapter's License You apply. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material, + including for purposes of Section 3(b); and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public licenses. Notwithstanding, +Creative Commons may elect to apply one of its public licenses to material it +publishes and in those instances will be considered the "Licensor." The text +of the Creative Commons public licenses is dedicated to the public domain +under the CC0 Public Domain Dedication. Except for the limited purpose of +indicating that material is shared under a Creative Commons public license or +as otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the use of +the trademark "Creative Commons" or any other trademark or logo of Creative +Commons without its prior written consent including, without limitation, in +connection with any unauthorized modifications to any of its public licenses +or any other arrangements, understandings, or agreements concerning use of +licensed material. For the avoidance of doubt, this paragraph does not form +part of the public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/core/dr-film/profiles/kodak_kodachrome_64.yaml b/core/dr-film/profiles/kodak_kodachrome_64.yaml new file mode 100644 index 0000000..fb45b2e --- /dev/null +++ b/core/dr-film/profiles/kodak_kodachrome_64.yaml @@ -0,0 +1,450 @@ +# Generated by tools/film-profiles/convert.py from spektrafilm. +# Do not edit by hand: re-run the converter instead. +# +# spektrafilm by Andrea Volpato, https://github.com/andreavolpato/spektrafilm +# Licensed CC BY-SA 4.0. Modified for DarkRoom: trimmed to the fields the +# renderer uses and reformatted; see profiles/CHANGELOG.txt. + +version: '0.3.2' +stock: kodak_kodachrome_64 +name: 'Kodak Kodachrome 64' +kind: positive # negative | positive +support: film # film | paper +reference_illuminant: D55 +viewing_illuminant: D50 + +# log10 spectral sensitivity per layer, 380-780nm at 5nm, in R,G,B layer +# order. A null upstream means the datasheet has no reading there, which is +# blindness, so it is written as the sentinel the loader reads as such. +log_sensitivity: + - [-4.46214, -3.15351, -0.995926] # 380nm + - [-4.40933, -3.09598, -0.929097] # 385nm + - [-4.35484, -3.03587, -0.857493] # 390nm + - [-4.29857, -2.973, -0.780586] # 395nm + - [-4.24045, -2.90719, -0.697763] # 400nm + - [-4.18038, -2.83825, -0.608314] # 405nm + - [-4.11826, -2.76598, -0.51141] # 410nm + - [-4.054, -2.69015, -0.406081] # 415nm + - [-3.98749, -2.61058, -0.29748] # 420nm + - [-3.91861, -2.5271, -0.283544] # 425nm + - [-3.84725, -2.43971, -0.304033] # 430nm + - [-3.77328, -2.3488, -0.336903] # 435nm + - [-3.69658, -2.25636, -0.381643] # 440nm + - [-3.61702, -2.17402, -0.438245] # 445nm + - [-3.5345, -2.11369, -0.492545] # 450nm + - [-3.44893, -2.05413, -0.545499] # 455nm + - [-3.36028, -1.99915, -0.598252] # 460nm + - [-3.26865, -1.95299, -0.651178] # 465nm + - [-3.17445, -1.89947, -0.713178] # 470nm + - [-3.07897, -1.7772, -0.802671] # 475nm + - [-2.98655, -1.62251, -0.91109] # 480nm + - [-2.92017, -1.49243, -1.0519] # 485nm + - [-2.90144, -1.38199, -1.21827] # 490nm + - [-2.88287, -1.26603, -1.39796] # 495nm + - [-2.85493, -1.15267, -1.60482] # 500nm + - [-2.80572, -1.02914, -1.81046] # 505nm + - [-2.72966, -0.920272, -2.03045] # 510nm + - [-2.63272, -0.817001, -2.25779] # 515nm + - [-2.55447, -0.725702, -2.48721] # 520nm + - [-2.49041, -0.645483, -2.72649] # 525nm + - [-2.43807, -0.567227, -2.96141] # 530nm + - [-2.39766, -0.481455, -3.18441] # 535nm + - [-2.36086, -0.392548, -3.38958] # 540nm + - [-2.32322, -0.313171, -3.57896] # 545nm + - [-2.2732, -0.271903, -3.75431] # 550nm + - [-2.18787, -0.271253, -3.91714] # 555nm + - [-2.05752, -0.306203, -4.06874] # 560nm + - [-1.9276, -0.368685, -4.21023] # 565nm + - [-1.78811, -0.479639, -4.3426] # 570nm + - [-1.62896, -0.655083, -4.46669] # 575nm + - [-1.44392, -0.924283, -4.58326] # 580nm + - [-1.23269, -1.28153, -4.69297] # 585nm + - [-1.03875, -1.72149, -4.79642] # 590nm + - [-0.92718, -2.15137, -4.89412] # 595nm + - [-0.842319, -2.49342, -4.98653] # 600nm + - [-0.783739, -2.73332, -5.07408] # 605nm + - [-0.731348, -2.94159, -5.15715] # 610nm + - [-0.679261, -3.19096, -5.23606] # 615nm + - [-0.620022, -3.42608, -5.31112] # 620nm + - [-0.514782, -3.64814, -5.3826] # 625nm + - [-0.398351, -3.8582, -5.45076] # 630nm + - [-0.305333, -4.0572, -5.51583] # 635nm + - [-0.237242, -4.246, -5.578] # 640nm + - [-0.178503, -4.42535, -5.63746] # 645nm + - [-0.150224, -4.59596, -5.6944] # 650nm + - [-0.204724, -4.75844, -5.74897] # 655nm + - [-0.36457, -4.91337, -5.8013] # 660nm + - [-0.564382, -5.06125, -5.85155] # 665nm + - [-0.820564, -5.20256, -5.89982] # 670nm + - [-1.17385, -5.33773, -5.94624] # 675nm + - [-1.62004, -5.46715, -5.99091] # 680nm + - [-1.96204, -5.59117, -6.03392] # 685nm + - [-2.24109, -5.71013, -6.07536] # 690nm + - [-2.49926, -5.82433, -6.11533] # 695nm + - [-2.74838, -5.93406, -6.1539] # 700nm + - [-2.99905, -6.03956, -6.19113] # 705nm + - [-3.23858, -6.14108, -6.2271] # 710nm + - [-3.46769, -6.23884, -6.26188] # 715nm + - [-3.68705, -6.33305, -6.29551] # 720nm + - [-3.89728, -6.4239, -6.32806] # 725nm + - [-4.09892, -6.51155, -6.35957] # 730nm + - [-4.2925, -6.59618, -6.3901] # 735nm + - [-4.47848, -6.67795, -6.4197] # 740nm + - [-4.65732, -6.75699, -6.44839] # 745nm + - [-4.8294, -6.83343, -6.47623] # 750nm + - [-4.99511, -6.90741, -6.50325] # 755nm + - [-5.1548, -6.97905, -6.52948] # 760nm + - [-5.30878, -7.04844, -6.55497] # 765nm + - [-5.45736, -7.1157, -6.57974] # 770nm + - [-5.60081, -7.18092, -6.60382] # 775nm + - [-5.7394, -7.24419, -6.62724] # 780nm + +# Spectral density of each layer's dye at unit density, same grid and order. +dye_density: + - [0, 0, 0] # 380nm + - [0, 0, 0] # 385nm + - [0, 0, 0] # 390nm + - [0, 0, 0] # 395nm + - [0, 0, 0] # 400nm + - [0, 0, 0] # 405nm + - [0, 0, 0] # 410nm + - [0, 0, 0] # 415nm + - [0, 0, 0] # 420nm + - [0.132063, 0.304413, 1.0302] # 425nm + - [0.110557, 0.285921, 1.09084] # 430nm + - [0.0907636, 0.26894, 1.12892] # 435nm + - [0.0731545, 0.254546, 1.14915] # 440nm + - [0.0582581, 0.242581, 1.15153] # 445nm + - [0.0465229, 0.235263, 1.13547] # 450nm + - [0.0375913, 0.233914, 1.10335] # 455nm + - [0.030681, 0.240389, 1.05635] # 460nm + - [0.0263108, 0.258726, 0.993064] # 465nm + - [0.0233067, 0.288314, 0.916001] # 470nm + - [0.0218301, 0.337849, 0.821932] # 475nm + - [0.0203692, 0.398233, 0.714279] # 480nm + - [0.019749, 0.461557, 0.60949] # 485nm + - [0.0184053, 0.52715, 0.513298] # 490nm + - [0.0184053, 0.596095, 0.425455] # 495nm + - [0.0184053, 0.669677, 0.345381] # 500nm + - [0.0184053, 0.74439, 0.277062] # 505nm + - [0.0187644, 0.82762, 0.221787] # 510nm + - [0.0205399, 0.901469, 0.175232] # 515nm + - [0.0224509, 0.962613, 0.134817] # 520nm + - [0.0254577, 1.01137, 0.103614] # 525nm + - [0.029295, 1.04935, 0.0783271] # 530nm + - [0.0344928, 1.07011, 0.0609916] # 535nm + - [0.0431791, 1.07603, 0.0444904] # 540nm + - [0.0535629, 1.06696, 0.0341927] # 545nm + - [0.0682108, 1.04256, 0.0264798] # 550nm + - [0.0846369, 1.00057, 0.0206373] # 555nm + - [0.103981, 0.949336, 0.015902] # 560nm + - [0.12617, 0.886999, 0.0123947] # 565nm + - [0.152656, 0.814541, 0.0088987] # 570nm + - [0.184423, 0.733298, 0.00755192] # 575nm + - [0.222236, 0.647432, 0.00667624] # 580nm + - [0.27164, 0.566835, 0.0054423] # 585nm + - [0.338617, 0.490093, 0.0054423] # 590nm + - [0.418873, 0.419349, 0.0054423] # 595nm + - [0.512248, 0.356261, 0.0054423] # 600nm + - [0.624482, 0.301552, 0.0054423] # 605nm + - [0.746012, 0.252773, 0.0054423] # 610nm + - [0.871893, 0.212174, 0.0054423] # 615nm + - [0.997155, 0.180612, 0.0054423] # 620nm + - [1.10993, 0.15459, 0.00662863] # 625nm + - [1.20916, 0.131815, 0.00685166] # 630nm + - [1.27283, 0.11411, 0.00826102] # 635nm + - [1.30487, 0.0995825, 0.00826102] # 640nm + - [1.30128, 0.088025, 0.00826102] # 645nm + - [1.27159, 0.0781854, 0.00826102] # 650nm + - [1.21934, 0.0702896, 0.00826102] # 655nm + - [1.15587, 0.0633927, 0.00826102] # 660nm + - [1.08056, 0.0573937, 0.00826102] # 665nm + - [1.0013, 0.0516546, 0.00826102] # 670nm + - [0.922994, 0.0462942, 0.00826102] # 675nm + - [0.850116, 0.041061, 0.00826102] # 680nm + - [0.780485, 0.0358446, 0.00826102] # 685nm + - [0.716448, 0.0309058, 0.00826102] # 690nm + - [0.655474, 0.0257217, 0.00826102] # 695nm + - [0, 0, 0] # 700nm + - [0, 0, 0] # 705nm + - [0, 0, 0] # 710nm + - [0, 0, 0] # 715nm + - [0, 0, 0] # 720nm + - [0, 0, 0] # 725nm + - [0, 0, 0] # 730nm + - [0, 0, 0] # 735nm + - [0, 0, 0] # 740nm + - [0, 0, 0] # 745nm + - [0, 0, 0] # 750nm + - [0, 0, 0] # 755nm + - [0, 0, 0] # 760nm + - [0, 0, 0] # 765nm + - [0, 0, 0] # 770nm + - [0, 0, 0] # 775nm + - [0, 0, 0] # 780nm + +# The support's own density -- film base plus, for a colour negative, the +# orange mask. Flat zero where the datasheet does not give it. +base_density: [0.199556, 0.199555, 0.199552, 0.199546, 0.199533, 0.199509, 0.199466, 0.199391, 0.199269, 0.19908, 0.198804, 0.198418, 0.197904, 0.19725, 0.196451, 0.195511, 0.194443, 0.193267, 0.192002, 0.190673, 0.189298, 0.187897, 0.186483, 0.185071, 0.183673, 0.182303, 0.180975, 0.179705, 0.178506, 0.177392, 0.17637, 0.175444, 0.17461, 0.173862, 0.173185, 0.172568, 0.171995, 0.171455, 0.17094, 0.170445, 0.169968, 0.169511, 0.169079, 0.168678, 0.168314, 0.167994, 0.167723, 0.1675, 0.167325, 0.167194, 0.1671, 0.167036, 0.166995, 0.166969, 0.166954, 0.166946, 0.166942, 0.16694, 0.166939, 0.166938, 0.166938, 0.166938, 0.166938, 0.166938, 0.166938, 0.166938, 0.166938, 0.166938, 0.166938, 0.166938, 0.166938, 0.166938, 0.166938, 0.166938, 0.166938, 0.166938, 0.166938, 0.166938, 0.166938, 0.166938, 0.166938] + +# The characteristic curves: density against log10 exposure, sampled +# uniformly over [-3, 4]. +log_exposure_min: -3 +log_exposure_max: 4 +density_curves: + - [2.9085, 2.8022, 2.258] + - [2.9086, 2.8023, 2.2581] + - [2.9088, 2.8026, 2.2583] + - [2.9091, 2.8028, 2.2585] + - [2.9092, 2.803, 2.2587] + - [2.9093, 2.803, 2.259] + - [2.9094, 2.803, 2.2592] + - [2.9096, 2.803, 2.2593] + - [2.9097, 2.8032, 2.2592] + - [2.9098, 2.8034, 2.2591] + - [2.9099, 2.8035, 2.2589] + - [2.9098, 2.8034, 2.2588] + - [2.9096, 2.8032, 2.2587] + - [2.9095, 2.8029, 2.2588] + - [2.9093, 2.8028, 2.2588] + - [2.9092, 2.8028, 2.2589] + - [2.9091, 2.8029, 2.259] + - [2.9091, 2.8031, 2.259] + - [2.9092, 2.8032, 2.2589] + - [2.9093, 2.8032, 2.2588] + - [2.9094, 2.8032, 2.2585] + - [2.9095, 2.8031, 2.2582] + - [2.9094, 2.8029, 2.258] + - [2.9092, 2.8027, 2.2578] + - [2.9088, 2.8025, 2.2577] + - [2.9084, 2.8024, 2.2576] + - [2.908, 2.8024, 2.2574] + - [2.9077, 2.8025, 2.257] + - [2.9075, 2.8025, 2.2565] + - [2.9074, 2.8024, 2.2559] + - [2.9071, 2.802, 2.2552] + - [2.9066, 2.8016, 2.2545] + - [2.9059, 2.8012, 2.2537] + - [2.905, 2.8008, 2.2527] + - [2.904, 2.8006, 2.2515] + - [2.903, 2.8002, 2.2498] + - [2.9019, 2.7995, 2.2478] + - [2.9006, 2.7985, 2.2455] + - [2.899, 2.797, 2.2429] + - [2.897, 2.7953, 2.2401] + - [2.8945, 2.7936, 2.2369] + - [2.8916, 2.7919, 2.2334] + - [2.8883, 2.7901, 2.2293] + - [2.8846, 2.788, 2.2246] + - [2.8805, 2.7854, 2.2192] + - [2.8758, 2.7821, 2.2129] + - [2.8706, 2.7781, 2.2057] + - [2.8647, 2.7734, 2.1974] + - [2.8578, 2.768, 2.1883] + - [2.8498, 2.762, 2.1784] + - [2.8406, 2.7552, 2.1676] + - [2.8301, 2.7477, 2.156] + - [2.8185, 2.7394, 2.1433] + - [2.8059, 2.7302, 2.1295] + - [2.7924, 2.7199, 2.1142] + - [2.7776, 2.708, 2.0973] + - [2.7611, 2.6942, 2.0784] + - [2.7428, 2.6785, 2.0579] + - [2.7227, 2.6613, 2.0363] + - [2.7008, 2.6431, 2.0137] + - [2.6772, 2.6241, 1.9898] + - [2.6521, 2.6041, 1.9641] + - [2.6254, 2.5823, 1.9362] + - [2.5972, 2.5582, 1.9062] + - [2.5672, 2.5314, 1.8746] + - [2.535, 2.5023, 1.8416] + - [2.5002, 2.4711, 1.8077] + - [2.4627, 2.4381, 1.7726] + - [2.4228, 2.4032, 1.7365] + - [2.3812, 2.3659, 1.6995] + - [2.3386, 2.3259, 1.6619] + - [2.2954, 2.2837, 1.6238] + - [2.2515, 2.24, 1.5851] + - [2.2063, 2.1958, 1.5453] + - [2.1589, 2.1512, 1.5038] + - [2.1092, 2.1059, 1.4608] + - [2.0575, 2.0587, 1.4169] + - [2.0049, 2.0092, 1.3731] + - [1.9523, 1.9576, 1.3304] + - [1.9, 1.9049, 1.2892] + - [1.8479, 1.852, 1.2489] + - [1.7954, 1.7992, 1.2091] + - [1.7423, 1.7462, 1.169] + - [1.6886, 1.6924, 1.1287] + - [1.6351, 1.6376, 1.0884] + - [1.5821, 1.5827, 1.0484] + - [1.5296, 1.529, 1.0089] + - [1.4774, 1.4772, 0.97011] + - [1.425, 1.4273, 0.93214] + - [1.3728, 1.3779, 0.89522] + - [1.3213, 1.3278, 0.85962] + - [1.2712, 1.2767, 0.82559] + - [1.223, 1.2257, 0.7932] + - [1.1767, 1.1761, 0.76213] + - [1.1317, 1.1289, 0.73162] + - [1.0874, 1.0842, 0.70096] + - [1.0435, 1.0413, 0.67004] + - [1.0001, 0.99908, 0.63933] + - [0.95744, 0.95715, 0.60948] + - [0.9158, 0.91569, 0.58086] + - [0.87518, 0.8753, 0.55346] + - [0.83535, 0.83644, 0.527] + - [0.79618, 0.79905, 0.50124] + - [0.75785, 0.76267, 0.47618] + - [0.72084, 0.72682, 0.45194] + - [0.68564, 0.69139, 0.42854] + - [0.65237, 0.65673, 0.40576] + - [0.62063, 0.62336, 0.38322] + - [0.58962, 0.59158, 0.36067] + - [0.55861, 0.56127, 0.33821] + - [0.52729, 0.53202, 0.31633] + - [0.49595, 0.5034, 0.29558] + - [0.46524, 0.4752, 0.27623] + - [0.43579, 0.44746, 0.25814] + - [0.40784, 0.42029, 0.24087] + - [0.38137, 0.39387, 0.22388] + - [0.3562, 0.36837, 0.20686] + - [0.33206, 0.3438, 0.18997] + - [0.3086, 0.31988, 0.17386] + - [0.28567, 0.29648, 0.15923] + - [0.26352, 0.27399, 0.14621] + - [0.24263, 0.25313, 0.13429] + - [0.22323, 0.23426, 0.12277] + - [0.20505, 0.21681, 0.1114] + - [0.18763, 0.19986, 0.10047] + - [0.17067, 0.18278, 0.090413] + - [0.15431, 0.16571, 0.081429] + - [0.139, 0.14939, 0.073327] + - [0.12518, 0.13464, 0.065691] + - [0.11293, 0.1218, 0.058226] + - [0.10196, 0.11055, 0.050983] + - [0.09181, 0.10019, 0.044298] + - [0.082155, 0.090144, 0.038512] + - [0.07297, 0.080304, 0.033704] + - [0.064433, 0.071013, 0.029651] + - [0.056692, 0.06271, 0.026] + - [0.049767, 0.055569, 0.022537] + - [0.043556, 0.049429, 0.019363] + - [0.037922, 0.043989, 0.016755] + - [0.032771, 0.038987, 0.014852] + - [0.028088, 0.034266, 0.013455] + - [0.02393, 0.02978, 0.012125] + - [0.020379, 0.025583, 0.010495] + - [0.017439, 0.021771, 0.0085081] + - [0.014967, 0.018403, 0.0064391] + - [0.012763, 0.015457, 0.0046916] + - [0.010736, 0.012876, 0.0035544] + - [0.0089286, 0.010648, 0.0030383] + - [0.0074304, 0.0088009, 0.0028922] + - [0.0062662, 0.0073444, 0.0027804] + - [0.0053567, 0.0062203, 0.0024881] + - [0.0045781, 0.0053078, 0.0020191] + - [0.0038543, 0.0044887, 0.001533] + - [0.0031959, 0.0037139, 0.0011894] + - [0.0026499, 0.0030152, 0.0010179] + - [0.0022153, 0.0024513, 0.00090555] + - [0.0018127, 0.0020369, 0.00069597] + - [0.0013485, 0.0017151, 0.00031643] + - [0.0008214, 0.0013997, -0.00016165] + - [0.00036673, 0.0010497, -0.00056707] + - [0.00017774, 0.00071158, -0.00073838] + - [0.00034171, 0.00047793, -0.00060922] + - [0.00076269, 0.0004524, -0.00025992] + - [0.0011692, 0.00067021, 0.00012444] + - [0.001241, 0.0010155, 0.00038129] + - [0.00086169, 0.0012732, 0.00046591] + - [0.00026144, 0.0012636, 0.00046137] + - [-0.0001334, 0.000958, 0.00048917] + - [-5.5336e-05, 0.00049013, 0.00058925] + - [0.00036801, 6.3205e-05, 0.00067619] + - [0.0007325, -0.00017662, 0.00061886] + - [0.00072513, -0.00021671, 0.00037106] + - [0.00036552, -0.00011557, -1.0987e-06] + - [-2.5902e-05, 4.8655e-05, -0.00035504] + - [-0.00010116, 0.00019943, -0.00054649] + - [0.00021386, 0.00026529, -0.00051014] + - [0.00064057, 0.00018964, -0.00029747] + - [0.0007655, -3.8185e-05, -4.8039e-05] + - [0.00038647, -0.00035067, 9.0634e-05] + - [-0.00029492, -0.00060922, 4.8954e-05] + - [-0.0008038, -0.00067051, -0.00012446] + - [-0.00075863, -0.00048123, -0.00029445] + - [-0.00018969, -0.00013211, -0.00032643] + - [0.00045097, 0.000182, -0.00017628] + - [0.00061513, 0.0002841, 7.5426e-05] + - [8.0946e-05, 0.00013819, 0.0002707] + - [-0.00085257, -0.00011985, 0.00027651] + - [-0.0015592, -0.00027633, 7.6242e-05] + - [-0.0015325, -0.00019738, -0.00021264] + - [-0.00075622, 6.6026e-05, -0.00041065] + - [0.00028047, 0.00031004, -0.00039291] + - [0.00093235, 0.00033118, -0.00017229] + - [0.00085257, 9.0093e-05, 0.00010842] + - [0.00021762, -0.00024001, 0.00027098] + - [-0.00043182, -0.00038625, 0.00021843] + - [-0.00059978, -0.00017656, -5.3081e-06] + - [-0.00018224, 0.00031533, -0.00025111] + - [0.00049482, 0.00079007, -0.00036657] + - [0.00094501, 0.0009022, -0.00028892] + - [0.00086622, 0.00048514, -6.6985e-05] + - [0.00036907, -0.00028899, 0.00017012] + - [-0.00021405, -0.0010194, 0.00033786] + - [-0.00064117, -0.0013644, 0.00045045] + - [-0.00084149, -0.0012406, 0.00054246] + - [-0.00080635, -0.00080673, 0.00057742] + - [-0.00054163, -0.00030449, 0.00047883] + - [-0.00014085, 9.0816e-05, 0.00025062] + - [0.00020645, 0.00032748, 2.2587e-05] + - [0.00039606, 0.00042477, -7.1076e-05] + - [0.00046421, 0.00045154, -3.5319e-06] + - [0.00045927, 0.00050805, 0.00014307] + - [0.00041221, 0.00062938, 0.00021518] + - [0.00032428, 0.00075855, 9.2604e-05] + - [0.00019112, 0.00078669, -0.0002217] + - [3.4831e-05, 0.00063167, -0.00059374] + - [-8.8526e-05, 0.00030183, -0.00084102] + - [-0.00011636, -9.7907e-05, -0.00084713] + - [-2.3143e-05, -0.00042194, -0.00062749] + - [0.00015297, -0.00056606, -0.00030374] + - [0.00032266, -0.0005232, -1.3704e-05] + - [0.00039221, -0.00037849, 0.00017119] + - [0.00031597, -0.00024908, 0.00026249] + - [0.00012212, -0.00020961, 0.00030531] + - [-0.00010306, -0.00025131, 0.00031688] + - [-0.00026154, -0.000299, 0.00027163] + - [-0.00029259, -0.00027061, 0.00014416] + - [-0.000198, -0.00013689, -3.495e-05] + - [-3.3834e-05, 5.8756e-05, -0.00017183] + - [0.00012171, 0.00023137, -0.00017006] + - [0.00020459, 0.00031205, -1.5488e-05] + - [0.00018864, 0.00029076, 0.00018992] + - [9.0619e-05, 0.00021548, 0.00028646] + - [-3.9992e-05, 0.00015062, 0.00017361] + - [-0.00013957, 0.00012675, -0.00010314] + - [-0.00015232, 0.00011888, -0.00037067] + - [-5.2034e-05, 6.9298e-05, -0.00044858] + - [0.00014165, -6.1556e-05, -0.00028559] + - [0.00036103, -0.00025352, -5.6164e-06] + - [0.00050974, -0.00042611, 0.00017901] + - [0.0005025, -0.00048282, 0.00012788] + - [0.00031031, -0.00037305, -0.0001142] + - [-1.2991e-05, -0.00013156, -0.00034072] + - [-0.00034329, 0.00013132, -0.00033198] + - [-0.00053914, 0.00028491, -1.9892e-05] + - [-0.00051235, 0.00024706, 0.00045446] + - [-0.00027822, 2.7433e-05, 0.00083498] + - [4.533e-05, -0.00027584, 0.0009199] + - [0.00028471, -0.0005081, 0.00068138] + - [0.00031968, -0.0005594, 0.00034871] + - [0.00017724, -0.00042893, 0.00014744] + - [-1.6195e-05, -0.00019955, 9.3372e-05] + - [-0.00015589, 3.0964e-05, 5.2417e-05] + - [-0.00023099, 0.00020411, -6.4328e-05] + - [-0.00028939, 0.00031064, -0.00021919] + - [-0.00035502, 0.00036863, -0.00033587] + - [-0.00039868, 0.00039829, -0.00039436] diff --git a/core/dr-film/profiles/kodak_portra_400.yaml b/core/dr-film/profiles/kodak_portra_400.yaml new file mode 100644 index 0000000..3dd5cc1 --- /dev/null +++ b/core/dr-film/profiles/kodak_portra_400.yaml @@ -0,0 +1,451 @@ +# Generated by tools/film-profiles/convert.py from spektrafilm. +# Do not edit by hand: re-run the converter instead. +# +# spektrafilm by Andrea Volpato, https://github.com/andreavolpato/spektrafilm +# Licensed CC BY-SA 4.0. Modified for DarkRoom: trimmed to the fields the +# renderer uses and reformatted; see profiles/CHANGELOG.txt. + +version: '0.3.2' +stock: kodak_portra_400 +name: 'Kodak Portra 400' +kind: negative # negative | positive +support: film # film | paper +reference_illuminant: D55 +viewing_illuminant: D50 +target_print: kodak_portra_endura + +# log10 spectral sensitivity per layer, 380-780nm at 5nm, in R,G,B layer +# order. A null upstream means the datasheet has no reading there, which is +# blindness, so it is written as the sentinel the loader reads as such. +log_sensitivity: + - [-3.66312, -1.9105, -1.05544] # 380nm + - [-3.62778, -1.75123, -0.812042] # 385nm + - [-3.5912, -1.58419, -0.577251] # 390nm + - [-3.55332, -1.41524, -0.439554] # 395nm + - [-3.51406, -1.36541, -0.421037] # 400nm + - [-3.47335, -1.38313, -0.42822] # 405nm + - [-3.43111, -1.41667, -0.454152] # 410nm + - [-3.38725, -1.45601, -0.465475] # 415nm + - [-3.34168, -1.49309, -0.46525] # 420nm + - [-3.29429, -1.52477, -0.461761] # 425nm + - [-3.24497, -1.55499, -0.462887] # 430nm + - [-3.19361, -1.58089, -0.477981] # 435nm + - [-3.14007, -1.59898, -0.49958] # 440nm + - [-3.08423, -1.60768, -0.513982] # 445nm + - [-3.02592, -1.59969, -0.505317] # 450nm + - [-2.965, -1.58737, -0.47808] # 455nm + - [-2.90129, -1.57299, -0.44441] # 460nm + - [-2.83461, -1.54727, -0.465573] # 465nm + - [-2.76478, -1.45629, -0.576974] # 470nm + - [-2.69162, -1.31698, -0.757578] # 475nm + - [-2.615, -1.17901, -1.04004] # 480nm + - [-2.53494, -1.05228, -1.31219] # 485nm + - [-2.45211, -0.9545, -1.55602] # 490nm + - [-2.37103, -0.889429, -1.74412] # 495nm + - [-2.30372, -0.844224, -1.93419] # 500nm + - [-2.22635, -0.80203, -2.14501] # 505nm + - [-2.1563, -0.757219, -2.35322] # 510nm + - [-2.09593, -0.708979, -2.56234] # 515nm + - [-2.05102, -0.659683, -2.76203] # 520nm + - [-2.03118, -0.600564, -2.94796] # 525nm + - [-2.03124, -0.52984, -3.12148] # 530nm + - [-2.04938, -0.460433, -3.28381] # 535nm + - [-2.06575, -0.406837, -3.436] # 540nm + - [-2.06866, -0.387961, -3.57896] # 545nm + - [-2.01996, -0.42299, -3.71352] # 550nm + - [-1.8979, -0.471326, -3.84038] # 555nm + - [-1.76573, -0.525351, -3.9602] # 560nm + - [-1.62369, -0.585682, -4.07354] # 565nm + - [-1.41794, -0.647456, -4.18091] # 570nm + - [-1.18867, -0.777194, -4.28278] # 575nm + - [-0.982419, -1.0025, -4.37955] # 580nm + - [-0.825985, -1.29162, -4.47161] # 585nm + - [-0.742621, -1.64695, -4.55928] # 590nm + - [-0.678892, -2.02199, -4.64287] # 595nm + - [-0.608978, -2.38502, -4.72266] # 600nm + - [-0.542076, -2.73117, -4.79891] # 605nm + - [-0.495836, -3.06158, -4.87184] # 610nm + - [-0.46373, -3.37731, -4.94167] # 615nm + - [-0.448918, -3.67931, -5.00859] # 620nm + - [-0.445041, -3.96846, -5.07278] # 625nm + - [-0.443984, -4.24556, -5.1344] # 630nm + - [-0.40515, -4.51135, -5.1936] # 635nm + - [-0.287594, -4.76652, -5.25053] # 640nm + - [-0.237399, -5.01167, -5.3053] # 645nm + - [-0.266077, -5.24739, -5.35805] # 650nm + - [-0.414397, -5.47422, -5.40888] # 655nm + - [-0.729115, -5.69265, -5.4579] # 660nm + - [-1.09275, -5.90314, -5.5052] # 665nm + - [-1.49817, -6.10611, -5.55086] # 670nm + - [-1.89931, -6.30195, -5.59498] # 675nm + - [-2.28672, -6.49105, -5.63762] # 680nm + - [-2.65374, -6.67373, -5.67887] # 685nm + - [-3.00194, -6.85032, -5.71879] # 690nm + - [-3.33273, -7.02113, -5.75744] # 695nm + - [-3.64738, -7.18642, -5.79488] # 700nm + - [-3.94705, -7.34647, -5.83117] # 705nm + - [-4.23278, -7.50151, -5.86636] # 710nm + - [-4.50552, -7.65179, -5.9005] # 715nm + - [-4.76614, -7.79751, -5.93364] # 720nm + - [-5.01543, -7.93888, -5.96582] # 725nm + - [-5.25411, -8.07609, -5.99707] # 730nm + - [-5.48285, -8.20933, -6.02745] # 735nm + - [-5.70225, -8.33876, -6.05698] # 740nm + - [-5.91287, -8.46454, -6.0857] # 745nm + - [-6.11524, -8.58683, -6.11365] # 750nm + - [-6.30982, -8.70577, -6.14085] # 755nm + - [-6.49706, -8.8215, -6.16734] # 760nm + - [-6.67736, -8.93413, -6.19314] # 765nm + - [-6.85111, -9.04381, -6.21827] # 770nm + - [-7.01865, -9.15063, -6.24277] # 775nm + - [-7.18032, -9.25472, -6.26666] # 780nm + +# Spectral density of each layer's dye at unit density, same grid and order. +dye_density: + - [0, 0, 0] # 380nm + - [0.403908, 0.0667059, 0.131477] # 385nm + - [0.367788, 0.0606233, 0.225086] # 390nm + - [0.334623, 0.0551188, 0.311665] # 395nm + - [0.30152, 0.0506611, 0.396483] # 400nm + - [0.264513, 0.0473523, 0.485257] # 405nm + - [0.221389, 0.0446968, 0.580102] # 410nm + - [0.173337, 0.0416705, 0.679412] # 415nm + - [0.124541, 0.0370168, 0.779979] # 420nm + - [0.0799763, 0.0296143, 0.878155] # 425nm + - [0.0431787, 0.0188834, 0.969734] # 430nm + - [0.0155139, 0.00522799, 1.04986] # 435nm + - [-0.00319846, -0.00966512, 1.11373] # 440nm + - [-0.013822, -0.0230595, 1.15771] # 445nm + - [-0.017493, -0.0319997, 1.17951] # 450nm + - [-0.0155223, -0.0340031, 1.17738] # 455nm + - [-0.00931813, -0.0266502, 1.14991] # 460nm + - [-0.000425396, -0.00643381, 1.09657] # 465nm + - [0.00957477, 0.0311778, 1.01844] # 470nm + - [0.0193053, 0.0897715, 0.918527] # 475nm + - [0.0277613, 0.170248, 0.801755] # 480nm + - [0.0344012, 0.270943, 0.674456] # 485nm + - [0.039108, 0.388631, 0.543319] # 490nm + - [0.0420001, 0.518745, 0.414641] # 495nm + - [0.0431986, 0.655203, 0.294549] # 500nm + - [0.0427053, 0.791143, 0.188993] # 505nm + - [0.0404019, 0.920428, 0.10285] # 510nm + - [0.0360661, 1.03835, 0.0387973] # 515nm + - [0.0293948, 1.14135, -0.00323375] # 520nm + - [0.0201419, 1.22614, -0.0257816] # 525nm + - [0.00841956, 1.28885, -0.0330331] # 530nm + - [-0.00501966, 1.32518, -0.0297272] # 535nm + - [-0.018723, 1.33234, -0.0203227] # 540nm + - [-0.0306564, 1.31034, -0.00860152] # 545nm + - [-0.0383851, 1.26141, 0.00253253] # 550nm + - [-0.0392928, 1.18834, 0.0113352] # 555nm + - [-0.0309223, 1.09412, 0.0172494] # 560nm + - [-0.0114667, 0.982893, 0.0205307] # 565nm + - [0.0198622, 0.860868, 0.0217858] # 570nm + - [0.0628668, 0.735986, 0.0216915] # 575nm + - [0.116549, 0.616053, 0.0208882] # 580nm + - [0.179172, 0.506574, 0.0199193] # 585nm + - [0.248268, 0.410247, 0.0191115] # 590nm + - [0.320861, 0.327992, 0.0184567] # 595nm + - [0.394136, 0.259757, 0.0176575] # 600nm + - [0.466336, 0.204598, 0.0163398] # 605nm + - [0.53702, 0.160639, 0.0142776] # 610nm + - [0.606468, 0.125418, 0.0115514] # 615nm + - [0.674916, 0.0966418, 0.00857863] # 620nm + - [0.742318, 0.0728261, 0.00594004] # 625nm + - [0.808706, 0.0531795, 0.00403784] # 630nm + - [0.874452, 0.0370944, 0.00287428] # 635nm + - [0.93984, 0.0238931, 0.00219026] # 640nm + - [1.0045, 0.0128995, 0.00172239] # 645nm + - [1.0676, 0.00359228, 0.0013275] # 650nm + - [1.12851, -0.00429998, 0.000968393] # 655nm + - [1.18682, -0.0108354, 0.000654986] # 660nm + - [1.24178, -0.0159821, 0.00040166] # 665nm + - [1.29179, -0.0197322, 0.000211292] # 670nm + - [1.33462, -0.0221459, 7.44371e-05] # 675nm + - [1.36839, -0.0233499, -2.79391e-14] # 680nm + - [1.39252, -0.023508, -4.55659e-15] # 685nm + - [1.40741, -0.0227797, -7.04121e-16] # 690nm + - [1.41307, -0.0213116, -1.03094e-16] # 695nm + - [1.40823, -0.0192756, -1.43022e-17] # 700nm + - [1.39096, -0.0168542, -1.87996e-18] # 705nm + - [1.35994, -0.0141712, -2.3414e-19] # 710nm + - [1.31526, -0.0112948, -2.76301e-20] # 715nm + - [1.25857, -0.00826775, -3.08936e-21] # 720nm + - [1.19237, -0.00512446, 9.34392e-06] # 725nm + - [1.11913, -0.00189787, 0.000144643] # 730nm + - [1.04097, 0.00138255, 0.000236008] # 735nm + - [0.959902, 0.00469464, 0.000243281] # 740nm + - [0.877953, 0.00802695, 0.0001264] # 745nm + - [0, 0.0113815, -1.94521e-27] # 750nm + - [0, 0, 0] # 755nm + - [0, 0, 0] # 760nm + - [0, 0, 0] # 765nm + - [0, 0, 0] # 770nm + - [0, 0, 0] # 775nm + - [0, 0, 0] # 780nm + +# The support's own density -- film base plus, for a colour negative, the +# orange mask. Flat zero where the datasheet does not give it. +base_density: [0, 0, 0, 0, 0.688743, 0.668732, 0.674145, 0.704822, 0.738875, 0.77018, 0.793902, 0.808976, 0.816583, 0.818014, 0.815048, 0.807834, 0.799308, 0.790429, 0.781571, 0.773041, 0.76365, 0.752376, 0.743825, 0.747508, 0.759366, 0.775658, 0.787589, 0.770607, 0.738775, 0.69906, 0.659996, 0.638018, 0.623696, 0.612488, 0.602398, 0.591425, 0.576479, 0.55225, 0.519473, 0.475224, 0.424808, 0.370525, 0.318688, 0.275608, 0.241346, 0.219229, 0.205387, 0.197521, 0.193575, 0.190765, 0.188882, 0.188114, 0.188779, 0.191225, 0.194555, 0.197605, 0.200865, 0.204898, 0.209047, 0.212126, 0.215084, 0.218257, 0.220278, 0.221012, 0.220415, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + +# The characteristic curves: density against log10 exposure, sampled +# uniformly over [-3, 4]. +log_exposure_min: -3 +log_exposure_max: 4 +density_curves: + - [-0.00073111, -0.00065213, -0.00069304] + - [-0.00062474, -0.00054039, -0.0006216] + - [-0.00045032, -0.00034771, -0.00049642] + - [-0.00026227, -0.00014774, -0.00033606] + - [-0.00010948, -2.4766e-05, -0.00014274] + - [-4.6701e-06, -9.5833e-06, 7.363e-05] + - [8.2384e-05, -4.0322e-05, 0.00026447] + - [0.00019143, -1.1913e-05, 0.00035835] + - [0.00032496, 0.00011845, 0.00032078] + - [0.00043695, 0.00028078, 0.00019266] + - [0.00047319, 0.00035737, 5.8608e-05] + - [0.00042036, 0.00029493, -2.3099e-05] + - [0.0002958, 0.00013055, -3.5339e-05] + - [0.00014169, -4.3263e-05, 8.8129e-06] + - [3.5432e-06, -0.00013931, 8.7678e-05] + - [-8.8492e-05, -0.00012422, 0.00018558] + - [-0.00012241, -2.485e-05, 0.00028953] + - [-9.5307e-05, 0.00010244, 0.00037738] + - [-6.7857e-06, 0.00020926, 0.00041647] + - [0.0001354, 0.00027201, 0.00038168] + - [0.00030203, 0.00028483, 0.00028172] + - [0.00043785, 0.00024749, 0.00016974] + - [0.00047889, 0.00016645, 0.00012254] + - [0.00038775, 6.8028e-05, 0.00019692] + - [0.00018506, 3.9085e-06, 0.00039217] + - [-4.8248e-05, 3.1824e-05, 0.00064801] + - [-0.00020636, 0.00017717, 0.00088341] + - [-0.00021438, 0.00040487, 0.0010516] + - [-7.0388e-05, 0.00063107, 0.0011739] + - [0.00015421, 0.00077627, 0.001327] + - [0.00035798, 0.00082635, 0.0015938] + - [0.00046989, 0.00085444, 0.0020096] + - [0.00048943, 0.0009801, 0.0025429] + - [0.00048239, 0.0012882, 0.0031211] + - [0.00053767, 0.0017637, 0.0036831] + - [0.00071445, 0.0022925, 0.0042229] + - [0.0010124, 0.0027348, 0.0047962] + - [0.0013806, 0.0030236, 0.005492] + - [0.0017541, 0.0032226, 0.006391] + - [0.0020941, 0.0035018, 0.0075396] + - [0.0024076, 0.0040442, 0.0089499] + - [0.002742, 0.0049461, 0.010615] + - [0.0031634, 0.0061729, 0.012522] + - [0.0037348, 0.0075954, 0.014649] + - [0.0045013, 0.0090772, 0.016968] + - [0.0054825, 0.010554, 0.019451] + - [0.0066658, 0.012057, 0.022103] + - [0.0080042, 0.013687, 0.024997] + - [0.0094266, 0.015555, 0.028279] + - [0.010863, 0.017761, 0.032112] + - [0.01229, 0.020356, 0.036574] + - [0.013801, 0.023376, 0.041665] + - [0.015601, 0.026884, 0.04739] + - [0.017895, 0.030911, 0.053724] + - [0.020733, 0.035346, 0.06052] + - [0.023983, 0.039929, 0.067561] + - [0.02747, 0.04445, 0.074797] + - [0.031136, 0.04901, 0.082506] + - [0.035063, 0.054073, 0.091121] + - [0.039359, 0.060157, 0.1008] + - [0.044172, 0.067527, 0.11131] + - [0.049652, 0.076026, 0.12221] + - [0.0559, 0.085161, 0.13323] + - [0.062913, 0.094424, 0.14438] + - [0.070545, 0.10361, 0.15598] + - [0.078526, 0.11294, 0.16832] + - [0.086576, 0.12278, 0.18153] + - [0.09458, 0.13334, 0.19549] + - [0.10273, 0.14443, 0.2101] + - [0.11149, 0.15565, 0.22537] + - [0.1214, 0.16675, 0.24143] + - [0.13274, 0.17796, 0.25832] + - [0.14532, 0.18991, 0.27574] + - [0.15852, 0.2032, 0.29311] + - [0.17166, 0.21791, 0.30988] + - [0.18437, 0.23346, 0.32589] + - [0.19687, 0.24893, 0.34155] + - [0.20977, 0.26369, 0.35762] + - [0.22367, 0.2778, 0.37472] + - [0.23873, 0.29186, 0.39293] + - [0.25462, 0.30648, 0.41182] + - [0.27074, 0.32174, 0.43068] + - [0.28666, 0.33713, 0.44895] + - [0.30237, 0.35199, 0.46646] + - [0.31815, 0.36612, 0.48329] + - [0.33425, 0.37998, 0.49963] + - [0.35053, 0.39441, 0.51558] + - [0.36654, 0.40996, 0.53119] + - [0.38187, 0.42632, 0.54658] + - [0.39654, 0.44244, 0.56191] + - [0.411, 0.45721, 0.5774] + - [0.42586, 0.47033, 0.59328] + - [0.44143, 0.48245, 0.60966] + - [0.45761, 0.49469, 0.62626] + - [0.47388, 0.50786, 0.64245] + - [0.4897, 0.52196, 0.6576] + - [0.50472, 0.53638, 0.6716] + - [0.51898, 0.55034, 0.68494] + - [0.53278, 0.56343, 0.69826] + - [0.54637, 0.57584, 0.712] + - [0.55978, 0.5881, 0.7262] + - [0.57283, 0.60064, 0.74066] + - [0.5854, 0.61348, 0.7552] + - [0.59772, 0.62628, 0.76986] + - [0.6103, 0.63863, 0.78481] + - [0.62373, 0.65046, 0.80013] + - [0.63821, 0.66212, 0.81559] + - [0.6534, 0.67416, 0.83079] + - [0.66852, 0.68695, 0.8454] + - [0.68279, 0.70045, 0.85951] + - [0.69583, 0.71429, 0.87364] + - [0.70791, 0.72805, 0.88849] + - [0.71976, 0.74151, 0.90444] + - [0.73208, 0.75467, 0.92136] + - [0.74525, 0.76764, 0.93866] + - [0.75926, 0.7806, 0.95557] + - [0.77393, 0.79374, 0.97152] + - [0.78891, 0.80704, 0.98663] + - [0.80372, 0.82014, 1.0018] + - [0.81807, 0.8328, 1.0182] + - [0.83225, 0.84548, 1.036] + - [0.84696, 0.85917, 1.0545] + - [0.86258, 0.87443, 1.0725] + - [0.87871, 0.89052, 1.0895] + - [0.89458, 0.90605, 1.1058] + - [0.90962, 0.92, 1.1224] + - [0.92396, 0.93241, 1.1397] + - [0.93841, 0.94438, 1.1572] + - [0.95382, 0.95729, 1.1743] + - [0.97053, 0.97184, 1.1903] + - [0.98807, 0.98761, 1.2054] + - [1.0055, 1.0034, 1.2201] + - [1.0223, 1.0183, 1.2355] + - [1.0382, 1.0319, 1.2518] + - [1.0537, 1.0449, 1.2687] + - [1.0693, 1.0583, 1.2853] + - [1.085, 1.0725, 1.3013] + - [1.1009, 1.0873, 1.317] + - [1.1165, 1.1023, 1.3331] + - [1.1318, 1.1169, 1.3502] + - [1.1468, 1.1309, 1.3679] + - [1.1618, 1.1442, 1.3851] + - [1.1772, 1.1569, 1.4007] + - [1.1931, 1.1695, 1.4148] + - [1.2093, 1.1821, 1.428] + - [1.2252, 1.1948, 1.4417] + - [1.2408, 1.2076, 1.4567] + - [1.2561, 1.2206, 1.4732] + - [1.2717, 1.2339, 1.4907] + - [1.2877, 1.2476, 1.508] + - [1.304, 1.2618, 1.5247] + - [1.3201, 1.276, 1.5406] + - [1.336, 1.2902, 1.5565] + - [1.3518, 1.3041, 1.5727] + - [1.3677, 1.318, 1.5894] + - [1.3836, 1.332, 1.6063] + - [1.3995, 1.3462, 1.6229] + - [1.415, 1.3606, 1.6389] + - [1.4302, 1.3747, 1.6546] + - [1.4454, 1.3886, 1.6705] + - [1.4616, 1.4025, 1.6872] + - [1.4789, 1.4165, 1.7049] + - [1.497, 1.431, 1.7232] + - [1.5151, 1.4461, 1.7416] + - [1.5319, 1.4615, 1.7595] + - [1.5471, 1.4765, 1.7767] + - [1.5614, 1.4904, 1.7934] + - [1.5764, 1.5034, 1.8101] + - [1.593, 1.5157, 1.8269] + - [1.6106, 1.5279, 1.8433] + - [1.628, 1.5405, 1.8591] + - [1.6438, 1.5534, 1.874] + - [1.6583, 1.5665, 1.8882] + - [1.6725, 1.5794, 1.9021] + - [1.6875, 1.592, 1.9162] + - [1.7036, 1.604, 1.9306] + - [1.7198, 1.6153, 1.9451] + - [1.7346, 1.6259, 1.9592] + - [1.7475, 1.6359, 1.9725] + - [1.7591, 1.6457, 1.9849] + - [1.771, 1.6556, 1.9965] + - [1.7842, 1.6657, 2.0076] + - [1.7986, 1.6757, 2.0185] + - [1.8126, 1.6852, 2.0294] + - [1.8247, 1.6938, 2.04] + - [1.8345, 1.7014, 2.0499] + - [1.8429, 1.7084, 2.0588] + - [1.8515, 1.7153, 2.0669] + - [1.8616, 1.7222, 2.0743] + - [1.873, 1.729, 2.0814] + - [1.8844, 1.7353, 2.0885] + - [1.8943, 1.7409, 2.0954] + - [1.902, 1.7457, 2.102] + - [1.9081, 1.75, 2.108] + - [1.9138, 1.7542, 2.1131] + - [1.9201, 1.7587, 2.1176] + - [1.9272, 1.7632, 2.1217] + - [1.9342, 1.7674, 2.1257] + - [1.9403, 1.7707, 2.1296] + - [1.945, 1.7731, 2.1333] + - [1.9488, 1.7749, 2.1368] + - [1.9521, 1.7765, 2.1398] + - [1.9553, 1.7783, 2.1425] + - [1.9586, 1.7805, 2.145] + - [1.962, 1.7829, 2.1471] + - [1.9653, 1.7851, 2.1489] + - [1.9686, 1.787, 2.1503] + - [1.9715, 1.7886, 2.1516] + - [1.974, 1.7898, 2.1529] + - [1.976, 1.7909, 2.1541] + - [1.9778, 1.7919, 2.1554] + - [1.9794, 1.7929, 2.1564] + - [1.9807, 1.7937, 2.1571] + - [1.9819, 1.7944, 2.1575] + - [1.9828, 1.7948, 2.1578] + - [1.9837, 1.795, 2.1582] + - [1.9846, 1.7951, 2.1587] + - [1.9855, 1.7952, 2.1593] + - [1.9864, 1.7954, 2.16] + - [1.9872, 1.7957, 2.1606] + - [1.9878, 1.7961, 2.161] + - [1.9882, 1.7964, 2.1613] + - [1.9884, 1.7967, 2.1616] + - [1.9885, 1.7968, 2.1618] + - [1.9887, 1.7969, 2.1619] + - [1.9889, 1.797, 2.1619] + - [1.9893, 1.7972, 2.1619] + - [1.9896, 1.7975, 2.1618] + - [1.9899, 1.7977, 2.1619] + - [1.9902, 1.7978, 2.1621] + - [1.9903, 1.7979, 2.1623] + - [1.9903, 1.7978, 2.1625] + - [1.9902, 1.7978, 2.1624] + - [1.9902, 1.7978, 2.1622] + - [1.9903, 1.7978, 2.162] + - [1.9904, 1.7978, 2.162] + - [1.9906, 1.7977, 2.1621] + - [1.9909, 1.7976, 2.1624] + - [1.991, 1.7975, 2.1626] + - [1.9911, 1.7974, 2.1625] + - [1.9909, 1.7975, 2.1623] + - [1.9906, 1.7977, 2.1621] + - [1.9904, 1.798, 2.1622] + - [1.9902, 1.7981, 2.1624] + - [1.9902, 1.7981, 2.1628] + - [1.9905, 1.7979, 2.1631] + - [1.9907, 1.7976, 2.1632] + - [1.991, 1.7975, 2.163] + - [1.991, 1.7974, 2.1627] + - [1.9909, 1.7975, 2.1626] + - [1.9907, 1.7977, 2.1625] + - [1.9906, 1.7979, 2.1625] + - [1.9905, 1.798, 2.1624] + - [1.9905, 1.7981, 2.1623] + - [1.9904, 1.7982, 2.1622] + - [1.9904, 1.7982, 2.1621] diff --git a/core/dr-film/profiles/kodak_portra_endura.yaml b/core/dr-film/profiles/kodak_portra_endura.yaml new file mode 100644 index 0000000..b4845c7 --- /dev/null +++ b/core/dr-film/profiles/kodak_portra_endura.yaml @@ -0,0 +1,450 @@ +# Generated by tools/film-profiles/convert.py from spektrafilm. +# Do not edit by hand: re-run the converter instead. +# +# spektrafilm by Andrea Volpato, https://github.com/andreavolpato/spektrafilm +# Licensed CC BY-SA 4.0. Modified for DarkRoom: trimmed to the fields the +# renderer uses and reformatted; see profiles/CHANGELOG.txt. + +version: '0.3.2' +stock: kodak_portra_endura +name: 'Kodak Professional Portra Endura' +kind: negative # negative | positive +support: paper # film | paper +reference_illuminant: TH-KG3 +viewing_illuminant: D50 + +# log10 spectral sensitivity per layer, 380-780nm at 5nm, in R,G,B layer +# order. A null upstream means the datasheet has no reading there, which is +# blindness, so it is written as the sentinel the loader reads as such. +log_sensitivity: + - [-2.06329, -0.66685, -0.121172] # 380nm + - [-2.03304, -0.305851, 0.0973343] # 385nm + - [-2.00191, 0.0741485, 0.250639] # 390nm + - [-1.96987, -0.310914, 0.383591] # 395nm + - [-1.93687, -0.31851, 0.551078] # 400nm + - [-1.90287, -0.326725, 0.635971] # 405nm + - [-1.86783, -0.383764, 0.62894] # 410nm + - [-1.83168, -0.518279, 0.593405] # 415nm + - [-1.7944, -0.581634, 0.619546] # 420nm + - [-1.7559, -0.561393, 0.687253] # 425nm + - [-1.71615, -0.514748, 0.796341] # 430nm + - [-1.67507, -0.454635, 0.865381] # 435nm + - [-1.6326, -0.380725, 0.954704] # 440nm + - [-1.58867, -0.298006, 1.00643] # 445nm + - [-1.54319, -0.213912, 1.07115] # 450nm + - [-1.49609, -0.12172, 1.17427] # 455nm + - [-1.44728, -0.0411163, 1.29397] # 460nm + - [-1.39665, 0.031887, 1.44968] # 465nm + - [-1.34412, 0.0901617, 1.58062] # 470nm + - [-1.28957, 0.152138, 1.62687] # 475nm + - [-1.23288, 0.25906, 1.43016] # 480nm + - [-1.17392, 0.384654, 0.994591] # 485nm + - [-1.11255, 0.470883, 0.412335] # 490nm + - [-1.04863, 0.551546, -0.0851402] # 495nm + - [-0.981986, 0.632001, -0.621981] # 500nm + - [-0.914599, 0.663976, -1.17939] # 505nm + - [-0.876885, 0.65417, -1.57519] # 510nm + - [-0.861842, 0.625837, -2.03941] # 515nm + - [-0.86324, 0.603816, -2.47047] # 520nm + - [-0.866, 0.638158, -2.87181] # 525nm + - [-0.838642, 0.719433, -3.24639] # 530nm + - [-0.798993, 0.811755, -3.5968] # 535nm + - [-0.727273, 0.960897, -3.92531] # 540nm + - [-0.668813, 1.11846, -4.23391] # 545nm + - [-0.697153, 1.24462, -4.52436] # 550nm + - [-0.875581, 1.11215, -4.79821] # 555nm + - [-0.997296, 0.522578, -5.05685] # 560nm + - [-1.03559, -0.298703, -5.3015] # 565nm + - [-1.06471, -0.778553, -5.53328] # 570nm + - [-1.03236, -1.08038, -5.75318] # 575nm + - [-1.0246, -1.37283, -5.96208] # 580nm + - [-1.00021, -1.74401, -6.16079] # 585nm + - [-0.972478, -2.11701, -6.35003] # 590nm + - [-0.949305, -2.47678, -6.53048] # 595nm + - [-0.914915, -2.82135, -6.70272] # 600nm + - [-0.864414, -3.15085, -6.86731] # 605nm + - [-0.820752, -3.4659, -7.02474] # 610nm + - [-0.765307, -3.76728, -7.17547] # 615nm + - [-0.707123, -4.05576, -7.31992] # 620nm + - [-0.64149, -4.3321, -7.45848] # 625nm + - [-0.595538, -4.59704, -7.59149] # 630nm + - [-0.55543, -4.85124, -7.71929] # 635nm + - [-0.532502, -5.09533, -7.84217] # 640nm + - [-0.533161, -5.32989, -7.96042] # 645nm + - [-0.544821, -5.55546, -8.07428] # 650nm + - [-0.55516, -5.77255, -8.18401] # 655nm + - [-0.537088, -5.98162, -8.28981] # 660nm + - [-0.498599, -6.1831, -8.39191] # 665nm + - [-0.451245, -6.37741, -8.49048] # 670nm + - [-0.370217, -6.56491, -8.58571] # 675nm + - [-0.275692, -6.74595, -8.67777] # 680nm + - [-0.164173, -6.92087, -8.76681] # 685nm + - [-0.0304521, -7.08996, -8.85297] # 690nm + - [0.0915279, -7.25352, -8.93641] # 695nm + - [0.180394, -7.4118, -9.01723] # 700nm + - [0.219145, -7.56507, -9.09557] # 705nm + - [0.161057, -7.71355, -9.17153] # 710nm + - [0.0281059, -7.85747, -9.24523] # 715nm + - [-0.153701, -7.99702, -9.31675] # 720nm + - [-0.423982, -8.13242, -9.38621] # 725nm + - [-0.711793, -8.26383, -9.45368] # 730nm + - [-0.959498, -8.39144, -9.51925] # 735nm + - [-1.21811, -8.51541, -9.583] # 740nm + - [-1.46617, -8.63588, -9.645] # 745nm + - [-1.7043, -8.75301, -9.70533] # 750nm + - [-1.9331, -8.86694, -9.76404] # 755nm + - [-2.15309, -8.97778, -9.82122] # 760nm + - [-2.36479, -9.08567, -9.8769] # 765nm + - [-2.56864, -9.19072, -9.93116] # 770nm + - [-2.76508, -9.29305, -9.98405] # 775nm + - [-2.95451, -9.39275, -10.0356] # 780nm + +# Spectral density of each layer's dye at unit density, same grid and order. +dye_density: + - [0, 0, 0] # 380nm + - [0, 0, 0] # 385nm + - [0, 0, 0] # 390nm + - [0, 0, 0] # 395nm + - [0, 0, 0] # 400nm + - [0.13463, 0.0557315, 0.583161] # 405nm + - [0.128013, 0.0595558, 0.699108] # 410nm + - [0.121487, 0.0609707, 0.822296] # 415nm + - [0.112527, 0.0624132, 0.935903] # 420nm + - [0.102341, 0.0628756, 1.0147] # 425nm + - [0.0880839, 0.0641499, 1.07006] # 430nm + - [0.0774323, 0.0588941, 1.10649] # 435nm + - [0.0688873, 0.0674788, 1.12786] # 440nm + - [0.0591469, 0.0755021, 1.14003] # 445nm + - [0.0529705, 0.087447, 1.13788] # 450nm + - [0.0486079, 0.104152, 1.12044] # 455nm + - [0.0461916, 0.125824, 1.08581] # 460nm + - [0.0423221, 0.15245, 1.03122] # 465nm + - [0.0387559, 0.185448, 0.956634] # 470nm + - [0.0373831, 0.224825, 0.870222] # 475nm + - [0.0403377, 0.271465, 0.773301] # 480nm + - [0.0439569, 0.326176, 0.673801] # 485nm + - [0.0457122, 0.389224, 0.57195] # 490nm + - [0.0495, 0.457945, 0.47455] # 495nm + - [0.0535765, 0.54069, 0.387515] # 500nm + - [0.0581268, 0.627548, 0.311545] # 505nm + - [0.064248, 0.715738, 0.247134] # 510nm + - [0.0719348, 0.801546, 0.193774] # 515nm + - [0.0821404, 0.883435, 0.151117] # 520nm + - [0.0920384, 0.95845, 0.114229] # 525nm + - [0.103544, 1.02756, 0.090246] # 530nm + - [0.119572, 1.08782, 0.0702681] # 535nm + - [0.13682, 1.13692, 0.0551267] # 540nm + - [0.157964, 1.17018, 0.0435376] # 545nm + - [0.184214, 1.17917, 0.031796] # 550nm + - [0.216843, 1.14297, 0.0280471] # 555nm + - [0.256148, 1.07173, 0.0221872] # 560nm + - [0.29836, 0.957004, 0.0144489] # 565nm + - [0.34956, 0.824702, 0.00937225] # 570nm + - [0.406378, 0.685333, 0.00691271] # 575nm + - [0.466262, 0.549027, 0.00704693] # 580nm + - [0.536021, 0.433989, 0.00728366] # 585nm + - [0.608747, 0.340986, 0.00720199] # 590nm + - [0.687167, 0.264, 0.00712033] # 595nm + - [0.769187, 0.204049, 0.00983489] # 600nm + - [0.853719, 0.158457, 0.00493586] # 605nm + - [0.936366, 0.123842, 0.00485419] # 610nm + - [1.01604, 0.0976066, 0.00477253] # 615nm + - [1.07871, 0.0794798, 0.0046245] # 620nm + - [1.12143, 0.0658755, 0.00359699] # 625nm + - [1.1415, 0.0569475, 0.00452753] # 630nm + - [1.13625, 0.0506214, 0.00444587] # 635nm + - [1.10666, 0.0478845, 0.0043642] # 640nm + - [1.05166, 0.0445471, 0.00428254] # 645nm + - [0.980878, 0.0397732, 0.00807407] # 650nm + - [0.898305, 0.0442732, 0.00578807] # 655nm + - [0.812798, 0.0429953, 0.00684416] # 660nm + - [0.731294, 0.0401979, 0.00748007] # 665nm + - [0.653378, 0.0396883, 0.00754484] # 670nm + - [0.579554, 0.0375944, 0.00817276] # 675nm + - [0.511348, 0.0353716, 0.00832798] # 680nm + - [0.450789, 0.0343428, 0.00839517] # 685nm + - [0.39617, 0.0315678, 0.0086004] # 690nm + - [0.352446, 0.0304327, 0.00851873] # 695nm + - [0.313195, 0, 0.0141632] # 700nm + - [0, 0, 0] # 705nm + - [0, 0, 0] # 710nm + - [0, 0, 0] # 715nm + - [0, 0, 0] # 720nm + - [0, 0, 0] # 725nm + - [0, 0, 0] # 730nm + - [0, 0, 0] # 735nm + - [0, 0, 0] # 740nm + - [0, 0, 0] # 745nm + - [0, 0, 0] # 750nm + - [0, 0, 0] # 755nm + - [0, 0, 0] # 760nm + - [0, 0, 0] # 765nm + - [0, 0, 0] # 770nm + - [0, 0, 0] # 775nm + - [0, 0, 0] # 780nm + +# The support's own density -- film base plus, for a colour negative, the +# orange mask. Flat zero where the datasheet does not give it. +base_density: [0.0965288, 0.0965285, 0.0965279, 0.0965263, 0.0965233, 0.0965174, 0.0965067, 0.0964884, 0.0964585, 0.0964124, 0.0963448, 0.0962504, 0.0961247, 0.0959646, 0.095769, 0.0955391, 0.0952779, 0.09499, 0.0946806, 0.0943551, 0.0940186, 0.093675, 0.0933279, 0.0929801, 0.0926342, 0.0922927, 0.0919581, 0.0916329, 0.0913197, 0.0910204, 0.0907366, 0.0904687, 0.0902163, 0.0899779, 0.0897517, 0.0895353, 0.0893266, 0.089124, 0.0889263, 0.0887335, 0.0885461, 0.0883659, 0.0881949, 0.0880359, 0.0878918, 0.087765, 0.0876572, 0.0875689, 0.0874996, 0.0874476, 0.0874103, 0.0873849, 0.0873684, 0.0873583, 0.0873524, 0.0873491, 0.0873475, 0.0873466, 0.0873462, 0.0873461, 0.087346, 0.087346, 0.087346, 0.087346, 0.087346, 0.087346, 0.087346, 0.087346, 0.087346, 0.087346, 0.087346, 0.087346, 0.087346, 0.087346, 0.087346, 0.087346, 0.087346, 0.087346, 0.087346, 0.087346, 0.087346] + +# The characteristic curves: density against log10 exposure, sampled +# uniformly over [-3, 4]. +log_exposure_min: -3 +log_exposure_max: 4 +density_curves: + - [-0.00082207, -0.00082089, -0.0008278] + - [-0.00070251, -0.00068042, -0.00074299] + - [-0.00050646, -0.00043818, -0.00059421] + - [-0.00029511, -0.00018684, -0.00040366] + - [-0.00012342, -3.2448e-05, -0.00017405] + - [-5.7059e-06, -1.3791e-05, 8.2719e-05] + - [9.1999e-05, -5.3052e-05, 0.00030861] + - [0.00021437, -1.8094e-05, 0.00041835] + - [0.00036418, 0.00014486, 0.00037072] + - [0.00048967, 0.00034766, 0.00021435] + - [0.00052986, 0.00044217, 4.9948e-05] + - [0.00046977, 0.0003613, -5.334e-05] + - [0.00032883, 0.00015162, -7.5506e-05] + - [0.00015441, -7.0724e-05, -3.2627e-05] + - [-2.3977e-06, -0.00019632, 4.8881e-05] + - [-0.00010783, -0.00018362, 0.00014965] + - [-0.00014853, -6.686e-05, 0.00025336] + - [-0.00012144, 8.2629e-05, 0.00033274] + - [-2.6361e-05, 0.00020353, 0.00034796] + - [0.00012767, 0.00026558, 0.00026816] + - [0.00030747, 0.00026076, 0.00010248] + - [0.00045058, 0.00018801, -8.7828e-05] + - [0.00048483, 5.4486e-05, -0.00021447] + - [0.00036793, -0.00010806, -0.0002141] + - [0.00012262, -0.00023675, -9.1985e-05] + - [-0.00016103, -0.00026207, 7.6502e-05] + - [-0.00036566, -0.00015576, 0.00019237] + - [-0.00040922, 3.4806e-05, 0.0001975] + - [-0.00029184, 0.00020223, 0.0001122] + - [-9.5598e-05, 0.0002456, 1.8958e-05] + - [6.4346e-05, 0.00014515, 3.3838e-06] + - [0.00010726, -1.3323e-05, 9.4186e-05] + - [3.073e-05, -9.0986e-05, 0.00024356] + - [-9.5183e-05, 4.539e-06, 0.00035985] + - [-0.00017677, 0.0002432, 0.00036739] + - [-0.00015513, 0.00047913, 0.00025196] + - [-3.7734e-05, 0.00054189, 6.3882e-05] + - [0.00011276, 0.00035217, -0.00011721] + - [0.00021999, -1.8483e-05, -0.00022712] + - [0.00023667, -0.00038648, -0.00024212] + - [0.00016448, -0.00056744, -0.00017543] + - [4.5715e-05, -0.00048499, -5.8608e-05] + - [-6.1191e-05, -0.00020712, 7.2192e-05] + - [-0.00010547, 0.00010637, 0.00017789] + - [-5.8446e-05, 0.00030226, 0.00021564] + - [7.9802e-05, 0.00031088, 0.00014983] + - [0.0002772, 0.00016015, -2.3921e-05] + - [0.00047128, -6.5728e-05, -0.00025896] + - [0.00058293, -0.00028098, -0.0004647] + - [0.00054165, -0.00042321, -0.00055092] + - [0.0003253, -0.00047619, -0.00049915] + - [7.3888e-06, -0.00044152, -0.00034298] + - [-0.00025808, -0.00030075, -0.00010896] + - [-0.00033975, -6.2065e-05, 0.00016363] + - [-0.00023732, 0.00017098, 0.00037559] + - [-7.8829e-05, 0.00021859, 0.00040886] + - [-4.4954e-06, -3.7905e-05, 0.00024984] + - [-5.8369e-05, -0.00051423, 4.6701e-05] + - [-0.0001911, -0.00093489, -1.4553e-06] + - [-0.00034321, -0.0010368, 0.00015455] + - [-0.00044226, -0.0007303, 0.00038475] + - [-0.00042548, -0.00014729, 0.000506] + - [-0.00026556, 0.00044183, 0.00041486] + - [8.8435e-06, 0.00080062, 0.00014824] + - [0.00030586, 0.00086561, -0.00016299] + - [0.00049284, 0.00075457, -0.00039756] + - [0.00045444, 0.00064466, -0.00051651] + - [0.00016436, 0.00061974, -0.00055319] + - [-0.00027428, 0.00060877, -0.00054402] + - [-0.00065897, 0.00046392, -0.00047331] + - [-0.0007874, 0.00011382, -0.00028769] + - [-0.00057432, -0.00033589, 3.0919e-05] + - [-0.00011044, -0.00064475, 0.00040313] + - [0.00038362, -0.00059918, 0.00066759] + - [0.00068867, -0.00017235, 0.00067851] + - [0.00072362, 0.00044485, 0.00041855] + - [0.00059903, 0.0009731, 3.9507e-05] + - [0.00055203, 0.0012421, -0.00020708] + - [0.0008068, 0.0013094, -0.00010653] + - [0.0014557, 0.0014131, 0.00040832] + - [0.0024402, 0.0017956, 0.0012452] + - [0.0036392, 0.0025391, 0.0022398] + - [0.0049973, 0.0035404, 0.0032739] + - [0.0066002, 0.0046495, 0.0043434] + - [0.0086436, 0.0058649, 0.0055424] + - [0.011326, 0.0074311, 0.0069988] + - [0.014747, 0.0097516, 0.0088261] + - [0.018898, 0.01318, 0.011141] + - [0.023789, 0.017786, 0.014089] + - [0.029576, 0.023312, 0.017857] + - [0.036589, 0.029431, 0.022674] + - [0.045255, 0.036081, 0.028806] + - [0.055938, 0.043634, 0.036488] + - [0.06883, 0.052747, 0.045803] + - [0.083934, 0.06403, 0.056628] + - [0.10116, 0.077739, 0.068765] + - [0.12047, 0.093735, 0.082227] + - [0.14198, 0.11169, 0.097357] + - [0.16592, 0.13141, 0.11464] + - [0.19254, 0.15304, 0.13452] + - [0.22197, 0.17701, 0.15718] + - [0.25414, 0.20378, 0.18267] + - [0.28906, 0.23351, 0.211] + - [0.32694, 0.26602, 0.24233] + - [0.36835, 0.30103, 0.27695] + - [0.41397, 0.33849, 0.31507] + - [0.46429, 0.37883, 0.35668] + - [0.51925, 0.42272, 0.40145] + - [0.57825, 0.47071, 0.44911] + - [0.64052, 0.52287, 0.49971] + - [0.70557, 0.57876, 0.55379] + - [0.7735, 0.63774, 0.61208] + - [0.84489, 0.69924, 0.67494] + - [0.92031, 0.76289, 0.74196] + - [0.99978, 0.82832, 0.81204] + - [1.0827, 0.8952, 0.88365] + - [1.1682, 0.96314, 0.95537] + - [1.2549, 1.0314, 1.0266] + - [1.3412, 1.0987, 1.0975] + - [1.4258, 1.164, 1.1687] + - [1.5082, 1.2274, 1.2394] + - [1.5882, 1.2894, 1.3079] + - [1.6656, 1.3501, 1.372] + - [1.7392, 1.4082, 1.4306] + - [1.8077, 1.4618, 1.4839] + - [1.8703, 1.5099, 1.5325] + - [1.9274, 1.5528, 1.5765] + - [1.9797, 1.5919, 1.6158] + - [2.0281, 1.6289, 1.6498] + - [2.0731, 1.6645, 1.6784] + - [2.1144, 1.6985, 1.7022] + - [2.1518, 1.7301, 1.7223] + - [2.1853, 1.7588, 1.7396] + - [2.2151, 1.7848, 1.7546] + - [2.242, 1.8088, 1.7673] + - [2.2665, 1.8316, 1.7778] + - [2.2889, 1.8537, 1.7862] + - [2.3094, 1.875, 1.793] + - [2.3279, 1.8952, 1.7987] + - [2.3445, 1.9138, 1.8038] + - [2.3594, 1.9309, 1.8082] + - [2.3728, 1.9464, 1.8117] + - [2.385, 1.9604, 1.8141] + - [2.396, 1.9732, 1.8154] + - [2.406, 1.9848, 1.8161] + - [2.4146, 1.9955, 1.8166] + - [2.4221, 2.0051, 1.8173] + - [2.4285, 2.0137, 1.8183] + - [2.4341, 2.0215, 1.8193] + - [2.4391, 2.0286, 1.8202] + - [2.4433, 2.0349, 1.8207] + - [2.4469, 2.0405, 1.8209] + - [2.4499, 2.0452, 1.8209] + - [2.4523, 2.0492, 1.821] + - [2.4543, 2.0525, 1.8212] + - [2.456, 2.0553, 1.8213] + - [2.4573, 2.0577, 1.8213] + - [2.4582, 2.0598, 1.8211] + - [2.4588, 2.0614, 1.8207] + - [2.4593, 2.0627, 1.8204] + - [2.4598, 2.0636, 1.8203] + - [2.4605, 2.0645, 1.8205] + - [2.4614, 2.0653, 1.8209] + - [2.4622, 2.0662, 1.8213] + - [2.4625, 2.0671, 1.8216] + - [2.4623, 2.0678, 1.8217] + - [2.4619, 2.0681, 1.8218] + - [2.4616, 2.068, 1.8218] + - [2.4618, 2.0678, 1.8219] + - [2.4623, 2.0675, 1.822] + - [2.4627, 2.0674, 1.8219] + - [2.4627, 2.0674, 1.8217] + - [2.4624, 2.0676, 1.8213] + - [2.462, 2.0678, 1.821] + - [2.462, 2.068, 1.8208] + - [2.4623, 2.0681, 1.8208] + - [2.4628, 2.068, 1.821] + - [2.4629, 2.0678, 1.8213] + - [2.4625, 2.0675, 1.8214] + - [2.4618, 2.0673, 1.8214] + - [2.4613, 2.0672, 1.8212] + - [2.4614, 2.0674, 1.8211] + - [2.4619, 2.0677, 1.821] + - [2.4626, 2.0681, 1.8212] + - [2.4627, 2.0682, 1.8214] + - [2.4622, 2.068, 1.8216] + - [2.4613, 2.0678, 1.8216] + - [2.4606, 2.0676, 1.8214] + - [2.4606, 2.0677, 1.8211] + - [2.4614, 2.068, 1.8209] + - [2.4624, 2.0682, 1.821] + - [2.4631, 2.0682, 1.8212] + - [2.463, 2.068, 1.8215] + - [2.4623, 2.0676, 1.8216] + - [2.4617, 2.0675, 1.8216] + - [2.4615, 2.0677, 1.8213] + - [2.462, 2.0682, 1.8211] + - [2.4626, 2.0687, 1.821] + - [2.4631, 2.0688, 1.8211] + - [2.463, 2.0684, 1.8213] + - [2.4625, 2.0676, 1.8215] + - [2.4619, 2.0669, 1.8217] + - [2.4615, 2.0665, 1.8218] + - [2.4613, 2.0666, 1.8219] + - [2.4613, 2.0671, 1.8219] + - [2.4616, 2.0676, 1.8218] + - [2.462, 2.068, 1.8216] + - [2.4623, 2.0682, 1.8214] + - [2.4625, 2.0683, 1.8213] + - [2.4626, 2.0683, 1.8213] + - [2.4626, 2.0684, 1.8215] + - [2.4625, 2.0685, 1.8216] + - [2.4625, 2.0686, 1.8214] + - [2.4623, 2.0687, 1.8211] + - [2.4622, 2.0685, 1.8208] + - [2.462, 2.0682, 1.8205] + - [2.462, 2.0678, 1.8205] + - [2.4621, 2.0675, 1.8207] + - [2.4623, 2.0673, 1.821] + - [2.4625, 2.0674, 1.8213] + - [2.4625, 2.0675, 1.8215] + - [2.4624, 2.0676, 1.8216] + - [2.4623, 2.0677, 1.8217] + - [2.462, 2.0676, 1.8217] + - [2.4619, 2.0676, 1.8216] + - [2.4618, 2.0676, 1.8215] + - [2.4619, 2.0678, 1.8213] + - [2.4621, 2.0679, 1.8212] + - [2.4623, 2.0681, 1.8212] + - [2.4623, 2.0682, 1.8213] + - [2.4623, 2.0682, 1.8215] + - [2.4622, 2.0681, 1.8216] + - [2.4621, 2.068, 1.8215] + - [2.462, 2.068, 1.8212] + - [2.462, 2.068, 1.821] + - [2.4621, 2.068, 1.8209] + - [2.4623, 2.0678, 1.8211] + - [2.4625, 2.0676, 1.8213] + - [2.4626, 2.0675, 1.8215] + - [2.4626, 2.0674, 1.8215] + - [2.4624, 2.0675, 1.8212] + - [2.4621, 2.0678, 1.821] + - [2.4618, 2.068, 1.821] + - [2.4616, 2.0682, 1.8213] + - [2.4616, 2.0681, 1.8218] + - [2.4619, 2.0679, 1.8222] + - [2.4622, 2.0676, 1.8223] + - [2.4624, 2.0674, 1.822] + - [2.4625, 2.0673, 1.8217] + - [2.4623, 2.0675, 1.8215] + - [2.4621, 2.0677, 1.8214] + - [2.462, 2.0679, 1.8214] + - [2.4619, 2.0681, 1.8213] + - [2.4618, 2.0682, 1.8211] + - [2.4618, 2.0683, 1.821] + - [2.4617, 2.0683, 1.821] diff --git a/core/dr-film/src/bake.rs b/core/dr-film/src/bake.rs new file mode 100644 index 0000000..b862a6f --- /dev/null +++ b/core/dr-film/src/bake.rs @@ -0,0 +1,456 @@ +//! Turning a stock into something a shader can run. +//! +//! # The decomposition +//! +//! A physically-honest film simulation looks like it needs a spectral +//! integration per pixel, and vkdt's does exactly that. It does not have to, +//! and the reason is worth writing down because it is what makes this cheap +//! enough to run on a phone: +//! +//! 1. **Exposure is a 3×3 matrix.** A layer's exposure is +//! `∫ S(λ)·L(λ) dλ`, and the scene spectrum `L` reconstructed from an sRGB +//! triple is *linear* in that triple — that is what a spectral basis is. So +//! the whole integral collapses into nine numbers, computed once, exactly. +//! No approximation is involved. +//! +//! 2. **The characteristic curve is three 1D functions.** Sampled exactly, at +//! [`crate::profile::CURVE_SAMPLES`]. +//! +//! 3. **Everything after that is a function of three densities.** The dye +//! transmittance, the print exposure through the negative, the paper's own +//! curves and dyes, the viewing illuminant, the adaptation — all of it takes +//! three numbers in and gives three numbers out. So it bakes into one small +//! 3D lookup, and the per-pixel cost is a matrix multiply, three curve taps +//! and one texture fetch. +//! +//! Splitting 2 from 3 rather than baking a single LUT over exposure is +//! deliberate and measured: the curve carries all of the sharp shape and the +//! dye mixing is smooth, so putting the curve in the 3D LUT would force it +//! three times larger for the same error. + +use crate::profile::{Profile, CURVE_SAMPLES}; +use crate::spectrum::{illuminant, Spectrum, Viewing}; +use crate::tables::{SPECTRUM, SRGB_BASIS}; + +/// The mid-grey a photographic exposure is reckoned from. +/// +/// 18.4% rather than 18%: it is the value the upstream profiles are calibrated +/// against, and a profile calibrated at one grey and rendered at another is +/// off by a fraction of a stop everywhere. +pub const MID_GREY: f32 = 0.184; + +/// The edge length of the baked density lookup. +/// +/// 32 holds the worst-case interpolation error to about 0.003 in linear sRGB, +/// which is below one 8-bit code value, in 384 kB. Doubling it buys a factor +/// of four in error for eight times the memory, and there is nothing to spend +/// that on: the error is already under what the output can represent. +pub const LUT_SIZE: usize = 32; + +/// What to develop, and how. +pub struct Recipe<'a> { + /// The stock the picture was taken on. + pub film: &'a Profile, + /// The paper it is printed on. `None` views the film directly, which is + /// what a reversal stock wants and what makes a negative come out orange + /// and inverted — that being what a negative actually looks like. + pub print: Option<&'a Profile>, + /// Camera exposure, in stops. + pub exposure_ev: f32, + /// Enlarger exposure, in stops. Ignored without a `print`. + pub print_exposure_ev: f32, +} + +impl<'a> Recipe<'a> { + /// The straightforward reading of a stock: reversal viewed directly, + /// negative printed on the paper its datasheet names. + pub fn new(film: &'a Profile, print: Option<&'a Profile>) -> Self { + Self { film, print, exposure_ev: 0.0, print_exposure_ev: 0.0 } + } +} + +/// A recipe reduced to three tables. +/// +/// Plain `f32` with a documented layout, and no notion of a texture: what to +/// bind this to is dr-gpu's decision, and keeping it out of here is what lets +/// the whole model be tested on the CPU. +#[derive(Debug, Clone)] +pub struct Baked { + /// Linear sRGB to the three layers' log₁₀ exposure, before the log — row + /// `l`, column `c` is layer `l`'s response to sRGB channel `c`. + pub exposure_matrix: [[f32; 3]; 3], + /// The characteristic curves, `CURVE_SAMPLES` samples per layer, uniform + /// over `[curve_log_min, curve_log_max]`. + pub curves: Vec<[f32; 3]>, + pub curve_log_min: f32, + pub curve_log_max: f32, + /// Density to linear sRGB, `LUT_SIZE³` entries in x-major order, uniform + /// over `[0, density_max]` on each axis. + pub lut: Vec<[f32; 3]>, + pub density_max: f32, + pub lut_size: usize, +} + +impl Baked { + /// Look a colour up the way the shader will, for tests and for previews. + pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] { + let mut log_exposure = [0.0f32; 3]; + for (l, slot) in log_exposure.iter_mut().enumerate() { + let m = self.exposure_matrix[l]; + let e = m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2]; + *slot = (e.max(0.0) + 1e-10).log10(); + } + self.sample_lut(self.sample_curves(log_exposure)) + } + + fn sample_curves(&self, log_exposure: [f32; 3]) -> [f32; 3] { + let last = self.curves.len() - 1; + let span = self.curve_log_max - self.curve_log_min; + let mut out = [0.0f32; 3]; + for (c, slot) in out.iter_mut().enumerate() { + let t = ((log_exposure[c] - self.curve_log_min) / span).clamp(0.0, 1.0) * last as f32; + let i = (t.floor() as usize).min(last - 1); + let f = t - i as f32; + *slot = self.curves[i][c] * (1.0 - f) + self.curves[i + 1][c] * f; + } + out + } + + fn sample_lut(&self, density: [f32; 3]) -> [f32; 3] { + let n = self.lut_size; + let mut base = [0usize; 3]; + let mut frac = [0f32; 3]; + for c in 0..3 { + let t = (density[c] / self.density_max).clamp(0.0, 1.0) * (n - 1) as f32; + base[c] = (t.floor() as usize).min(n - 2); + frac[c] = t - base[c] as f32; + } + let mut out = [0.0f32; 3]; + for dx in 0..2 { + for dy in 0..2 { + for dz in 0..2 { + let w = if dx == 0 { 1.0 - frac[0] } else { frac[0] } + * if dy == 0 { 1.0 - frac[1] } else { frac[1] } + * if dz == 0 { 1.0 - frac[2] } else { frac[2] }; + let e = self.lut[((base[0] + dx) * n + base[1] + dy) * n + base[2] + dz]; + for c in 0..3 { + out[c] += w * e[c]; + } + } + } + } + out + } +} + +/// Linear sRGB to the three layers' exposure, mid-grey normalised. +/// +/// Normalised on the *green* layer alone, one shared scalar for all three. +/// Doing it per layer is tempting and wrong: it would silently flatten the +/// film's own channel balance, which is a large part of what distinguishes one +/// stock from another. Where the balance genuinely has to come out — printing a +/// negative — it is [`print_balance`]'s job, which is also where it belongs +/// physically. +pub fn exposure_matrix(film: &Profile) -> [[f32; 3]; 3] { + let reference = illuminant(&film.reference_illuminant); + let sensitivity = film.sensitivity(); + + let mut m = [[0.0f32; 3]; 3]; + let mut mid_grey = [0.0f32; 3]; + for i in 0..SPECTRUM { + for layer in 0..3 { + let s = sensitivity[i][layer] * reference[i]; + mid_grey[layer] += s * MID_GREY; + for channel in 0..3 { + m[layer][channel] += s * SRGB_BASIS[i][channel]; + } + } + } + + let scale = 1.0 / mid_grey[1]; + for row in &mut m { + for v in row.iter_mut() { + *v *= scale; + } + } + m +} + +/// The enlarger head's filtration, solved rather than dialled. +/// +/// Returns the per-layer log exposure offsets that make a mid-grey scene print +/// as a neutral mid-grey. This is also where a colour negative's orange mask +/// goes: the mask is a fixed density, so balancing mid-grey to neutral cancels +/// it — which is why a printed negative looks like a photograph while a scanned +/// one looks orange. +fn print_balance(film: &Profile, paper: &Profile, exposure_ev: f32, print_exposure_ev: f32) -> [f32; 3] { + let matrix = exposure_matrix(film); + let scene = MID_GREY * 2f32.powf(exposure_ev); + let mut log_exposure = [0.0f32; 3]; + for (l, slot) in log_exposure.iter_mut().enumerate() { + let m = matrix[l]; + *slot = ((m[0] + m[1] + m[2]) * scene + 1e-10).log10(); + } + let mid_raw = paper_exposure(film, paper, film.density_at(log_exposure)); + + // Where on the paper's curve mid-grey belongs: the density that reflects + // 18%, read off the average of the three curves. Averaged because the + // point of the balance is that the three end up at the same place. + let target_density = -MID_GREY.log10() - mean(&paper.base_density); + let target = invert_mean_curve(paper, target_density); + + let mut offsets = [0.0f32; 3]; + for (l, slot) in offsets.iter_mut().enumerate() { + *slot = target - (mid_raw[l] + 1e-10).log10() + print_exposure_ev * 2f32.log10(); + } + offsets +} + +/// The paper's three layer exposures, printing through a negative at these +/// densities. +/// +/// The one genuinely spectral step left in the chain — the negative's +/// transmittance is `10^-D`, so this is not a matrix and cannot be made into +/// one. It takes exactly three numbers in, which is what lets the whole +/// negative-and-print chain still bake into a 3D lookup. +fn paper_exposure(film: &Profile, paper: &Profile, density: [f32; 3]) -> [f32; 3] { + let enlarger = illuminant(&paper.reference_illuminant); + let sensitivity = paper.sensitivity(); + let transmittance = film.transmittance(density); + + let mut raw = [0.0f32; 3]; + for i in 0..SPECTRUM { + let light = transmittance[i] * enlarger[i]; + for layer in 0..3 { + raw[layer] += light * sensitivity[i][layer]; + } + } + raw +} + +/// Bake a recipe into the tables a shader runs. +pub fn bake(recipe: &Recipe) -> Baked { + let film = recipe.film; + let mut matrix = exposure_matrix(film); + // Camera exposure rides in the matrix rather than in the shader: it is a + // scalar on a linear quantity, and folding it in here costs nothing and + // keeps the per-pixel work identical whether or not it has been moved. + let gain = 2f32.powf(recipe.exposure_ev); + for row in &mut matrix { + for v in row.iter_mut() { + *v *= gain; + } + } + + let curves = film.density_curves.clone(); + let density_max = film.max_density().max(1e-3); + + let viewing = match recipe.print { + Some(paper) => Viewing::new(&paper.viewing_illuminant), + None => Viewing::new(&film.viewing_illuminant), + }; + let balance = recipe + .print + .map(|paper| print_balance(film, paper, recipe.exposure_ev, recipe.print_exposure_ev)); + + let n = LUT_SIZE; + let mut lut = Vec::with_capacity(n * n * n); + for x in 0..n { + for y in 0..n { + for z in 0..n { + let density = [ + density_max * x as f32 / (n - 1) as f32, + density_max * y as f32 / (n - 1) as f32, + density_max * z as f32 / (n - 1) as f32, + ]; + lut.push(match recipe.print.zip(balance) { + Some((paper, offsets)) => { + let raw = paper_exposure(film, paper, density); + let mut log_exposure = [0.0f32; 3]; + for (l, slot) in log_exposure.iter_mut().enumerate() { + *slot = (raw[l] + 1e-10).log10() + offsets[l]; + } + let paper_density = paper.density_at(log_exposure); + viewing.to_srgb(&paper.transmittance(paper_density)) + } + None => viewing.to_srgb(&film.transmittance(density)), + }); + } + } + } + + Baked { + exposure_matrix: matrix, + curves, + curve_log_min: film.log_exposure_min, + curve_log_max: film.log_exposure_max, + lut, + density_max, + lut_size: n, + } +} + +fn mean(s: &Spectrum) -> f32 { + s.iter().sum::() / SPECTRUM as f32 +} + +/// The log exposure at which the paper's average curve reaches `density`. +fn invert_mean_curve(paper: &Profile, density: f32) -> f32 { + let curves = &paper.density_curves; + let last = curves.len() - 1; + let span = paper.log_exposure_max - paper.log_exposure_min; + let at = |i: usize| (curves[i][0] + curves[i][1] + curves[i][2]) / 3.0; + let log_at = |i: usize| paper.log_exposure_min + span * i as f32 / last as f32; + + // A paper is negative-working, so its mean curve rises. Walk it rather + // than binary-search: 256 samples is nothing, and a linear scan is correct + // even where the curve is flat, which a bisection is not. + let ascending = at(last) >= at(0); + for i in 0..last { + let (lo, hi) = (at(i), at(i + 1)); + let brackets = if ascending { lo <= density && density <= hi } else { hi <= density && density <= lo }; + if brackets && (hi - lo).abs() > f32::EPSILON { + let f = (density - lo) / (hi - lo); + return log_at(i) + (log_at(i + 1) - log_at(i)) * f; + } + } + // Off the end of the curve: the nearest end is the honest answer, and it + // keeps a badly-scaled contributed profile from producing a NaN that would + // propagate silently through the whole LUT. + if (density <= at(0)) == ascending { paper.log_exposure_min } else { paper.log_exposure_max } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn profile(yaml: &str) -> Profile { + Profile::parse(yaml).unwrap() + } + + fn portra() -> Profile { + profile(include_str!("../profiles/kodak_portra_400.yaml")) + } + + fn endura() -> Profile { + profile(include_str!("../profiles/kodak_portra_endura.yaml")) + } + + fn kodachrome() -> Profile { + profile(include_str!("../profiles/kodak_kodachrome_64.yaml")) + } + + fn spread(rgb: [f32; 3]) -> f32 { + rgb.iter().cloned().fold(f32::MIN, f32::max) - rgb.iter().cloned().fold(f32::MAX, f32::min) + } + + #[test] + fn the_exposure_matrix_is_diagonally_dominant() { + // Each layer must respond most strongly to its own primary. A matrix + // that failed this would mean the sensitivity table had been pasted in + // the wrong channel order, which produces a picture that renders + // perfectly and has its colours swapped. + for film in [portra(), kodachrome()] { + let m = exposure_matrix(&film); + for layer in 0..3 { + for channel in 0..3 { + if channel != layer { + assert!( + m[layer][layer] > m[layer][channel] * 3.0, + "{}: layer {layer} responds to channel {channel} too strongly: {m:?}", + film.stock + ); + } + } + } + } + } + + #[test] + fn a_reversal_stock_renders_a_positive() { + let film = kodachrome(); + let baked = bake(&Recipe::new(&film, None)); + let shadow = baked.apply([0.02; 3]); + let mid = baked.apply([MID_GREY; 3]); + let highlight = baked.apply([0.8; 3]); + assert!(shadow[1] < mid[1] && mid[1] < highlight[1], "{shadow:?} {mid:?} {highlight:?}"); + } + + #[test] + fn a_scanned_negative_is_inverted_and_orange() { + // Not a defect: it is what a negative looks like, and rendering it any + // other way would mean the print stage was silently applied. + let film = portra(); + let baked = bake(&Recipe::new(&film, None)); + let shadow = baked.apply([0.02; 3]); + let highlight = baked.apply([0.8; 3]); + assert!(shadow[1] > highlight[1], "not inverted: {shadow:?} -> {highlight:?}"); + let mid = baked.apply([MID_GREY; 3]); + assert!(mid[0] > mid[2] * 4.0, "no orange mask: {mid:?}"); + } + + #[test] + fn printing_a_negative_restores_the_picture() { + // The property the whole print stage exists for: through the paper, + // the same negative is the right way up and neutral again. + let film = portra(); + let paper = endura(); + let baked = bake(&Recipe::new(&film, Some(&paper))); + + let shadow = baked.apply([0.02; 3]); + let mid = baked.apply([MID_GREY; 3]); + let highlight = baked.apply([0.8; 3]); + assert!( + shadow[1] < mid[1] && mid[1] < highlight[1], + "print is not a positive: {shadow:?} {mid:?} {highlight:?}" + ); + for grey in [shadow, mid, highlight] { + assert!(spread(grey) < 0.06, "print of a neutral is not neutral: {grey:?}"); + } + } + + #[test] + fn exposure_moves_the_print_the_way_it_moves_a_photograph() { + let film = portra(); + let paper = endura(); + let brighter = bake(&Recipe { exposure_ev: 1.0, ..Recipe::new(&film, Some(&paper)) }); + let base = bake(&Recipe::new(&film, Some(&paper))); + assert!(brighter.apply([MID_GREY; 3])[1] > base.apply([MID_GREY; 3])[1]); + } + + #[test] + fn the_lut_holds_its_error_under_a_code_value() { + // The claim LUT_SIZE is chosen on. Compared against the same chain + // evaluated exactly, so it measures interpolation error and nothing + // else. + let film = kodachrome(); + let baked = bake(&Recipe::new(&film, None)); + let viewing = Viewing::new(&film.viewing_illuminant); + + let mut worst = 0.0f32; + for i in 0..40 { + for j in 0..40 { + let rgb = [i as f32 / 39.0, j as f32 / 39.0, ((i + j) % 40) as f32 / 39.0]; + let mut log_exposure = [0.0f32; 3]; + for (l, slot) in log_exposure.iter_mut().enumerate() { + let m = baked.exposure_matrix[l]; + *slot = ((m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2]).max(0.0) + 1e-10).log10(); + } + let exact = viewing.to_srgb(&film.transmittance(film.density_at(log_exposure))); + let approx = baked.apply(rgb); + for c in 0..3 { + worst = worst.max((exact[c] - approx[c]).abs()); + } + } + } + assert!(worst < 1.0 / 255.0, "worst LUT error {worst} exceeds one code value"); + } + + #[test] + fn the_lut_is_the_size_it_says_it_is() { + let film = kodachrome(); + let baked = bake(&Recipe::new(&film, None)); + assert_eq!(baked.lut.len(), LUT_SIZE * LUT_SIZE * LUT_SIZE); + assert_eq!(baked.curves.len(), CURVE_SAMPLES); + } +} diff --git a/core/dr-film/src/lib.rs b/core/dr-film/src/lib.rs new file mode 100644 index 0000000..ec9477e --- /dev/null +++ b/core/dr-film/src/lib.rs @@ -0,0 +1,113 @@ +//! TRACES: FR-DEV-3f +//! Spectral film simulation — what a stock would have done with this light. +//! +//! # What this is, and what it is not +//! +//! Not a look-up table someone graded by eye. Each stock here is its +//! manufacturer's own measurements — spectral sensitivity, characteristic +//! curves, dye densities — run through the physics: light exposes three +//! emulsion layers, the layers develop to densities, the densities are dyes +//! that absorb, and what is left is what reaches the eye. A colour negative +//! comes out orange and upside down because that is what a colour negative is; +//! it becomes a photograph when [`bake::Recipe`] prints it on paper. +//! +//! What that buys over a LUT is that the *parameters are physical*. Exposing a +//! stop over moves the picture along the film's real characteristic curve, +//! shoulder and all, rather than scaling a number someone baked at one +//! exposure. And the data cost is the other way round from a LUT collection: a +//! stock is about 17 kB of published measurements, where one HaldCLUT is 800 kB +//! of one person's grade. +//! +//! # The shape of the crate +//! +//! - [`profile`] — a stock, as measured. Data, contributable as a file. +//! - [`spectrum`] — the fixed colour science: observer, illuminants, basis. +//! - [`bake`] — the reduction to three tables a shader can run. +//! +//! No wgpu dependency, deliberately: what comes out is plain `f32` with a +//! documented layout, and every property worth asserting about the model is +//! asserted on the CPU. +//! +//! # Provenance +//! +//! The shipped profiles are converted from **spektrafilm** by Andrea Volpato +//! (), licensed CC BY-SA 4.0 and +//! modified for DarkRoom — see `profiles/LICENSE-PROFILES.txt` and +//! `profiles/CHANGELOG.txt`. The conversion is reproducible from +//! `tools/film-profiles/convert.py` rather than pasted, so what changed is +//! auditable. The sRGB reflectance basis is Mallett & Yuksel (2019). + +pub mod bake; +pub mod profile; +pub mod spectrum; +pub mod tables; + +pub use bake::{bake, Baked, Recipe}; +pub use profile::{Kind, Profile, Support}; + +/// The stocks compiled in as a floor. +/// +/// A floor rather than the whole story: like `dr_decode::base_curve`, the point +/// is that a stock is a file. These are here so a fresh install has something +/// to offer, not because the list is meant to stay short. +static BUILT_IN: &[(&str, &str)] = &[ + ("kodak_portra_400", include_str!("../profiles/kodak_portra_400.yaml")), + ("kodak_kodachrome_64", include_str!("../profiles/kodak_kodachrome_64.yaml")), + ("kodak_portra_endura", include_str!("../profiles/kodak_portra_endura.yaml")), +]; + +/// Every stock that is compiled in, parsed on first use. +pub fn built_in() -> &'static [Profile] { + use std::sync::OnceLock; + static PARSED: OnceLock> = OnceLock::new(); + PARSED.get_or_init(|| { + BUILT_IN + .iter() + .filter_map(|(stock, yaml)| match Profile::parse(yaml) { + Ok(p) => Some(p), + Err(e) => { + // A compiled-in profile that does not parse is a build + // mistake, but refusing to start over one would take the + // whole application down for a stock nobody asked for. + log::error!("built-in film profile {stock} is malformed: {e}"); + None + } + }) + .collect() + }) +} + +/// Find a compiled-in stock by its identifier. +pub fn find(stock: &str) -> Option<&'static Profile> { + built_in().iter().find(|p| p.stock == stock) +} + +/// The paper a stock should be printed on, if it names one and we have it. +/// +/// A reversal stock names none, and needs none: it is the picture already. +pub fn default_print(film: &Profile) -> Option<&'static Profile> { + film.target_print.as_deref().and_then(find) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_built_in_profile_parses() { + assert_eq!(built_in().len(), BUILT_IN.len(), "a built-in profile failed to parse"); + } + + #[test] + fn a_negative_finds_the_paper_it_names() { + let portra = find("kodak_portra_400").unwrap(); + assert_eq!(default_print(portra).unwrap().stock, "kodak_portra_endura"); + } + + #[test] + fn a_reversal_stock_names_no_paper() { + let k64 = find("kodak_kodachrome_64").unwrap(); + assert_eq!(k64.kind, Kind::Positive); + assert!(default_print(k64).is_none()); + } +} diff --git a/core/dr-film/src/profile.rs b/core/dr-film/src/profile.rs new file mode 100644 index 0000000..ce7e471 --- /dev/null +++ b/core/dr-film/src/profile.rs @@ -0,0 +1,279 @@ +//! A film stock, as measured. +//! +//! # Why it is data +//! +//! The same argument `dr_decode::base_curve` makes for camera bodies, and for +//! the same requirement: under the GPLv3 a stock should be contributable +//! without a release. A profile is three tables and a handful of facts, all of +//! them published in the manufacturer's datasheet, so adding a stock is adding +//! a file — not a code change, not a shader, and not a new operation. +//! +//! # What a stock actually is +//! +//! Three numbers per wavelength, three times over: +//! +//! - **Spectral sensitivity** — how strongly each emulsion layer responds to +//! light of each wavelength. Decides what the film *sees*. +//! - **The characteristic curve** — density against log exposure, per layer. +//! Decides the film's contrast, its latitude, and where it clips. +//! - **Dye density** — the spectral absorption each layer's developed dye +//! contributes. Decides what the film *looks* like. +//! +//! A print paper is the same three things; `support` is the only field that +//! distinguishes it, and it exists so that a UI can offer papers separately +//! rather than because the renderer treats them differently. + +use serde::Deserialize; + +use crate::spectrum::Spectrum; +use crate::tables::SPECTRUM; + +/// How many samples a characteristic curve carries. +/// +/// Enough that the toe and the shoulder survive linear interpolation, which is +/// the resolution that matters: the 3D LUT downstream is deliberately coarse +/// because the dye mixing is smooth, and it can only be coarse if the curve's +/// shape is carried exactly here rather than folded into it. +pub const CURVE_SAMPLES: usize = 256; + +/// Which way the material works. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Kind { + /// More light, more density: a camera negative, and also every print paper. + Negative, + /// More light, *less* density: a reversal stock, viewed as it comes. + Positive, +} + +/// What the emulsion is coated on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Support { + Film, + Paper, +} + +/// TRACES: FR-DEV-3f +/// One stock's measured response. +#[derive(Debug, Clone, Deserialize)] +pub struct Profile { + /// The upstream profile's version, so a mismatch is diagnosable. + #[serde(default)] + pub version: String, + /// The identifier a recipe names, e.g. `kodak_portra_400`. + pub stock: String, + /// The stock's display name. Not a localisation key: "Kodak Portra 400" is + /// a product name and does not translate. + pub name: String, + pub kind: Kind, + pub support: Support, + /// The light the sensitivity data was measured under. + pub reference_illuminant: String, + /// The light the developed result is meant to be looked at under. + pub viewing_illuminant: String, + /// For a negative, the paper it was designed to be printed on. + #[serde(default)] + pub target_print: Option, + + /// log₁₀ sensitivity per layer. `-9` marks a wavelength the datasheet does + /// not cover, which the renderer reads as blindness rather than as data. + #[serde(deserialize_with = "spectral_triples")] + pub log_sensitivity: Vec<[f32; 3]>, + /// Spectral density contributed by each layer's dye at unit density. + #[serde(deserialize_with = "spectral_triples")] + pub dye_density: Vec<[f32; 3]>, + /// The support's own density — film base, plus a colour negative's orange + /// mask. + #[serde(deserialize_with = "spectral_scalars")] + pub base_density: Spectrum, + + pub log_exposure_min: f32, + pub log_exposure_max: f32, + /// Density against log exposure, per layer, uniformly sampled across + /// `[log_exposure_min, log_exposure_max]`. + pub density_curves: Vec<[f32; 3]>, +} + +impl Profile { + /// Read a profile from YAML. + pub fn parse(yaml: &str) -> Result { + let profile: Profile = serde_norway::from_str(yaml).map_err(|e| e.to_string())?; + profile.validate()?; + Ok(profile) + } + + fn validate(&self) -> Result<(), String> { + // Checked rather than assumed because a profile is a *contributed* + // file. A short table would otherwise be caught as an index panic + // somewhere in the baker, which names neither the file nor the field. + if self.log_sensitivity.len() != SPECTRUM { + return Err(format!( + "{}: log_sensitivity has {} rows, expected {SPECTRUM}", + self.stock, + self.log_sensitivity.len() + )); + } + if self.dye_density.len() != SPECTRUM { + return Err(format!( + "{}: dye_density has {} rows, expected {SPECTRUM}", + self.stock, + self.dye_density.len() + )); + } + if self.density_curves.len() < 2 { + return Err(format!("{}: density_curves needs at least two samples", self.stock)); + } + if self.log_exposure_max <= self.log_exposure_min { + return Err(format!( + "{}: log exposure range [{}, {}] is empty or inverted", + self.stock, self.log_exposure_min, self.log_exposure_max + )); + } + Ok(()) + } + + /// Linear spectral sensitivity, undoing the log the datasheet quotes. + pub fn sensitivity(&self) -> Vec<[f32; 3]> { + self.log_sensitivity + .iter() + .map(|row| row.map(|v| if v <= -8.0 { 0.0 } else { 10f32.powf(v) })) + .collect() + } + + /// The density each layer reaches at a given log exposure. + /// + /// Clamped at both ends rather than extrapolated: past the shoulder a real + /// emulsion stops responding, and a linear extrapolation of the last two + /// samples would keep climbing and turn a blown highlight into a colour. + pub fn density_at(&self, log_exposure: [f32; 3]) -> [f32; 3] { + let last = self.density_curves.len() - 1; + let span = self.log_exposure_max - self.log_exposure_min; + let mut out = [0.0f32; 3]; + for (c, slot) in out.iter_mut().enumerate() { + let t = ((log_exposure[c] - self.log_exposure_min) / span).clamp(0.0, 1.0) + * last as f32; + let i = (t.floor() as usize).min(last - 1); + let f = t - i as f32; + *slot = self.density_curves[i][c] * (1.0 - f) + self.density_curves[i + 1][c] * f; + } + out + } + + /// The largest density any layer reaches. The 3D LUT's upper bound. + pub fn max_density(&self) -> f32 { + self.density_curves + .iter() + .flat_map(|row| row.iter()) + .fold(0.0f32, |a, &b| a.max(b)) + } + + /// Spectral transmittance of the developed material at these densities. + /// + /// Beer–Lambert: densities add in log space, so the layers' dyes and the + /// support's own base compose by summing before the exponent. + pub fn transmittance(&self, density: [f32; 3]) -> Spectrum { + let mut out = [0.0f32; SPECTRUM]; + for (i, slot) in out.iter_mut().enumerate() { + let dye = &self.dye_density[i]; + let total = density[0] * dye[0] + + density[1] * dye[1] + + density[2] * dye[2] + + self.base_density[i]; + *slot = 10f32.powf(-total); + } + out + } +} + +fn spectral_triples<'de, D>(d: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Vec::<[f32; 3]>::deserialize(d) +} + +fn spectral_scalars<'de, D>(d: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error as _; + let v = Vec::::deserialize(d)?; + v.try_into() + .map_err(|v: Vec| D::Error::custom(format!("expected {SPECTRUM} samples, got {}", v.len()))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn portra() -> Profile { + Profile::parse(include_str!("../profiles/kodak_portra_400.yaml")).unwrap() + } + + fn kodachrome() -> Profile { + Profile::parse(include_str!("../profiles/kodak_kodachrome_64.yaml")).unwrap() + } + + #[test] + fn the_shipped_profiles_load() { + for p in [portra(), kodachrome()] { + assert_eq!(p.log_sensitivity.len(), SPECTRUM); + assert_eq!(p.density_curves.len(), CURVE_SAMPLES); + } + } + + #[test] + fn a_negative_gains_density_with_exposure_and_a_positive_loses_it() { + // The one property that decides whether the picture comes out or comes + // out inverted, and it is a property of the *data*, not of the code + // that reads it — so it is asserted against the shipped files. + let shadow = [-2.0; 3]; + let highlight = [2.0; 3]; + assert!(portra().density_at(highlight)[1] > portra().density_at(shadow)[1]); + assert!(kodachrome().density_at(highlight)[1] < kodachrome().density_at(shadow)[1]); + } + + #[test] + fn the_curve_is_clamped_rather_than_extrapolated() { + // Past the shoulder a real emulsion stops responding. Extrapolating + // would keep climbing, which turns a blown highlight into a colour + // cast that gets stronger the more it is overexposed. + let p = portra(); + let shoulder = p.density_at([p.log_exposure_max; 3]); + let far_past = p.density_at([p.log_exposure_max + 40.0; 3]); + assert_eq!(shoulder, far_past); + } + + #[test] + fn an_undeveloped_frame_transmits_its_base_and_nothing_else() { + let p = portra(); + let clear = p.transmittance([0.0; 3]); + for (i, t) in clear.iter().enumerate() { + assert!((t - 10f32.powf(-p.base_density[i])).abs() < 1e-5); + } + } + + #[test] + fn the_orange_mask_is_in_the_data() { + // Portra's base is a real orange mask: it must absorb blue far more + // than red. If this fails the base density has been dropped or + // zeroed, and the print balance downstream would have nothing to + // correct — which looks like a working picture, only wrong. + let p = portra(); + let blue = p.base_density[16]; // 460nm + let red = p.base_density[64]; // 700nm + assert!(blue > red + 0.2, "base density blue {blue} red {red} is not a mask"); + } + + #[test] + fn a_blind_wavelength_reads_as_zero_sensitivity() { + let p = kodachrome(); + let sens = p.sensitivity(); + for (i, row) in sens.iter().enumerate() { + for (c, v) in row.iter().enumerate() { + assert!(v.is_finite() && *v >= 0.0, "sensitivity[{i}][{c}] = {v}"); + } + } + } +} diff --git a/core/dr-film/src/spectrum.rs b/core/dr-film/src/spectrum.rs new file mode 100644 index 0000000..8dd8864 --- /dev/null +++ b/core/dr-film/src/spectrum.rs @@ -0,0 +1,236 @@ +//! The fixed colour science: spectra in, tristimulus out. +//! +//! Everything here is per-installation rather than per-stock — one observer, +//! a handful of illuminants, one basis — and all of it is small enough to +//! compile in. See [`crate::tables`] for the numbers themselves. + +use crate::tables::{ILLUMINANT_D50, ILLUMINANT_D55, ILLUMINANT_D65, OBSERVER, SPECTRUM}; + +/// A spectral distribution on the crate's fixed 380–780 nm, 5 nm grid. +pub type Spectrum = [f32; SPECTRUM]; + +/// Linear sRGB primaries from CIE XYZ, for a D65 white. +/// +/// The adaptation to whatever white the picture is actually being viewed under +/// happens before this is applied — see [`Viewing::to_srgb`]. +const XYZ_TO_SRGB: [[f32; 3]; 3] = [ + [3.2404542, -1.5371385, -0.4985314], + [-0.9692660, 1.8760108, 0.0415560], + [0.0556434, -0.2040259, 1.0572252], +]; + +/// sRGB's own white, which is what [`XYZ_TO_SRGB`] expects to be handed. +const D65_WHITE: [f32; 3] = [0.9504559, 1.0, 1.0890578]; + +/// Bradford cone response, and its inverse. +/// +/// A von Kries adaptation done in XYZ — dividing each channel by the white — +/// is the obvious thing and it is wrong: XYZ axes are not cone responses, so +/// the result drifts in hue. Bradford is the transform that makes "the same +/// colour, seen under a different light" mean what a viewer means by it, and +/// it matters here because a print is specified under D50 while sRGB is D65. +const BRADFORD: [[f32; 3]; 3] = [ + [0.8951, 0.2664, -0.1614], + [-0.7502, 1.7135, 0.0367], + [0.0389, -0.0685, 1.0296], +]; + +const BRADFORD_INV: [[f32; 3]; 3] = [ + [0.9869929, -0.1470543, 0.1599627], + [0.4323053, 0.5183603, 0.0492912], + [-0.0085287, 0.0400428, 0.9684867], +]; + +/// Named illuminants a profile may ask for. +/// +/// The daylight ones are tabulated because the CIE defines them that way. The +/// tungsten ones are computed from Planck's law, which costs no data at all, +/// and the approximation is unusually safe here: an enlarger's lamp colour is +/// cancelled by the filtration solved in [`crate::bake::print_balance`], the +/// same way a darkroom worker dials it out on the colour head. +pub fn illuminant(name: &str) -> Spectrum { + match name { + "D50" => ILLUMINANT_D50, + "D55" => ILLUMINANT_D55, + "D65" => ILLUMINANT_D65, + // The tungsten-halogen enlarger source, with and without the heat + // filter that a real head carries. Both land on the same blackbody: + // the filter's effect is a colour shift, and a colour shift ahead of + // the balance step is by construction invisible. + "TH-KG3" | "TH-KG3-L" | "T" => blackbody(3400.0), + other => { + if let Some(kelvin) = other.strip_prefix("BB").and_then(|k| k.parse::().ok()) { + return blackbody(kelvin); + } + log::warn!("unknown illuminant {other:?}, falling back to D55"); + ILLUMINANT_D55 + } + } +} + +/// Planck's law on the grid, normalised to unit mean. +/// +/// Normalised because only the shape matters: absolute level is set by the +/// exposure controls, and leaving it in would make the choice of units a +/// visible parameter. +pub fn blackbody(kelvin: f32) -> Spectrum { + const H: f64 = 6.626_070_15e-34; + const C: f64 = 2.997_924_58e8; + const KB: f64 = 1.380_649e-23; + + let mut out = [0.0f32; SPECTRUM]; + let mut total = 0.0f64; + for (i, slot) in out.iter_mut().enumerate() { + let lambda = f64::from(crate::tables::LAMBDA_MIN + crate::tables::LAMBDA_STEP * i as f32) + * 1e-9; + let radiance = (2.0 * H * C * C) + / (lambda.powi(5) * ((H * C / (lambda * KB * f64::from(kelvin))).exp() - 1.0)); + *slot = radiance as f32; + total += radiance; + } + let mean = (total / SPECTRUM as f64) as f32; + for slot in &mut out { + *slot /= mean; + } + out +} + +/// How a developed image is looked at: an illuminant, and the adaptation it +/// implies. +/// +/// Built once per bake rather than per sample, because the white point and the +/// adaptation matrix depend only on the illuminant and computing them inside +/// the LUT loop would be the same work 32 768 times. +pub struct Viewing { + illuminant: Spectrum, + /// The illuminant's own Y, which normalises the integral so that a clear + /// frame comes out at exactly 1.0 rather than at whatever the tabulated + /// units happen to give. + normalisation: f32, + /// XYZ under this illuminant to linear sRGB, adaptation folded in. + matrix: [[f32; 3]; 3], +} + +impl Viewing { + pub fn new(illuminant_name: &str) -> Self { + let illuminant = illuminant(illuminant_name); + + let mut white = [0.0f32; 3]; + let mut normalisation = 0.0f32; + for i in 0..SPECTRUM { + normalisation += illuminant[i] * OBSERVER[i][1]; + for c in 0..3 { + white[c] += illuminant[i] * OBSERVER[i][c]; + } + } + for c in &mut white { + *c /= normalisation; + } + + Self { + illuminant, + normalisation, + matrix: mat3_mul(XYZ_TO_SRGB, bradford_adaptation(white, D65_WHITE)), + } + } + + /// Integrate a transmittance (or reflectance) against the illuminant and + /// the observer, and convert to linear sRGB. + pub fn to_srgb(&self, transmittance: &Spectrum) -> [f32; 3] { + let mut xyz = [0.0f32; 3]; + for i in 0..SPECTRUM { + let light = transmittance[i] * self.illuminant[i]; + for c in 0..3 { + xyz[c] += light * OBSERVER[i][c]; + } + } + for c in &mut xyz { + *c /= self.normalisation; + } + mat3_apply(self.matrix, xyz) + } +} + +/// The Bradford transform taking `from` white to `to` white. +fn bradford_adaptation(from: [f32; 3], to: [f32; 3]) -> [[f32; 3]; 3] { + let src = mat3_apply(BRADFORD, from); + let dst = mat3_apply(BRADFORD, to); + let scale = [ + [dst[0] / src[0], 0.0, 0.0], + [0.0, dst[1] / src[1], 0.0], + [0.0, 0.0, dst[2] / src[2]], + ]; + mat3_mul(BRADFORD_INV, mat3_mul(scale, BRADFORD)) +} + +pub(crate) fn mat3_apply(m: [[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] { + [ + m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], + m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], + m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2], + ] +} + +pub(crate) fn mat3_mul(a: [[f32; 3]; 3], b: [[f32; 3]; 3]) -> [[f32; 3]; 3] { + let mut out = [[0.0f32; 3]; 3]; + for (r, row) in out.iter_mut().enumerate() { + for (c, slot) in row.iter_mut().enumerate() { + *slot = (0..3).map(|k| a[r][k] * b[k][c]).sum(); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_clear_frame_is_white_under_every_viewing_illuminant() { + // The property the whole viewing step exists to hold: an unexposed + // transparency on a light table is white, whatever the light table is. + // Normalising on luminance alone would leave the illuminant's own + // colour in the result, and a D50 print would render warm. + for name in ["D50", "D55", "D65", "TH-KG3"] { + let rgb = Viewing::new(name).to_srgb(&[1.0; SPECTRUM]); + for c in rgb { + assert!( + (c - 1.0).abs() < 0.01, + "{name}: clear frame gave {rgb:?}, which is not neutral white" + ); + } + } + } + + #[test] + fn adapting_a_white_to_itself_changes_nothing() { + let m = bradford_adaptation(D65_WHITE, D65_WHITE); + for r in 0..3 { + for c in 0..3 { + let expected = if r == c { 1.0 } else { 0.0 }; + assert!((m[r][c] - expected).abs() < 1e-5, "{m:?} is not the identity"); + } + } + } + + #[test] + fn a_hotter_blackbody_is_bluer() { + // Cheap, but it is the one property that catches Planck's law written + // with a sign or a reciprocal wrong, which otherwise produces a + // plausible-looking curve pointing the wrong way. + let cool = blackbody(2800.0); + let hot = blackbody(9000.0); + let blue = 10; // 430nm + let red = 70; // 730nm + assert!(hot[blue] / hot[red] > cool[blue] / cool[red]); + } + + #[test] + fn the_observer_integrates_to_a_plausible_white() { + // Guards the generated table against a transposed or misaligned paste: + // D65 through the 1931 observer must land on sRGB's own white. + let v = Viewing::new("D65"); + let rgb = v.to_srgb(&[1.0; SPECTRUM]); + assert!(rgb.iter().all(|c| (c - 1.0).abs() < 0.01), "{rgb:?}"); + } +} diff --git a/core/dr-film/src/tables.rs b/core/dr-film/src/tables.rs new file mode 100644 index 0000000..ac71b92 --- /dev/null +++ b/core/dr-film/src/tables.rs @@ -0,0 +1,246 @@ +//! Generated by tools/film-profiles/convert.py. Do not edit. +//! +//! The fixed colour science: the observer, the illuminants and the spectral +//! basis. None of it is per-stock, all of it is published data, and together it +//! is under 6 kB of source -- which is the point. A film simulation's data cost +//! is dominated by whatever it uses to turn a pixel back into a spectrum, and a +//! basis is three curves where a coefficient table is megabytes. + +/// The lowest wavelength sampled, in nanometres. +pub const LAMBDA_MIN: f32 = 380.0; +/// The spacing between samples, in nanometres. +pub const LAMBDA_STEP: f32 = 5.0; +/// How many wavelengths every spectral table carries. +/// +/// The profiles, the observer and the basis all arrive on this grid already, so +/// nothing in this crate resamples anything. +pub const SPECTRUM: usize = 81; + + +/// CIE 1931 2-degree standard observer, x-bar/y-bar/z-bar per wavelength. +pub static OBSERVER: [[f32; 3]; SPECTRUM] = [ + [0.001368, 3.9e-05, 0.006450001], // 380nm + [0.002236, 6.4e-05, 0.01054999], // 385nm + [0.004243, 0.00012, 0.02005001], // 390nm + [0.00765, 0.000217, 0.03621], // 395nm + [0.01431, 0.000396, 0.06785001], // 400nm + [0.02319, 0.00064, 0.1102], // 405nm + [0.04351, 0.00121, 0.2074], // 410nm + [0.07763, 0.00218, 0.3713], // 415nm + [0.13438, 0.004, 0.6456], // 420nm + [0.21477, 0.0073, 1.0390501], // 425nm + [0.2839, 0.0116, 1.3856], // 430nm + [0.3285, 0.01684, 1.62296], // 435nm + [0.34828, 0.023, 1.74706], // 440nm + [0.34806, 0.0298, 1.7826], // 445nm + [0.3362, 0.038, 1.77211], // 450nm + [0.3187, 0.048, 1.7441], // 455nm + [0.2908, 0.06, 1.6692], // 460nm + [0.2511, 0.0739, 1.5281], // 465nm + [0.19536, 0.09098, 1.28764], // 470nm + [0.1421, 0.1126, 1.0419], // 475nm + [0.09564, 0.13902, 0.8129501], // 480nm + [0.05795001, 0.1693, 0.6162], // 485nm + [0.03201, 0.20802, 0.46518], // 490nm + [0.0147, 0.2586, 0.3533], // 495nm + [0.0049, 0.323, 0.272], // 500nm + [0.0024, 0.4073, 0.2123], // 505nm + [0.0093, 0.503, 0.1582], // 510nm + [0.0291, 0.6082, 0.1117], // 515nm + [0.06327, 0.71, 0.07824999], // 520nm + [0.1096, 0.7932, 0.05725001], // 525nm + [0.1655, 0.862, 0.04216], // 530nm + [0.2257499, 0.9148501, 0.02984], // 535nm + [0.2904, 0.954, 0.0203], // 540nm + [0.3597, 0.9803, 0.0134], // 545nm + [0.4334499, 0.9949501, 0.008749999], // 550nm + [0.5120501, 1.0, 0.005749999], // 555nm + [0.5945, 0.995, 0.0039], // 560nm + [0.6784, 0.9786, 0.002749999], // 565nm + [0.7621, 0.952, 0.0021], // 570nm + [0.8425, 0.9154, 0.0018], // 575nm + [0.9163, 0.87, 0.001650001], // 580nm + [0.9786, 0.8163, 0.0014], // 585nm + [1.0263, 0.757, 0.0011], // 590nm + [1.0567, 0.6949, 0.001], // 595nm + [1.0622, 0.631, 0.0008], // 600nm + [1.0456, 0.5668, 0.0006], // 605nm + [1.0026, 0.503, 0.00034], // 610nm + [0.9384, 0.4412, 0.00024], // 615nm + [0.8544499, 0.381, 0.00019], // 620nm + [0.7514, 0.321, 0.0001], // 625nm + [0.6424, 0.265, 4.999999e-05], // 630nm + [0.5419, 0.217, 3e-05], // 635nm + [0.4479, 0.175, 2e-05], // 640nm + [0.3608, 0.1382, 1e-05], // 645nm + [0.2835, 0.107, 2.1175824e-22], // 650nm + [0.2187, 0.0816, 0.0], // 655nm + [0.1649, 0.061, 0.0], // 660nm + [0.1212, 0.04458, 0.0], // 665nm + [0.0874, 0.032, 0.0], // 670nm + [0.0636, 0.0232, 0.0], // 675nm + [0.04677, 0.017, 0.0], // 680nm + [0.0329, 0.01192, 0.0], // 685nm + [0.0227, 0.00821, 0.0], // 690nm + [0.01584, 0.005723, 0.0], // 695nm + [0.01135916, 0.004102, 0.0], // 700nm + [0.008110916, 0.002929, 0.0], // 705nm + [0.005790346, 0.002091, 0.0], // 710nm + [0.004109457, 0.001484, 0.0], // 715nm + [0.002899327, 0.001047, 0.0], // 720nm + [0.00204919, 0.00074, 0.0], // 725nm + [0.001439971, 0.00052, 0.0], // 730nm + [0.0009999493, 0.0003611, 0.0], // 735nm + [0.0006900786, 0.0002492, 0.0], // 740nm + [0.0004760213, 0.0001719, 0.0], // 745nm + [0.0003323011, 0.00012, 0.0], // 750nm + [0.0002348261, 8.48e-05, 0.0], // 755nm + [0.0001661505, 6e-05, 0.0], // 760nm + [0.000117413, 4.24e-05, 0.0], // 765nm + [8.307527e-05, 3e-05, 0.0], // 770nm + [5.870652e-05, 2.12e-05, 0.0], // 775nm + [4.150994e-05, 1.499e-05, 0.0], // 780nm +]; + +/// Mallett & Yuksel (2019) sRGB reflectance basis: the three smooth, +/// non-negative spectra that reconstruct any sRGB colour exactly. +/// +/// This is what makes the exposure step a 3x3 matrix rather than a +/// per-pixel spectral integration -- see [`crate::bake`]. +pub static SRGB_BASIS: [[f32; 3]; SPECTRUM] = [ + [0.32745741, 0.33186171, 0.34068079], // 380nm + [0.32375058, 0.32968819, 0.34656119], // 385nm + [0.31343946, 0.32786002, 0.35870049], // 390nm + [0.28887938, 0.31917358, 0.39194703], // 395nm + [0.23920568, 0.29432258, 0.46647173], // 400nm + [0.18970204, 0.25869706, 0.5516009], // 405nm + [0.12174607, 0.18889432, 0.68935961], // 410nm + [0.074578271, 0.12538838, 0.80003335], // 415nm + [0.044433159, 0.07868706, 0.87687978], // 420nm + [0.028928632, 0.053143271, 0.9179281], // 425nm + [0.022316653, 0.042288146, 0.9353952], // 430nm + [0.016911307, 0.033318346, 0.94977035], // 435nm + [0.014181107, 0.029755948, 0.95606294], // 440nm + [0.013053143, 0.030331251, 0.95661561], // 445nm + [0.011986164, 0.030988572, 0.95702526], // 450nm + [0.011288715, 0.031686355, 0.95702493], // 455nm + [0.010906066, 0.034669962, 0.95442397], // 460nm + [0.010400713, 0.034551957, 0.95504733], // 465nm + [0.01063736, 0.040684806, 0.94867783], // 470nm + [0.010907663, 0.054460037, 0.9346323], // 475nm + [0.011032712, 0.080905287, 0.908062], // 480nm + [0.011310657, 0.1463483, 0.84234104], // 485nm + [0.011154642, 0.37967964, 0.60916572], // 490nm + [0.01014877, 0.76674427, 0.22310696], // 495nm + [0.0089185821, 0.87621475, 0.11486667], // 500nm + [0.0076855763, 0.91849166, 0.073822768], // 505nm + [0.0067057083, 0.94065556, 0.052638729], // 510nm + [0.005995806, 0.95373188, 0.040272309], // 515nm + [0.0055372566, 0.96164328, 0.032819463], // 520nm + [0.0051937842, 0.96720002, 0.027606196], // 525nm + [0.0050253623, 0.97098975, 0.023984891], // 530nm + [0.0051363628, 0.9728523, 0.022011333], // 535nm + [0.0054332003, 0.97311659, 0.021450205], // 540nm + [0.0058199859, 0.97335107, 0.020828945], // 545nm + [0.0064005728, 0.97335112, 0.020248311], // 550nm + [0.0074495287, 0.97226108, 0.020289391], // 555nm + [0.0085836358, 0.97335102, 0.018065342], // 560nm + [0.010395762, 0.9731485, 0.016455742], // 565nm + [0.013565434, 0.97106131, 0.01537326], // 570nm + [0.019384516, 0.96637131, 0.014244178], // 575nm + [0.032084071, 0.95494197, 0.012973962], // 580nm + [0.074356038, 0.91357899, 0.012064974], // 585nm + [0.62439372, 0.3643488, 0.011257478], // 590nm + [0.91831003, 0.071507243, 0.010182725], // 595nm + [0.94925303, 0.041230434, 0.0095165354], // 600nm + [0.95818783, 0.032423874, 0.0093882927], // 605nm + [0.95818775, 0.03192463, 0.0098876191], // 610nm + [0.95818763, 0.031276033, 0.010536342], // 615nm + [0.95567906, 0.03263037, 0.011690569], // 620nm + [0.95800615, 0.029530872, 0.012462973], // 625nm + [0.95410157, 0.031561761, 0.014336665], // 630nm + [0.94760761, 0.035674218, 0.016718175], // 635nm + [0.93868133, 0.041403005, 0.019915666], // 640nm + [0.92446668, 0.05060426, 0.024929056], // 645nm + [0.90460603, 0.0634343, 0.031959674], // 650nm + [0.8804122, 0.078918245, 0.040669554], // 655nm + [0.84778787, 0.099542743, 0.052669382], // 660nm + [0.80577913, 0.12559576, 0.068625111], // 665nm + [0.75253185, 0.15759091, 0.089877232], // 670nm + [0.6864394, 0.19539824, 0.11816236], // 675nm + [0.61869457, 0.23147447, 0.14983095], // 680nm + [0.54026444, 0.26885214, 0.19088341], // 685nm + [0.47296442, 0.29602916, 0.2310064], // 690nm + [0.4327016, 0.30975499, 0.25754339], // 695nm + [0.40535805, 0.31781588, 0.27682604], // 700nm + [0.38549183, 0.32299035, 0.29151777], // 705nm + [0.37098358, 0.32635385, 0.30266251], // 710nm + [0.3576087, 0.3291439, 0.3132473], // 715nm + [0.3487128, 0.33080873, 0.32047833], // 720nm + [0.34488012, 0.33148269, 0.32363699], // 725nm + [0.34191788, 0.33198455, 0.32609731], // 730nm + [0.33953109, 0.33234117, 0.32812737], // 735nm + [0.3371695, 0.33291201, 0.32991798], // 740nm + [0.33617202, 0.33291928, 0.3309079], // 745nm + [0.33516744, 0.33302767, 0.33180363], // 750nm + [0.33442163, 0.3331797, 0.33239663], // 755nm + [0.33400876, 0.33324703, 0.33274078], // 760nm + [0.33391579, 0.33325935, 0.33282086], // 765nm + [0.33381845, 0.33327505, 0.33290173], // 770nm + [0.33367277, 0.33329433, 0.33302597], // 775nm + [0.33356951, 0.33330942, 0.33311108], // 780nm +]; + +/// CIE standard illuminant D50, normalised to unit mean. +pub static ILLUMINANT_D50: [f32; SPECTRUM] = [ + 0.28500907, 0.31632887, 0.34766031, 0.46076543, 0.57388219, 0.61580487, + 0.65773919, 0.67822334, 0.69871914, 0.68582343, 0.67292773, 0.77189179, + 0.8708675, 0.94315561, 1.0154437, 1.0350317, 1.054608, 1.0590075, + 1.0634069, 1.0851713, 1.1069474, 1.0886397, 1.0703319, 1.0922127, + 1.1141052, 1.1192844, 1.124452, 1.1274548, 1.1304576, 1.1593798, + 1.188302, 1.1804808, 1.1726596, 1.1817494, 1.1908393, 1.1773616, + 1.1638724, 1.1506973, 1.1375107, 1.1444008, 1.1512793, 1.1197383, + 1.088209, 1.1125805, 1.1369636, 1.1461582, 1.1553645, 1.1540376, + 1.1527225, 1.1334022, 1.1140819, 1.1323314, 1.1505693, 1.1320055, + 1.1134418, 1.1281298, 1.1428063, 1.1708207, 1.1988235, 1.1763025, + 1.1537816, 1.0853925, 1.0170033, 1.0415727, 1.0661536, 1.0736257, + 1.0811094, 0.98780176, 0.89448247, 0.95068587, 1.0068776, 1.0422011, + 1.077513, 0.9940052, 0.91049735, 0.7909793, 0.67146125, 0.81828375, + 0.96511788, 0.93806949, 0.91100946, +]; + +/// CIE standard illuminant D55, normalised to unit mean. +pub static ILLUMINANT_D55: [f32; SPECTRUM] = [ + 0.37928266, 0.41130471, 0.44333841, 0.57639697, 0.70945552, 0.75371138, + 0.79797887, 0.81556713, 0.83316704, 0.81185393, 0.79052917, 0.89349794, + 0.99645507, 1.0685542, 1.1406533, 1.1550288, 1.1694044, 1.1662034, + 1.1630023, 1.1794499, 1.1958974, 1.1687759, 1.1416427, 1.1567865, + 1.1719303, 1.1720235, 1.1721049, 1.1679843, 1.1638637, 1.1884361, + 1.2130201, 1.2007514, 1.1884826, 1.1935228, 1.198563, 1.181289, + 1.164015, 1.1478119, 1.1316089, 1.1347051, 1.1378131, 1.1010418, + 1.0642822, 1.0816726, 1.0990514, 1.1032535, 1.1074439, 1.1020894, + 1.096735, 1.0747816, 1.0528283, 1.0637817, 1.0747351, 1.0545045, + 1.0342739, 1.0427945, 1.0513035, 1.072442, 1.0935921, 1.0703467, + 1.0471014, 0.98728263, 0.92745226, 0.94585534, 0.96427006, 0.97593349, + 0.98759692, 0.90256562, 0.81754596, 0.87031076, 0.92307556, 0.95620343, + 0.9893313, 0.91301847, 0.83669401, 0.72561205, 0.61451846, 0.74916008, + 0.8838017, 0.85988119, 0.83597232, +]; + +/// CIE standard illuminant D65, normalised to unit mean. +pub static ILLUMINANT_D65: [f32; SPECTRUM] = [ + 0.57072795, 0.59740886, 0.62409091, 0.78458177, 0.94507378, 0.99492846, + 1.0447843, 1.055895, 1.0670056, 1.0284649, 0.98992529, 1.0937493, + 1.1975745, 1.2669063, 1.3362495, 1.3408404, 1.3454313, 1.3285751, + 1.3117304, 1.3177945, 1.3238586, 1.2832485, 1.2426385, 1.2457333, + 1.2488396, 1.2399776, 1.2311155, 1.2139168, 1.196718, 1.2132658, + 1.2298251, 1.2110732, 1.1923213, 1.1902656, 1.1882214, 1.1651185, + 1.1420155, 1.1210835, 1.1001515, 1.0970326, 1.0939138, 1.0533585, + 1.0128033, 1.020344, 1.0278847, 1.0255596, 1.0232356, 1.0123842, + 1.0015327, 0.97635016, 0.95116871, 0.95351327, 0.95585783, 0.93488814, + 0.91391845, 0.91499081, 0.91606316, 0.92784419, 0.93962522, 0.91682146, + 0.89401769, 0.8451223, 0.79622805, 0.80700753, 0.81778702, 0.83343149, + 0.8490771, 0.77630216, 0.70352722, 0.7508158, 0.79810438, 0.82780478, + 0.85750517, 0.79187126, 0.72623849, 0.62817019, 0.53010303, 0.64651553, + 0.76292802, 0.74338471, 0.72384139, +]; diff --git a/core/dr-film/tests/agrees_with_the_reference_implementation.rs b/core/dr-film/tests/agrees_with_the_reference_implementation.rs new file mode 100644 index 0000000..8fbab7c --- /dev/null +++ b/core/dr-film/tests/agrees_with_the_reference_implementation.rs @@ -0,0 +1,64 @@ +//! Cross-check against the model this was ported from. +//! +//! The expected values were produced by `tools/film-profiles/proto.py` running +//! against spektrafilm's own colour-science stack — a different language, a +//! different observer table, a different chromatic adaptation implementation. +//! Agreement to three decimal places is therefore evidence about the *model*, +//! not about one implementation of it: a transposed matrix, a mispasted +//! observer row or an illuminant normalised differently would all show up here +//! and in none of the unit tests, every one of which would still pass. + +use dr_film::bake::{bake, Recipe, MID_GREY}; +use dr_film::find; + +/// Slack allowed against the reference. +/// +/// The baked lookup's own interpolation error is about 0.003, and the two +/// implementations differ in float width throughout, so anything under 0.01 is +/// agreement. A real error — a swapped channel, a dropped illuminant — moves +/// these numbers by tenths, not by thousandths. +const TOLERANCE: f32 = 0.01; + +fn assert_close(label: &str, input: f32, got: [f32; 3], want: [f32; 3]) { + for c in 0..3 { + assert!( + (got[c] - want[c]).abs() < TOLERANCE, + "{label} at {input}: channel {c} was {:.4}, reference says {:.4}\n got {got:?}\n want {want:?}", + got[c], + want[c] + ); + } +} + +#[test] +fn a_reversal_stock_matches_the_reference() { + let film = find("kodak_kodachrome_64").unwrap(); + let baked = bake(&Recipe::new(film, None)); + + for (input, want) in [ + (0.02f32, [0.0130f32, 0.0005, 0.0386]), + (0.09, [0.0827, 0.0670, 0.0996]), + (MID_GREY, [0.1965, 0.1794, 0.2055]), + (0.40, [0.4021, 0.3772, 0.3937]), + (0.80, [0.5743, 0.5481, 0.5450]), + ] { + assert_close("Kodachrome 64", input, baked.apply([input; 3]), want); + } +} + +#[test] +fn a_printed_negative_matches_the_reference() { + let film = find("kodak_portra_400").unwrap(); + let paper = dr_film::default_print(film).unwrap(); + let baked = bake(&Recipe::new(film, Some(paper))); + + for (input, want) in [ + (0.02f32, [0.0078f32, 0.0061, 0.0107]), + (0.09, [0.0435, 0.0495, 0.0584]), + (MID_GREY, [0.1412, 0.1468, 0.1616]), + (0.40, [0.3567, 0.3578, 0.3721]), + (0.80, [0.5960, 0.5846, 0.5815]), + ] { + assert_close("Portra 400 on Endura", input, baked.apply([input; 3]), want); + } +} diff --git a/docs/traceability.md b/docs/traceability.md index 146814d..a232573 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 180 | -| TRACES tags found | 562 | +| Source files scanned | 189 | +| TRACES tags found | 564 | | Requirements defined | 177 | -| Requirements covered | 89 | -| **Coverage** | **50.3%** (89/177) | +| Requirements covered | 90 | +| **Coverage** | **50.8%** (90/177) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 71 | 122 | +| FR | 72 | 122 | | NFR | 16 | 49 | | R | 2 | 6 | @@ -58,6 +58,7 @@ _None._ | FR-DEV-3c | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:184`](../core/dr-pipeline/src/graph.rs#L184), [`core/dr-pipeline/src/graph.rs:40`](../core/dr-pipeline/src/graph.rs#L40), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`ui/dr-ui/src/develop.rs:3730`](../ui/dr-ui/src/develop.rs#L3730) | | FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:709`](../core/dr-gpu/src/adjust.rs#L709), [`core/dr-gpu/src/adjust.rs:764`](../core/dr-gpu/src/adjust.rs#L764), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/src/adjust.rs:97`](../core/dr-gpu/src/adjust.rs#L97), [`core/dr-gpu/tests/capture_sharpen.rs:429`](../core/dr-gpu/tests/capture_sharpen.rs#L429), [`core/dr-gpu/tests/detail_stage.rs:241`](../core/dr-gpu/tests/detail_stage.rs#L241), [`core/dr-gpu/tests/local_contrast.rs:475`](../core/dr-gpu/tests/local_contrast.rs#L475), [`core/dr-gpu/tests/noise_reduction.rs:555`](../core/dr-gpu/tests/noise_reduction.rs#L555), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:426`](../core/dr-pipeline/src/graph.rs#L426), [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) | | FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:690`](../core/dr-gpu/src/adjust.rs#L690), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1341`](../core/dr-pipeline/src/operation.rs#L1341), [`core/dr-pipeline/src/operation.rs:1366`](../core/dr-pipeline/src/operation.rs#L1366), [`core/dr-pipeline/src/operation.rs:1381`](../core/dr-pipeline/src/operation.rs#L1381), [`core/dr-pipeline/src/operation.rs:1405`](../core/dr-pipeline/src/operation.rs#L1405), [`core/dr-pipeline/src/operation.rs:369`](../core/dr-pipeline/src/operation.rs#L369), [`core/dr-pipeline/src/operation.rs:379`](../core/dr-pipeline/src/operation.rs#L379), [`core/dr-pipeline/src/operation.rs:510`](../core/dr-pipeline/src/operation.rs#L510) | +| FR-DEV-3f | [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:57`](../core/dr-film/src/profile.rs#L57) | | FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336) | | FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | | FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:2644`](../ui/dr-ui/src/develop.rs#L2644), [`ui/dr-ui/src/develop.rs:2654`](../ui/dr-ui/src/develop.rs#L2654), [`ui/dr-ui/src/develop.rs:488`](../ui/dr-ui/src/develop.rs#L488), [`ui/dr-ui/src/lib.rs:1214`](../ui/dr-ui/src/lib.rs#L1214) | @@ -125,7 +126,7 @@ _None._ ## Not yet tagged -88 of 177 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +87 of 177 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements @@ -140,7 +141,6 @@ _None._ - FR-CULL-8 - FR-CULL-9 - FR-DEV-1 -- FR-DEV-3f - FR-DEV-3g - FR-DEV-7 - FR-DSP-2 diff --git a/tools/film-profiles/convert.py b/tools/film-profiles/convert.py new file mode 100644 index 0000000..0193250 --- /dev/null +++ b/tools/film-profiles/convert.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Convert spektrafilm film profiles into DarkRoom's own compact format, and +generate the colour-science tables `dr-film` compiles in. + + python3 tools/film-profiles/convert.py --fetch + +Kept in the tree, and kept runnable, so that the conversion from upstream is +reproducible and auditable rather than a one-off paste. CC BY-SA 4.0 requires +that a modified copy say it was modified; this script *is* the statement of +what was done, and `core/dr-film/profiles/CHANGELOG.txt` records it in prose. + +The upstream profiles are published on the same 380-780nm, 5nm grid as the +Mallett 2019 sRGB basis and the CIE 1931 observer, so nothing here resamples +anything -- the conversion is a trim and a reformat, not an interpolation. +""" +import argparse +import json +import pathlib +import sys +import urllib.request + +ROOT = pathlib.Path(__file__).resolve().parents[2] +PROFILE_DIR = ROOT / "core/dr-film/profiles" +UPSTREAM = "https://raw.githubusercontent.com/andreavolpato/spektrafilm/main" +UPSTREAM_PROFILES = f"{UPSTREAM}/src/spektrafilm/data/profiles" + +# The stocks we ship. Each is a colour stock whose datasheet spektrafilm has +# already digitised; a print paper is a stock like any other, distinguished +# only by `support: paper`. +STOCKS = [ + "kodak_portra_400", + "kodak_kodachrome_64", + "kodak_portra_endura", +] + +CACHE = pathlib.Path(__file__).parent / "upstream" + + +def fetch(): + CACHE.mkdir(exist_ok=True) + for stock in STOCKS: + dest = CACHE / f"{stock}.json" + if dest.exists(): + continue + print(f"fetching {stock}", file=sys.stderr) + urllib.request.urlretrieve(f"{UPSTREAM_PROFILES}/{stock}.json", dest) + lic = CACHE / "SPEKTRAFILM_LICENSE.txt" + if not lic.exists(): + urllib.request.urlretrieve(f"{UPSTREAM}/SPEKTRAFILM_LICENSE.txt", lic) + + +def num(v, places=6): + """A null becomes an explicit 0, so the YAML has no holes to interpret.""" + if v is None or v != v: + return "0" + s = f"{v:.{places}g}" + return "0" if s in ("-0", "-0.0") else s + + +def row(values, places=6): + return "[" + ", ".join(num(v, places) for v in values) + "]" + + +def convert(stock): + d = json.loads((CACHE / f"{stock}.json").read_text()) + info, data = d["info"], d["data"] + n = len(data["wavelengths"]) + assert data["wavelengths"][0] == 380.0 and data["wavelengths"][-1] == 780.0 and n == 81, ( + f"{stock}: unexpected wavelength grid; the conversion assumes 380-780nm at 5nm" + ) + + out = [ + "# Generated by tools/film-profiles/convert.py from spektrafilm.", + "# Do not edit by hand: re-run the converter instead.", + "#", + "# spektrafilm by Andrea Volpato, https://github.com/andreavolpato/spektrafilm", + "# Licensed CC BY-SA 4.0. Modified for DarkRoom: trimmed to the fields the", + "# renderer uses and reformatted; see profiles/CHANGELOG.txt.", + "", + f"version: {d['metadata']['version']!r}", + f"stock: {info['stock']}", + f"name: {info['name']!r}", + f"kind: {info['type']} # negative | positive", + f"support: {info['support']} # film | paper", + f"reference_illuminant: {info['reference_illuminant']}", + f"viewing_illuminant: {info['viewing_illuminant']}", + ] + if info.get("target_print"): + out.append(f"target_print: {info['target_print']}") + out += [ + "", + "# log10 spectral sensitivity per layer, 380-780nm at 5nm, in R,G,B layer", + "# order. A null upstream means the datasheet has no reading there, which is", + "# blindness, so it is written as the sentinel the loader reads as such.", + "log_sensitivity:", + ] + for wl, triple in zip(data["wavelengths"], data["log_sensitivity"]): + vals = ["-9" if (v is None or v != v) else num(v) for v in triple] + out.append(f" - [{', '.join(vals)}] # {wl:.0f}nm") + + out += [ + "", + "# Spectral density of each layer's dye at unit density, same grid and order.", + "dye_density:", + ] + for wl, triple in zip(data["wavelengths"], data["channel_density"]): + out.append(f" - {row(triple)} # {wl:.0f}nm") + + base = data.get("base_density") + out += [ + "", + "# The support's own density -- film base plus, for a colour negative, the", + "# orange mask. Flat zero where the datasheet does not give it.", + f"base_density: {row(base) if base else row([0.0] * n)}", + "", + "# The characteristic curves: density against log10 exposure, sampled", + f"# uniformly over [{data['log_exposure'][0]:g}, {data['log_exposure'][-1]:g}].", + f"log_exposure_min: {num(data['log_exposure'][0])}", + f"log_exposure_max: {num(data['log_exposure'][-1])}", + "density_curves:", + ] + for triple in data["density_curves"]: + out.append(f" - {row(triple, 5)}") + return "\n".join(out) + "\n" + + +TABLE_HEADER = '''//! Generated by tools/film-profiles/convert.py. Do not edit. +//! +//! The fixed colour science: the observer, the illuminants and the spectral +//! basis. None of it is per-stock, all of it is published data, and together it +//! is under 6 kB of source -- which is the point. A film simulation's data cost +//! is dominated by whatever it uses to turn a pixel back into a spectrum, and a +//! basis is three curves where a coefficient table is megabytes. + +/// The lowest wavelength sampled, in nanometres. +pub const LAMBDA_MIN: f32 = 380.0; +/// The spacing between samples, in nanometres. +pub const LAMBDA_STEP: f32 = 5.0; +/// How many wavelengths every spectral table carries. +/// +/// The profiles, the observer and the basis all arrive on this grid already, so +/// nothing in this crate resamples anything. +pub const SPECTRUM: usize = 81; +''' + + +def emit_tables(dest): + import numpy as np + import colour + + grid = colour.SpectralShape(380, 780, 5) + cmf = colour.MSDS_CMFS["CIE 1931 2 Degree Standard Observer"].copy().align(grid).values + basis = colour.recovery.MSDS_BASIS_FUNCTIONS_sRGB_MALLETT2019.copy().align(grid).values + + def lit(v): + """Always a float literal: `%g` renders an exact zero as `0`, which is + an integer in Rust and will not compile in an `[f32; _]`.""" + s = f"{v:.8g}" + return s if any(ch in s for ch in ".eE") else s + ".0" + + def table(name, doc, values): + lines = [f"\n{doc}\npub static {name}: [[f32; 3]; SPECTRUM] = ["] + for wl, triple in zip(grid.wavelengths, values): + cells = ", ".join(lit(v) for v in triple) + lines.append(f" [{cells}], // {wl:.0f}nm") + lines.append("];") + return "\n".join(lines) + + def flat(name, doc, values, wavelengths): + lines = [f"\n{doc}\npub static {name}: [f32; SPECTRUM] = ["] + for i in range(0, len(values), 6): + chunk = ", ".join(lit(v) for v in values[i:i + 6]) + lines.append(f" {chunk},") + lines.append("];") + return "\n".join(lines) + + parts = [TABLE_HEADER] + parts.append(table( + "OBSERVER", + "/// CIE 1931 2-degree standard observer, x-bar/y-bar/z-bar per wavelength.", + cmf, + )) + parts.append(table( + "SRGB_BASIS", + "/// Mallett & Yuksel (2019) sRGB reflectance basis: the three smooth,\n" + "/// non-negative spectra that reconstruct any sRGB colour exactly.\n" + "///\n" + "/// This is what makes the exposure step a 3x3 matrix rather than a\n" + "/// per-pixel spectral integration -- see [`crate::bake`].", + basis, + )) + for name in ("D50", "D55", "D65"): + sd = colour.SDS_ILLUMINANTS[name].copy().align(grid).values + parts.append(flat( + f"ILLUMINANT_{name}", + f"/// CIE standard illuminant {name}, normalised to unit mean.", + sd / sd.mean(), + grid.wavelengths, + )) + dest.write_text("\n".join(parts) + "\n") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--fetch", action="store_true", help="download upstream profiles first") + args = ap.parse_args() + + if args.fetch: + fetch() + if not CACHE.exists(): + sys.exit("no upstream cache; run with --fetch") + + PROFILE_DIR.mkdir(parents=True, exist_ok=True) + for stock in STOCKS: + dest = PROFILE_DIR / f"{stock}.yaml" + dest.write_text(convert(stock)) + print(f"{dest.relative_to(ROOT)} {dest.stat().st_size / 1024:.1f} kB") + + lic = CACHE / "SPEKTRAFILM_LICENSE.txt" + if lic.exists(): + (PROFILE_DIR / "LICENSE-PROFILES.txt").write_text(lic.read_text()) + + tables = ROOT / "core/dr-film/src/tables.rs" + emit_tables(tables) + print(f"{tables.relative_to(ROOT)} {tables.stat().st_size / 1024:.1f} kB") + + +if __name__ == "__main__": + main() diff --git a/tools/film-profiles/proto.py b/tools/film-profiles/proto.py new file mode 100644 index 0000000..ae57764 --- /dev/null +++ b/tools/film-profiles/proto.py @@ -0,0 +1,244 @@ +"""Prototype of the spectral film chain, to be ported to core/dr-film. + +linear sRGB -> reflectance (Mallett 2019 basis) -> x reference illuminant -> +layer exposures -> densities -> dye transmittance -> XYZ -> linear sRGB. + +Everything runs on the profiles' own 380-780nm @5nm, 81-sample grid, which is +also the grid the Mallett basis is published on, so nothing is resampled. + +Conventions follow spektrafilm's own reference implementation rather than being +invented here, because the profile data is calibrated against them: + + - exposure is normalised by the *green* layer's mid-grey response, one shared + scalar for all three layers. The residual channel imbalance is the film's + real one, and for a negative it is the print stage's job to balance it out. + - the viewing step chromatically adapts from the viewing illuminant to the + output space's white, rather than dividing XYZ channelwise. +""" +import json +import numpy as np +import colour + +GRID = colour.SpectralShape(380, 780, 5) +WL = GRID.wavelengths +N = len(WL) + +CMF = colour.MSDS_CMFS["CIE 1931 2 Degree Standard Observer"].copy().align(GRID).values # (81,3) +BASIS = colour.recovery.MSDS_BASIS_FUNCTIONS_sRGB_MALLETT2019.copy().align(GRID).values # (81,3) + +MID_GREY = 0.184 + + +def illuminant(name): + sd = colour.SDS_ILLUMINANTS[name].copy().align(GRID).values + return sd / sd.mean() + + +def load(path): + d = json.load(open(path)) + data = d["data"] + p = { + "info": d["info"], + "log_sensitivity": np.array(data["log_sensitivity"], dtype=float), # (81,3) + "dye_density": np.array(data["channel_density"], dtype=float), # (81,3) + "log_exposure": np.array(data["log_exposure"], dtype=float), # (256,) + "density_curves": np.array(data["density_curves"], dtype=float), # (256,3) + } + base = data.get("base_density") + p["base_density"] = ( + np.array([0.0 if v is None else v for v in base], dtype=float) + if base is not None else np.zeros(N) + ) + # A sample is null where the datasheet has no data. Sensitivity there means + # "blind at this wavelength"; density there means "no absorption". + p["log_sensitivity"] = np.nan_to_num(p["log_sensitivity"], nan=-9.0) + p["dye_density"] = np.nan_to_num(p["dye_density"], nan=0.0) + return p + + +def sensitivity(profile): + return 10.0 ** profile["log_sensitivity"] # (81, 3 layers) + + +def exposure_matrix(profile): + """3x3: linear sRGB -> the three layers' exposures, mid-grey normalised. + + Exposure is an integral of sensitivity against the scene spectrum, and the + scene spectrum is linear in the sRGB coefficients, so the whole step is a + matrix. This is what makes a per-pixel spectral integration unnecessary on + the way *in* -- the only place the spectrum is genuinely needed is the dye + transmittance on the way out, which is a function of three densities and so + bakes into a small 3D LUT. + """ + ill = illuminant(profile["info"].get("reference_illuminant", "D55")) + sens = sensitivity(profile) + m = sens.T @ (BASIS * ill[:, None]) # (3 layers, 3 sRGB) + # Normalised on the green layer's mid-grey response, matching spektrafilm. + # A per-layer normalisation would silently absorb the film's own channel + # balance, which is a large part of what distinguishes one stock's look + # from another's. + mid_grey_raw = (ill * MID_GREY) @ sens # (3 layers,) + return m / mid_grey_raw[1] + + +def densities(profile, log_exposure): + """Sample the tabulated characteristic curves. (...,3) -> (...,3).""" + out = np.empty_like(log_exposure) + for c in range(3): + out[..., c] = np.interp( + log_exposure[..., c], profile["log_exposure"], profile["density_curves"][:, c] + ) + return out + + +def transmittance(profile, dens): + """Dye densities -> spectral transmittance. (...,3) -> (...,81).""" + return 10.0 ** (-(dens @ profile["dye_density"].T + profile["base_density"])) + + +def view(profile, trans, view_illuminant=None, output_space="sRGB"): + """Spectral transmittance -> linear output RGB, viewed on a light table. + + Adapted from the viewing illuminant to the output space's own white, so a + clear frame comes out neutral instead of carrying the light table's colour. + """ + ill = illuminant(view_illuminant or profile["info"].get("viewing_illuminant", "D50")) + norm = (ill * CMF[:, 1]).sum() + white_xyz = (ill[:, None] * CMF).sum(axis=0) / norm + xyz = ((trans * ill)[..., None] * CMF).sum(axis=-2) / norm + return colour.XYZ_to_RGB( + xyz, + colourspace=output_space, + illuminant=colour.XYZ_to_xy(white_xyz), + apply_cctf_encoding=False, + ) + + +def develop(profile, rgb, exposure_ev=0.0): + """Full chain for a positive (reversal) stock, viewed directly.""" + raw = np.asarray(rgb, dtype=float) @ exposure_matrix(profile).T * (2.0 ** exposure_ev) + log_raw = np.log10(np.maximum(raw, 0.0) + 1e-10) + return view(profile, transmittance(profile, densities(profile, log_raw))) + + +def report(name): + p = load(name) + print(f"=== {p['info']['name']} ({p['info']['type']}) ===") + print("exposure matrix (rows = layers, cols = R,G,B):") + print(np.array2string(exposure_matrix(p), precision=4, suppress_small=True)) + + clear = view(p, np.ones(N)) + print(f"clear frame -> [{clear[0]:.4f} {clear[1]:.4f} {clear[2]:.4f}]" + f" (should be neutral)") + + ramp = np.array([[v] * 3 for v in (0.02, 0.09, 0.184, 0.4, 0.8)]) + out = develop(p, ramp) + print("neutral ramp in -> linear sRGB out:") + for v, o in zip(ramp[:, 0], out): + print(f" {v:6.3f} -> [{o[0]:8.4f} {o[1]:8.4f} {o[2]:8.4f}]" + f" spread {o.max() - o.min():+.4f}") + + print("primaries in -> out:") + for i, lbl in enumerate("RGB"): + rgb = np.full(3, 0.05) + rgb[i] = 0.5 + o = develop(p, rgb) + print(f" {lbl}: [{o[0]:7.4f} {o[1]:7.4f} {o[2]:7.4f}]") + print() + + + + +# --------------------------------------------------------------------------- +# The print stage, for negative stocks. +# --------------------------------------------------------------------------- + + +def blackbody(temperature_k): + """Planck's law on the grid, normalised. Analytic, so it costs no data. + + Stands in for the enlarger's tungsten-halogen lamp. The lamp's own colour + is very nearly irrelevant to the result because the filtration below is + *solved* rather than specified: whatever cast the source has, the balance + step removes it, exactly as a darkroom worker dials it out on the head. + """ + wl = WL * 1e-9 + h, c, kb = 6.62607015e-34, 2.99792458e8, 1.380649e-23 + radiance = (2 * h * c**2) / (wl**5 * (np.exp(h * c / (wl * kb * temperature_k)) - 1)) + return radiance / radiance.mean() + + +ENLARGER = blackbody(3400.0) + + +def paper_exposure_operator(neg, paper): + """Return f(neg_densities) -> the paper's three layer exposures. + + Not a matrix: the negative's transmittance is 10^-D, so the paper's + exposure is exponential in the negative's densities. This is the one + genuinely spectral step left, and it takes exactly three numbers in -- which + is what lets the whole negative-plus-print chain bake into one 3D LUT. + """ + sens = sensitivity(paper) # (81, 3) + def f(dens): + trans = transmittance(neg, dens) # (...,81) + return (trans * ENLARGER) @ sens # (...,3) + return f + + +def print_balance(neg, paper, print_exposure_ev=0.0): + """Per-layer log offsets that make a mid-grey scene print neutral mid-grey. + + This is the enlarger's colour head, solved instead of dialled. It is also + where a colour negative's orange mask goes: the mask is a fixed density, so + balancing mid-grey to neutral removes it, which is why a printed negative + looks like a photograph and a scanned one looks orange. + """ + expose = paper_exposure_operator(neg, paper) + mid_dens = densities(neg, np.log10((np.full(3, MID_GREY) @ exposure_matrix(neg).T) + 1e-10)) + mid_raw = expose(mid_dens) # (3,) + + # Where on the paper's curve mid-grey should land: the point whose density + # reflects 18%, read off the average of the three curves. + target_density = -np.log10(MID_GREY) - np.nanmean(paper["base_density"]) + curve_av = np.nanmean(paper["density_curves"], axis=1) + target_log_e = np.interp(target_density, curve_av, paper["log_exposure"]) + return target_log_e - np.log10(mid_raw) + print_exposure_ev * np.log10(2.0) + + +def print_develop(neg, paper, rgb, exposure_ev=0.0, print_exposure_ev=0.0): + """Full negative -> print chain, viewed as a reflection print.""" + raw = np.asarray(rgb, dtype=float) @ exposure_matrix(neg).T * (2.0 ** exposure_ev) + neg_dens = densities(neg, np.log10(np.maximum(raw, 0.0) + 1e-10)) + + offsets = print_balance(neg, paper, print_exposure_ev) + paper_raw = paper_exposure_operator(neg, paper)(neg_dens) + paper_dens = densities(paper, np.log10(paper_raw + 1e-10) + offsets) + return view(paper, transmittance(paper, paper_dens)) + + +def report_print(neg_name, paper_name): + neg, paper = load(neg_name), load(paper_name) + print(f"=== {neg['info']['name']} printed on {paper['info']['name']} ===") + print("print balance (per-layer logE offset):", + np.array2string(print_balance(neg, paper), precision=4)) + + ramp = np.array([[v] * 3 for v in (0.02, 0.09, 0.184, 0.4, 0.8)]) + out = print_develop(neg, paper, ramp) + print("neutral ramp in -> linear sRGB out:") + for v, o in zip(ramp[:, 0], out): + print(f" {v:6.3f} -> [{o[0]:8.4f} {o[1]:8.4f} {o[2]:8.4f}]" + f" spread {o.max() - o.min():+.4f}") + print("primaries in -> out:") + for i, lbl in enumerate("RGB"): + rgb = np.full(3, 0.05) + rgb[i] = 0.5 + o = print_develop(neg, paper, rgb) + print(f" {lbl}: [{o[0]:7.4f} {o[1]:7.4f} {o[2]:7.4f}]") + print() + + +if __name__ == "__main__": + for name in ("kodachrome64.json", "portra400.json"): + report(name) + report_print("portra400.json", "portra_endura.json")