Skip to content

About

Create LUT based on pairs of tif image to match color of two cameras

Resources

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

Color-Match-LUT

Take shots of color checker and create LUT based on pairs of tif image to match color of two cameras. I used this to match my Nikon Z6iii to Hasselbald 907x 50c.

Usage

python generate.py SOURCE TARGET [method_flag] [options]

SOURCE is the camera you want to correct and TARGET is the camera whose look you want to match. Each one can be a single image or a folder of images, so two photos of the same Calibrite (formerly X-Rite) ColorChecker are enough:

python generate.py z6iii_chart.tif x907_chart.tif
python generate.py shots/sony/ shots/fuji/ --compare --output-dir luts/

Supported charts, selected with --chart:

--chart Calibrite chart
classic (default) ColorChecker Classic 24: Classic, Mini, XL, Mega, and the Classic side of Passport Photo 2, Passport Duo and Passport Video 2
nano ColorChecker Classic Nano
sg ColorChecker Digital SG (140 patches)

Only one chart per photo is used, and it should fill a good part of the frame. The other targets on the Passport (creative enhancement, white balance) are not detected; the grey card is still useful for setting white balance in camera. Detection of the Nano and SG presets has only been tested on synthetic images.

When detection fails: corners and other charts

Automatic detection can miss a chart that is strongly tilted, small in the frame, inside an open Passport or on a busy background. For those images, give the centres of the chart's four corner patches instead, in this order:

  1. the first patch (brown on a ColorChecker Classic),
  2. the last patch of the first row,
  3. the last patch,
  4. the first patch of the last row.

The patches in between are found from a perspective transform, so tilted charts work. Corners go in a JSON file, keyed by image path relative to that file:

{
  "iphone/IMG_0001.heic": [[812, 604], [2410, 588], [2436, 1662], [790, 1650]],
  "x907/B0001234.tif":    [[1020, 911], [4630, 940], [4610, 3330], [1001, 3301]]
}
python generate.py iphone/ x907/ --corners corners.json

Images listed in the file are sampled from their corners; the others are still detected automatically. Coordinates are pixels of the image as the tool reads it. A viewer that applies the photo's rotation tag may show it turned, so the easiest way to get them is --pick-corners corners.json: it shows each image that has no corners yet, records four clicks, saves them, and continues. It needs a display (on a headless machine, set DISPLAY to a running X or VNC desktop).

The patch-order check still applies, so corners clicked in the wrong order are reported as "check the corner order". Clicks need not be exact: in tests, corners up to about a tenth of a patch away from the centre gave the same patch values. If clicks are far enough off that a patch's samples mix two colours, the pair is skipped with a message asking to pick again.

Charts without built-in support (for example a SpyderCheckr or a printed chart) work with --grid ROWSxCOLS and corners for every image. Without reference values, the order check only compares the two cameras, and exposure or white balance matching uses the patches that are near-neutral in the source image. Only uniform grids are supported, so not the Passport's creative-enhancement side.

Folders are sorted by filename and paired by position: the n-th SOURCE image must show the same chart, scene and lighting as the n-th TARGET image. The tool prints the pairing before it starts. Supported files are .tif, .tiff, .png, .jpg, .jpeg, .exr and HEIF (.heic, .heif, .hif, read at full bit depth). Raw files (.NEF, .3FR, .ARW, ProRAW .DNG, ...) must be exported first.

Colour profiles

The LUT works in the colour space of the SOURCE images, because that is what an editor feeds it when you edit SOURCE photos: its input is a SOURCE pixel value and its output is the matching TARGET pixel value in the same space.

  • Embedded colour profiles are read: ICC profiles in TIFF, PNG, JPEG and HEIF, and the nclx colour tag that HEIF files may use instead. A file without an ICC profile whose EXIF says sRGB (ColorSpace 1, as in most in-camera JPEGs) is read as sRGB, and one marked uncalibrated with the Adobe RGB interoperability index (R03) as Adobe RGB; the run prints "(EXIF)" for these. Only matrix/TRC ICC profiles are supported (sRGB, Display P3, Adobe RGB, ProPhoto and the usual camera and editor exports); other profiles stop the run with an error.
  • TARGET patches are converted into the SOURCE space, so the two cameras can be exported in different spaces (for example an iPhone HEIC in Display P3 and a Phocus TIFF in Adobe RGB). --working-space chooses another LUT space.
  • If no image has a profile, nothing is converted, as in earlier versions; both cameras must then be exported in the same space. If only some images have one, the run stops until you pass --assume-space for the untagged ones.
  • HDR HEIF (PQ or HLG) is refused; export an SDR image. For an iPhone HDR photo the standard image is used and the HDR gain map is ignored.
  • The run prints each image's profile and the working space. The LUT title names the working space.

The original layout still works: a single folder with nikon/ (source) and hasselblad/ (target) subfolders writes nikon_to_hasselblad_<method>.cube into that folder.

