Skin Tone Match (fix a colour cast, anchored on skin)
Kill the colour cast without touching your exposure
- image
- reference
- image
- applied
- report
The problem: a whole set with one weird tint
Colourised scans and cheap print scans don't come back neutral. They come back as one orange wash, or one teal wash - high chroma, all of it the same hue. The standard fix is a whole-image histogram match against a reference, and it works, but it drags luminance along with it and repaints the background to match. Your exposure and your detail go with the cast.
Skin Tone Match fixes the cast by anchoring on the only part of the image you know the colour of: the skin. It shifts Lab a and b globally so the skin lands on a target, and leaves L completely untouched, so brightness and detail survive intact.
How it works
It locates the face with the same InsightFace landmarks the rest of the pack uses, builds a small mask of three patches - left cheek, right cheek, forehead - and takes the mean Lab a and b on that mask. Then it computes the offset to your target and applies it to the entire image's a/b channels, scaled by strength.
If you wire the optional reference image, the targets come from its skin patches instead of your numbers - measure one, match the rest. That's the cleanest version for a batch.
Then there's a gate you'll want to understand before you're surprised by it. only_if_hue_spread_below (0.5) means the node checks whether the image is a single-hue wash first, using the same chroma/hue-spread maths as ImageChromaCheck. A naturally multi-hued photo - real colours, multiple hues present - passes straight through unchanged. Set it to 0 and it applies every time. For a batch of vintage scans this default is exactly right; for a batch of modern colour photos it's why nothing happens.
The two numbers that decide everything
target_a and target_b are Lab coordinates for skin, and they are not equal in importance. a (red–green) sits around 10–12 across datasets and is stable. b (yellow–blue) is dataset-specific and the author says so in the tooltip: medians seen are about 14.3 for a warm/European set and 9.2 for an East Asian set. Set b too high and every face in your set goes yellow.
Which means: measure your own set. PortraitQualityScore's csv_path will happily log per-image skin measurements across a folder, and the median from that - not a number from someone else's README - is your b target. The README's warning is blunt and correct: using a target from another dataset shifts every face toward that dataset's tone, and a training set with a systematically wrong skin tone teaches the LoRA a colour it never had.
Inputs, outputs, wiring
Required: image, target_a (11), target_b (14), strength (1.0 - pull it to 0.5 if the correction is too aggressive), only_if_hue_spread_below (0.5). Optional: reference.
Outputs: image, applied (boolean), report (a string like applied: skin ab (12.4,6.1) -> (11.0,14.0) [canonical] shift (-1.4,+7.9) cast chroma=6.8 hue_spread=0.31). The report is the reason to use this node rather than a black-box grade node: it tells you the direction and size of the shift it just made, which is how you find out your target is wrong. Wire applied into a counter if you want to know what fraction of a batch got treated.
Install
ComfyUI Manager: search Portrait Dataset Tools. Manual:
cd ComfyUI/custom_nodes
git clone https://github.com/BillyBGit/ComfyUI-PortraitDatasetTools
pip install -r ComfyUI-PortraitDatasetTools/requirements.txt
insightface, opencv-python, numpy, Pillow, onnxruntime, plus the ~300 MB buffalo_l download on first face-related run.
Where people get burned
applied: false is a silent no-op unless you look for it. It means either no face was found for the skin anchor or the image wasn't a single-hue wash. Both are legitimate reasons, and both look exactly like "the node did nothing".
More subtly: the shift is global. On a head crop that's perfect - skin is basically the whole subject. On a full landscape with a colour you care about, shifting a/b across the frame to fix the face will move that background too. Crop first, then match tone, then save.
And keep it in its lane. This fixes a colour cast; it is not a grade. If you're chasing a specific look for the training set, the honest advice from the photorealism side of the fence is that AI images already skew oversaturated, so pulling saturation down a little helps more than pushing skin toward a warmer target. Match the cast, then stop.
Inputs (6)
| Name | Type | Default | Description |
|---|---|---|---|
| image | IMAGE | — | |
| target_a | FLOAT | 11.0-60–60 | Skin Lab a (red-green) when no reference. MEASURE YOUR SET: the survey script prints skin_a. typical medians 10-12 across sets; a is stable. |
| target_b | FLOAT | 14.0-60–60 | Skin Lab b (yellow-blue) when no reference. THIS IS THE DATASET-SPECIFIC ONE: measure YOUR set with tools/survey.py. Medians seen: 14.3 (warm/European), 9.2 (East Asian). Too high = skin goes yellow. |
| strength | FLOAT | 1.000–1 | — |
| only_if_hue_spread_below | FLOAT | 0.500–3 | — |
| referenceopt | IMAGE | — |
Outputs (3)
| Name | Type | Description |
|---|---|---|
| image | IMAGE | — |
| applied | BOOLEAN | — |
| report | STRING | — |