Color Correction (measure, don't eyeball)

Diagnose and fix video/image color OBJECTIVELY with the get_image (action:"analyze_color") tool (scopes/stats such as black/white points, contrast, saturation, clipping, cast) instead of eyeballing a contact sheet.

How to install

How to install

  1. Setup differs for this server — follow the Installation part of the README below.
  2. Claude Code: claude mcp add <name> -- <command>.
  3. Claude Desktop / Cursor: add it under mcpServers in the MCP config file.
Claude Code — installs the whole folder, not just SKILL.md
npx degit artokun/comfyui-mcp/plugin/skills/color-correction#main ~/.claude/skills/color-correction

For one project only, change the path to .claude/skills/color-correction.

This one runs on your machine and can reach your files. Read the README below before you connect it.

Not working?
  • Check which app you pasted it into — the steps above name the right one.
  • Some skills need the paid tier of Claude or ChatGPT.
Step-by-step guide with screenshots · Ask in the forum

Paste into Claude, ChatGPT or Cursor.

Show the full text194 lines
color-correction/SKILL.md194 lines9.6 KBpushed 27d agoRawView on GitHub

Color Correction (measure, don't eyeball)

The core principle

You cannot reliably judge color from a storyboard or contact sheet. "Is it washed out?" flip-flops by eye, especially on AI-gen video. Make color measurable with the get_image (action:"analyze_color") MCP tool, read the numbers like a colorist reads scopes, pick the fix the data points to, then re-measure to confirm. The whole skill is this loop:

extract a frame ─► get_image (action:"analyze_color") ─► read black/white points + contrast + saturation
       ▲                                          │
       │                                          ▼
   re-measure  ◄──── apply fix (levels / contrast / match) ◄── diagnose from the numbers

Origin: on a WAN-Animate render we argued for many turns over whether the clip was "washed out." The instant we measured it, the answer was unambiguous and the correct fix (a contrast stretch, not the color-match nodes we'd been adding) fell straight out.


The get_image (action:"analyze_color") tool

Read-only. Source = asset_id, a ComfyUI output ref (filename/subfolder/type), or an image path (absolute, or under the output dir). It returns per-image stats, heuristic flags, a one-line verdict, and optionally an overlaid R/G/B/luma histogram PNG.

get_image({ action: "analyze_color", filename: "render_00007_.png" })       # absolute numbers
get_image({ action: "analyze_color", path: "frame.png", reference_path: "src.jpg" })  # + shot-match deltas
get_image({ action: "analyze_color", filename: "x.png", histogram: true })  # + histogram image

For videos, get_image (action:"analyze_color") is image-only (no ffmpeg dep). Extract a frame first with the ComfyUI venv's cv2:

"<comfy-venv>/python" -c "import cv2; c=cv2.VideoCapture(r'IN.mp4'); n=int(c.get(7)); \
  c.set(1, n//2); _,f=c.read(); cv2.imwrite(r'frame.png', f)"

Grab the middle frame, or frame 0. For window-drift checks grab a frame from each window.

What the numbers mean (8-bit)

Field Reads like a scope Healthy-ish
luma.blackPoint (1st pct) where shadows bottom out ~0–16 (lifted if >16)
luma.whitePoint (99th pct) where highlights top out ~240–255 (dim if <235)
luma.contrast (std) overall punch ~45+ (flat if <45)
luma.dynamicRange white−black wide is good
saturation.meanSaturation (HSV S) vectorscope spread ~0.25+ (dull if <0.22)
channels.{r,g,b}Mean + castHint RGB parade / white balance spread <~12 = neutral
luma.clippedHighPct/LowPct blown / crushed pixels keep low (<~2%)

Flags: washedOut, lowContrast, liftedBlacks, dimHighlights, lowSaturation, colorCast.


The "washed out" signature

Washed out means compressed tonal range, and it has an exact fingerprint:

  • whitePoint well below 255 (e.g. 191), so highlights never reach white
  • blackPoint lifted off 0 (e.g. 45), giving milky shadows
  • contrast low (std < 45)
  • often normal saturation. Washout is usually not a saturation problem.

If you see that, the fix is a levels / contrast stretch, not a color match. In a real case, a WAN-Animate frame measured blackPoint 45 / whitePoint 191 / contrast 43, clearly a range problem; saturation 0.25 was fine.


The load-bearing insight: a reference-match can't add contrast the source lacks

The instinct is to "match the render to the input photo" with a color-match node (ColorMatchV2 mkl/hm, ImageColorMatchAdobe+, easy imageColorMatch). Measure the reference first. If the reference is itself flat (e.g. a casual phone selfie with blackPoint 40, contrast 44), matching to it cannot produce punch; you'll match your way to the same flat numbers. In the real case, mkl and Adobe matches both left the frame flagged washedOut (whitePoint only crept from 191 to ~218).

So:

  • Use a reference-match (ColorMatchV2, ImageColorMatchAdobe+) when you want to match a known-good graded frame or shot-match across clips, and the reference is actually good.
  • Use a levels / contrast stretch when the defect is compressed range (the washout case). It targets full range regardless of the reference. This is usually the real fix.

The fix: contrast / levels stretch (core nodes, no install)

AdjustContrast (core comfy_extras.nodes_dataset, category image/adjustments) has one factor (1.0 = none, >1 = more). It pivots around mid-gray, so it pushes the white point up and the black point down together. Tune it by measurement, not feel. A real measured sweep on the washout frame:

factor blackPoint whitePoint contrast sat clippedHigh verdict
1.3 29 239 56 0.33 0% ✅ not washed (slightly soft)
~1.4 ~20 ~247 ~60 ~0.38 ~1–2% sweet spot
1.6 7 254 67 0.44 7.2% ⚠️ punchy but blows highlights

Pick the factor that lands whitePoint ~248 to 255 with clippedHighPct < ~2%. Going too far (1.6 here) blows highlights and over-warms, because per-channel contrast drops blue more than red and castHint worsens. The sweet spot was ~1.4.

Other levers when contrast alone isn't enough:

  • CurveEditor (core, utilities) feeds a CURVE for precise black/white-point and gamma control (a true levels curve) when you need more than a single contrast pivot.
  • AdjustContrast plus a small saturation/brightness adjust (same image/adjustments family) to fine-tune after the stretch.
  • A mild reference-match after the stretch, only if you need to shot-match.

The sandbox pattern (test corrections side by side, then measure)

Don't tune blind on the full pipeline. Build a tiny separate workflow (panel_new_workflow) and let one run produce several candidates you then measure:

LoadImage (the washed frame, staged into input/)
LoadImage (the reference, if shot-matching)
        │
        ├─► AdjustContrast factor 1.3 ─► SaveImage "contrast13"
        ├─► AdjustContrast factor 1.6 ─► SaveImage "contrast16"
        ├─► ColorMatchV2 (mkl, ref)   ─► SaveImage "balance_mkl"
        └─► ImageColorMatchAdobe+(LAB)─► SaveImage "balance_adobe"

Run once, then get_image (action:"analyze_color") every output, plus the reference and the untouched frame, and compare blackPoint / whitePoint / contrast / saturation / clippedHigh. Whichever lands the numbers in range wins; interpolate the factor (1.3 vs 1.6 gives 1.4) and confirm with one more pass. Stage the frame into the ComfyUI input dir first (cp the extracted PNG there) so LoadImage can see it. Input and output dirs may be custom, so don't guess paths.


Porting the fix into a render graph

Place the chosen correction after decode, before the save. For a WAN/video graph that's right after WanVideoDecode (or the per-chunk color-match) and feeding the VHS_VideoCombine/save node. One AdjustContrast node is usually the whole fix. Keep it as a single inline node (or a small bypassable group) so it's easy to toggle and re-tune. Re-render a clip, extract a frame, get_image (action:"analyze_color") it, and nudge the factor to hit whitePoint ~250.


Video-specific gotchas

  • Per-window drift (WAN long video). WAN-Animate/long clips render in temporal windows and can desaturate or dim across them. Measure a frame from the first window and a late window. If they differ, that's drift, not a global grade issue; use the embeds' between-window colormatch, not a final stretch.
  • Relight LoRA is not levels. A WanAnimate relight LoRA changes lighting, not black/white points. It won't fix a compressed-range washout. Measure before and after to prove it.
  • Don't over-stretch. Watch clippedHighPct; blown highlights are unrecoverable. Prefer the lower factor that still clears dimHighlights.
  • Cast is often inherited. If the reference has the same warm/cool cast (festival/sunset light), a matching cast on the render is correct. Don't "fix" it.

Quick reference — the decision tree

get_image (action:"analyze_color") on the frame
  ├─ washedOut / lowContrast / dimHighlights ........ contrast/levels stretch (AdjustContrast ~1.4 → measure)
  ├─ lowSaturation only ............................. saturation boost (small)
  ├─ colorCast (and reference is neutral) ........... white-balance / neutralization, or reference-match
  ├─ want to MATCH a known-good graded frame ........ ColorMatchV2 / ImageColorMatchAdobe+ (ref must be good)
  └─ blackPoint/whitePoint already 0/255, sat ok .... color is healthy — stop touching it

Always re-measure after the fix. If get_image (action:"analyze_color") still flags it, the fix was wrong. Adjust and measure again. Numbers over vibes.

Sources

  • Official: comfyui-mcp get_image action:"analyze_color" product behavior (this repo).
  • Empirical: washed-out signature and levels-stretch recipes from observed renders.
1---
2name: color-correction
3description: Diagnose and fix video/image color OBJECTIVELY with the get_image (action:"analyze_color") tool (scopes/stats such as black/white points, contrast, saturation, clipping, cast) instead of eyeballing a contact sheet. Covers the "washed out" signature, why reference color-match (mkl/ColorMatch/ColorMatchAdobe) CAN'T add contrast a flat source lacks, the levels/contrast-stretch fix (core AdjustContrast / CurveEditor), the measure→fix→re-measure loop, the side-by-side sandbox pattern, and where to place the fix in a render graph (after decode, before save). Use when a render looks washed out / flat / dull / over-saturated / color-cast, or when deciding between a color-match and a contrast/levels fix.
4globs:
5 - "**/*.json"
6 - "**/packs/**"
7---
8 
9# Color Correction (measure, don't eyeball)
10 
11## The core principle
12 
13You cannot reliably judge color from a storyboard or contact sheet. "Is it washed
14out?" flip-flops by eye, especially on AI-gen video. Make color measurable with the
15`get_image (action:"analyze_color")` MCP tool, read the numbers like a colorist reads scopes, pick the
16fix the data points to, then re-measure to confirm. The whole skill is this loop:
17 
18```
19extract a frame ─► get_image (action:"analyze_color") ─► read black/white points + contrast + saturation
20 ▲ │
21 │ ▼
22 re-measure ◄──── apply fix (levels / contrast / match) ◄── diagnose from the numbers
23```
24 
25> Origin: on a WAN-Animate render we argued for many turns over whether the clip was
26> "washed out." The instant we measured it, the answer was unambiguous and the *correct*
27> fix (a contrast stretch, not the color-match nodes we'd been adding) fell straight out.
28 
29---
30 
31## The `get_image (action:"analyze_color")` tool
32 
33Read-only. Source = `asset_id`, a ComfyUI output ref (`filename`/`subfolder`/`type`), or
34an image `path` (absolute, or under the output dir). It returns per-image stats, heuristic
35flags, a one-line verdict, and optionally an overlaid R/G/B/luma histogram PNG.
36 
37```
38get_image({ action: "analyze_color", filename: "render_00007_.png" }) # absolute numbers
39get_image({ action: "analyze_color", path: "frame.png", reference_path: "src.jpg" }) # + shot-match deltas
40get_image({ action: "analyze_color", filename: "x.png", histogram: true }) # + histogram image
41```
42 
43For videos, `get_image (action:"analyze_color")` is image-only (no ffmpeg dep). Extract a frame first with the
44ComfyUI venv's cv2:
45 
46```bash
47"<comfy-venv>/python" -c "import cv2; c=cv2.VideoCapture(r'IN.mp4'); n=int(c.get(7)); \
48 c.set(1, n//2); _,f=c.read(); cv2.imwrite(r'frame.png', f)"
49```
50 
51Grab the middle frame, or frame 0. For window-drift checks grab a frame from each window.
52 
53### What the numbers mean (8-bit)
54 
55| Field | Reads like a scope | Healthy-ish |
56|---|---|---|
57| `luma.blackPoint` (1st pct) | where shadows bottom out | ~0–16 (lifted if >16) |
58| `luma.whitePoint` (99th pct) | where highlights top out | ~240–255 (dim if <235) |
59| `luma.contrast` (std) | overall punch | ~45+ (flat if <45) |
60| `luma.dynamicRange` | white−black | wide is good |
61| `saturation.meanSaturation` (HSV S) | vectorscope spread | ~0.25+ (dull if <0.22) |
62| `channels.{r,g,b}Mean` + `castHint` | RGB parade / white balance | spread <~12 = neutral |
63| `luma.clippedHighPct/LowPct` | blown / crushed pixels | keep low (<~2%) |
64 
65Flags: `washedOut, lowContrast, liftedBlacks, dimHighlights, lowSaturation, colorCast`.
66 
67---
68 
69## The "washed out" signature
70 
71Washed out means compressed tonal range, and it has an exact fingerprint:
72 
73- `whitePoint` well below 255 (e.g. 191), so highlights never reach white
74- `blackPoint` lifted off 0 (e.g. 45), giving milky shadows
75- `contrast` low (std < 45)
76- often *normal* saturation. Washout is usually not a saturation problem.
77 
78If you see that, the fix is a levels / contrast stretch, not a color match. In a real case,
79a WAN-Animate frame measured blackPoint 45 / whitePoint 191 / contrast 43, clearly a range
80problem; saturation 0.25 was fine.
81 
82---
83 
84## The load-bearing insight: a reference-match can't add contrast the source lacks
85 
86The instinct is to "match the render to the input photo" with a color-match node
87(`ColorMatchV2` mkl/hm, `ImageColorMatchAdobe+`, easy `imageColorMatch`). Measure the
88reference first. If the reference is itself flat (e.g. a casual phone selfie with
89blackPoint 40, contrast 44), matching to it cannot produce punch; you'll match your way
90to the *same* flat numbers. In the real case, mkl and Adobe matches both left the frame
91flagged `washedOut` (whitePoint only crept from 191 to ~218).
92 
93So:
94 
95- Use a reference-match (`ColorMatchV2`, `ImageColorMatchAdobe+`) when you want to *match
96 a known-good graded frame or shot-match across clips*, and the reference is actually good.
97- Use a levels / contrast stretch when the defect is compressed range (the washout case).
98 It targets full range *regardless* of the reference. This is usually the real fix.
99 
100---
101 
102## The fix: contrast / levels stretch (core nodes, no install)
103 
104`AdjustContrast` (core `comfy_extras.nodes_dataset`, category *image/adjustments*) has one
105`factor` (1.0 = none, >1 = more). It pivots around mid-gray, so it pushes the white point up
106and the black point down together. Tune it by measurement, not feel. A real measured sweep
107on the washout frame:
108 
109| factor | blackPoint | whitePoint | contrast | sat | clippedHigh | verdict |
110|---|---|---|---|---|---|---|
111| 1.3 | 29 | 239 | 56 | 0.33 | **0%** | ✅ not washed (slightly soft) |
112| ~1.4 | ~20 | ~247 | ~60 | ~0.38 | ~1–2% | ✅ **sweet spot** |
113| 1.6 | 7 | 254 | 67 | 0.44 | **7.2%** ⚠️ | punchy but blows highlights |
114 
115Pick the factor that lands whitePoint ~248 to 255 with `clippedHighPct` < ~2%. Going too far
116(1.6 here) blows highlights *and* over-warms, because per-channel contrast drops blue more than red and
117`castHint` worsens. The sweet spot was ~1.4.
118 
119Other levers when contrast alone isn't enough:
120- `CurveEditor` (core, *utilities*) feeds a CURVE for precise black/white-point and gamma
121 control (a true levels curve) when you need more than a single contrast pivot.
122- `AdjustContrast` plus a small saturation/brightness adjust (same *image/adjustments* family)
123 to fine-tune after the stretch.
124- A mild reference-match *after* the stretch, only if you need to shot-match.
125 
126---
127 
128## The sandbox pattern (test corrections side by side, then measure)
129 
130Don't tune blind on the full pipeline. Build a tiny separate workflow (`panel_new_workflow`)
131and let one run produce several candidates you then measure:
132 
133```
134LoadImage (the washed frame, staged into input/)
135LoadImage (the reference, if shot-matching)
136
137 ├─► AdjustContrast factor 1.3 ─► SaveImage "contrast13"
138 ├─► AdjustContrast factor 1.6 ─► SaveImage "contrast16"
139 ├─► ColorMatchV2 (mkl, ref) ─► SaveImage "balance_mkl"
140 └─► ImageColorMatchAdobe+(LAB)─► SaveImage "balance_adobe"
141```
142 
143Run once, then `get_image (action:"analyze_color")` every output, plus the reference and the untouched frame, and
144compare blackPoint / whitePoint / contrast / saturation / clippedHigh. Whichever lands the
145numbers in range wins; interpolate the factor (1.3 vs 1.6 gives 1.4) and confirm with one more
146pass. Stage the frame into the ComfyUI input dir first (cp the extracted PNG there) so
147`LoadImage` can see it. Input and output dirs may be custom, so don't guess paths.
148 
149---
150 
151## Porting the fix into a render graph
152 
153Place the chosen correction after decode, before the save. For a WAN/video graph that's
154right after `WanVideoDecode` (or the per-chunk color-match) and feeding the
155`VHS_VideoCombine`/save node. One `AdjustContrast` node is usually the whole fix. Keep it as a
156single inline node (or a small bypassable group) so it's easy to toggle and re-tune. Re-render
157a clip, extract a frame, `get_image (action:"analyze_color")` it, and nudge the factor to hit whitePoint ~250.
158 
159---
160 
161## Video-specific gotchas
162 
163- **Per-window drift (WAN long video).** WAN-Animate/long clips render in temporal windows and
164 can desaturate or dim across them. Measure a frame from the *first* window and a *late* window.
165 If they differ, that's drift, not a global grade issue; use the embeds' between-window
166 `colormatch`, not a final stretch.
167- **Relight LoRA is not levels.** A WanAnimate relight LoRA changes *lighting*, not black/white
168 points. It won't fix a compressed-range washout. Measure before and after to prove it.
169- **Don't over-stretch.** Watch `clippedHighPct`; blown highlights are unrecoverable. Prefer
170 the lower factor that still clears `dimHighlights`.
171- **Cast is often inherited.** If the reference has the same warm/cool cast (festival/sunset
172 light), a matching cast on the render is *correct*. Don't "fix" it.
173 
174---
175 
176## Quick reference — the decision tree
177 
178```
179get_image (action:"analyze_color") on the frame
180 ├─ washedOut / lowContrast / dimHighlights ........ contrast/levels stretch (AdjustContrast ~1.4 → measure)
181 ├─ lowSaturation only ............................. saturation boost (small)
182 ├─ colorCast (and reference is neutral) ........... white-balance / neutralization, or reference-match
183 ├─ want to MATCH a known-good graded frame ........ ColorMatchV2 / ImageColorMatchAdobe+ (ref must be good)
184 └─ blackPoint/whitePoint already 0/255, sat ok .... color is healthy — stop touching it
185```
186 
187Always re-measure after the fix. If `get_image (action:"analyze_color")` still flags it, the fix was wrong.
188Adjust and measure again. Numbers over vibes.
189 
190## Sources
191 
192- **Official:** comfyui-mcp `get_image` action:"analyze_color" product behavior (this repo).
193- **Empirical:** washed-out signature and levels-stretch recipes from observed renders.
194 

Discussion

Alternatives

Also in Accessibility