Skip to content

Spektrafilm analog film simulation module - #21967

Open
piratenpanda wants to merge 117 commits into
darktable-org:masterfrom
piratenpanda:spektrafilm
Open

Spektrafilm analog film simulation module#21967
piratenpanda wants to merge 117 commits into
darktable-org:masterfrom
piratenpanda:spektrafilm

Conversation

@piratenpanda

Copy link
Copy Markdown
Contributor

after my git mistake, here's another PR. Sorry for the noise. Continuing from #21534

@piratenpanda

Copy link
Copy Markdown
Contributor Author

@kofa73 this should address all the issues your bot found

@andriiryzhkov

Copy link
Copy Markdown
Collaborator

Is each of your commits compilable?
We recently had issues with PR containing transient commits which were not compilable. Please see discussion in #21881.

@piratenpanda

Copy link
Copy Markdown
Contributor Author

Yes, this should be true as I manually test each one before in my PKGBUILD and do local testing

@piratenpanda
piratenpanda force-pushed the spektrafilm branch 2 times, most recently from e950b50 to d648a87 Compare August 23, 2026 15:42
@TurboGit

Copy link
Copy Markdown
Member

This is a new module, I'll squash all commits together anyway.

@TurboGit TurboGit added this to the 5.8 milestone Aug 23, 2026
@TurboGit TurboGit added feature: new new features to add difficulty: hard big changes across different parts of the code base scope: image processing correcting pixels labels Aug 23, 2026
@piratenpanda

Copy link
Copy Markdown
Contributor Author

will do a bit of UI changes later today, then I'll collect presets

@kofa73

kofa73 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Note that I did not ask the bots the compare that the processing algo matches Spektrafilm (they were told to look for generic maths issues like NaN). Let me know if you need that reviewed.

@piratenpanda

Copy link
Copy Markdown
Contributor Author

Thanks, I already have a script for that myself

piratenpanda and others added 17 commits August 24, 2026 06:33
…mpression boost option by @Arecsu, better organize print and film tab
Add a second, independent diffusion filter stage applied after print
exposure and before print development in the pipeline, mirroring the
existing film-stage diffusion with its own set of controls (on/off,
family, strength, scale, warmth). Includes CPU, OpenCL, and GUI paths.

The print diffusion controls live in the diffusion tab, below the
film-stage diffusion controls. They are grayed out when scan-film
mode is active (no print stage).
…al LUT

A NaN or infinite input channel makes the chromaticity divide in the expose
stage return NaN, and the triangle-to-square map propagates it, so the LUT
coordinate is NaN.

cubic_base_fraction() then ran (int)floor() on it. The conversion is undefined
for NaN and yields INT_MIN in practice; safe_index() negates that into a large
positive index and the caller multiplies it by the table stride, so the cubic
gather reads far outside the table.

Order the comparisons so a NaN falls into the low branch. Every finite and
infinite coordinate keeps its previous result, and the behaviour matches the
OpenCL twin, which builds the coordinate with fmin/fmax and maps NaN to the low
bound. The scalar, NEON and double-precision callers all reach the table
through this helper.
The header check tested that the first two dimensions agree and that the third
matches the spectral shape, but not that any of them is positive, although the
same expression bounds the identifier length and validates the magic, header
version and element type.

A negative side length passes. The element count multiplies the two equal
dimensions, so the sign cancels and both the allocation and the read succeed at
a plausible size, while the side length is stored signed and negative. Every
cubic gather then multiplies its row index by that negative stride and
addresses outside the buffer; the simulation's own copy of the table is
allocated at the same wrapped size and its fill loop never runs, so the gather
also reads uninitialised memory. A side length below 2 leaves the cubic base
index at -1.

Reject both, which surfaces as the existing "regenerate the data pack" error.
SF_TC_N_MAX is far above the 192 the shipped table uses and keeps the element
count clear of overflow.
…rashing

build_lut3d() allocated six tables and dereferenced them unconditionally,
writing through the first inside its sampling loop and passing all six to
pchip3d_prepare(). The spectral exposure table and the gamut compression table
were allocated without a check as well.

At the highest quality one build_lut3d() call takes four arrays of steps^3
doubles plus two of (steps-1)^3, and a printing simulation makes two such
calls, so a single build requests tens of megabytes; builds also run per pipe.
On failure the result is a null dereference on the processing thread.

Give build_lut3d() and build_cmax_table() a success return and fail the build
through the path used for a missing illuminant, which frees the partial
simulation and returns a message. The compression table needs this because its
lookup dereferences the pointer unconditionally: a NULL there is meaningful
only when compression is not oklch. The two float mirror tables keep their
existing behaviour, since both consumers test them and fall back to the double
originals.
…libration

layer_max_raw is a running maximum over the exposure grid, seeded with a large
negative sentinel. Three inputs leave a sub-layer's entry unusable.

