CommunityImagegithub.com

naokuotong-1/photo-color-grader

Photographic fidelity color grading. Turn 'color styles' into reusable 3D LUTs and apply them in batch, with support for semantic region adjustment (sky/portrait/vegetation), batch-level automatic consolidation and undo, automatic subject identification and style selection, and classic statistical self-checking when no reference image is available. It also identifies whether footage is log and not yet converted. Only performs pixel-level color mapping—no repainting, no content changes, no geometry alteration—ideal for photography delivery with strict fidelity requirements.

What is photo-color-grader?

This skill, named `photo-color-grader`, provides a robust, fidelity-first color grading system for photographers and AI agent users. It transforms color styles into reusable 3D LUTs (Look-Up Tables) and applies them across batches of images. Unlike generative AI tools that modify content, this skill strictly performs pixel-level color mapping, ensuring no changes to geometry, resolution, or subject matter. It supports semantic region-based adjustments (e.g., darkening only the sky without affecting the ground or faces) and can automatically identify image subjects (portrait, landscape, street, etc.) to suggest or apply the most appropriate style. The skill also includes intelligent features like automatic style submission and consolidation, undo capabilities for previous batches, and a 'check' mode that analyzes images without a reference to detect issues such as color casts, clipping, or exposure problems. It can even detect if source footage is in a log color space (like S-Log3 or V-Log) and warn the user before applying a LUT that would incorrectly interpret the colors. This skill is designed for use with Python CLI tools and integrates with libraries like `numpy`, `PIL`, `onnxruntime`, and `tifffile` for segmentation and high-bit-depth file support.

Works with~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/naokuotong-1/photo-color-grader/tree/HEAD

Installed? Explore more Image skills: steipete/songsee, affaan-m/frontend-design, mattpocock/improve-codebase-architecture · View all 6 →

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation

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

  1. A "style" = a base .cube + optional region .cubes (one per region) + version history + batch archive + subject tags, stored in color-styles/<name>/
  2. 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.
  3. 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+ pairs
    • ref: Transfers statistics from a reference image, fast but rough (measured ΔE2000 ≈ 4), works with a single image, suitable for cold start
  4. Each fit is 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 ΔE2000Non-Sky ΔE2000
Global LUT11.270.322
Global + sky region1.500.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's log, directly fit will learn the log curve into the LUT; the image might look reasonable but be wrong. --person runs 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 / --tgt pair by filename stem; you can also use --pair original retouched to 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_film specifies a name: if the name exists, merges; if not, creates a new one.
  • --regions sky,people,veg enables region adjustment (default). Use --regions "" to revert to purely global.
  • --auto-threshold 1.5 is 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:

ProductDefaultUsage
<name>__<style>_v<N>.jpgq98, 4:4:4, preserves source ICC/EXIF/DPIDeliver, share, post on social media
<name>__<style>_v<N>__master.png8-bit lossless, with ±0.5 LSB ditheringFurther 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: tif uses 16-bit (requires tifffile; if not installable, gracefully degrades to 8-bit PNG with a master_note, without corrupting the generated JPG). For batch output, use --master none to 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.

FieldMeaningJudgement
quality.dE2000_meanAverage color difference on training pairs (including regions)< 2 usable; 2–4 marginal; > 4 indicates pairing is not purely color-based
decisionHow this submission was handledcreate-new / merge-nearest / merge-into-named
auto.distanceDistance to the nearest styleIf significantly less than the threshold but created new, or significantly more but merged, it's an error
auto.nearestNearest three styles and their distancesUsed to understand "why this decision"
learned_fromHow many historical samples were used for mergingIf only new_samples without archived, it's the first time
diagnostics.coverage_pctPercentage of LUT grid points covered by data30–50% is normal (natural images only occupy a portion of the color space)
diagnostics.lut_clipped_pct_observedPercentage of data regions compressed to 0/1> 2% indicates potential highlight/shadow loss
regions.activeRegions actually in effectEmpty means these regions were not present in this batch's material
region_diagnostics.<r>.skippedReason a region was skippedtoo few pixels is normal; no residual learned means the region behaved identically to the global one
description.summaryOne-sentence style descriptionFor the user, relay directly
description.region_extraWhat each region did relative to the globalOnly present when regions are active
tagsAutomatically determined subjectportrait / landscape / street / indoor / night
diagnostics.prior_shrinkPrior strength; 1.0 means fully adoptedEach fit tries 1.0 / 0.5 / 0.2 / 0.05 empirically, prior_trials gives the dE for each
health.monotonic_violationsNumber of monotonicity violationsDefault forced to zero; only appears if --no-monotonic is used
fit_quality.geometry_preservedWhether geometry is preservedMust be true
key_match.appliedWhether this image was exposure-matched to the style firstDefault off; if enabled, gives stops, gamma, and adjusted median
transfer.verdictWhether the material is display-referenced, log, or ambiguouslog must be converted before fitting; ambiguous asks user to confirm source
warnings[].metric / .noteIssues found by no-reference self-checkRelay the note verbatim to the user; an empty array means no hard issues found
look[original, result] median luminance / dead white ratio / saturationUsed to answer "what did this style do to this image"
input_guardWhen fitting, found that the original is not display-referenced encodingblocking: false, just a reminder, doesn't block
input_transferWhen applying, input contains non-display imagesVerdict per file, same as above

Fidelity Red Lines

  1. 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.
  2. The LUT anchor space is sRGB gamma 2.2. When exporting .cube for others, explain this; otherwise, the wrong space will produce completely wrong colors.
  3. A region style cannot be represented by a single .cube. vN.cube is just the global base layer; region mappings are in vN_<region>.cube and require mask coordination. Use export --full which outputs both region layers and connection instructions.
  4. 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-guard when you need to compress overexposed areas as well.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. Do not market the ref mode as "precision reproduction." It only matches Lab distribution, measured ΔE ≈ 4; for precision, you must use fit.
  10. Slider values in the description layer are estimates. The measured column is reproducible; estimate_lightroom is not – there is no unique solution from LUT to sliders.
  11. 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.
  12. 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 as ambiguous – a known false positive direction.
  13. 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.
  14. Don't default to "matching exposure" for the user. --match-key can 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?
  15. 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.
  16. 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, cast climbs 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.
  17. 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

ScenarioTime for 10.8 MP Image
Purely global rendering3.6 s
Region rendering (2 active regions)6.7 s
Segmentation inference (512px) + upsampling0.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 SaysAction
"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.json fields, version and batch semantics
  • references/color-pipeline.md: Color management, algorithm details, known limitations, and parameter tuning suggestions

Related Skills