Simulate a film stock from its measurements, not from someone's grade

FR-DEV-3f asks for look emulation and proposes HaldCLUT import to inherit
the free film-simulation ecosystem. This takes the other road for the
stocks where the measurements exist: run the physics.

A stock here is its manufacturer's own datasheet -- spectral sensitivity,
characteristic curves, dye densities. 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 a paper profile prints it, with the enlarger's filtration
solved rather than dialled.

What that buys over a LUT is that the parameters stay physical. Opening up
a stop moves the picture along the film's real characteristic curve,
shoulder and all, instead of scaling a number baked at one exposure. The
data cost runs the other way too: a stock is 17 kB of published
measurements where one HaldCLUT is 800 kB of one person's grade.

It looks like it needs a spectral integration per pixel. It does not, and
that is the whole design:

  - Exposure is a 3x3 matrix. The reconstructed scene spectrum is linear
    in the sRGB triple, so the integral collapses into nine numbers,
    exactly -- no approximation.
  - The characteristic curve is three 1D functions, sampled exactly.
  - Everything after that -- dye absorption, the print through the
    negative, the paper, the viewing illuminant, the adaptation -- takes
    exactly three numbers in, so it bakes into one 32^3 lookup.

Per pixel: a matrix multiply, three curve taps, one fetch. Splitting the
curve out of the 3D lookup rather than baking one LUT over exposure is
measured, not assumed: the curve carries the sharp shape and the dye
mixing is smooth, so folding them together would need three times the
resolution for the same error. At 32^3 the worst error is 0.003 in linear
sRGB, under one 8-bit code value, and a test says so.

No wgpu dependency, deliberately, and the same isolation argument dr-lens
makes: the model is plain f32 with a documented layout, so every property
worth asserting is asserted on the CPU. Binding it to a texture is dr-gpu's
job and is not done here yet.

The expected values in tests/ came from a Python prototype running against
a different colour-science stack. Agreement to three decimals is evidence
about the model rather than about one implementation of it -- a transposed
matrix or a mispasted observer row would pass every unit test and fail
that one.

Profiles are converted from spektrafilm by Andrea Volpato, CC BY-SA 4.0.
The converter is in the tree and runnable, so what was changed from
upstream is auditable rather than taken on trust; profiles/CHANGELOG.txt
records it, including the one deliberate deviation -- Mallett & Yuksel's
1 kB basis instead of Hanatos's 4 MB table, which costs accuracy at the
gamut edge and saves four megabytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 14:44:54 +02:00
co-authored by Claude Opus 5
parent 940058c78a
commit b6a95e1965
19 changed files with 3931 additions and 7 deletions
+5
View File
@@ -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/
Generated
+9
View File
@@ -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"
+2
View File
@@ -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" }
+20
View File
@@ -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
+76
View File
@@ -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°.
+74
View File
@@ -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.
+520
View File
@@ -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.
@@ -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]
+451
View File
@@ -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]
@@ -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]
+456
View File
@@ -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::<f32>() / 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);
}
}
+113
View File
@@ -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
//! (<https://github.com/andreavolpato/spektrafilm>), 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<Vec<Profile>> = 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());
}
}
+279
View File
@@ -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<String>,
/// 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<Self, String> {
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<Vec<[f32; 3]>, D::Error>
where
D: serde::Deserializer<'de>,
{
Vec::<[f32; 3]>::deserialize(d)
}
fn spectral_scalars<'de, D>(d: D) -> Result<Spectrum, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
let v = Vec::<f32>::deserialize(d)?;
v.try_into()
.map_err(|v: Vec<f32>| 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}");
}
}
}
}
+236
View File
@@ -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::<f32>().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:?}");
}
}
+246
View File
@@ -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,
];
@@ -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);
}
}
+7 -7
View File
@@ -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.
<details><summary>Show untagged requirements</summary>
@@ -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
+229
View File
@@ -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()
+244
View File
@@ -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")