A sub-layer can reach zero legitimately: a zero fitted amplitude, or a CDF that
clamps to zero across the whole grid, which the sept variant does from
z <= -2.9007. Its share of the channel's density is then zero, and the
calibration divides by that share, producing an infinity that the multiply
turns into a NaN.

A sub-layer can also keep the sentinel, when every sample is NaN. The profile
loader validates array shape only, and a JSON null becomes NaN by design
because the exporter writes null for non-finite values. The sentinel divides
into the channel total and yields an infinite fraction, which propagates into
that layer's dmin, dmax and particle count and into the grain sampler; since
the total collapses to its own 1e-9 floor, it also scales every healthy sibling
layer in the channel by about 1e9.

The shipped profiles keep their per-layer maxima positive across every
reachable morph corner. A hand-installed pack takes precedence over any
downloaded one, so foreign profile data is an ordinary input path.

Sanitise the maximum where it stops being a running value: anything not
positive and finite becomes zero, which is what a layer carrying no density
means, and the inverted comparison catches NaN. The two remaining divides then
need only floors to stay finite. The OpenCL copy of the sampler carries the
same floor.
Cancelling a data-pack download while the manifest request is in flight jumps
to the cleanup label. That block clears the cancel flag and posts the
completion idle but does not touch the fetch state, which stays RUNNING after
the worker thread returns.

Nothing recovers from that. sf_fetch_start() refuses to start while the state
reads RUNNING, and the module's data row turns its download control into a
cancel button for the same reason, so the only control that can install a pack
becomes a no-op until darktable is restarted. The row also keeps re-arming its
half-second poll timer, and the completion notice reports the message left by
the last status update, so the user is told the download failed while fetching
the manifest.

The manifest is fetched with the string helper, which installs no progress
callback, so a cancel cannot abort that transfer and is first observed on
return from it. Record the cancellation the way the per-file loop does.
_ensure_sim() copied the process-global pack pointer, released the lock that
guards it, and kept reading through that pointer: the mismatch check, and the
simulation build, which reads the spectra table inside a parallel loop and the
film defaults, illuminants, dichroics and locus throughout. The lock excludes
other threads inside the critical section, not one that has left holding the
pointer, and the pack carried no reference count.

Any thread entering _ensure_sim() frees the pack when it resolves a different
directory or sees a newer fetch generation. The module holds one pack at a time
and reloads when the spectral table recorded in an edit changes; a completed
download bumps the generation and reprocesses everything, so the full and
preview pipes cross the generation change together. The fetch generation is
sampled before the lock is taken, so two threads can hold different values. The
colour picker calls _ensure_sim() from the GUI thread.

The result is a use-after-free of a pack whose JSON tree has been released and
whose spectra buffer is gone, read by a darkroom, thumbnail or export pipe
mid-build.

Give the pack an atomic reference count and take one before releasing the lock.
Holding the lock across the whole build would also close the race but would
serialise a multi-hundred-millisecond build across every concurrent pipe. The
reference is dropped once the build returns; the simulation copies everything
it needs and keeps no pointer into the pack. The other two readers of the
global do their work inside the lock.
The two status strings live in gui_data. The preview pixelpipe thread compares
them against the current verdict and overwrites them while holding only the
per-piece lock; the GTK thread reads the same arrays from the idle callback,
the half-second data poll and gui_update(). The per-piece lock does not order
those accesses, so this is a data race.

Both arrays are fixed at 256 bytes and the copy always terminates inside them,
so a torn read yields garbled banner text rather than an out-of-bounds access.

Take the module's GUI critical section around the compare-and-copy on the
writing side and around a snapshot on the reading side, so the banner renders
from a consistent pair of strings. The idle post stays outside the section: it
needs only the change flag, and posting under the section would nest a
main-loop call inside it.
The banner refresh is posted from the pixelpipe thread with a bare module
pointer and no source id, and the callback dereferences that pointer to reach
gui_data. gui_cleanup() removes the data-poll timeout but nothing removes this
source.

Leaving the darkroom cleans up each module and frees it in the same loop
without draining pending idles, and develop teardown does the same. Leaving
also takes the pipe mutexes before the free loop, and a running preview job
holds its pipe mutex for the whole run, so a preview that changes the status
during teardown posts its idle after the last main-loop dispatch and the module
is freed before the source can fire.

Keep the source id in gui_data and remove it in gui_cleanup, matching the poll
timeout. The id is written from the posting pipe thread and from the GTK thread
that runs or cancels it, so it lives under the same GUI critical section as the
strings it announces. Not re-posting while one is pending collapses a burst of
status changes into a single refresh, which is correct because the callback
reads whatever the strings hold when it runs.
The ROI padding and the tiling overlap assumed a fixed 20 um inhibitor spread
and a fixed 200 um tail whenever couplers were active. Both are persisted
parameters: the spread reaches 60 um and the tail 400 um, and the module hands
them to the simulation as absolutes, so the blur that runs is the one the
sliders ask for. Any setting above the assumed pair convolves a kernel wider
than the padded region.

