diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index d19d3229b14..38821bafdb0 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -231,6 +231,11 @@ changes (where available).
colorin module, selectable manually. The existing "embedded
matrix" profile remains the default for fresh imports and
reloading defaults.
+ DNG files now also supports provided "ProfileHueSatMapData"
+ and applies these automatically if "DNG forward matrix" is
+ selected as the colorin profile.
+ Some images have "ProfileHueSatMapData" but no forward matrix.
+ You may chose the "DNG embedded look" to apply the DNG look.
- The highlights module will be disabled automatically when a raw
image with a clean history is opended in darkroom the first time
diff --git a/src/common/colorspaces.c b/src/common/colorspaces.c
index 21200bf426d..adc39e8f417 100644
--- a/src/common/colorspaces.c
+++ b/src/common/colorspaces.c
@@ -1836,6 +1836,8 @@ const char *dt_colorspaces_get_name(dt_colorspaces_color_profile_type_t type,
return _("Display P3");
case DT_COLORSPACE_FORWARD_MATRIX:
return _("DNG forward matrix");
+ case DT_COLORSPACE_DNG_LOOK:
+ return _("DNG embedded look");
case DT_COLORSPACE_LAST:
break;
}
@@ -2654,6 +2656,7 @@ gboolean dt_colorspaces_profile_is_wide_gamut(const dt_colorspaces_color_profile
case DT_COLORSPACE_DISPLAY2:
case DT_COLORSPACE_EMBEDDED_ICC:
case DT_COLORSPACE_EMBEDDED_MATRIX:
+ case DT_COLORSPACE_DNG_LOOK:
case DT_COLORSPACE_STANDARD_MATRIX:
case DT_COLORSPACE_ENHANCED_MATRIX:
case DT_COLORSPACE_VENDOR_MATRIX:
diff --git a/src/common/colorspaces.h b/src/common/colorspaces.h
index 19110ab7b62..702c8437df6 100644
--- a/src/common/colorspaces.h
+++ b/src/common/colorspaces.h
@@ -105,7 +105,8 @@ typedef enum dt_colorspaces_color_profile_type_t
DT_COLORSPACE_HLG_P3 = 25,
DT_COLORSPACE_DISPLAY_P3 = 26,
DT_COLORSPACE_FORWARD_MATRIX = 27,
- DT_COLORSPACE_LAST = 28
+ DT_COLORSPACE_DNG_LOOK = 28,
+ DT_COLORSPACE_LAST = 29
} dt_colorspaces_color_profile_type_t;
typedef enum dt_colorspaces_color_mode_t
diff --git a/src/common/exif.cc b/src/common/exif.cc
index 62dd192e73a..be4cda21d99 100644
--- a/src/common/exif.cc
+++ b/src/common/exif.cc
@@ -1077,8 +1077,8 @@ static bool _valid_dng_matrix(const float *M)
return fabsf(det) > 1e-6f;
}
-static void _check_forward_matrix(Exiv2::ExifData &exifData,
- dt_image_t *img)
+static int _check_forward_matrix(Exiv2::ExifData &exifData,
+ dt_image_t *img)
{
Exiv2::ExifData::const_iterator pos;
dt_mark_colormatrix_invalid(&img->dng_forward_matrix[0]);
@@ -1202,7 +1202,7 @@ static void _check_forward_matrix(Exiv2::ExifData &exifData,
// We only want a valid forward matrix if we got an illu and matrix are good
const gboolean has_forward_illuminant = sel_illu >= 0 && sel_illu < 3 && has_FM[sel_illu];
if(!has_forward_illuminant)
- return;
+ return sel_illu;
// CameraToXYZ = ForwardMatrix * Inverse(AnalogBalance * CameraCalibration)
// (white balance D is applied later, upstream, via temperature.iop's wb_coeffs)
@@ -1226,7 +1226,52 @@ static void _check_forward_matrix(Exiv2::ExifData &exifData,
illu[2] ? ", [3] " : "", illu[2] ? _illu_to_str(illu[2]) : "" );
_print_matrix_data("img forward matrix", 0, img->dng_forward_matrix);
_print_matrix_data(has_CC[sel_illu] ? "calibration matrix" : "ident calibration matrix", 0, CC[sel_illu]);
+ return sel_illu;
+ }
+ return sel_illu;
+}
+
+static void _check_profile_look_table(Exiv2::ExifData &exifData,
+ dt_image_t *img,
+ const int sel_illu)
+{
+ g_free(img->profile_hsm_data);
+ img->profile_hsm_data = NULL;
+ img->profile_hsm_hue_div = img->profile_hsm_sat_div = img->profile_hsm_val_div = 0;
+ Exiv2::ExifData::const_iterator dims_pos =
+ exifData.findKey(Exiv2::ExifKey("Exif.Image.ProfileHueSatMapDims"));
+ if(dims_pos != exifData.end() && dims_pos->count() == 3)
+ {
+ const auto hue_div = dims_pos->toLong(0);
+ const auto sat_div = dims_pos->toLong(1);
+ const auto val_div = dims_pos->toLong(2);
+ if(hue_div >= 1 && sat_div >= 2 && val_div >= 1
+ && hue_div <= G_MAXINT / 3 / sat_div / val_div)
+ {
+ const int n_entries = hue_div * sat_div * val_div * 3;
+ const char *keys[] = {
+ "Exif.Image.ProfileHueSatMapData1",
+ "Exif.Image.ProfileHueSatMapData2",
+#if EXIV2_TEST_VERSION(0,27,4)
+ "Exif.Image.ProfileHueSatMapData3"
+#endif
+ };
+ const int index = sel_illu >= 0 && sel_illu < (int)G_N_ELEMENTS(keys) ? sel_illu : 0;
+ Exiv2::ExifData::const_iterator pos = exifData.findKey(Exiv2::ExifKey(keys[index]));
+ if(pos != exifData.end() && (int)pos->count() == n_entries)
+ {
+ img->profile_hsm_data = (float *)g_malloc_n(n_entries, sizeof(float));
+ for(int i = 0; i < n_entries; i++)
+ img->profile_hsm_data[i] = pos->toFloat(i);
+ img->profile_hsm_hue_div = hue_div;
+ img->profile_hsm_sat_div = sat_div;
+ img->profile_hsm_val_div = val_div;
+ dt_print(DT_DEBUG_IMAGEIO, "[exif] found ProfileHueSatMapData%d (%dx%dx%d)",
+ index + 1, img->profile_hsm_hue_div, img->profile_hsm_sat_div, img->profile_hsm_val_div);
+ }
+ }
}
+
}
static gboolean _check_dng_opcodes(Exiv2::ExifData &exifData,
@@ -1682,7 +1727,8 @@ void dt_exif_img_check_additional_tags(dt_image_t *img,
_check_dng_opcodes(exifData, img);
_check_lens_correction_data(exifData, img);
_check_linear_response_limit(exifData, img);
- _check_forward_matrix(exifData, img);
+ const int sel_illu = _check_forward_matrix(exifData, img);
+ _check_profile_look_table(exifData, img, sel_illu);
_check_highlight_preservation(exifData, img);
_check_shading_compensation(exifData, img);
}
@@ -3298,7 +3344,6 @@ int dt_exif_read_blob(uint8_t **buf,
"Exif.Image.ProfileHueSatMapDims",
"Exif.Image.ProfileHueSatMapData1",
"Exif.Image.ProfileHueSatMapData2",
- "Exif.Image.ProfileToneCurve",
"Exif.Image.ProfileEmbedPolicy",
"Exif.Image.ProfileCopyright",
"Exif.Image.ForwardMatrix1",
diff --git a/src/common/image.c b/src/common/image.c
index a75c8970ab4..eedd3ef9c9e 100644
--- a/src/common/image.c
+++ b/src/common/image.c
@@ -2245,6 +2245,8 @@ void dt_image_init(dt_image_t *img)
img->raw_white_point = 16384; // 2^14
dt_mark_colormatrix_invalid(&img->d65_color_matrix[0]);
dt_mark_colormatrix_invalid(&img->dng_forward_matrix[0]);
+ img->profile_hsm_data = NULL;
+ img->profile_hsm_hue_div = img->profile_hsm_sat_div = img->profile_hsm_val_div = 0;
img->profile = NULL;
img->profile_size = 0;
img->colorspace = DT_IMAGE_COLORSPACE_NONE;
diff --git a/src/common/image.h b/src/common/image.h
index ff7d865c239..869574a0f4d 100644
--- a/src/common/image.h
+++ b/src/common/image.h
@@ -324,6 +324,10 @@ typedef struct dt_image_t
float d65_color_matrix[9]; // the 3x3 matrix embedded in some DNGs
float dng_forward_matrix[9]; // the 3x3 forward matrix supplied in some DNG files
+ float *profile_hsm_data; // hue_div * sat_div * val_div * 3 floats, owned by the struct
+ int profile_hsm_hue_div;
+ int profile_hsm_sat_div;
+ int profile_hsm_val_div;
uint8_t *profile; // embedded profile, for example from JPEGs
uint32_t profile_size;
dt_image_colorspace_t colorspace; // the colorspace that is
diff --git a/src/common/image_cache.c b/src/common/image_cache.c
index 2ef90ba0114..8d08d571ef9 100644
--- a/src/common/image_cache.c
+++ b/src/common/image_cache.c
@@ -116,6 +116,9 @@ static void _image_cache_allocate(void *data,
g_free(img->profile);
img->profile = NULL;
img->profile_size = 0;
+ g_free(img->profile_hsm_data);
+ img->profile_hsm_data = NULL;
+ img->profile_hsm_hue_div = img->profile_hsm_sat_div = img->profile_hsm_val_div = 0;
img->colorspace = sqlite3_column_int(stmt, 23);
img->version = sqlite3_column_int(stmt, 24);
img->raw_black_level = sqlite3_column_int(stmt, 25);
@@ -205,6 +208,7 @@ static void _image_cache_deallocate(void *data, dt_cache_entry_t *entry)
{
dt_image_t *img = entry->data;
g_free(img->profile);
+ g_free(img->profile_hsm_data);
g_list_free_full(img->dng_gain_maps, g_free);
g_free(img);
entry->data = NULL;
diff --git a/src/common/iop_order.c b/src/common/iop_order.c
index 58bc689b40e..758f4649a0d 100644
--- a/src/common/iop_order.c
+++ b/src/common/iop_order.c
@@ -113,6 +113,7 @@ const dt_iop_order_entry_t legacy_order[] = {
{ {25.0f }, "profile_gamma", 0},
{ {26.0f }, "hazeremoval", 0},
{ {27.0f }, "colorin", 0},
+ { {27.1f }, "dng_look", 0},
{ {27.5f }, "channelmixerrgb", 0},
{ {27.5f }, "contrastntexture", 0},
{ {27.5f }, "diffuse", 0},
@@ -221,6 +222,7 @@ const dt_iop_order_entry_t v30_order[] = {
{ {26.0f }, "profile_gamma", 0},
{ {27.0f }, "equalizer", 0},
{ {28.0f }, "colorin", 0},
+ { {28.1f }, "dng_look", 0},
{ {28.5f }, "channelmixerrgb", 0},
{ {28.5f }, "contrastntexture", 0},
{ {28.5f }, "diffuse", 0},
@@ -344,6 +346,7 @@ const dt_iop_order_entry_t v50_order[] = {
{ {26.0f }, "profile_gamma", 0},
{ {27.0f }, "equalizer", 0},
{ {28.0f }, "colorin", 0},
+ { {28.1f }, "dng_look", 0},
{ {28.5f }, "channelmixerrgb", 0},
{ {28.5f }, "contrastntexture", 0},
{ {28.5f }, "diffuse", 0},
@@ -441,6 +444,7 @@ const dt_iop_order_entry_t v30_jpg_order[] = {
{ { 8.5f }, "demosaicscale", 0 },
// all the modules between [8.5; 28] expect linear RGB, so they need to be moved after colorin
{ { 28.0f }, "colorin", 0 },
+ { { 28.0f }, "dng_look", 0 },
// moved modules : (copy-pasted in the same order)
{ { 28.0f }, "denoiseprofile", 0},
{ { 28.0f }, "bilateral", 0},
@@ -567,6 +571,7 @@ const dt_iop_order_entry_t v50_jpg_order[] = {
{ { 8.5f }, "demosaicscale", 0 },
// all the modules between [8.5; 28] expect linear RGB, so they need to be moved after colorin
{ { 28.0f }, "colorin", 0 },
+ { { 28.0f }, "dng_look", 0 },
// moved modules : (copy-pasted in the same order)
{ { 28.0f }, "denoiseprofile", 0},
{ { 28.0f }, "bilateral", 0},
@@ -767,6 +772,7 @@ void dt_ioppr_migrate_legacy_iop_order_list(GList *iop_order_list)
_insert_before_after(iop_order_list, "highlights", "rasterfile", TRUE);
_insert_before_after(iop_order_list, "demosaic", "demosaicscale", FALSE);
_insert_before_after(iop_order_list, "colorbalance", "colorharmonizer", TRUE);
+ _insert_before_after(iop_order_list, "colorin", "dng_look", FALSE);
}
static dt_iop_order_t _ioppr_get_default_iop_order_version(const dt_imgid_t imgid)
diff --git a/src/common/iop_profile.c b/src/common/iop_profile.c
index 76ee7d56a5b..d766a62bea4 100644
--- a/src/common/iop_profile.c
+++ b/src/common/iop_profile.c
@@ -937,8 +937,10 @@ dt_ioppr_set_pipe_input_profile_info(struct dt_develop_t *dev,
dt_image_cache_write_release_info(wimg, DT_IMAGE_CACHE_RELAXED, NULL);
}
- if(ptype >= DT_COLORSPACE_EMBEDDED_ICC
- && ptype <= DT_COLORSPACE_ALTERNATE_MATRIX)
+ if((ptype >= DT_COLORSPACE_EMBEDDED_ICC
+ && ptype <= DT_COLORSPACE_ALTERNATE_MATRIX)
+ || ptype == DT_COLORSPACE_FORWARD_MATRIX
+ || ptype == DT_COLORSPACE_DNG_LOOK)
{
/* We have a camera input matrix, these are not generated from files but in colorin,
* so we need to fetch and replace them from somewhere.
diff --git a/src/common/mipmap_cache.c b/src/common/mipmap_cache.c
index 0f37ee79a00..815bf4e2854 100644
--- a/src/common/mipmap_cache.c
+++ b/src/common/mipmap_cache.c
@@ -986,6 +986,8 @@ void dt_mipmap_cache_get_with_caller(dt_mipmap_buffer_t *buf,
dt_image_t DT_ALIGNED_ARRAY buffered_image;
const dt_image_t *cimg = dt_image_cache_get(imgid, 'r');
buffered_image = *cimg;
+ buffered_image.profile_hsm_data = NULL;
+ buffered_image.profile_hsm_hue_div = buffered_image.profile_hsm_sat_div = buffered_image.profile_hsm_val_div = 0;
// dt_image_t *img = dt_image_cache_write_get(cimg);
// dt_image_cache_write_release(img, DT_IMAGE_CACHE_RELAXED);
dt_image_cache_read_release(cimg);
@@ -1010,6 +1012,7 @@ void dt_mipmap_cache_get_with_caller(dt_mipmap_buffer_t *buf,
{
// swap back new image data:
dt_image_t *img = dt_image_cache_get(imgid, 'w');
+ g_free(img->profile_hsm_data);
*img = buffered_image;
img->load_status = DT_IMAGEIO_OK;
// dt_print(DT_DEBUG_ALWAYS, "[mipmap read get] initializing full buffer img %u with %u %u -> %d %d (%p)",
@@ -1019,6 +1022,7 @@ void dt_mipmap_cache_get_with_caller(dt_mipmap_buffer_t *buf,
}
else
{
+ g_free(buffered_image.profile_hsm_data);
dt_print(DT_DEBUG_PIPE, "[mipmap read get] error loading ID=%d", imgid);
//
// we can only return a zero dimension buffer if the buffer
diff --git a/src/iop/CMakeLists.txt b/src/iop/CMakeLists.txt
index baecf677763..ed387089a96 100644
--- a/src/iop/CMakeLists.txt
+++ b/src/iop/CMakeLists.txt
@@ -82,6 +82,7 @@ add_iop(equalizer "equalizer.c")
add_iop(rgbcurve "rgbcurve.c")
add_iop(colorbalance "colorbalance.c" DEFAULT_VISIBLE)
add_iop(colorin "colorin.c" DEFAULT_VISIBLE)
+add_iop(dng_look "dng_look.c")
add_iop(colorout "colorout.c")
add_iop(colorchecker "colorchecker.c")
add_iop(clipping "clipping.c" DEFAULT_VISIBLE)
diff --git a/src/iop/colorin.c b/src/iop/colorin.c
index 856169e8877..e11c5638b4e 100644
--- a/src/iop/colorin.c
+++ b/src/iop/colorin.c
@@ -522,9 +522,16 @@ static void _profile_changed(GtkWidget *widget, dt_iop_module_t *self)
dt_colorspaces_color_profile_t *pp = prof->data;
if(pp->in_pos == pos)
{
+ const gboolean old_look = p->type == DT_COLORSPACE_FORWARD_MATRIX
+ || p->type == DT_COLORSPACE_DNG_LOOK;
+ const gboolean new_look = pp->type == DT_COLORSPACE_FORWARD_MATRIX
+ || pp->type == DT_COLORSPACE_DNG_LOOK;
p->type = pp->type;
memcpy(p->filename, pp->filename, sizeof(p->filename));
dt_dev_add_history_item(darktable.develop, self, TRUE);
+ // top-only sync would leave dng_look's enablement stale on the other pipes
+ if(old_look != new_look)
+ dt_dev_pipe_synch_all(self->dev);
DT_CONTROL_SIGNAL_RAISE(DT_SIGNAL_CONTROL_PROFILE_USER_CHANGED,
DT_COLORSPACES_PROFILE_TYPE_INPUT);
@@ -1384,7 +1391,7 @@ void commit_params(dt_iop_module_t *self,
else
type = DT_COLORSPACE_EMBEDDED_MATRIX;
}
- if(type == DT_COLORSPACE_EMBEDDED_MATRIX)
+ if(type == DT_COLORSPACE_EMBEDDED_MATRIX || type == DT_COLORSPACE_DNG_LOOK)
{
// embedded matrix, hopefully D65
const dt_image_t *cimg = dt_image_cache_get(pipe->image.id, 'r');
@@ -1964,6 +1971,7 @@ static void update_profile_list(dt_iop_module_t *self)
// some file formats like jpeg can have an embedded color profile
// currently we only support jpeg, j2k, tiff and png
const dt_image_t *cimg = dt_image_cache_get(self->dev->image_storage.id, 'r');
+ const gboolean has_dng_look = cimg && cimg->profile_hsm_data;
if(cimg && cimg->profile)
{
dt_colorspaces_color_profile_t *prof = calloc(1, sizeof(dt_colorspaces_color_profile_t));
@@ -1983,6 +1991,16 @@ static void update_profile_list(dt_iop_module_t *self)
prof->type = DT_COLORSPACE_EMBEDDED_MATRIX;
g->image_profiles = g_list_append(g->image_profiles, prof);
prof->in_pos = ++pos;
+
+ if(!dt_is_valid_colormatrix(self->dev->image_storage.dng_forward_matrix[0]) && has_dng_look)
+ {
+ prof = calloc(1, sizeof(dt_colorspaces_color_profile_t));
+ g_strlcpy(prof->name, dt_colorspaces_get_name(DT_COLORSPACE_DNG_LOOK, ""),
+ sizeof(prof->name));
+ prof->type = DT_COLORSPACE_DNG_LOOK;
+ g->image_profiles = g_list_append(g->image_profiles, prof);
+ prof->in_pos = ++pos;
+ }
}
// use the DNG forward matrix if present -- gives the "as intended by
diff --git a/src/iop/dng_look.c b/src/iop/dng_look.c
new file mode 100644
index 00000000000..009acced102
--- /dev/null
+++ b/src/iop/dng_look.c
@@ -0,0 +1,277 @@
+/*
+ This file is part of darktable,
+ Copyright (C) 2026 darktable developers.
+
+ darktable is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ darktable is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with darktable. If not, see .
+*/
+
+#include "common/colorspaces_inline_conversions.h"
+#include "common/image_cache.h"
+#include "common/imagebuf.h"
+#include "common/iop_profile.h"
+#include "develop/develop.h"
+#include "develop/imageop.h"
+#include "gui/gtk.h"
+#include "iop/iop_api.h"
+
+DT_MODULE_INTROSPECTION(2, dt_iop_dng_look_params_t)
+
+typedef struct dt_iop_dng_look_params_t
+{
+ int reserved; // $DEFAULT: 0
+} dt_iop_dng_look_params_t;
+
+typedef struct dt_iop_dng_look_data_t
+{
+ float *hsm;
+ int hue_div, sat_div, val_div;
+} dt_iop_dng_look_data_t;
+
+const char *name()
+{
+ return _("dng look");
+}
+
+int flags()
+{
+ return IOP_FLAGS_HIDDEN | IOP_FLAGS_ONE_INSTANCE | IOP_FLAGS_ALLOW_TILING;
+}
+
+int default_group()
+{
+ return IOP_GROUP_COLOR | IOP_GROUP_TECHNICAL;
+}
+
+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;
+}
+
+void reload_defaults(dt_iop_module_t *self)
+{
+ // defaults load before colorin and this image's history (develop.c:2458)
+ // stay off here; enable the hidden pipe piece in commit_params once history is available
+ self->default_enabled = FALSE;
+}
+
+static gboolean _dng_look_profile_selected(const dt_develop_t *dev)
+{
+ const dt_iop_module_t *colorin = dt_iop_get_module_from_list(dev->iop, "colorin");
+ if(!colorin || !colorin->get_p || !colorin->default_params
+ || g_list_find_custom(dev->module_filter_out, "colorin", (GCompareFunc)g_strcmp0))
+ return FALSE;
+
+ const dt_iop_params_t *params = colorin->default_params;
+ gboolean enabled = colorin->default_enabled;
+ const GList *history = dev->history;
+ for(int i = 0; i < dev->history_end && history; i++, history = history->next)
+ {
+ const dt_dev_history_item_t *hist = history->data;
+ if(hist->module == colorin)
+ {
+ params = hist->params;
+ enabled = hist->enabled;
+ }
+ }
+
+ const dt_colorspaces_color_profile_type_t *type =
+ params ? colorin->get_p(params, "type") : NULL;
+ return enabled && type
+ && ((*type == DT_COLORSPACE_FORWARD_MATRIX
+ && dt_is_valid_colormatrix(dev->image_storage.dng_forward_matrix[0]))
+ || *type == DT_COLORSPACE_DNG_LOOK);
+}
+
+static void _lookup_hsm(const dt_iop_dng_look_data_t *d,
+ const dt_aligned_pixel_t hsv,
+ dt_aligned_pixel_t correction)
+{
+ const float h = (hsv[0] - floorf(hsv[0])) * d->hue_div;
+ const float s = CLIP(hsv[1]) * (d->sat_div - 1);
+ const float v = CLIP(hsv[2]) * (d->val_div - 1);
+ const int hi[2] = { MIN((int)h, d->hue_div - 1),
+ (MIN((int)h, d->hue_div - 1) + 1) % d->hue_div };
+ const int si[2] = { MIN((int)s, d->sat_div - 1),
+ MIN((int)s + 1, d->sat_div - 1) };
+ const int vi[2] = { MIN((int)v, d->val_div - 1),
+ MIN((int)v + 1, d->val_div - 1) };
+ const float hf = CLIP(h - hi[0]);
+ const float sf = CLIP(s - si[0]);
+ const float vf = CLIP(v - vi[0]);
+
+ correction[0] = correction[1] = correction[2] = 0.0f;
+ for(int z = 0; z < 2; z++)
+ for(int y = 0; y < 2; y++)
+ for(int x = 0; x < 2; x++)
+ {
+ // DNG stores saturation fastest, then hue, then value, with three floats per cell
+ const size_t index = 3 * (((size_t)vi[z] * d->hue_div + hi[y]) * d->sat_div + si[x]);
+ const float weight = (z ? vf : 1.0f - vf)
+ * (y ? hf : 1.0f - hf)
+ * (x ? sf : 1.0f - sf);
+ for(int c = 0; c < 3; c++)
+ correction[c] += weight * d->hsm[index + c];
+ }
+}
+
+void commit_params(dt_iop_module_t *self,
+ dt_iop_params_t *params,
+ dt_dev_pixelpipe_t *pipe,
+ dt_dev_pixelpipe_iop_t *piece)
+{
+ dt_iop_dng_look_data_t *d = piece->data;
+ g_free(d->hsm);
+ d->hsm = NULL;
+ d->hue_div = d->sat_div = d->val_div = 0;
+ piece->process_cl_ready = FALSE;
+
+ // image_storage borrows cache-owned tables; keep them locked until the pipe has its own copies
+ const dt_imgid_t imgid = self->dev->image_storage.id;
+ const gboolean cached = dt_is_valid_imgid(imgid);
+ const dt_image_t *img = cached ? dt_image_cache_get(imgid, 'r') : &self->dev->image_storage;
+ if(!img)
+ {
+ piece->enabled = FALSE;
+ return;
+ }
+
+ // require a look-capable profile selection, not just available metadata
+ // pipe sync holds history_mutex, and the last active history entry wins regardless of commit order
+ const gboolean enabled =
+ _dng_look_profile_selected(self->dev)
+ && img->profile_hsm_data != NULL
+ && !g_list_find_custom(self->dev->module_filter_out, self->op, (GCompareFunc)g_strcmp0);
+ // auto-enable defaults, but preserve an explicit disable recorded in history
+ piece->enabled = enabled && (params == self->default_params || piece->enabled);
+
+ if(img->profile_hsm_data && img->profile_hsm_hue_div >= 1
+ && img->profile_hsm_sat_div >= 2 && img->profile_hsm_val_div >= 1
+ && img->profile_hsm_hue_div <= G_MAXINT / 3 / img->profile_hsm_sat_div / img->profile_hsm_val_div)
+ {
+ const size_t count = (size_t)img->profile_hsm_hue_div * img->profile_hsm_sat_div
+ * img->profile_hsm_val_div * 3;
+ gboolean valid = TRUE;
+ for(size_t i = 0; i < count; i++)
+ if(!isfinite(img->profile_hsm_data[i])
+ || (i % 3 != 0 && img->profile_hsm_data[i] < 0.0f))
+ {
+ valid = FALSE;
+ break;
+ }
+
+ if(valid)
+ {
+ d->hsm = g_try_malloc_n(count, sizeof(float));
+ if(d->hsm)
+ {
+ memcpy(d->hsm, img->profile_hsm_data, count * sizeof(float));
+ d->hue_div = img->profile_hsm_hue_div;
+ d->sat_div = img->profile_hsm_sat_div;
+ d->val_div = img->profile_hsm_val_div;
+ }
+ }
+ }
+ if(cached)
+ dt_image_cache_read_release(img);
+}
+
+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)
+{
+ if(!dt_iop_have_required_input_format(4, self, piece->colors, ivoid, ovoid, roi_in, roi_out))
+ return;
+
+ const dt_iop_dng_look_data_t *d = piece->data;
+ // synch_top can change only colorin: never apply a stale look after switching away
+ // colorin requests full sync on look-profile transitions to re-evaluate enablement
+ const dt_iop_order_iccprofile_info_t *input_profile =
+ dt_ioppr_get_pipe_input_profile_info(piece->pipe);
+
+ // the DNG hue/saturation tables are specified for ProPhoto primaries!
+ const dt_iop_order_iccprofile_info_t *work_profile =
+ dt_ioppr_get_pipe_work_profile_info(piece->pipe);
+
+ if(!input_profile
+ || !work_profile
+ || (input_profile->type != DT_COLORSPACE_FORWARD_MATRIX
+ && input_profile->type != DT_COLORSPACE_DNG_LOOK)
+ || !d->hsm)
+ {
+ dt_iop_image_copy(ovoid, ivoid, (size_t)4 * roi_out->width * roi_out->height);
+ return;
+ }
+
+ DT_OMP_FOR(collapse(2))
+ for(size_t row = 0; row < roi_out->height; row++)
+ {
+ for(size_t col = 0; col < roi_out->width; col++)
+ {
+ const float *in = (const float *)ivoid + (size_t)4 * (roi_in->width * row + col);
+ float *out = (float *)ovoid + (size_t)4 * (roi_out->width * row + col);
+ dt_aligned_pixel_t rgb = { in[0], in[1], in[2], in[3] };
+ dt_aligned_pixel_t look_rgb;
+ dt_aligned_pixel_t XYZ;
+ dt_apply_transposed_color_matrix(rgb, work_profile->matrix_in_transposed, XYZ);
+ dt_XYZ_to_prophotorgb(XYZ, look_rgb);
+
+ dt_aligned_pixel_t hsv;
+ dt_aligned_pixel_t correction;
+ dt_RGB_2_HSV(look_rgb, hsv);
+ _lookup_hsm(d, hsv, correction);
+ hsv[0] += correction[0] / 360.0f;
+ hsv[0] -= floorf(hsv[0]);
+ hsv[1] = CLIP(hsv[1] * correction[1]);
+ dt_HSV_2_RGB(hsv, look_rgb);
+
+ // dt_aligned_pixel_t XYZ;
+ dt_prophotorgb_to_XYZ(look_rgb, XYZ);
+ dt_apply_transposed_color_matrix(XYZ, work_profile->matrix_out_transposed, rgb);
+ rgb[3] = in[3];
+ copy_pixel(out, rgb);
+ }
+ }
+}
+
+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_dng_look_data_t));
+}
+
+void cleanup_pipe(dt_iop_module_t *self,
+ dt_dev_pixelpipe_t *pipe,
+ dt_dev_pixelpipe_iop_t *piece)
+{
+ dt_iop_dng_look_data_t *d = piece->data;
+ g_free(d->hsm);
+ free(d);
+ piece->data = NULL;
+}
+
+void gui_init(dt_iop_module_t *self)
+{
+ self->widget = dt_gui_vbox(dt_ui_label_new(_("automatically derived from the embedded DNG profile")));
+}
+// clang-format off
+// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py
+// vim: shiftwidth=2 expandtab tabstop=2 cindent
+// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified;
+// clang-format on
diff --git a/src/tests/unittests/iop/CMakeLists.txt b/src/tests/unittests/iop/CMakeLists.txt
index 094a0924ae6..46e84241957 100644
--- a/src/tests/unittests/iop/CMakeLists.txt
+++ b/src/tests/unittests/iop/CMakeLists.txt
@@ -6,8 +6,18 @@ add_cmocka_test(test_lut3d_bounds
SOURCES test_lut3d_bounds.c
LINK_LIBRARIES lib_darktable cmocka)
+add_cmocka_test(test_dng_look
+ SOURCES test_dng_look.c
+ LINK_LIBRARIES lib_darktable cmocka)
+
+add_cmocka_test(test_colorin
+ SOURCES test_colorin.c
+ LINK_LIBRARIES lib_darktable cmocka)
+
# Windows: libs have to be copied next to the executable
if(WIN32)
_copy_required_library(test_filmicrgb lib_darktable)
_copy_required_library(test_lut3d_bounds lib_darktable)
+ _copy_required_library(test_dng_look lib_darktable)
+ _copy_required_library(test_colorin lib_darktable)
endif(WIN32)
diff --git a/src/tests/unittests/iop/test_colorin.c b/src/tests/unittests/iop/test_colorin.c
new file mode 100644
index 00000000000..21fc07c30fe
--- /dev/null
+++ b/src/tests/unittests/iop/test_colorin.c
@@ -0,0 +1,238 @@
+/*
+ This file is part of darktable,
+ Copyright (C) 2026 darktable developers.
+
+ darktable is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ darktable is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with darktable. If not, see .
+*/
+
+#include
+#include
+#include
+#include
+
+#define dt_image_cache_get __wrap_dt_image_cache_get
+#define dt_image_cache_read_release __wrap_dt_image_cache_read_release
+#define dt_bauhaus_combobox_clear __wrap_dt_bauhaus_combobox_clear
+#define dt_bauhaus_combobox_add __wrap_dt_bauhaus_combobox_add
+#define dt_bauhaus_combobox_get __wrap_dt_bauhaus_combobox_get
+#define dt_iop_request_focus __wrap_dt_iop_request_focus
+#define dt_dev_add_history_item __wrap_dt_dev_add_history_item
+#define dt_control_signal_raise __wrap_dt_control_signal_raise
+
+#include "common/iop_profile.c"
+#include "iop/colorin.c"
+
+#ifdef _WIN32
+#include "win/main_wrapper.h"
+#endif
+
+static dt_image_t image;
+static gboolean cache_locked;
+static int selected_profile;
+
+dt_image_t *__wrap_dt_image_cache_get(const dt_imgid_t imgid, const char mode)
+{
+ assert_int_equal(imgid, image.id);
+ assert_int_equal(mode, 'r');
+ assert_false(cache_locked);
+ cache_locked = TRUE;
+ return ℑ
+}
+
+void __wrap_dt_image_cache_read_release(const dt_image_t *img)
+{
+ assert_ptr_equal(img, &image);
+ assert_true(cache_locked);
+ cache_locked = FALSE;
+}
+
+void __wrap_dt_bauhaus_combobox_clear(GtkWidget *widget) {}
+void __wrap_dt_bauhaus_combobox_add(GtkWidget *widget, const char *text) {}
+int __wrap_dt_bauhaus_combobox_get(GtkWidget *widget) { return selected_profile; }
+void __wrap_dt_iop_request_focus(dt_iop_module_t *module) {}
+void __wrap_dt_control_signal_raise(const struct dt_control_signal_t *signals,
+ const dt_signal_t signal, ...) {}
+
+void __wrap_dt_dev_add_history_item(dt_develop_t *dev, dt_iop_module_t *module,
+ const gboolean enable)
+{
+ assert_ptr_equal(dev, module->dev);
+ assert_true(enable);
+ dev->full.pipe->changed |= DT_DEV_PIPE_TOP_CHANGED;
+ dev->preview_pipe->changed |= DT_DEV_PIPE_TOP_CHANGED;
+ dev->preview2.pipe->changed |= DT_DEV_PIPE_TOP_CHANGED;
+}
+
+static void test_dng_look_profile_list(void **state)
+{
+ dt_colorspaces_t profiles = { 0 };
+ darktable.color_profiles = &profiles;
+ dt_develop_t dev = { 0 };
+ dt_iop_colorin_gui_data_t gui = { 0 };
+ dt_iop_module_t module = { .dev = &dev, .gui_data = &gui };
+ float table[] = { 0.0f, 1.0f, 1.0f };
+ for(int d65 = 0; d65 < 2; d65++)
+ for(int forward = 0; forward < 2; forward++)
+ for(int data = 0; data < 2; data++)
+ {
+ dt_mark_colormatrix_invalid(&dev.image_storage.d65_color_matrix[0]);
+ dt_mark_colormatrix_invalid(&dev.image_storage.dng_forward_matrix[0]);
+ if(d65) dev.image_storage.d65_color_matrix[0] = 1.0f;
+ if(forward) dev.image_storage.dng_forward_matrix[0] = 1.0f;
+ image.profile_hsm_data = data & 1 ? table : NULL;
+ update_profile_list(&module);
+ assert_false(cache_locked);
+ int found = 0;
+ int pos = 0;
+ for(const GList *l = gui.image_profiles; l; l = l->next)
+ {
+ const dt_colorspaces_color_profile_t *profile = l->data;
+ assert_int_equal(profile->in_pos, pos++);
+ if(profile->type == DT_COLORSPACE_DNG_LOOK) found++;
+ }
+ assert_int_equal(found, d65 && !forward && data != 0);
+ assert_int_equal(gui.n_image_profiles, pos);
+ }
+ g_list_free_full(gui.image_profiles, free);
+ darktable.color_profiles = NULL;
+}
+
+static void test_embedded_matrix_commit(void **state)
+{
+ const float camera_matrix[9] =
+ { 3.2404542f, -1.5371385f, -0.4985314f,
+ -0.9692660f, 1.8760108f, 0.0415560f,
+ 0.0556434f, -0.2040259f, 1.0572252f };
+ cmsCIExyY whitepoint;
+ cmsWhitePointFromTemp(&whitepoint, 6504.0);
+ const cmsCIExyYTRIPLE primaries = { { 0.64, 0.33, 1.0 },
+ { 0.30, 0.60, 1.0 },
+ { 0.15, 0.06, 1.0 } };
+ cmsToneCurve *gamma = cmsBuildGamma(NULL, 1.0);
+ cmsToneCurve *curves[] = { gamma, gamma, gamma };
+ dt_colorspaces_color_profile_t lab = { .type = DT_COLORSPACE_LAB,
+ .profile = cmsCreateLab4Profile(NULL) };
+ dt_colorspaces_color_profile_t rec709 = { .type = DT_COLORSPACE_LIN_REC709,
+ .profile = cmsCreateRGBProfile(&whitepoint, &primaries, curves) };
+ cmsFreeToneCurve(gamma);
+ dt_colorspaces_t profiles = { 0 };
+ profiles.profiles = g_list_append(NULL, &lab);
+ profiles.profiles = g_list_append(profiles.profiles, &rec709);
+ darktable.color_profiles = &profiles;
+ dt_develop_t dev = { 0 };
+ dt_iop_module_t module = { .dev = &dev };
+ dt_dev_pixelpipe_t pipe = { 0 };
+ dt_dev_pixelpipe_iop_t piece = { .pipe = &pipe };
+ dt_iop_colorin_params_t params = { .type_work = DT_COLORSPACE_LIN_REC709 };
+ init_pipe(&module, &pipe, &piece);
+ dt_iop_colorin_data_t *d = piece.data;
+
+ for(int fallback = 0; fallback < 3; fallback++)
+ {
+ memcpy(image.d65_color_matrix, camera_matrix, sizeof(camera_matrix));
+ memset(pipe.image.adobe_XYZ_to_CAM, 0, sizeof(pipe.image.adobe_XYZ_to_CAM));
+ if(fallback) dt_mark_colormatrix_invalid(&image.d65_color_matrix[0]);
+ if(fallback == 1)
+ memcpy(pipe.image.adobe_XYZ_to_CAM, camera_matrix, sizeof(camera_matrix));
+ params.type = DT_COLORSPACE_EMBEDDED_MATRIX;
+ commit_params(&module, (dt_iop_params_t *)¶ms, &pipe, &piece);
+ assert_true(dt_is_valid_colormatrix(d->cmatrix[0][0]));
+ dt_colormatrix_t expected;
+ memcpy(expected, d->cmatrix, sizeof(expected));
+ params.type = DT_COLORSPACE_DNG_LOOK;
+ commit_params(&module, (dt_iop_params_t *)¶ms, &pipe, &piece);
+ assert_int_equal(d->type, DT_COLORSPACE_DNG_LOOK);
+ assert_int_equal(d->clear_input, fallback < 2);
+ assert_int_equal(pipe.input_profile_info->type, DT_COLORSPACE_DNG_LOOK);
+ assert_memory_equal(d->cmatrix, expected, sizeof(expected));
+ assert_memory_equal(pipe.input_profile_info->matrix_in, expected, sizeof(expected));
+ assert_true(dt_is_valid_colormatrix(pipe.input_profile_info->matrix_out[0][0]));
+ assert_false(cache_locked);
+ }
+
+ const float forward_matrix[9] =
+ { 0.4124564f, 0.3575761f, 0.1804375f,
+ 0.2126729f, 0.7151522f, 0.0721750f,
+ 0.0193339f, 0.1191920f, 0.9503041f };
+ memcpy(pipe.image.dng_forward_matrix, forward_matrix, sizeof(forward_matrix));
+ params.type = DT_COLORSPACE_FORWARD_MATRIX;
+ commit_params(&module, (dt_iop_params_t *)¶ms, &pipe, &piece);
+ assert_int_equal(pipe.input_profile_info->type, DT_COLORSPACE_FORWARD_MATRIX);
+ assert_memory_equal(pipe.input_profile_info->matrix_in, d->cmatrix, sizeof(d->cmatrix));
+ assert_true(dt_is_valid_colormatrix(pipe.input_profile_info->matrix_out[0][0]));
+
+ cleanup_pipe(&module, &pipe, &piece);
+ for(GList *l = dev.allprofile_info; l; l = l->next)
+ {
+ dt_ioppr_cleanup_profile_info(l->data);
+ dt_free_align(l->data);
+ }
+ g_list_free(dev.allprofile_info);
+ cmsCloseProfile(lab.profile);
+ cmsCloseProfile(rec709.profile);
+ g_list_free(profiles.profiles);
+ darktable.color_profiles = NULL;
+}
+
+static void test_profile_switch_resync(void **state)
+{
+ dt_gui_gtk_t gui = { 0 };
+ darktable.gui = &gui;
+ dt_dev_pixelpipe_t pipes[3] = { 0 };
+ dt_develop_t dev = { .preview_pipe = &pipes[1] };
+ dev.full.pipe = &pipes[0];
+ dev.preview2.pipe = &pipes[2];
+ darktable.develop = &dev;
+ dt_iop_colorin_params_t params = { 0 };
+ dt_colorspaces_color_profile_t profile = { .in_pos = 0 };
+ dt_iop_colorin_gui_data_t data = { .n_image_profiles = 1 };
+ data.image_profiles = g_list_append(NULL, &profile);
+ dt_iop_module_t module = { .dev = &dev, .params = ¶ms, .gui_data = &data };
+ const dt_colorspaces_color_profile_type_t types[] =
+ { DT_COLORSPACE_FORWARD_MATRIX, DT_COLORSPACE_DNG_LOOK,
+ DT_COLORSPACE_EMBEDDED_MATRIX, DT_COLORSPACE_LIN_REC709 };
+ for(int old = 0; old < (int)G_N_ELEMENTS(types); old++)
+ for(int next = 0; next < (int)G_N_ELEMENTS(types); next++)
+ {
+ params.type = types[old];
+ profile.type = types[next];
+ for(int i = 0; i < 3; i++) pipes[i].changed = DT_DEV_PIPE_UNCHANGED;
+ _profile_changed(NULL, &module);
+ assert_int_equal(params.type, profile.type);
+ const gboolean expected = (old < 2) != (next < 2);
+ for(int i = 0; i < 3; i++)
+ {
+ assert_true(pipes[i].changed & DT_DEV_PIPE_TOP_CHANGED);
+ assert_int_equal(!!(pipes[i].changed & DT_DEV_PIPE_SYNCH), expected);
+ }
+ }
+ g_list_free(data.image_profiles);
+ darktable.gui = NULL;
+ darktable.develop = NULL;
+}
+
+int main(int argc, char *argv[])
+{
+ const struct CMUnitTest tests[] = {
+ cmocka_unit_test(test_dng_look_profile_list),
+ cmocka_unit_test(test_embedded_matrix_commit),
+ cmocka_unit_test(test_profile_switch_resync),
+ };
+ return cmocka_run_group_tests(tests, NULL, NULL);
+}
+// clang-format off
+// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py
+// vim: shiftwidth=2 expandtab tabstop=2 cindent
+// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified;
+// clang-format on
diff --git a/src/tests/unittests/iop/test_dng_look.c b/src/tests/unittests/iop/test_dng_look.c
new file mode 100644
index 00000000000..4de5a81dcc2
--- /dev/null
+++ b/src/tests/unittests/iop/test_dng_look.c
@@ -0,0 +1,609 @@
+/*
+ This file is part of darktable,
+ Copyright (C) 2026 darktable developers.
+
+ darktable is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ darktable is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with darktable. If not, see .
+*/
+
+#include
+#include
+#include
+
+#include
+
+#define dt_image_cache_get __wrap_dt_image_cache_get
+#define dt_image_cache_read_release __wrap_dt_image_cache_read_release
+
+#include "iop/dng_look.c"
+#include "common/iop_order.h"
+#include "common/matrices.h"
+
+#undef dt_image_cache_get
+#undef dt_image_cache_read_release
+
+#ifdef _WIN32
+#include "win/main_wrapper.h"
+#endif
+
+static dt_image_t *cached_image;
+static gboolean cache_locked;
+
+dt_image_t *__wrap_dt_image_cache_get(const dt_imgid_t imgid, const char mode)
+{
+ assert_non_null(cached_image);
+ assert_int_equal(imgid, cached_image->id);
+ assert_int_equal(mode, 'r');
+ assert_false(cache_locked);
+ cache_locked = TRUE;
+ return cached_image;
+}
+
+void __wrap_dt_image_cache_read_release(const dt_image_t *img)
+{
+ assert_ptr_equal(img, cached_image);
+ assert_true(cache_locked);
+ cache_locked = FALSE;
+}
+
+static void test_interpolation_and_hue_wrap(void **state)
+{
+ float table[24];
+ for(int v = 0; v < 2; v++)
+ for(int h = 0; h < 2; h++)
+ for(int s = 0; s < 2; s++)
+ {
+ const int index = 3 * ((v * 2 + h) * 2 + s);
+ table[index] = 100.0f * v + 10.0f * h + s;
+ table[index + 1] = 1.0f + s;
+ table[index + 2] = 1.0f + v;
+ }
+ const dt_iop_dng_look_data_t d = { .hsm = table, .hue_div = 2, .sat_div = 2, .val_div = 2 };
+ dt_aligned_pixel_t hsv = { 0.25f, 0.5f, 0.5f, 0.0f };
+ dt_aligned_pixel_t correction;
+ _lookup_hsm(&d, hsv, correction);
+ assert_float_equal(correction[0], 55.5f, 1e-6f);
+ assert_float_equal(correction[1], 1.5f, 1e-6f);
+ assert_float_equal(correction[2], 1.5f, 1e-6f);
+
+ hsv[0] = 0.875f;
+ _lookup_hsm(&d, hsv, correction);
+ assert_float_equal(correction[0], 53.0f, 1e-6f);
+ hsv[0] = -0.125f;
+ _lookup_hsm(&d, hsv, correction);
+ assert_float_equal(correction[0], 53.0f, 1e-6f);
+
+ hsv[0] = 1.0f;
+ hsv[1] = 2.0f;
+ hsv[2] = -1.0f;
+ _lookup_hsm(&d, hsv, correction);
+ assert_float_equal(correction[0], 1.0f, 1e-6f);
+}
+
+static void test_full_saturation_and_unchanged_value(void **state)
+{
+ float table[] = { 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 1.0f };
+ dt_iop_dng_look_data_t d = { .hsm = table, .hue_div = 1, .sat_div = 2, .val_div = 1 };
+ dt_iop_module_t module = { 0 };
+ dt_iop_order_iccprofile_info_t profile = { .type = DT_COLORSPACE_FORWARD_MATRIX };
+ dt_iop_order_iccprofile_info_t work_profile = { 0 };
+ memcpy(work_profile.matrix_in_transposed, prophotorgb_to_xyz_transpose,
+ sizeof(work_profile.matrix_in_transposed));
+ assert_int_equal(mat3SSEinv(work_profile.matrix_out_transposed,
+ work_profile.matrix_in_transposed), 0);
+ dt_dev_pixelpipe_t pipe = { .input_profile_info = &profile,
+ .work_profile_info = &work_profile };
+ dt_dev_pixelpipe_iop_t piece = { .data = &d, .colors = 4, .pipe = &pipe };
+ const dt_iop_roi_t roi = { .width = 1, .height = 1, .scale = 1.0f };
+ const float DT_ALIGNED_ARRAY colored[] = { 0.5f, 0.25f, 0.25f, 1.0f };
+ const float DT_ALIGNED_ARRAY gray[] = { 0.4f, 0.4f, 0.4f, 1.0f };
+ float DT_ALIGNED_ARRAY out[4];
+ const float sat_scales[] = { -0.1f, 1.0f, 1.3f, 1.31f, 2.0f };
+ const float expected_green[] = { 0.5f, 0.25f, 0.175f, 0.1725f, 0.0f };
+ const float val_scales[] = { -0.1f, 1.0f, 1.2f, 1.21f, 2.0f };
+ for(int i = 0; i < 5; i++)
+ {
+ table[1] = table[4] = sat_scales[i];
+ table[2] = table[5] = 1.0f;
+ process(&module, &piece, colored, out, &roi, &roi);
+ assert_float_equal(out[1], expected_green[i], 1e-6f);
+ table[1] = table[4] = 1.0f;
+ table[2] = table[5] = val_scales[i];
+ process(&module, &piece, gray, out, &roi, &roi);
+ assert_float_equal(out[0], gray[0], 1e-6f);
+ }
+ const float DT_ALIGNED_ARRAY bright[] = { 0.9f, 0.9f, 0.9f, 1.0f };
+ process(&module, &piece, bright, out, &roi, &roi);
+ assert_float_equal(out[0], bright[0], 1e-6f);
+}
+
+static void test_processing(void **state)
+{
+ float table[] = { 120.0f, 0.5f, 0.5f, 120.0f, 0.5f, 0.5f };
+ dt_iop_dng_look_data_t d = { .hsm = table, .hue_div = 1, .sat_div = 2, .val_div = 1 };
+ dt_iop_module_t module = { 0 };
+ dt_iop_order_iccprofile_info_t profile = { .type = DT_COLORSPACE_FORWARD_MATRIX };
+ dt_iop_order_iccprofile_info_t work_profile = { 0 };
+ memcpy(work_profile.matrix_in_transposed, prophotorgb_to_xyz_transpose,
+ sizeof(work_profile.matrix_in_transposed));
+ assert_int_equal(mat3SSEinv(work_profile.matrix_out_transposed,
+ work_profile.matrix_in_transposed), 0);
+ dt_dev_pixelpipe_t pipe = { .input_profile_info = &profile,
+ .work_profile_info = &work_profile };
+ dt_dev_pixelpipe_iop_t piece = { .data = &d, .colors = 4, .pipe = &pipe };
+ const dt_iop_roi_t roi = { .width = 1, .height = 2, .scale = 1.0f };
+ const float DT_ALIGNED_ARRAY in[] = { 1.0f, 0.0f, 0.0f, 0.37f, 0.4f, 0.4f, 0.4f, 0.81f };
+ float DT_ALIGNED_ARRAY out[8];
+ process(&module, &piece, in, out, &roi, &roi);
+ assert_float_equal(out[0], 0.5f, 1e-6f);
+ assert_float_equal(out[1], 1.0f, 1e-6f);
+ assert_float_equal(out[3], in[3], 1e-6f);
+ assert_float_equal(out[4], 0.4f, 1e-6f);
+ assert_float_equal(out[7], in[7], 1e-6f);
+
+ profile.type = DT_COLORSPACE_DNG_LOOK;
+ process(&module, &piece, in, out, &roi, &roi);
+ assert_float_equal(out[0], 0.5f, 1e-6f);
+ assert_float_equal(out[1], 1.0f, 1e-6f);
+ assert_float_equal(out[3], in[3], 1e-6f);
+
+ profile.type = DT_COLORSPACE_EMBEDDED_MATRIX;
+ process(&module, &piece, in, out, &roi, &roi);
+ assert_memory_equal(out, in, sizeof(in));
+ pipe.input_profile_info = NULL;
+ process(&module, &piece, in, out, &roi, &roi);
+ assert_memory_equal(out, in, sizeof(in));
+ profile.type = DT_COLORSPACE_FORWARD_MATRIX;
+ pipe.input_profile_info = &profile;
+
+ d.hsm = NULL;
+ process(&module, &piece, in, out, &roi, &roi);
+ assert_memory_equal(out, in, sizeof(in));
+}
+
+static void _init_work_profile(dt_iop_order_iccprofile_info_t *work_profile,
+ const cmsCIExyY *whitepoint,
+ const cmsCIExyYTRIPLE *primaries)
+{
+ cmsToneCurve *gamma = cmsBuildGamma(NULL, 1.0);
+ assert_non_null(gamma);
+ cmsToneCurve *curves[] = { gamma, gamma, gamma };
+ cmsHPROFILE profile = cmsCreateRGBProfile(whitepoint, primaries, curves);
+ cmsFreeToneCurve(gamma);
+ assert_non_null(profile);
+ const cmsTagSignature tags[] = { cmsSigRedColorantTag, cmsSigGreenColorantTag,
+ cmsSigBlueColorantTag };
+ for(int c = 0; c < 3; c++)
+ {
+ const cmsCIEXYZ *colorant = cmsReadTag(profile, tags[c]);
+ assert_non_null(colorant);
+ work_profile->matrix_in_transposed[c][0] = colorant->X;
+ work_profile->matrix_in_transposed[c][1] = colorant->Y;
+ work_profile->matrix_in_transposed[c][2] = colorant->Z;
+ }
+ assert_int_equal(mat3SSEinv(work_profile->matrix_out_transposed,
+ work_profile->matrix_in_transposed), 0);
+ cmsCloseProfile(profile);
+}
+
+static void _apply_direct_look(const dt_iop_dng_look_data_t *d,
+ const dt_aligned_pixel_t rgb,
+ dt_aligned_pixel_t out)
+{
+ dt_aligned_pixel_t hsv;
+ dt_aligned_pixel_t correction;
+ dt_RGB_2_HSV(rgb, hsv);
+ _lookup_hsm(d, hsv, correction);
+ hsv[0] += correction[0] / 360.0f;
+ hsv[0] -= floorf(hsv[0]);
+ hsv[1] = CLIP(hsv[1] * correction[1]);
+ dt_HSV_2_RGB(hsv, out);
+ out[3] = rgb[3];
+}
+
+static void test_work_profile_independence(void **state)
+{
+ float table[36 * 3 * 2 * 3];
+ for(int v = 0; v < 2; v++)
+ for(int h = 0; h < 36; h++)
+ for(int s = 0; s < 3; s++)
+ {
+ const int index = 3 * ((v * 36 + h) * 3 + s);
+ table[index] = 2.0f * h + 3.0f * s + 5.0f * v;
+ table[index + 1] = 0.5f + 0.01f * h + 0.1f * s + 0.05f * v;
+ table[index + 2] = 2.0f;
+ }
+ dt_iop_dng_look_data_t d = { .hsm = table, .hue_div = 36, .sat_div = 3, .val_div = 2 };
+ dt_iop_module_t module = { 0 };
+ dt_iop_order_iccprofile_info_t input_profile = { .type = DT_COLORSPACE_FORWARD_MATRIX };
+ dt_dev_pixelpipe_t pipe = { .input_profile_info = &input_profile };
+ dt_dev_pixelpipe_iop_t piece = { .data = &d, .colors = 4, .pipe = &pipe };
+ const dt_iop_roi_t roi = { .width = 1, .height = 1, .scale = 1.0f };
+ const cmsCIExyY whitepoints[] = { { 0.3127, 0.3290, 1.0 }, { 0.3127, 0.3290, 1.0 },
+ { 0.3457, 0.3585, 1.0 } };
+ const cmsCIExyYTRIPLE primaries[] = {
+ { { 0.64, 0.33, 1.0 }, { 0.30, 0.60, 1.0 }, { 0.15, 0.06, 1.0 } },
+ { { 0.708, 0.292, 1.0 }, { 0.170, 0.797, 1.0 }, { 0.131, 0.046, 1.0 } },
+ { { 0.7347, 0.2653, 1.0 }, { 0.1596, 0.8404, 1.0 }, { 0.0366, 0.0001, 1.0 } },
+ };
+ const dt_aligned_pixel_t stimuli[] = {
+ { 0.6f, 0.25f, 0.1f, 0.37f },
+ { 0.1f, 0.6f, 0.25f, 0.81f },
+ { 0.25f, 0.1f, 0.6f, 0.5f },
+ };
+ gboolean different_raw_bin = FALSE;
+ for(int sample = 0; sample < (int)G_N_ELEMENTS(stimuli); sample++)
+ {
+ dt_aligned_pixel_t XYZ, look_rgb, expected, reference_hsv;
+ dt_linearRGB_to_XYZ(stimuli[sample], XYZ);
+ dt_XYZ_to_prophotorgb(XYZ, look_rgb);
+ look_rgb[3] = stimuli[sample][3];
+ dt_RGB_2_HSV(look_rgb, reference_hsv);
+ _apply_direct_look(&d, look_rgb, expected);
+ for(int profile = 0; profile < (int)G_N_ELEMENTS(primaries); profile++)
+ {
+ dt_iop_order_iccprofile_info_t work_profile = { 0 };
+ _init_work_profile(&work_profile, &whitepoints[profile], &primaries[profile]);
+ pipe.work_profile_info = &work_profile;
+ dt_aligned_pixel_t in, out, raw_hsv, corrected_XYZ, corrected_rgb;
+ dt_apply_transposed_color_matrix(XYZ, work_profile.matrix_out_transposed, in);
+ in[3] = stimuli[sample][3];
+ dt_RGB_2_HSV(in, raw_hsv);
+ different_raw_bin |= (int)(raw_hsv[0] * d.hue_div)
+ != (int)(reference_hsv[0] * d.hue_div);
+ process(&module, &piece, in, out, &roi, &roi);
+ dt_apply_transposed_color_matrix(out, work_profile.matrix_in_transposed, corrected_XYZ);
+ dt_XYZ_to_prophotorgb(corrected_XYZ, corrected_rgb);
+ for_three_channels(c)
+ assert_float_equal(corrected_rgb[c], expected[c], 2e-6f);
+ assert_float_equal(out[3], in[3], 0.0f);
+ }
+ }
+ assert_true(different_raw_bin);
+}
+
+static void test_null_and_prophoto_work_profiles(void **state)
+{
+ float table[] = { 30.0f, 0.6f, 2.0f, 90.0f, 1.4f, 0.5f };
+ dt_iop_dng_look_data_t d = { .hsm = table, .hue_div = 1, .sat_div = 2, .val_div = 1 };
+ dt_iop_module_t module = { 0 };
+ dt_iop_order_iccprofile_info_t input_profile = { .type = DT_COLORSPACE_FORWARD_MATRIX };
+ dt_dev_pixelpipe_t pipe = { .input_profile_info = &input_profile };
+ dt_dev_pixelpipe_iop_t piece = { .data = &d, .colors = 4, .pipe = &pipe };
+ const dt_iop_roi_t roi = { .width = 1, .height = 1, .scale = 1.0f };
+ dt_iop_order_iccprofile_info_t work_profile = { 0 };
+ memcpy(work_profile.matrix_in_transposed, prophotorgb_to_xyz_transpose,
+ sizeof(work_profile.matrix_in_transposed));
+ assert_int_equal(mat3SSEinv(work_profile.matrix_out_transposed,
+ work_profile.matrix_in_transposed), 0);
+ const dt_aligned_pixel_t stimuli[] = {
+ { 0.6f, 0.25f, 0.1f, 0.37f }, { 0.1f, 0.6f, 0.25f, 0.81f },
+ { 0.25f, 0.1f, 0.6f, 0.5f }, { 0.4f, 0.4f, 0.4f, 1.0f },
+ { 0.0f, 0.0f, 0.0f, 0.0f },
+ };
+ for(int sample = 0; sample < (int)G_N_ELEMENTS(stimuli); sample++)
+ {
+ dt_aligned_pixel_t expected, out;
+ pipe.work_profile_info = NULL;
+ assert_null(dt_ioppr_get_pipe_work_profile_info(&pipe));
+ process(&module, &piece, stimuli[sample], out, &roi, &roi);
+ assert_memory_equal(out, stimuli[sample], sizeof(out));
+ _apply_direct_look(&d, stimuli[sample], expected);
+ pipe.work_profile_info = &work_profile;
+ process(&module, &piece, stimuli[sample], out, &roi, &roi);
+ for_each_channel(c)
+ assert_float_equal(out[c], expected[c], 2e-6f);
+ assert_float_equal(out[3], expected[3], 0.0f);
+ }
+}
+
+static void test_commit_and_defaults(void **state)
+{
+ dt_develop_t dev = { 0 };
+ dt_iop_module_t module = { .dev = &dev };
+ dt_dev_pixelpipe_t pipe = { 0 };
+ dt_dev_pixelpipe_iop_t piece = { .colors = 4 };
+ init_pipe(&module, &pipe, &piece);
+ dt_iop_dng_look_data_t *d = piece.data;
+ reload_defaults(&module);
+ assert_false(module.default_enabled);
+
+ float table[] = { 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 1.0f };
+ dev.image_storage.profile_hsm_data = table;
+ dev.image_storage.profile_hsm_hue_div = 1;
+ dev.image_storage.profile_hsm_sat_div = 2;
+ dev.image_storage.profile_hsm_val_div = 1;
+ reload_defaults(&module);
+ assert_false(module.default_enabled);
+ commit_params(&module, NULL, &pipe, &piece);
+ assert_memory_equal(d->hsm, table, sizeof(table));
+ assert_false(piece.process_cl_ready);
+ table[0] = 60.0f;
+ assert_float_equal(d->hsm[0], 0.0f, 1e-6f);
+
+ dev.image_storage.profile_hsm_hue_div = G_MAXINT;
+ commit_params(&module, NULL, &pipe, &piece);
+ assert_null(d->hsm);
+ dev.image_storage.profile_hsm_hue_div = 1;
+ table[1] = NAN;
+ commit_params(&module, NULL, &pipe, &piece);
+ assert_null(d->hsm);
+ table[1] = -1.0f;
+ commit_params(&module, NULL, &pipe, &piece);
+ assert_null(d->hsm);
+
+ dev.image_storage.profile_hsm_data = NULL;
+ commit_params(&module, NULL, &pipe, &piece);
+ cleanup_pipe(&module, &pipe, &piece);
+}
+
+static void *_colorin_get_p(const void *params, const char *name)
+{
+ assert_string_equal(name, "type");
+ return (void *)params;
+}
+
+static void test_automatic_enablement(void **state)
+{
+ dt_colorspaces_color_profile_type_t type = DT_COLORSPACE_FORWARD_MATRIX;
+ dt_colorspaces_color_profile_type_t stale_type = DT_COLORSPACE_EMBEDDED_MATRIX;
+ dt_iop_module_so_t colorin_so = { .op = "colorin" };
+ dt_iop_module_t colorin = { .so = &colorin_so, .get_p = _colorin_get_p,
+ .default_params = &type, .params = &stale_type,
+ .default_enabled = TRUE };
+ dt_develop_t dev = { 0 };
+ dev.iop = g_list_append(NULL, &colorin);
+ dt_iop_dng_look_params_t defaults = { 0 };
+ dt_iop_dng_look_params_t history_params = { 0 };
+ dt_iop_module_t module = { .op = "dng_look", .dev = &dev, .default_params = &defaults };
+ dt_dev_pixelpipe_t pipe = { 0 };
+ dt_dev_pixelpipe_iop_t piece = { 0 };
+ init_pipe(&module, &pipe, &piece);
+
+ float table[] = { 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 1.0f };
+ dev.image_storage.profile_hsm_hue_div = 1;
+ dev.image_storage.profile_hsm_sat_div = 2;
+ dev.image_storage.profile_hsm_val_div = 1;
+
+ const dt_colorspaces_color_profile_type_t types[] =
+ { DT_COLORSPACE_FORWARD_MATRIX, DT_COLORSPACE_DNG_LOOK,
+ DT_COLORSPACE_EMBEDDED_MATRIX, DT_COLORSPACE_LIN_REC709 };
+ for(int matrix = 0; matrix < 2; matrix++)
+ for(int profile = 0; profile < (int)G_N_ELEMENTS(types); profile++)
+ for(int data = 0; data < 2; data++)
+ {
+ if(matrix)
+ dev.image_storage.dng_forward_matrix[0] = 1.0f;
+ else
+ dt_mark_colormatrix_invalid(&dev.image_storage.dng_forward_matrix[0]);
+ type = types[profile];
+ stale_type = type == DT_COLORSPACE_EMBEDDED_MATRIX
+ ? DT_COLORSPACE_FORWARD_MATRIX : DT_COLORSPACE_EMBEDDED_MATRIX;
+ dev.image_storage.profile_hsm_data = data & 1 ? table : NULL;
+ module.default_enabled = TRUE;
+ reload_defaults(&module);
+ commit_params(&module, module.default_params, &pipe, &piece);
+ assert_int_equal(piece.enabled,
+ ((matrix && type == DT_COLORSPACE_FORWARD_MATRIX)
+ || type == DT_COLORSPACE_DNG_LOOK) && data != 0);
+ }
+
+ type = DT_COLORSPACE_FORWARD_MATRIX;
+ dt_colorspaces_color_profile_type_t embedded = DT_COLORSPACE_EMBEDDED_MATRIX;
+ dt_dev_history_item_t hist1 = { .module = &colorin, .params = &embedded, .enabled = TRUE };
+ dt_dev_history_item_t hist2 = { .module = &colorin, .params = &type, .enabled = TRUE };
+ dev.history = g_list_append(NULL, &hist1);
+ dev.history = g_list_append(dev.history, &hist2);
+ dev.history_end = 1;
+ commit_params(&module, module.default_params, &pipe, &piece);
+ assert_false(piece.enabled);
+ dev.history_end = 2;
+ commit_params(&module, module.default_params, &pipe, &piece);
+ assert_true(piece.enabled);
+ hist2.enabled = FALSE;
+ commit_params(&module, module.default_params, &pipe, &piece);
+ assert_false(piece.enabled);
+ hist2.enabled = TRUE;
+
+ piece.enabled = FALSE;
+ commit_params(&module, &history_params, &pipe, &piece);
+ assert_false(piece.enabled);
+ piece.enabled = TRUE;
+ commit_params(&module, &history_params, &pipe, &piece);
+ assert_true(piece.enabled);
+
+ dev.module_filter_out = g_list_append(NULL, "exposure");
+ commit_params(&module, module.default_params, &pipe, &piece);
+ assert_true(piece.enabled);
+ dev.module_filter_out = g_list_append(dev.module_filter_out, "dng_look");
+ commit_params(&module, module.default_params, &pipe, &piece);
+ assert_false(piece.enabled);
+ g_list_free(dev.module_filter_out);
+ dev.module_filter_out = g_list_append(NULL, "colorin");
+ commit_params(&module, module.default_params, &pipe, &piece);
+ assert_false(piece.enabled);
+ g_list_free(dev.module_filter_out);
+ dev.module_filter_out = NULL;
+
+ colorin.get_p = NULL;
+ commit_params(&module, module.default_params, &pipe, &piece);
+ assert_false(piece.enabled);
+ g_list_free(dev.iop);
+ dev.iop = NULL;
+ commit_params(&module, module.default_params, &pipe, &piece);
+ assert_false(piece.enabled);
+
+ assert_true(flags() & IOP_FLAGS_HIDDEN);
+ cleanup_pipe(&module, &pipe, &piece);
+ g_list_free(dev.history);
+}
+
+static void test_profile_switch_tracking(void **state)
+{
+ dt_colorspaces_color_profile_type_t default_type = DT_COLORSPACE_EMBEDDED_MATRIX;
+ dt_colorspaces_color_profile_type_t type = DT_COLORSPACE_FORWARD_MATRIX;
+ dt_iop_module_so_t colorin_so = { .op = "colorin" };
+ dt_iop_module_t colorin = { .so = &colorin_so, .get_p = _colorin_get_p,
+ .default_params = &default_type, .default_enabled = TRUE };
+ dt_develop_t dev = { 0 };
+ dev.iop = g_list_append(NULL, &colorin);
+ dt_iop_dng_look_params_t defaults = { 0 };
+ dt_iop_dng_look_params_t history_params = { 0 };
+ dt_iop_module_t module = { .op = "dng_look", .dev = &dev, .default_params = &defaults };
+ dt_dev_pixelpipe_t pipe = { 0 };
+ dt_dev_pixelpipe_iop_t piece = { 0 };
+ init_pipe(&module, &pipe, &piece);
+ reload_defaults(&module);
+
+ float table[] = { 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 1.0f };
+ dev.image_storage.profile_hsm_hue_div = 1;
+ dev.image_storage.profile_hsm_sat_div = 2;
+ dev.image_storage.profile_hsm_val_div = 1;
+
+ // the combobox rewrites colorin's top history item in place
+ dt_dev_history_item_t colorin_hist = { .module = &colorin, .params = &type, .enabled = TRUE };
+ dev.history = g_list_append(NULL, &colorin_hist);
+ dev.history_end = 1;
+ dt_dev_history_item_t look_hist = { .module = &module, .params = &history_params, .enabled = TRUE };
+ const dt_colorspaces_color_profile_type_t switches[] =
+ { DT_COLORSPACE_FORWARD_MATRIX, DT_COLORSPACE_EMBEDDED_MATRIX, DT_COLORSPACE_DNG_LOOK,
+ DT_COLORSPACE_STANDARD_MATRIX, DT_COLORSPACE_DNG_LOOK, DT_COLORSPACE_FORWARD_MATRIX,
+ DT_COLORSPACE_DNG_LOOK, DT_COLORSPACE_EMBEDDED_MATRIX, DT_COLORSPACE_FORWARD_MATRIX };
+
+ for(int own_history = 0; own_history < 2; own_history++)
+ {
+ if(own_history)
+ {
+ dev.history = g_list_append(dev.history, &look_hist);
+ dev.history_end = 2;
+ }
+ for(int matrix = 0; matrix < 2; matrix++)
+ {
+ if(matrix)
+ dev.image_storage.dng_forward_matrix[0] = 1.0f;
+ else
+ dt_mark_colormatrix_invalid(&dev.image_storage.dng_forward_matrix[0]);
+ for(int i = 0; i < (int)G_N_ELEMENTS(switches); i++)
+ {
+ type = switches[i];
+ const gboolean expected = (matrix && type == DT_COLORSPACE_FORWARD_MATRIX)
+ || type == DT_COLORSPACE_DNG_LOOK;
+ dt_iop_params_t *params = own_history ? look_hist.params : module.default_params;
+
+ // missing data disables the piece, but a full sync must recover once data is present
+ dev.image_storage.profile_hsm_data = NULL;
+ piece.enabled = own_history ? look_hist.enabled : module.default_enabled;
+ commit_params(&module, params, &pipe, &piece);
+ assert_false(piece.enabled);
+ dev.image_storage.profile_hsm_data = table;
+ for(int previous_enabled = 0; previous_enabled < 2; previous_enabled++)
+ {
+ piece.enabled = previous_enabled;
+ // history replay restores the user's enabled flag before committing the piece
+ if(own_history) piece.enabled = look_hist.enabled;
+ commit_params(&module, params, &pipe, &piece);
+ assert_int_equal(piece.enabled, expected);
+ commit_params(&module, params, &pipe, &piece);
+ assert_int_equal(piece.enabled, expected);
+ }
+ }
+ }
+ }
+
+ // an explicit disable in the look's history wins for both look-capable profiles
+ dev.image_storage.dng_forward_matrix[0] = 1.0f;
+ for(int i = 0; i < 2; i++)
+ {
+ type = i ? DT_COLORSPACE_DNG_LOOK : DT_COLORSPACE_FORWARD_MATRIX;
+ for(int enabled = 0; enabled < 2; enabled++)
+ {
+ look_hist.enabled = enabled;
+ piece.enabled = look_hist.enabled;
+ commit_params(&module, look_hist.params, &pipe, &piece);
+ assert_int_equal(piece.enabled, enabled);
+ }
+ }
+
+ cleanup_pipe(&module, &pipe, &piece);
+ g_list_free(dev.history);
+ g_list_free(dev.iop);
+}
+
+static void test_module_order(void **state)
+{
+ for(int version = DT_IOP_ORDER_LEGACY; version < DT_IOP_ORDER_LAST; version++)
+ {
+ GList *list = dt_ioppr_get_iop_order_list_version(version);
+ gboolean found = FALSE;
+ for(const GList *l = list; l; l = l->next)
+ {
+ const dt_iop_order_entry_t *entry = l->data;
+ if(!strcmp(entry->operation, "colorin"))
+ {
+ assert_non_null(l->next);
+ entry = l->next->data;
+ assert_string_equal(entry->operation, "dng_look");
+ found = TRUE;
+ break;
+ }
+ }
+ assert_true(found);
+ g_list_free_full(list, free);
+ }
+}
+
+static void test_cache_snapshot(void **state)
+{
+ float table[] = { 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 1.0f };
+ dt_image_t image = { .id = 42, .profile_hsm_data = table,
+ .profile_hsm_hue_div = 1, .profile_hsm_sat_div = 2, .profile_hsm_val_div = 1 };
+ dt_develop_t dev = { 0 };
+ dev.image_storage.id = image.id;
+ dt_iop_dng_look_params_t params = { 0 };
+ dt_iop_module_t module = { .dev = &dev };
+ dt_dev_pixelpipe_t pipe = { 0 };
+ dt_dev_pixelpipe_iop_t piece = { .colors = 4 };
+ cached_image = ℑ
+ cache_locked = FALSE;
+ init_pipe(&module, &pipe, &piece);
+ commit_params(&module, ¶ms, &pipe, &piece);
+ dt_iop_dng_look_data_t *d = piece.data;
+ assert_false(cache_locked);
+ assert_memory_equal(d->hsm, table, sizeof(table));
+ table[0] = 90.0f;
+ assert_float_equal(d->hsm[0], 0.0f, 1e-6f);
+ cleanup_pipe(&module, &pipe, &piece);
+ cached_image = NULL;
+}
+
+int main(int argc, char *argv[])
+{
+ const struct CMUnitTest tests[] = {
+ cmocka_unit_test(test_interpolation_and_hue_wrap),
+ cmocka_unit_test(test_full_saturation_and_unchanged_value),
+ cmocka_unit_test(test_processing),
+ cmocka_unit_test(test_work_profile_independence),
+ cmocka_unit_test(test_null_and_prophoto_work_profiles),
+ cmocka_unit_test(test_commit_and_defaults),
+ cmocka_unit_test(test_automatic_enablement),
+ cmocka_unit_test(test_profile_switch_tracking),
+ cmocka_unit_test(test_module_order),
+ cmocka_unit_test(test_cache_snapshot),
+ };
+ return cmocka_run_group_tests(tests, NULL, NULL);
+}
+// clang-format off
+// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py
+// vim: shiftwidth=2 expandtab tabstop=2 cindent
+// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified;
+// clang-format on