This guide documents the functions that darktable Image Operation (IOP) modules can implement. The API is defined in src/iop/iop_api.h and used via src/develop/imageop.h. See src/iop/useless.c for a fully documented example module.
See also:
- Pixelpipe Architecture for pipeline data flow and caching.
- Module Lifecycle for when these callbacks fire and what the system does around them.
- Introspection System for parameter management.
- GUI Architecture for GUI events, callbacks, and widget reparenting.
- GUI Threading for sharing
gui_databetween the GTK and pipe worker threads.
Required — every IOP module has these:
- Parameter struct (
dt_iop_modulename_params_t) - the user-facing parameters, serialized to the database, controlled via UI widgets inself->params - Core functions -
name(),default_colorspace(),process()
Conditional — present when the module's shape calls for them:
- Processing data struct (
dt_iop_modulename_data_t) - a processing-optimized version of the parameters, stored inpiece->dataand used byprocess(); when not provided,piece->datais a plain copy ofparams_t(see params_t vs data_t below). A module that defines one usually implementsinit_pipe()andcleanup_pipe()too. - GUI data struct (
dt_iop_modulename_gui_data_t) - widget references, present only where the module has a GUI: the darkroom, plus a throw-away instance built at startup (see thep/g/dconvention below). A hidden module never gets one.
Optional:
- Other callbacks - GUI, lifecycle, geometry, metadata, etc.
A module carries up to three structs of its own. Each has a fixed home on the framework side, and module code conventionally binds it to a single-letter local:
| Struct | Home | Local | Present |
|---|---|---|---|
dt_iop_mymodule_params_t |
self->params |
p |
always |
dt_iop_mymodule_data_t |
piece->data |
d |
wherever the pipe has a node for the module; when the module defines no data_t of its own, piece->data is a params_t and d is declared as one |
dt_iop_mymodule_gui_data_t |
self->gui_data |
g |
only on an instance that was loaded with a GUI |
The names are convention, not API: nothing enforces them, but nearly every module uses
them and a reviewer will read p, g and d as these three. self is the module
instance, piece its node in one particular pipe — one instance has a piece per pipe,
so anything per-pipe belongs in piece->data, not in the module.
Which threads touch them. Only gui_data is shared, and it is the only one with a
lock:
| Struct | Written by | Read by | What makes it safe |
|---|---|---|---|
params_t |
the GTK main thread alone: a _from_params widget writes its field directly (src/bauhaus/bauhaus.c), as do history replay, init(), reload_defaults() and presets |
the GTK thread, in gui_update() and gui_changed() |
no lock, and none is needed: it stays on the GTK thread, and the pipe works from a copy (below) |
data_t |
init_pipe(), commit_params() |
process(), process_cl(), the tiling and ROI callbacks |
it belongs to one pipe node. A pipe serializes its own synchronization against its own run, and every pipe has its own piece, so there is no sharing to protect |
gui_data_t |
the GTK thread, and the pipe worker threads | both | nothing implicit. Every field both sides touch needs the module's gui_lock (dt_iop_gui_enter_critical_section()), and GTK calls have to be marshalled onto the main loop |
The pipe never reads self->params. Synchronizing a pipe passes hist->params — the
history item's own copy — to dt_iop_commit_params() (_dev_pixelpipe_synch() in
src/develop/pixelpipe_hb.c), with dev->history_mutex held across the whole walk. So
commit_params() must work from its params argument and process() from piece->data.
Reaching for self->params in either is a race, and wrong on its own terms: the pipe's
defaults sync hands commit_params() default_params instead, so the two do not even
hold the same values.
commit_params() has no thread affinity of its own, not even on the darkroom instance,
and the three darkroom pipes run concurrently over one module instance. See
GUI_Threading.md — Which Thread Am I On?.
Where gui_data exists. It is allocated by the module's own gui_init(), through
IOP_GUI_ALLOC (src/develop/imageop.h); the framework itself never allocates it, and
dt_iop_gui_init() (src/develop/imageop.c) only initializes gui_lock and calls the
module's gui_init if it has one. Every call site of dt_iop_gui_init() is a GUI path,
which leaves g non-NULL in exactly two places:
- The darkroom instance. The full, preview and preview2 pipes share one set of module
instances, so
process()andcommit_params()see the same non-NULLgon all three. - A throw-away instance built at startup to register the module's shortcuts
(
_init_module_so()insrc/develop/imageop.c). Itsdevis NULL, sog != NULLdoes not by itself mean "darkroom"; see GUI_Threading.md.
Everywhere else self->gui_data is NULL: export, thumbnailing, style application,
snapshots and the pinned second-window preview each build their own dt_develop_t with
dt_dev_init(dev, FALSE) (src/imageio/imageio.c, src/common/styles.c,
src/develop/develop.c), and the module instances in it never get a gui_init(). That
holds even while the darkroom has the same image open, because those are different
instances — availability follows the instance, not the pipe. darktable-cli has no GUI at all, and a hidden module
(IOP_FLAGS_HIDDEN), or one that implements no gui_init(), never has a gui_data
anywhere.
Anything reachable from the pipe therefore has to test g before dereferencing it, and
must not depend on it for its result: the same commit_params() and process() run
during export, where there is nothing to read. See
GUI_Threading.md.
What belongs in gui_data. Widget pointers, but only the ones later code touches:
one that gui_changed() shows, hides or desensitizes, one a picker or button callback
writes, a GtkDrawingArea you redraw, a label whose text or format changes at runtime.
Keeping a pointer merely to sync a slider's value is unnecessary — dt_iop_gui_update()
calls dt_bauhaus_update_from_field(), which walks module->widget_list_bh and sets
every _from_params widget from self->params by itself (src/bauhaus/bauhaus.c).
src/iop/velvia.c still does it by hand; src/iop/flip.c and src/iop/scalepixels.c
have a GUI and no gui_data at all.
Alongside them goes state that exists to run the interface, and computed values the GUI needs but the pixel path does not. From the simplest up:
src/iop/monochrome.c—draggingand the drawing area, plusxform, an lcms transform built once for painting the color patch.src/iop/graduatednd.c—xa, ya, xb, yb, oldx, oldy, selected, dragging: the on-canvas line as it is dragged. The params holdrotationandoffset; these are the screen-space form derived from them.src/iop/levels.c—auto_levels[]andhash: automatic levels computed by the preview pipe and published under the lock, which the full pipe then waits for and reads (commit_params_late()). The advanced case, and the deliberate exception to the placement rule below; see GUI_Threading.md.
Deciding where a value goes. Three questions, in order:
- Does it change the exported image, and must it survive a restart? →
params_t. It is the record of user intent: serialized to database and XMP, versioned, migrated bylegacy_params(), no pointers, every byte initialized. - Is it derived from the params and needed by
process()? →data_t, built incommit_params(). No serialization constraints; it may hold pointers and LUTs. Anything that differs per pipe — scale-dependent radii, ROI-dependent tables — has to live here rather than on the module, because the pipes run concurrently. - Is it needed only to run the interface? →
gui_data_t.
The line between 2 and 3 is the one that bites: the pixel result must not depend on
gui_data, because the same commit_params() and process() run during export, where
g is NULL. If the darkroom caches something the export path cannot have, the non-GUI
branch has to compute it independently — which is exactly what levels.c does when the
preview values are missing.
Two corollaries. gui_data may hold a value copied out of params or piece->data, but
never a pointer into piece->data: that buffer belongs to a pipe and dies with it.
And whatever you allocate inside gui_data is yours to free in gui_cleanup() — the
framework frees the struct itself and nothing under it (dt_iop_gui_cleanup_module() in
src/develop/imageop.c).
This struct defines the user-facing parameters. It is the contract between the module and the outside world:
- Database: Serialized as a binary blob and stored in the history stack. This is how edits persist across sessions.
- UI widgets:
dt_bauhaus_*_from_params()functions read and write fields inself->paramsvia introspection. - Presets/styles: Params are what gets exported and imported.
// Version number - increment when struct changes
DT_MODULE_INTROSPECTION(1, dt_iop_mymodule_params_t)
typedef struct dt_iop_mymodule_params_t
{
// Introspection tags configure widgets automatically:
// $MIN, $MAX, $DEFAULT set slider range/default
// $DESCRIPTION sets the widget label
float exposure; // $MIN: -10.0 $MAX: 10.0 $DEFAULT: 0.0 $DESCRIPTION: "exposure"
float gamma; // $MIN: 0.1 $MAX: 4.0 $DEFAULT: 1.0
gboolean enabled; // $DEFAULT: TRUE $DESCRIPTION: "enable correction"
// Enum fields auto-populate comboboxes
dt_mymodule_method_t method; // $DEFAULT: METHOD_AUTO
} dt_iop_mymodule_params_t;Rules:
- Use
gbooleannotbool(4-byte alignment) - Changes require version bump and
legacy_params()migration - Values are serialized to database - no pointers
- Every byte of the struct must be initialized, padding and unused string tails included. The block is serialized and hashed whole, so indeterminate bytes both travel into files and disturb the pipe cache; see introspection.md — Serialization and Initialization
Introspection tags (parsed from comments at compile time, used by dt_bauhaus_*_from_params()):
| Tag | Applies to | Purpose |
|---|---|---|
$MIN: value |
float, int |
Hard minimum for the widget |
$MAX: value |
float, int |
Hard maximum for the widget |
$DEFAULT: value |
all types | Default value (also used by dt_iop_default_init()) |
$DESCRIPTION: "text" |
all types | Widget label in the GUI (translatable) |
For enums, $DESCRIPTION on each enum member becomes the combobox entry text. For gboolean, $DEFAULT accepts TRUE / FALSE.
The DT_MODULE_INTROSPECTION(version, struct_type) macro at the top of the file activates this system and sets the parameter version number.
typedef enum dt_mymodule_method_t
{
METHOD_AUTO = 0, // $DESCRIPTION: "automatic"
METHOD_MANUAL = 1, // $DESCRIPTION: "manual"
METHOD_CUSTOM = 2, // $DESCRIPTION: "custom"
} dt_mymodule_method_t;The dt_iop_module_t structure (defined in src/iop/iop_api.h) is the main handle for your module instance. Key members:
params: Pointer to the current parameter struct.default_params: Pointer to the default parameters.gui_data: Pointer to your GUI data struct.dev: Pointer to thedt_develop_tsession.widget: The main widget container for the module.multi_priority(orinstance): The integer instance number (0 for the first instance).iop_order: The module's execution order position in the pixelpipe.enabled: boolean flag for the module's enabled state.
A common source of confusion is the relationship between the parameter struct (params_t) and the processing data struct (data_t). They serve different purposes:
dt_iop_modulename_params_t |
dt_iop_modulename_data_t |
|
|---|---|---|
| Where it lives | self->params |
piece->data |
| Source | Database / UI widgets | Built by commit_params() |
| Purpose | Record user intent in a stable, serializable format | Provide processing-ready values to process() |
| May contain | Raw user values (e.g. EV, percentages, enum choices) | Precomputed LUTs, splines, normalized/transformed values, expensive one-time calculations |
| Constraints | No pointers; must be serializable; changing it requires a version bump; every byte, padding included, has to be initialized (introspection.md) | No constraints — can contain pointers, LUTs, runtime-only data; its size and lifetime are yours (Pipe Lifecycle Functions) |
When you don't need a data_t: If process() can work directly from the raw user parameters without any transformation, you don't need a separate data_t. The default init_pipe() allocates piece->data as a params_t-sized buffer, and the default commit_params() does memcpy(piece->data, params, self->params_size).
When you do need a data_t: Most non-trivial modules define a separate data_t struct because:
- Some user parameters need transformation before they are useful for processing (e.g. converting percentages to linear factors, degrees to radians, EV values to exposure multipliers).
- Expensive calculations should happen once in
commit_params(), not per-pixel inprocess()(e.g. building interpolation splines, computing lookup tables, solving matrices). - Additional runtime state is needed that doesn't belong in the database (e.g. pointers to the current working color profile, gamut boundary LUTs).
Concrete examples from the codebase:
// exposure.c: data_t embeds params_t and adds precomputed fields
typedef struct dt_iop_exposure_data_t
{
dt_iop_exposure_params_t params; // raw user params
gboolean deflicker; // computed: is deflicker mode active?
float black; // computed: adjusted black point
float scale; // computed: exposure multiplier
} dt_iop_exposure_data_t;
// filmicrgb.c: data_t is entirely different from params_t
typedef struct dt_iop_filmicrgb_data_t
{
float dynamic_range; // derived from white_point - black_point
float grey_source; // computed from user grey_point / 100
float contrast; // clamped/adjusted contrast
float saturation; // normalized from user percentage
float sigma_toe, sigma_shoulder; // computed from spline
struct dt_iop_filmic_rgb_spline_t spline; // fully solved spline LUT
// ... no direct copy of params fields
} dt_iop_filmicrgb_data_t;The data flow:
Database ──load──→ self->params ──UI widgets──→ self->params
│
│ the pipe's defaults sync
│ passes default_params
▼ here instead
commit_params(p)
│
▼
piece->data ──→ process()
(params_t or data_t)
When a data_t is used, allocating and freeing it is usually yours to do in init_pipe() and cleanup_pipe(). See Pipe Lifecycle Functions below for when the defaults are enough.
const char *name() { return _("my module"); }
dt_iop_colorspace_type_t default_colorspace(dt_iop_module_t *self,
dt_dev_pixelpipe_t *pipe, dt_dev_pixelpipe_iop_t *piece)
{
return IOP_CS_RGB; // or IOP_CS_LAB, IOP_CS_RAW
}void process(dt_iop_module_t *self,
dt_dev_pixelpipe_iop_t *piece,
const void *const ivoid,
void *const ovoid,
const dt_iop_roi_t *const roi_in,
const dt_iop_roi_t *const roi_out)
{
// Get processing data — this is piece->data as prepared by commit_params().
const dt_iop_mymodule_params_t *d = piece->data;
const size_t ch = piece->colors; // Usually 4 (RGBA)
// Validate input format
if(!dt_iop_have_required_input_format(4, self, piece->colors,
ivoid, ovoid, roi_in, roi_out))
return;
// Process pixels
DT_OMP_FOR()
for(int j = 0; j < roi_out->height; j++)
{
const float *in = ((float *)ivoid) + (size_t)ch * roi_in->width * j;
float *out = ((float *)ovoid) + (size_t)ch * roi_out->width * j;
for(int i = 0; i < roi_out->width; i++)
{
for_each_channel(c, aligned(in, out))
out[c] = in[c] * d->exposure;
in += ch;
out += ch;
}
}
}Important:
- Never use GTK API directly in
process()— see GUI_Threading.md for the correct approach commit_params()has no thread affinity either —gui_datait shares with widget callbacks needs the GUI critical section, and only when a GUI exists; seecommit_params()- Use
piece->datafor parameters, notself->params - Use
DT_OMP_FOR()for parallelization - Use
for_each_channel()for vectorization
typedef struct dt_iop_roi_t
{
int x, y, width, height; // position and dimensions in pixels (at current scale)
float scale; // zoom factor relative to full image (0 < scale <= 1.0)
} dt_iop_roi_t;For most modules (those that don't change geometry), roi_in and roi_out are identical.
Multiple pipelines may process an image simultaneously. Check dt_pipe_is_full() and friends when behavior should differ:
DT_DEV_PIXELPIPE_FULL // Full-resolution center view
DT_DEV_PIXELPIPE_PREVIEW // Navigation preview
DT_DEV_PIXELPIPE_PREVIEW2 // Secondary preview (dual view)
DT_DEV_PIXELPIPE_EXPORT // Full export
DT_DEV_PIXELPIPE_THUMBNAIL // Thumbnail generation
DT_DEV_PIXELPIPE_SCREEN // PREVIEW | FULL | PREVIEW2
DT_DEV_PIXELPIPE_ANY // All typesThe ratio between the input buffer and the full image. Use to scale spatial parameters:
const float sigma = user_radius * roi_out->scale / piece->iscale;DT_OMP_FOR() // OpenMP parallel for with safe defaults
for_each_channel(c, aligned(in, out : 16)) // SIMD-vectorized 4-channel loop
copy_pixel(out, in); // 4-channel copy
copy_pixel_nontemporal(out, in); // bypass cache (sequential writes only)
dt_omploop_sfence(); // memory fence after nontemporal writesfloat *temp = NULL;
if(!dt_iop_alloc_image_buffers(module, roi_in, roi_out,
4 | DT_IMGSZ_OUTPUT | DT_IMGSZ_CLEARBUF,
&temp, NULL))
return; // allocation failed — trouble message already set
// ... use temp ...
dt_free_align(temp);dt_iop_set_module_trouble_message(module,
_("unsupported input"), _("expected 4-channel input"), NULL);
dt_iop_set_module_trouble_message(module, NULL, NULL, NULL); // clearTrigger reprocessing from GUI callbacks:
dt_iop_refresh_center(module); // invalidate full pipe, redraw center view
dt_iop_refresh_preview(module); // invalidate preview pipe
dt_iop_refresh_all(module); // invalidate all pipesThese functions provide metadata for the module. See src/iop/iop_api.h for signatures.
Common flags: IOP_FLAGS_INCLUDE_IN_STYLES, IOP_FLAGS_SUPPORTS_BLENDING, IOP_FLAGS_ALLOW_TILING, IOP_FLAGS_HIDDEN, IOP_FLAGS_DEPRECATED, IOP_FLAGS_ONE_INSTANCE.
For gui_init(), gui_update(), gui_changed(), gui_cleanup(), color_picker_apply(), and mouse/drawing events, see GUI.md.
gui_focus(self, in) is not covered there: the framework calls it when the module gains or loses focus in the darkroom (src/iop/iop_api.h). rasterfile uses it to re-read a file that may have changed while the module was not in focus (src/iop/rasterfile.c).
Called once per module type to register built-in presets:
void init_presets(dt_iop_module_so_t *self)
{
dt_iop_mymodule_params_t p = {
.exposure = 1.0f,
.gamma = 2.2f,
.method = METHOD_MANUAL,
.enabled = TRUE
};
dt_gui_presets_add_generic(_("my awesome preset"), self->op, self->version(), &p, sizeof(p));
}Called once per module instance. Usually not needed if using $DEFAULT tags.
void init(dt_iop_module_t *self)
{
dt_iop_default_init(self); // Use introspection defaults
self->hide_enable_button = TRUE; // Override specific settings
}Called once per module type (not per instance). Used primarily to load OpenCL kernels:
typedef struct dt_iop_mymodule_global_data_t
{
int kernel_process;
} dt_iop_mymodule_global_data_t;
void init_global(dt_iop_module_so_t *self)
{
dt_iop_mymodule_global_data_t *gd = calloc(1, sizeof(*gd));
self->data = gd;
gd->kernel_process = dt_opencl_create_kernel(42, "mymodule_process");
}
void cleanup_global(dt_iop_module_so_t *self)
{
dt_iop_mymodule_global_data_t *gd = self->data;
dt_opencl_free_kernel(gd->kernel_process);
free(gd);
}Access in process_cl() via self->global_data.
Called when an image is loaded, including when switching images. Its purpose is to make self->default_params image-specific: many modules have defaults that depend on whether the image is raw, HDR, monochrome, and so on, and those cannot be compile-time constants.
When invoked through the wrapper dt_iop_reload_defaults(), the framework calls dt_iop_load_default_params() after the module's own reload_defaults() returns, copying default_params into self->params. So on the wrapper path, writing to default_params inside reload_defaults() immediately affects self->params as well. Most callers use the wrapper (for example _dt_dev_load_pipeline_defaults(), the per-module reset button, and instance duplication).
Caveat: direct calls skip only the framework copy, not the module body. A few sites invoke module->reload_defaults(module) directly instead of through the wrapper (for example, dt_dev_read_history_ext() calls temperature->reload_defaults(temperature) to refresh the white balance state). Those callers do not get the automatic dt_iop_load_default_params(), so the wholesale default_params -> self->params copy does not happen. This does not mean self->params is left untouched: the module's own reload_defaults() body may still write specific self->params fields directly. temperature.reload_defaults() does exactly this: it writes p->preset (via p = self->params) together with default_params, while the white balance coefficients are written to default_params only. On a direct call, the result is therefore a partial update: the fields the body touches change in self->params, the rest do not, and no history entry is recorded. If you add such a call, prefer dt_iop_reload_defaults(), or check exactly which self->params fields the body changes and sync the rest yourself.
void reload_defaults(dt_iop_module_t *self)
{
dt_iop_mymodule_params_t *d = self->default_params;
const dt_image_t *img = &self->dev->image_storage;
if(!dt_image_is_raw(img))
self->default_enabled = FALSE;
}Example: exposure.c sets a default exposure of +0.7 EV for raw images in the scene-referred workflow (first instance only, and not for monochrome raw images), and 0 EV otherwise.
Common checks: dt_image_is_raw(), dt_image_is_hdr(), dt_image_is_ldr(), dt_image_is_monochrome(), dt_image_is_bayerRGB().
Widgets created with the _from_params helpers take their default value (the one a double-click restores) from default_params when they are created. If reload_defaults() changes a default per image, update the widget's default too, with dt_bauhaus_slider_set_default(), dt_bauhaus_combobox_set_default() or dt_bauhaus_toggle_set_default(); exposure.c does this.
Some modules use reload_defaults() to write shared state, such as dev->chroma, that other modules depend on. This happens whether or not the module is enabled or has a history entry.
Example: temperature.c always computes the camera's as-shot white balance coefficients and its D65 reference coefficients, and writes them into dev->chroma.as_shot[] and dev->chroma.D65coeffs[]. channelmixerrgb.c reads D65coeffs in its own reload_defaults(), commit_params() and process() for chromatic adaptation. White balance allows only one instance, which is always present when defaults are loaded, so its reload_defaults() runs on every image load. Color calibration therefore has this reference even when the white balance module is disabled or absent from the history.
Initial image load. Inside dt_dev_read_history_ext(), _dt_dev_load_pipeline_defaults() calls reload_defaults() for every instance already in dev->iop (in reverse pipe order) and copies each default_params into params. The function then adds the workflow default modules and any auto-presets, and builds dev->history from the database rows. It does not copy the history into module->params: in darkroom, the caller does that afterwards with dt_dev_pop_history_items(), which resets every module to default_params, replays the history and calls gui_update(). After that, a module with a history row has the saved params from that row, and a module without one has its image-specific defaults. This is the same for the first open of an image and for an edited one; the only difference is whether the database has rows for that module.
An extra instance that exists only in the history (multi_priority > 0) is created while the history is read, with init() but without reload_defaults(). Entering darkroom calls reload_defaults() for it later; switching to another image in darkroom does not. So do not rely on reload_defaults() having run for such an instance. See Module_Lifecycle.md.
Undo / redo / history-panel navigation. These use dt_dev_pop_history_items_ext(), which does two passes:
- Resets all modules:
module->params = module->default_params, so modules absent from the replayed range start from their image-specific default. - Replays history entries:
module->params = hist->paramsfor each entry up to the target position.
So default_params is consumed in two ways: as the starting point for modules with no (or not-yet-replayed) history, and as the value the per-module reset button restores.
For the full sequence (load order, the dev->chroma side effects, and the reverse-iteration hazard), see Module_Lifecycle.md.
Called on an image switch, after reload_defaults() and before the new image's params reach gui_update(). Switching image does not tear down every module: each module's base instance — the one with the lowest multi_priority — is kept, GUI and all, and reused for the new image (src/views/darkroom.c). Anything in gui_data that describes the old image — a cached curve, a selected region, a pending readout — therefore survives unless you clear it here:
void change_image(dt_iop_module_t *self)
{
dt_iop_mymodule_gui_data_t *g = self->gui_data;
if(!g) return;
g->cache_valid = FALSE;
g->selected_node = -1;
}gui_cleanup() does not run for that retained instance — only the module's extra instances are torn down, and what replaces them is whatever the new image's history needs — so change_image() is also where a module that schedules GUI updates from the pipe has to cancel them; see GUI_Threading.md. basicadj, retouch, rgblevels and rgbcurve implement it, and each also calls it from gui_init() to set the same initial state.
Each pixelpipe has its own copy of every module's data via dt_dev_pixelpipe_iop_t ("piece").
Default behavior (if not implemented): init_pipe() calloc()s self->params_size bytes, and cleanup_pipe() free()s them (src/develop/imageop.c). Read the size literally: the default measures the buffer by the params struct, not by the data_t that commit_params() is about to write into it. Nothing checks the two against each other.
Implement your own when the default cannot produce the allocation you need:
sizeof(data_t) > sizeof(params_t)— the default buffer is short, andcommit_params()writes past its end- the
data_tneeds an alignment plaincalloc()does not promise — this one obliges you to writecleanup_pipe()as well, see below - it owns sub-allocations that
free(piece->data)alone would leak — which also calls for a matchingcleanup_pipe()
The defaults are a supported arrangement, not a trap in themselves: a data_t no larger than the params_t that owns nothing works on them. enlargecanvas defines a data_t field-for-field identical to its params_t and implements neither callback. rasterfile does the same, but its two structs are only coincidentally compatible: its mode + char[PATH_MAX] happens not to exceed the params' mode + two char[2048] (on Linux the two come out exactly equal), which is not a property either struct declares. Allocating by sizeof(data_t) costs one short function and ends the coupling; letting the two sizes stay in a fixed relation is the part that ages badly.
The failure mode is silent. contrastntexture was introduced (1067c653c0) with a 20-byte params_t, a 24-byte data_t and no init_pipe(). Its commit_params() wrote the last float past the end of the allocation and process() read it back from there — undefined behavior on every parameter change, with no diagnostic on an ordinary build, until PR #22154 gave the module the two callbacks.
void init_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe,
dt_dev_pixelpipe_iop_t *piece)
{
piece->data = calloc(1, sizeof(dt_iop_mymodule_data_t));
}
void cleanup_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe,
dt_dev_pixelpipe_iop_t *piece)
{
dt_iop_mymodule_data_t *d = piece->data;
dt_free_align(d->gamut_LUT); // sub-allocation, from dt_alloc_align_float()
free(piece->data); // the struct itself: calloc'd above
piece->data = NULL;
}Pair the allocators. The two families are not interchangeable in either direction: malloc()/calloc() are released by free(), and the aligned allocators (dt_alloc_aligned(), dt_calloc1_align_type(), dt_alloc_align_float() and the rest of that family in src/common/darktable.h) by dt_free_align(). The snippet above uses both, one per allocation, which is why the struct and the LUT are freed by different calls.
Getting this wrong is easy to miss, because on an ordinary Linux release build dt_free_align is a macro for free() and the two families coincide, so the mismatch does nothing (src/common/darktable.h). It only bites on the other two configurations, and it bites in both directions:
free()on an aligned pointer. On a debug builddt_alloc_aligned()returns a pointer deliberately offset past the real block, precisely so that this crashes: "for a debug build, ensure that we get a crash if we use plainfree()to release the allocated memory, by returning a pointer which isn't a valid memory block address" (src/common/darktable.c). On Windows the block came from_aligned_malloc(), whichfree()may not release.dt_free_align()on amalloc()/calloc()pointer. On a debug build it reads an offset from((short *)mem)[-1]— whatever the allocator happens to keep in front of the payload — subtracts it and frees the resulting address. On Windows it calls_aligned_free(). Both are undefined.
The first direction is the one this section can walk you into, because cleanup_pipe() has a default and init_pipe() does not have to declare what it did. If you allocate piece->data with an aligned allocator, you must also write a cleanup_pipe() that calls dt_free_align(). Leaving cleanup to the default means free() on an aligned block: nothing visible on a Linux release build, a crash on a debug build by design, undefined on Windows.
Aligned allocation for the data_t is worth it when process() reads it with SIMD, and contrastntexture pairs it correctly: dt_calloc1_align_type(dt_iop_contrast_data_t) in init_pipe(), dt_free_align(piece->data) in cleanup_pipe(). It is a choice, not a requirement — sigmoid allocates its data_t with calloc(1, sizeof(...)) in init_pipe() and correctly lets the default cleanup_pipe() free() it, with no cleanup_pipe() of its own.
Called by the framework whenever parameters are synced to the pixelpipe. Its job is to translate the incoming parameters into processing-ready piece->data. Work from the params argument, not from self->params: they are the same in the normal case, but the pipe's defaults sync passes default_params instead (src/develop/pixelpipe_hb.c).
Threading note: commit_params() has no thread affinity. For your darkroom instance it usually runs on a pixelpipe worker thread, but it also runs on the GTK main thread when an image switch rebuilds the screen pipes' nodes. Any gui_data it shares with widget callbacks therefore needs the GUI critical section, i.e. the module's gui_lock.
Enter that section only when gui_data is non-NULL: gui_data is NULL on export and gui_lock is only initialized when a GUI exists, so the non-GUI branch must compute what processing needs on its own. The in-tree idiom writes self->dev->gui_attached && g. The dev dereference is safe here, since a module always has a dev by the time a pipe commits it, but g is the half that does the work — and even g is not quite proof that gui_lock was initialized; see GUI_Threading.md.
Caching note: After this function returns, dt_iop_commit_params() hashes the operation name, the instance, module->params, and the blend parameters and mask group if blending is on (src/develop/imageop.c). Read that list literally in both directions: it is module->params, not the params argument your callback was just handed — on the defaults sync those are different objects — and it never includes piece->data, the output you just produced. The hash is also the wrapper's work, not the callback's, so a commit_params() reached another way does not update it; basecurve calls its own directly from init_pipe(). If that piece->hash changes, the cache for this module and all subsequent ones is invalidated.
piece->hash is only part of the cache key: a lookup also folds in the image id, the color profiles, the hashes of the preceding pieces and — when it supplies a ROI, which is the normal case — the pipe type, the detail-mask flag, the ROI, the Scharr state and the color picker's sample (dt_dev_pixelpipe_cache_hash(), src/develop/pixelpipe_cache.c; the full list is in pixelpipe_architecture.md). So pipe type, scale and color profiles do not need to be in params — a normal lookup has them already.
There is no hook for adding to that key. The wrapper builds piece->hash after your callback returns and assigns it unconditionally, and src/iop/iop_api.h declares nothing else for the purpose. So keep the output a deterministic function of what the key represents, and give any other input either a place in the key or an explicit invalidation path. The three routes are to put a serializable value in module->params, to use a pipe field the framework already hashes, or to invalidate explicitly; which inputs the key leaves out, and which route suits which, are set out in pixelpipe_architecture.md — What the Cache Key Does Not Cover.
Simple case — no transformation needed, default memcpy suffices. Don't implement this function.
Common case — precompute expensive values:
void commit_params(dt_iop_module_t *self, dt_iop_params_t *p1,
dt_dev_pixelpipe_t *pipe, dt_dev_pixelpipe_iop_t *piece)
{
const dt_iop_mymodule_params_t *p = (dt_iop_mymodule_params_t *)p1;
dt_iop_mymodule_data_t *d = piece->data;
d->exposure_scale = exp2f(p->exposure); // EV → linear multiplier
d->blending = p->blending / 100.0f; // percentage → 0-1 range
dt_iop_compute_spline(p, &d->spline); // solve spline
}int legacy_params(dt_iop_module_t *self,
const void *const old_params, const int old_version,
void **new_params, int32_t *new_params_size, int *new_version)
{
if(old_version == 1)
{
typedef struct { float exposure; } v1_params_t;
const v1_params_t *o = old_params;
// calloc, not malloc: the caller memcpy's this block whole into the
// module's params, so its padding ends up serialized and hashed
dt_iop_mymodule_params_t *n = calloc(1, sizeof(dt_iop_mymodule_params_t));
n->exposure = o->exposure;
n->gamma = 1.0f; // default for new field
n->method = METHOD_AUTO;
*new_params = n;
*new_params_size = sizeof(dt_iop_mymodule_params_t);
*new_version = 2;
return 0;
}
return 1; // Unknown version
}If the module can use the GPU, implement process_cl() wrapped in #ifdef HAVE_OPENCL. OpenCL kernels are loaded in init_global() and accessed via self->global_data. The pipeline falls back to CPU process() on failure. See existing modules (e.g., exposure.c, sharpen.c) for complete examples.
If IOP_FLAGS_ALLOW_TILING is set, the pixelpipe is allowed to process a piece in tiling mode. If some parameter combinations do not allow tiling, clear that permission for the piece in commit_params().
Memory requirements and tile alignment are reported by tiling_callback(). A module that does not provide one gets the defaults computed by default_tiling_callback().
Provide a specific tiling_callback() whenever the module may exceed the requirements assumed by default_tiling_callback(), or needs special alignment, so that:
- the tiling process will not allocate more memory than it was granted;
- the OpenCL code path will not be tried when the requirements are too high, which avoids costly late fallbacks to the CPU path;
- tile stitching will be correct for the alignment the module needs.
| Field | Purpose |
|---|---|
factor / factor_cl |
Total CPU/GPU memory as a multiple of input buffer size |
maxbuf / maxbuf_cl |
Largest single temporary buffer as a multiple of input size |
overhead |
Fixed memory overhead in bytes |
overlap |
Pixels of overlap between adjacent tiles (for spatial filters) |
align |
Tile origin alignment (1 = none, other values only for special algorithms) |
An example
void tiling_callback(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece,
const dt_iop_roi_t *roi_in, const dt_iop_roi_t *roi_out,
dt_develop_tiling_t *tiling)
{
tiling->factor = 2.5f; // input + output + 2 single channel temp buffers
tiling->factor_cl = 3.75f; // as above but we need an additional rgb buffer plus a single channel buffer for a mask
tiling->maxbuf = 1.0f;
tiling->maxbuf_cl = 1.0f;
tiling->overhead = 0;
tiling->overlap = 4; // 4-pixel overlap for a 3×3 kernel
tiling->align = 1; // no special care for sensor patterns
}For modules that change image geometry (crop, rotate, lens correction), implement modify_roi_in(), modify_roi_out(), distort_transform(), distort_backtransform(), and optionally distort_mask(). See src/iop/iop_api.h for signatures and modules like ashift.c for examples.
Module Load:
init_global() [once per module type]
Image Open: [simplified]
init() → reload_defaults() → gui_init() → reload_defaults()
[defaults are recomputed
once the widgets exist]
→ [history params loaded] → gui_update()
instances created from the history skip the first reload_defaults()
Pixelpipe Creation (per pipe):
init_pipe() [allocates piece->data]
Params Change:
gui_update() [if the module implements gui_changed(), its gui_update()
should end with gui_changed(self, NULL, NULL); the
framework does not call gui_changed() here]
User Edits Widget:
[auto-callback] → gui_changed() [if implemented]
→ commit_params(p) [transforms its p argument → piece->data]
→ process() [reads piece->data]
Image Switch: [the base instance is kept; extra instances are destroyed,
then rebuilt from the new image's history]
reload_defaults() → change_image() [if implemented] → [history params loaded]
→ gui_update() [see Params Change]
instances created from the history: init() → gui_init() → [history params
loaded] → gui_update(), with no reload_defaults()
Darkroom Exit:
cleanup_pipe() [per pipe] → gui_cleanup() → cleanup()
Module Unload:
cleanup_global()
[if implemented] marks the optional callbacks: the framework skips them for a module that does not have them. The GUI steps are skipped entirely for a hidden module. See GUI.md for the full event flow.