At a tile boundary that clamps the kernel against an internal edge rather than
an image edge, which shows as a seam; at the frame edge it is the crop artifact
the padding exists to prevent. The tail is the widest component, so the
shortfall reaches twice the padded radius at the top of the range.

Read the parameters, gating the tail on its weight the way process() gates the
dispatch. The neighbouring halation and scatter terms clamp their per-film
sigmas to the constants their padding assumes, so a pack cannot outrun the ROI;
the coupler tail comes from the GUI instead, and reading the parameter is the
equivalent guarantee.
The clump blur is a fixed pixel radius by design, but _max_halo_sigma()
converted it through pixel_um, making the padded radius the real sigma times
SF_GRAIN_REF_UM/pixel_um. That factor falls below 1 once the full-resolution
long edge drops under film_format_mm*1000/SF_GRAIN_REF_UM pixels, which is
3600 px at the 36 mm default and 2400 px for half-frame, so the pad reaches
0.44 px against a dispatched 0.80 px on a small frame. The ratio is independent
of the pipe scale, which divides out of both sides. The acutance recovery that
follows the clump blur was not in the calculation.

Take both in pixels, bounded by their unscaled radii, since the preview
correction that scales them never exceeds 1. The tiling overlap comes from the
same helper, so an under-computed halo clamps these blurs against an internal
tile edge, which is a seam in a tiled export rather than a frame-edge effect.

The per-sub-layer dye-cloud blur inside the sampler is a third pixel-domain
blur in this stage and is not covered. Its sigma derives from a sub-layer's
per-particle optical density, which exists only once the simulation is built,
and the simulation is not available at ROI time. Capping the dispatched sigma
with a constant would cap a blur the reference does not cap, changing rendered
grain on every stock. Covering it needs the sigma plumbed out of the
simulation.
The tiling callback claimed 2.5 buffers on the CPU and 4.0 on OpenCL with no
fixed overhead. Both are under the real peak.

On the CPU, input and output plus the three always-live temporaries -- two
three-channel planes and a single-channel blur scratch -- are 3.75 before any
spatial stage allocates. Halation is the widest of those stages, holding two
single-channel and three three-channel buffers at once, which puts the peak at
6.5; the coupler tail reaches 5.25 and grain 4.5 at three sub-layers. The
stages run in sequence, so the largest wins.

On OpenCL the always-live set is five float4 buffers and two single-channel
ones, a 7.5 baseline with input and output, and the grain stage adds an
accumulator plus one buffer per sub-layer.

Understating this lets the tiler choose a tile that then fails to allocate.
process() runs once per tile, and its answer to a failed temporary allocation
is to pass that tile through unchanged, so the export contains unprocessed
rectangles inside an otherwise processed frame.

Report the constant tables as overhead as well. They are uploaded once and do
not scale with tile size, but the two PCHIP sets grow with the quality steps,
from about 1 MB at draft to 17 MB at high. Exact-spectral quality builds no
tables and reports zero steps, so only the spectral upsampling table counts
there.
The grain table set is uploaded once per simulation and device, keyed on a
pointer to the exported tables and the device id. Those fields were recorded
immediately after the five uploads, before the check that any of them returned
NULL.

A single failed upload then satisfies the rebuild guard on every later call:
the rebuild is skipped, the NULL check below fails again, and the module
returns an allocation error for as long as that simulation and device pair
lasts. Only a rebuild of the simulation clears it.

Output stays correct, since the pipe treats the error as a reason to fall back
to the CPU path, so the cost is the loss of GPU acceleration for this module
well past the transient memory pressure that caused it. The whole set is under
30 kB, so the condition is rare.

Record the key only when every upload succeeded. The rebuild branch releases
what it holds before re-uploading, so a later attempt starts clean.
The output-scale picker reused the boost solver. That solver measures with the
boost overridden and the gamut compressor forced off, which is correct for the
boost, since it sits before the compressor and the knee's asymptote would
flatten the measurement. The scale sits after both, so the number it produces
describes a transfer the render does not perform.

The probe also left out_scale at whatever the parameter held while the trial
factor went into the boost slot, so the solved value scaled inversely with the
value it was about to replace. Two picks on the same area alternate rather than
converge: for a sampled lightness of 0.85 successive picks give 1.60403, 1.0,
1.60403; for 0.5 they give the clamped 4.0, 1.97015, 4.0. The picked colour
comes from the module's input and does not change between picks, so the
alternation is deterministic, and re-arming the picker over an unchanged area
triggers it. With a boost other than 1.0 the delivered result is wrong by that
factor as well.