python generate.py <base_folder_path> --no-display-patches

For method_flag, use one of the following flags (default is --rootpoly, fitted in linear light). The default changed from --standard on 2026-11-01: on the first real iPhone and Hasselblad 907X chart pair, --standard (fitted on encoded values, the old default) matched held-out patches worse than no LUT at all (3.7 against 3.0 ΔE2000), while --rootpoly in linear light improved them (2.6). That is one pair under one light, so compare methods with --compare on your own pairs. To get the old behaviour, pass --standard --fit-in encoded.

--standard   : Global linear transformation using Colour's matrix_colour_correction.
               (Computes a 3x3 matrix mapping source patches to target patches.)
--robust     : Robust affine transformation using RANSAC (3x4 matrix with intercepts).
               (Fits a separate linear model per channel and is robust to outliers.)
--nonlinear  : Nonlinear (polynomial) transformation using a degree-2 polynomial.
               (Captures nonlinearities in the color mapping.)
--piecewise  : Piecewise linear transformation that segments the data by luminance
               (shadows, midtones, highlights) and interpolates between separate matrices.
--loess      : LOESS (locally weighted linear regression) for local models.
               (Fits a local linear model for each new input using Gaussian weighting.)
--local      : Local linear models using clustering (e.g. k-means).
               (Clusters the data and fits separate linear models in each cluster.)
--rootpoly   : Root-polynomial matrix, degree 2, from camera_match.
               (A 3x3 matrix plus square-root cross terms; scales with exposure.)
--tetra      : Tetrahedral matrix after Steve Yedlin, from camera_match.
               (Moves the six primary and secondary hues independently. White
               always stays white, so it changes rendering, not white balance.)
--lut2d      : 2D LUT from camera_match.
               (Per-channel adjustments on a smoothed 2D lattice.)
--curves     : Per-channel tone curves fitted on the grey patches, then a 3x3
               matrix (a camera_match pipeline). The curves only rise, so
               repeated or noisy grey values are safe.
--compare    : Compare all methods and view their R², ΔE, exposure gains, and previews.

--fit-in linear (the default) fits each model on linear-light values (decoded with the working space's transfer function, or --encoding without colour profiles) and encodes its predictions again; --fit-in encoded fits on the stored values, as versions before 2026-11-01 did. Camera colour matrices act on linear light, so the matrix and curve methods (--standard, --piecewise, --rootpoly, --tetra, --curves) are expected to suit it. LOESS, local and robust fits were tuned on encoded values: in a synthetic test, --loess in linear light produced an out-of-order grey ramp. Compare both with --compare on held-out pairs.

--curves uses this project's own curve fitting instead of camera_match's CurvesInterpolation, which stops with an error when two patches share a value and passes exactly through noisy samples.

The camera_match models were written for linear-light data, which is how they are fitted by default. Its 3D lattice LUT, degree-3 root polynomial and RBF models are not offered: on chart-sized data the first gave about 10 ΔE on held-out pairs, the second extrapolated far outside [0, 1], and RBF needs a library (xalglib) that has no macOS build.

--mlp, --ridge, --kernel and --iterative were removed on 2026-10-22: they fitted chart data poorly or, for --iterative, duplicated --standard. Using one of them prints the suggested replacement.

Options:

--chart NAME           : classic (default), nano or sg; see the table above.
--grid ROWSxCOLS       : Any other uniform-grid chart; needs --corners for every image.
--corners JSON         : Corner-patch centres for images that are not detected
                         automatically; see "When detection fails".
--pick-corners JSON    : Click the corners of every image not yet in the file
                         (needs a display), save them, and use them.
-o, --output-dir DIR   : Where to write .cube files (default: current folder,
                         or the base folder in the legacy layout).
--source-label NAME    : Camera names used in the file name and LUT title
--target-label NAME      (default: folder name or file name).
--lut-size N           : LUT points per axis (default: 33).
--overwrite            : Replace existing .cube or preview files (otherwise the tool
                         stops). Input images are never replaced.
--normalize MODE       : How the target is rescaled to the source, using the chart's
                         grey patches, before fitting (the source is never changed):
                           exposure (default): one brightness gain;
                           white-balance: per-channel gains in linear light, so
                             the LUT changes colour rendering only and each photo
                             keeps its own white balance and exposure;
                           none: no rescaling.
--no-exposure-match    : Same as --normalize none.
--assume-space NAME    : Colour space of images without an embedded profile:
                         srgb, display-p3, adobe-rgb, prophoto, linear-srgb, linear-p3.
--working-space NAME   : Colour space the LUT works in (default: the SOURCE's).
--fit-in linear|encoded: Fit the models in linear light (default) or on encoded
                         values. The LUT maps encoded values either way.
--encoding srgb|linear : Transfer function for ΔE scores when no image has a
                         colour profile.
--no-display-patches   : Do not display the color checker detection figures.
--no-preview           : Do not display the final preview.
--save-preview FILE    : Save the final preview to a file (.png, .jpg, .tif,
                         .webp, .pdf or .svg; no extension saves a .png).

Output files are named <source>_to_<target>_<method>.cube. The preview is made by applying the written LUT to the first SOURCE image, next to the first TARGET image, so it shows what an editor will produce. With colour profiles, the preview panels are converted to sRGB for display.

Scores

Each method reports R² and CIEDE2000 ΔE on the calibration patches, measured by applying the written LUT (so LUT size, interpolation and clipping are included). With two or more image pairs it also builds a LUT without each pair and reports ΔE on the held-out pair, which is a better guide than training scores. With a single pair it holds out patches instead: each patch is scored by a LUT built without it (charts with more than 24 patches are split into 24 groups). That shows how a method handles colours it was not fitted on, but not other lighting or other photos, so shoot more pairs when you can. ΔE is computed in the working space (CIELAB relative to D50); without colour profiles it assumes sRGB, or linear sRGB with --encoding linear.

Patch scores cannot see the rest of the LUT, so each method also reports the range of its values before clipping, the share of entries clipped, and whether a grey ramp stays in order. A warning is printed when the values stray far outside [0, 1] or the grey ramp is out of order: such a LUT can look right on the chart and wrong on other colours. A chart only covers part of the colour space, so a LUT that adds saturation often clips its most saturated corners: on the first real iPhone pair a third of the RootPoly LUT was clipped, yet under 0.2% of the chart photo's pixels and almost none of an indoor scene's reached those entries. Check a photo with strongly saturated colours before relying on such a LUT.

Tests

python -m venv .venv
.venv/bin/python -m pip install -r requirements.txt pytest
.venv/bin/python -m pytest tests

The tests use synthetic ColorChecker images. They check detection, patch order, the LUT format and the CLI, not colour accuracy on real cameras.

Applying a LUT to photos

render.py applies a .cube LUT to photos and saves standard images to share:

python render.py luts/iphone_to_x907_curves.cube ~/Pictures/today/ -o shared/
python render.py look.cube IMG_0001.heic --format jpeg --output-space srgb
  • Colour space. The LUT's space is read from the title generate.py wrote (for example ... (Curves, Display P3)), or given with --lut-space. Each photo is converted into it using its embedded profile (--assume-space for photos without one), the LUT is applied, and the result is saved in --output-space (default: the LUT's space) with that profile embedded. If the LUT's space is unknown, the LUT is applied to the photo's values as they are and the photo's own profile is kept.
  • Formats. --format auto (default) keeps HEIF as HEIF and TIFF as TIFF and turns other inputs into JPEG; heif, jpeg and tiff force one. HEIF is 8-bit by default (--bit-depth 10 for 10-bit), JPEG uses full-resolution colour at quality 90 and above, TIFF is 16-bit. --quality sets HEIF and JPEG quality.
  • Strength. --strength 0.5 applies the LUT at half strength.
  • Metadata. Camera, lens, exposure and date fields are copied to HEIF and JPEG results. The thumbnail, maker notes, comments and serial numbers are not (maker notes and comments can hold location). GPS location is removed unless you pass --keep-gps; --no-exif copies nothing. TIFF results carry no EXIF. Results are always saved upright: any EXIF rotation is applied to the pixels.
  • Safety. Results are named <photo>_<lut>.<ext> in -o (default ./rendered). Existing results need --overwrite; input photos are never replaced, including through hard links. Each result is written to a temporary file and renamed into place, so a failed write leaves the previous result intact.
  • Profiles. When a photo keeps its own colour profile (LUT space unknown, no --output-space), its original ICC profile is embedded unchanged, whatever its version. Otherwise an ICC v2 profile is generated for the output space: identity curves for linear spaces, a gamma curve for Adobe RGB and sampled curve tables for the others, including spaces such as BT.2020 that a HEIC describes only with an nclx tag.
  • Limits. Results are standard dynamic range: an iPhone HDR gain map is dropped. Colours outside the output space are clipped, and the tool reports how much. A 48-megapixel HEIC takes about 20 seconds and 2.4 GB of memory.

Example Display Patch

Figure_2

Example Result

In my case, LOESS took a huge amount of time (3 hours), but it looked the most accurate. Most of that time was the per-pixel preview, which now uses the generated LUT and takes seconds.

Figure_1

Licence

The code is licensed under the GNU General Public License, version 2 or (at your option) any later version (GPL-2.0-or-later); the licence text is in LICENSE. LUT files that you generate with the tool are your own: the GPL covers the program, not its output.

LUT files contributed to this repository for sharing are licensed under Creative Commons Attribution 4.0 (CC-BY-4.0): anyone may use them, including commercially, with credit to the author.

Development

I used mostly ChatGPT for this, as I have no idea how color works. So feel free to modify anything that is incorrect.

About

Create LUT based on pairs of tif image to match color of two cameras

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages