Photographic Fidelity Color Grading
When to Use
- The user has a reference image and a photo to be graded, asking "match the tones of this one."
- The user has a batch of already-retouched photos and wants to solidify the style to apply to a new batch.
- The user wants region-specific adjustments like "darken only the sky, don't touch the ground or faces."
- The user wants to apply a specific Look in batch, requiring "don't change the content, don't repaint."
- The user wants to verify if a style is faithful: checking for banding, color casts, crushed highlights or shadows.
- The user uploads an image and asks "Is this graded correctly?" or "What's wrong with it?", with no reference image.
- The user's source material might be log / unconverted color (S-Log3, V-Log, HLG frames from camera RAW).
- The user complains that generative retouching "changed the face" or "reduced resolution" – this is precisely the scenario for this skill.
Not applicable: Tasks requiring changing light direction, replacing the sky, adding objects, or semantic repainting. This skill only outputs color mapping and cannot modify content.
Core Model
- A "style" = a base
.cube+ optional region.cubes (one per region) + version history + batch archive + subject tags, stored incolor-styles/<name>/ - Applying a style = pixel-by-pixel lookup table, then overlay the region LUTs using a semantic mask. Output is strictly the same size as the original image, with a structural SSIM always equal to 1.0.
- Styles can be generated in two ways:
fit: Learns from a pair of "original / retouched" images, accurate (measured ΔE2000 0.55–0.9), requires 3+ pairsref: Transfers statistics from a reference image, fast but rough (measured ΔE2000 ≈ 4), works with a single image, suitable for cold start
- Each
fitis a "commit" that auto-consolidates: It determines which style it belongs to → merges into it by re-fitting with all historical samples → archives → adds subject tags. No model training, no GPU required.
What Region Adjustment Solves
A global LUT can only answer "map this color to that." When the same color is expected to map differently in different regions – most typically, a gray sky should be darkened, while a similarly gray road should be preserved – a single global mapping is mathematically unsolvable; least squares will average, pleasing neither.
Measured (a cloudy street scene, only darken the sky by −28%):
| Sky ΔE2000 | Non-Sky ΔE2000 | |
|---|---|---|
| Global LUT | 11.27 | 0.322 |
| Global + sky region | 1.50 | 0.324 |
The non-sky error is identical, showing that region adjustment introduces no additional pollution; it only fills in the part "the global LUT couldn't learn."
Region dimensions: sky / people / veg / water / ground / arch, default: sky,people,veg, provided by a built-in SegFormer-B0 (4.4 MB) soft mask.
Workflow
1. Verify Dependencies
python -c "import numpy, PIL; print('ok')"
python -c "import onnxruntime; print('seg ok')" # Required for region and subject tagging
python -c "import tifffile; print('tif ok')" # Required for 16-bit TIFF read/write
python -c "import rawpy; print('raw ok')" # Required for RAW direct reading, optional
If missing: python -m pip install numpy pillow onnxruntime tifffile rawpy. Missing onnxruntime will not crash; it will degrade to pure global color grading.
2. First, Inspect the Material (Recommended, Saves Time)
python <SKILL_DIR>/scripts/grade.py check --in ./photos/original --person
No reference image needed; it answers two things:
- What is this material?:
transfer.verdict=display/ambiguous/log. If it'slog, directlyfitwill learn the log curve into the LUT; the image might look reasonable but be wrong.--personruns a segmentation so skin hue is meaningful (not judged if no person present). - Does the resulting image have issues?: Median luminance, black point, dead white/black ratios, color cast stops, color richness. Out-of-bounds items appear in
warnings.
Both apply and fit run these checks internally, so running this once before batch delivery is sufficient.
3. Submit a Batch of Material (Core Action)
python <SKILL_DIR>/scripts/grade.py fit \
--src ./photos/original --tgt ./photos/retouched
--src/--tgtpair by filename stem; you can also use--pair original retouchedto specify pairs repeatedly.- Mixed sizes in the directory have no effect, but each pair must be the same size internally.
- Without
--name, it's auto mode: compares distance to existing styles; if close enough, merges; if not, creates a new one. --name warm_filmspecifies a name: if the name exists, merges; if not, creates a new one.--regions sky,people,vegenables region adjustment (default). Use--regions ""to revert to purely global.--auto-threshold 1.5is the ΔE threshold for considering "the same style."
4. Apply
python <SKILL_DIR>/scripts/grade.py apply \
--style warm_film --in ./photos/new --out ./out --strength 1.0
--style auto first identifies the input photo's subject, then selects the best-matching style tag (the result explains why it was chosen in the auto field).
Check look and warnings before delivery. If look.median_luma changes from [0.195, 0.302] to [0.55, 0.95], it means this image is much brighter than the style's learned material; applying it will clip highlights. In this case, there are only three options—--strength 0.7 to reduce intensity, --match-key on to first match the exposure to the style's learned level (only suitable for same-light-ratio burst shots), or accept the background brightening. Don't choose for the user without asking.
--match-key off|on|auto (default off): First adjusts the image's median luminance to the style's learned level before applying the LUT. This can harm the subject in backlit images; see Fidelity Red Lines #14.
Output Formats and Quality
By default, two files are written; don't just send the first one:
| Product | Default | Usage |
|---|---|---|
<name>__<style>_v<N>.jpg | q98, 4:4:4, preserves source ICC/EXIF/DPI | Deliver, share, post on social media |
<name>__<style>_v<N>__master.png | 8-bit lossless, with ±0.5 LSB dithering | Further editing, enlarging, printing |
--quality 98: JPEG quality. q95→q98 measured PSNR 48.7→50.6 dB, file size 337→512 KB. Dropping to q92 halves the size, loses 3 dB, and is noticeable in gradient skies.--master png|tif|none:tifuses 16-bit (requirestifffile; if not installable, gracefully degrades to 8-bit PNG with amaster_note, without corrupting the generated JPG). For batch output, use--master noneto save space.- When the source file is already
.png/.tif/.bmp, no master is generated—the main product is lossless. - ICC, EXIF, and DPI are passed through from the source. Losing ICC is the most common cause of "worse quality": a P3/AdobeRGB image saved with an untagged RGB profile will be interpreted as sRGB by any viewer, instantly making colors appear dull and muddy.
- Dithering is only applied to lossless formats. JPEG's quantization error is already an order of magnitude coarser than 1 LSB; dithering there only wastes bitrate.
If the user says "it's been compressed," first check the source image, don't adjust parameters. apply will note in source_note: if the source JPEG is below 0.10 bytes/pixel, it's a copy forwarded through a chat app (a 1080p image might be only ~100 KB); details were lost before this step, and outputting losslessly won't recover them. Two other common misdiagnoses: judging quality from a preview in a chat window (platforms re-compress), and judging from a composite preview like final_sheet.jpg (which is half-sized).
5. Verify
python <SKILL_DIR>/scripts/grade.py verify \
--style warm_film --src ./photos/original/a.jpg --tgt ./photos/retouched/a.jpg \
--threshold 2.0
With --threshold, exits with code 2 if exceeded, ideal for pipeline gating.
6. Export for Others
python <SKILL_DIR>/scripts/grade.py export --style warm_film --out ./for_lightroom --full
--full additionally writes a connection guide (base layer covers the entire image → region layers superimposed with soft masks → feather 1–2% of the frame).
Region styles cannot be given as a single .cube: a single 3D LUT is a function of pixel color, and cannot represent "different treatment for the same color in different regions."
7. Undo and Traceability
python <SKILL_DIR>/scripts/grade.py batches --style warm_film # See which batches compose this style
python <SKILL_DIR>/scripts/grade.py undo --style warm_film --batch b20261108_101500
python <SKILL_DIR>/scripts/grade.py styles # All styles + tags + one-sentence description
python <SKILL_DIR>/scripts/grade.py styles --name warm_film # Full manifest of a single style
undo doesn't delete a version; it removes that batch from the composition, re-fits with the remaining batches, and generates a new version. Old versions are always preserved.
Output Interpretation
All commands print JSON to stdout.
| Field | Meaning | Judgement |
|---|---|---|
quality.dE2000_mean | Average color difference on training pairs (including regions) | < 2 usable; 2–4 marginal; > 4 indicates pairing is not purely color-based |
decision | How this submission was handled | create-new / merge-nearest / merge-into-named |
auto.distance | Distance to the nearest style | If significantly less than the threshold but created new, or significantly more but merged, it's an error |
auto.nearest | Nearest three styles and their distances | Used to understand "why this decision" |
learned_from | How many historical samples were used for merging | If only new_samples without archived, it's the first time |
diagnostics.coverage_pct | Percentage of LUT grid points covered by data | 30–50% is normal (natural images only occupy a portion of the color space) |
diagnostics.lut_clipped_pct_observed | Percentage of data regions compressed to 0/1 | > 2% indicates potential highlight/shadow loss |
regions.active | Regions actually in effect | Empty means these regions were not present in this batch's material |
region_diagnostics.<r>.skipped | Reason a region was skipped | too few pixels is normal; no residual learned means the region behaved identically to the global one |
description.summary | One-sentence style description | For the user, relay directly |
description.region_extra | What each region did relative to the global | Only present when regions are active |
tags | Automatically determined subject | portrait / landscape / street / indoor / night |
diagnostics.prior_shrink | Prior strength; 1.0 means fully adopted | Each fit tries 1.0 / 0.5 / 0.2 / 0.05 empirically, prior_trials gives the dE for each |
health.monotonic_violations | Number of monotonicity violations | Default forced to zero; only appears if --no-monotonic is used |
fit_quality.geometry_preserved | Whether geometry is preserved | Must be true |
key_match.applied | Whether this image was exposure-matched to the style first | Default off; if enabled, gives stops, gamma, and adjusted median |
transfer.verdict | Whether the material is display-referenced, log, or ambiguous | log must be converted before fitting; ambiguous asks user to confirm source |
warnings[].metric / .note | Issues found by no-reference self-check | Relay the note verbatim to the user; an empty array means no hard issues found |
look | [original, result] median luminance / dead white ratio / saturation | Used to answer "what did this style do to this image" |
input_guard | When fitting, found that the original is not display-referenced encoding | blocking: false, just a reminder, doesn't block |
input_transfer | When applying, input contains non-display images | Verdict per file, same as above |
Fidelity Red Lines
- RAW is approximate decoding. libraw uses camera white balance + generic color matrix, not the camera's DCP. The same style will look different on different camera bodies, and it's not Lightroom's rendering. For precise delivery, have the user export 16-bit TIFF.
- The LUT anchor space is sRGB gamma 2.2. When exporting
.cubefor others, explain this; otherwise, the wrong space will produce completely wrong colors. - A region style cannot be represented by a single
.cube.vN.cubeis just the global base layer; region mappings are invN_<region>.cubeand require mask coordination. Useexport --fullwhich outputs both region layers and connection instructions. - Overexposed pixels pass through unchanged. At the end of rendering, pixels with the brightest channel ≥0.97 are soft-blended back to the original: overexposed areas have no color information, so any mapping is invention. Measured with a backlit sun disk, it was incorrectly darkened into a gray-blue patch by the region block; with protection, it's restored to a warm white light source. The cost is that the brightest, high-saturation highlights are no longer remapped (measured training ΔE 1.15→1.21 on a color chart). Use
--no-clip-guardwhen you need to compress overexposed areas as well. - Soft mask boundaries have a transition zone – this is intentional. Abrupt transitions between two different mappings would create visible contours. The cost is a transitional "half-tone" effect of about a dozen pixels at the border between sky and treetops.
- Masks are only 128×128 resolution. Structures finer than about 1/128 of the frame (twigs, wires, thin railings) will be attributed to the region they fall in. These structures are often near-silhouettes, invisible when darkened; for pixel-level precision, use an external masking tool.
- Low-confidence regions are not adjusted. Overexposed, colorless skies, or skies fragmented by foliage yield unreliable masks from the model; these areas fall back to the global LUT. Better to adjust less than to misalign.
- Animal close-ups might be misidentified as portraits. ADE20K can classify a cat close-up as
person(measured up to 88% of the frame). This skill mitigates by checking "skin color proportion within the person region," resetting to zero if below 5%. Extremely pale pets might still slip through. - Do not market the
refmode as "precision reproduction." It only matches Lab distribution, measured ΔE ≈ 4; for precision, you must usefit. - Slider values in the description layer are estimates. The
measuredcolumn is reproducible;estimate_lightroomis not – there is no unique solution from LUT to sliders. - The no-reference self-check can only detect "obvious errors," not judge "good-looking." It uses classic statistical targets (median luminance 0.45, black point 0.02, skin hue 20°). Night shots naturally have low median luminance; sunsets naturally have a warm bias. Each item has a tolerance band, and a warning simply means "take a look," not "it's wrong." Two hard thresholds: skin hue is only judged when a person is detected by segmentation; otherwise, a warm-toned street scene could misjudge 30% of the frame as skin. White balance is only judged when the image contains enough neutral pixels.
- The log detection method analyzes histogram shape, not metadata. By the time an image reaches this step, it's typically a PNG or a libraw rendering, and EXIF is often gone. So it counts: raised black point / rolled-off highlights / narrow dynamic range / low saturation. Three or more consistent items →
log; two →ambiguous. A flat, faded-style result image may be falsely flagged asambiguous– a known false positive direction. - A style carries exposure properties; the LUT is only reliable within the luminance range it was trained on. A look that raises the median from 0.19 to 0.28, when applied to an image with median 0.55, will push the median to 0.95 – not a bug, but a consequence of the look having no highlight headroom. Measured: fitting only observed source values in the 0~0.42 range, with source p99 at 0.42 / target p99 at 0.56; applied to a source with p99 0.72, the out-of-range portion is linearly extrapolated, effectively clipping the highlights.
- Don't default to "matching exposure" for the user.
--match-keycan first pull the image's median to the style's learned level before applying the LUT, but for backlit images, it backfires: in a real backlit portrait (face 0.27, background 0.62), aligning by full-frame median pulls the face down to 0.08, making the subject completely black. So it's off by default, and should only be enabled for "same camera position, same light ratio, inconsistent exposure in a burst." The decision is simple: is the relationship between subject and background luminance the same in this image as in the reference? - When in a frame, the subject is darker than the background, while in the reference frame, the subject is the brightest, no single global monotonic mapping can satisfy both. To satisfy both, only local adjustments work. In this case, preserve the subject first, accept the background brightening; the reverse would crush the face.
- A warm bias in the source will additively combine with a warm style, producing orange-red. A dark portrait with an inherent 0.46-stop warm cast, when styled with a warm look, pushes the face from warm brown to orange-red,
castclimbs to 0.77, and the cheeks develop a patchy color cast. Do not start with a white balance neutralization – the only "neutral" color in the frame might be a wall lit by the same lamp; correcting it would shift the wall to green and the skin to yellow-green (measured, both 0.5/0.75 strength trials made it worse). The correct order: first reduce--strength(around 0.6). If still unsatisfactory, abandon color grading and switch to a pure luminance curve or linear exposure gain – only adjust brightness, not hue, preserving the warm cast. - Every JPEG save reduces quality, and it's irreversible. Deliver as a single q98. For further edits, work from
__master.png, don't repeatedly re-save the JPG. Saving three times with q95 from the same LUT gives PSNR 48.7→48.6, which seems negligible, but each save accumulates blocky steps in smooth gradients (brightened skies, background blur). To determine who lost quality: if the source was already compressed (<0.10 bytes/pixel), the source is to blame; don't search for encoding parameter issues.
Performance Baseline
| Scenario | Time for 10.8 MP Image |
|---|---|
| Purely global rendering | 3.6 s |
| Region rendering (2 active regions) | 6.7 s |
| Segmentation inference (512px) + upsampling | 0.7 s |
One fit (10 image pairs, with region and tagging) | ~12 s |
Bottleneck is pixel-by-pixel LUT gather (8 random reads per pixel), not the algorithm branch. For batch processing, multi-process parallelism per image is most efficient.
Common Tasks
| User Says | Action |
|---|---|
| "Match the tones of this reference for my batch of photos." | ref generate style → apply batch |
| "These are retouched; use this style for everything from now on." | fit --src original --tgt retouched → apply |
| "I've retouched another batch; use the same style." | fit with --name pointing to the same style, auto-merges and re-fits |
| "Only darken the sky, don't touch the ground or faces." | Region adjustment enabled by default; confirm regions.active contains sky |
| "This style is too strong." | apply --strength 0.6, no need to re-fit |
| "This version is worse than the previous one." | apply --version <old version number> |
| "The previous batch was wrong; undo it." | batches to find batch_id → undo |
| "Which style should I use for this batch of photos?" | apply --style auto, check auto.ranking |
| "What exactly does this style adjust?" | styles --name X, read description.summary |
| "Export for DaVinci Resolve." | export --full; remind that the anchor is sRGB gamma 2.2, and region layers require soft masks |
| "Backlit/sunrise image, don't let the sun turn gray." | Default protection is on; if still gray, check if the sun is truly overexposed |
| "Is this graded correctly? What's wrong?" | check --in X --person, relay warnings items verbatim, don't add your own judgment |
| "Is this log material?" | check --in X, check transfer.verdict and transfer.signals |
| "Will this style clip my highlights?" | apply's look gives before/after comparison; warnings will directly point it out |
| "This image is much brighter than the reference; it looks washed out after applying." | First check look.median_luma difference; reduce --strength, or for same-light-ratio bursts, use --match-key on |
| "The subject is darker than the background; after applying, the subject is black." | Do not enable --match-key; this means the reference and this image have different light ratios; only local adjustments work |
| "This image has a warm cast; after styling, the face turns orange." | First try reducing --strength to 0.6; don't do white balance neutralization (it will push the wall green), see Red Line 16 |
References
references/style-schema.md: Style directory,style.jsonfields, version and batch semanticsreferences/color-pipeline.md: Color management, algorithm details, known limitations, and parameter tuning suggestions