Give the scale its own probe, keeping the boost and the compressor as the
render has them and overriding only out_scale. out_scale multiplies the
finished RGB uniformly and RGB->XYZ is linear, so lightness goes as the cube
root of the scale and a single probe at 1.0 solves it in closed form. Probing
at 1.0 rather than at the current value makes the answer independent of the
slider's position, which is the idempotence the picker documents.
git diff --check reports "new blank line at EOF", which fails the repository's
whitespace check.
The film page carries three sections and does not fit a 16:10 FHD panel. One
of its rows is redundant: the pipeline already treats "no print stage" and
"scan the film" as the same state, and the two controls were coupled anyway,
since the paper combobox was made insensitive whenever scanning was on.

Give the paper combobox a "none (scan the film)" entry alongside "auto", and
drive the scan_film parameter from it. The parameter itself is unchanged, so
existing edits keep working and every consumer in the pipeline is untouched;
only the widget goes away.

Sensitivity now follows the film rather than the scan state. A positive or
reversal stock has no print stage, so a paper is not a choice there and
selecting one would put the image through a print it does not have; the
combobox is insensitive for those films. For a negative it stays live even
while scanning, because it is the control that turns scanning off again.

The sensitivity update runs on every film change, not only when scan_film
moves: selecting "none" on a negative and then switching to a positive stock
leaves scan_film already set, and the combobox would otherwise stay live on a
film with no print stage.

gui_update selects the "none" entry whenever the parameter says scanning,
which outranks any stored paper hash: that is the state the pipeline is in.
Build the sections on those two tabs as collapsible sections instead of plain
headings. The expanded state persists per section, so a section opened once
stays open. Grain, halation, diffusion and scanner keep plain headings: they
are short enough already, and a disclosure arrow on a two-section tab costs
more than it saves.

Exposure stays fixed on both tabs. It is the first thing reached on either and
hiding it behind a click costs more than the rows it saves. A fixed section
gets the same container and the same reset button, only without the disclosure
arrow, so both forms behave identically everywhere else.

The section reset button moves into the section header and carries a pointer to
the section's container. That also removes the reason the previous arrangement
needed sentinels: sections were flat siblings, so the reset had to walk forward
from its header until the next one. Every widget in a container now belongs to
that section by construction, so the "sf_section" marker and the forward walk
both go. The exempt marker and the toggles-last ordering are unchanged.

_section_add points self->widget at the new container, so widgets built after
it land inside the section without any change at the call sites, and returns
the section's root widget. The page box is tracked separately, since
self->widget no longer holds it once the first section on a page is open.

dt_gui_new_collapsible_section packs with gtk_box_pack_end, which suits a
module carrying one trailing section but reverses the order of several, so each
expander is repacked at the start.

Section labels repeat across the two tabs -- both have exposure and chemistry
-- so the configuration names are qualified by tab.

The helper that names the resolved paper in the paper combobox's "auto" entry
wrote its label to list position 0, which the "none (scan the film)" entry now
occupies: the list showed "auto (Kodak Portra Endura)" where the scan entry
belonged, and the auto entry below kept its plain text. Record where the auto
entry lands when the list is built and relabel there, asking the combobox for
its length ahead of the insertion so the two stay in step.
git diff --check reports seven lines with trailing whitespace, which fails the
repository's whitespace check. All seven are inside comments and only the
trailing space differs; no text changes.
Six tabs is more than the module needs, and two of them were short. The
diffusion tab held the same four controls twice, once for the film stage and
once for the print stage, with nothing else on it.

The film-stage diffusion filter sits in front of the taking lens and the
print-stage one at the enlarger, so each belongs with the stage it acts on.
Move them there as collapsible sections and drop the tab, leaving five. On the
film tab diffusion goes above advanced, which holds quality and the adaptation
switches and reads as the tail of the page.

Both moves are page changes only: the widgets, their parameters, their order
and their master toggles are unchanged, and gui_changed() keys off the toggle
widgets rather than the page they sit on.

Collapse what is left over on the grain and halation tabs: texture on one, and
threshold on the other. Scatter and halation are the two stages the tab exists
for and stay open; threshold holds the highlight boost and protection that feed
both, which is a step removed from either.
_section_add() repoints self->widget at the new section's container, so a
widget built before it goes into whichever container is still open. The two
diffusion master switches were created ahead of their headings, which put them
at the foot of the preceding section: DIR couplers on the film tab, preflash on
the print tab. On the tab they came from there was no preceding section and the
placement worked.

Build them after the heading instead, as the first widget in the section they
switch on. They keep the section-exempt marker, so resetting the section leaves
the switch alone and only its parameters return to their defaults.

Also restore the lead-in comment above the advanced heading, which stayed
behind when diffusion moved above it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

difficulty: hard big changes across different parts of the code base feature: new new features to add scope: image processing correcting pixels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants