Portrait Quality Score (face size / clarity / grain gate)
The gate that decides which of your photos deserve a LoRA
- image
- image_pass
- image_reject
- accepted
- hard_reject
- needs_refine
- sr_short_edge
- report
- suffix
What it is
This is the decision node in the pack, and it's the one worth understanding even if you use nothing else. Point it at a photo and it measures four things about the face - how many native pixels the face actually has, how sharp it is, how much sensor noise sits on the skin, and how colourful it is - then emits routing signals: pass, needs super-res, or reject.
The reason to care is the thing everybody gets wrong. "Run everything through the upscaler" burns hours on images that were already fine, and on images whose face had no detail it invents plausible-looking pores that your LoRA then learns as reality. Measuring first is the whole idea.
How it works
Everything is measured on the face region at a normalised scale, not on the whole frame. The detector finds the largest face, the face region (plus 15% padding) is scaled so its short edge is 512px, and the metrics are computed there. That means a sharp face in front of bokeh passes and a sharp background behind a soft face fails - and a 219px source is comparable to a 6590px one, which is what makes the thresholds mean anything across a mixed dataset.
Two metrics are worth knowing by name. Clarity is the variance of the Laplacian on that normalised face region - the standard "how much high-frequency structure is here" measure. Grain is the Immerkaer fast noise estimate on flat skin patches (two cheeks, forehead, picked by landmarks): mean absolute response to a small Laplacian kernel, scaled, which mostly ignores edges.
Verdicts come out as pass, needs-refine or hard reject with reasons attached. sr_short_edge is the size the super-res pass should aim for, capped by sr_max_short_edge - 2048 is the author's proven SeedVR2 7b setting on 16GB.
The settings a beginner actually touches
hard_floor_face_px (200) is a face too small to be worth anything: rejected outright, never refined, and the tooltip says only set it on the first stage. min_face_px (500) is the face short edge you need to pass. min_clarity (60) and max_grain (2.5) are the sharpness and noise gates. min_source_grain (0.15, stage 1 only) is the clever one: skin with no sensor noise at all isn't a photograph - it's an illustration, an airbrushed export, or AI output - so it's a hard reject with no super-res, because super-res would only produce wax. min_texture_after_sr (0.6) and review_texture_below (0.85) are the post-SR equivalents, and anything between 0.6 and 0.85 is accepted but tagged _rev so you can eyeball it.
stage is a label that must end in the stage number; force (off / accept / refine_1x / refine_2x) is a manual override wired from one primitive so all stages agree; suffix_in carries the previous stage's suffix so a _rev raised at s2 reaches the filename. accepted_in and hard_reject_in let later stages see the verdict was already made and pass it along - that's what makes a three-stage graph cheap.
Outputs
image_pass and image_reject are the split, and the unused one comes back blocked rather than duplicated. accepted, hard_reject and needs_refine are the booleans you wire into LazyImageSelect to gate the expensive branch. sr_short_edge goes into your resize node. report and suffix go to your save node and log; csv_path appends one row per scoring, which is how you survey your own set.
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 one-time buffalo_l download (~300 MB) to ~/.insightface/models/.
Where people get burned
The defaults are somebody else's dataset. They came from a survey of the author's first run, and the README says to redo it on yours. Point csv_path at a file, score your whole candidate set, and read the medians before trusting a threshold - you want your cut in a valley in the distribution, not on a peak. The tooltips reference a tools/survey.py; if your checkout doesn't ship one, the CSV rows give you the same picture.
Texture can't tell pores from dither. The author's own warning, and the honest limit of the metric: a VAE spraying uniform dither, an edit model laying a regular dot lattice, and a base model keeping the input's chroma noise all scored higher than real skin while looking obviously wrong at 1:1. A metric rise is necessary, not sufficient.
Face pixel size beats face-area percentage. You're cropping to the head anyway, so what predicts your final crop is native pixels on the face. A 4000px landscape with a 300px face is worse material than a 1200px headshot, even though one is "high resolution".
Reference numbers, so you can read your own. On a 1024 crop with an ~800px face: grain under 0.15 is no sensor noise at all, 0.4–0.7 is waxy (super-res invented it), 1.2–2.5 is real photographic skin. If your whole set lands in the 0.4–0.7 band you don't have a threshold problem, you have a source problem.
Inputs (17)
| Name | Type | Default | Description |
|---|---|---|---|
| image | IMAGE | — | |
| hard_floor_face_px | INT | 2000–4096 | Face short edge below this is rejected outright, no refine (0 = off). Set on the FIRST stage only. |
| min_face_px | INT | 5000–4096 | Face short edge needed to pass. |
| min_clarity | FLOAT | 600–10000 | Variance of Laplacian on the face at normalised scale. ~20 = soft, ~60 = acceptable, 150+ = crisp. |
| max_grain | FLOAT | 2.50–50 | Noise sigma on flat skin at normalised scale. <0.7 clean, >2.5 visibly grainy. |
| sr_face_target_px | INT | 800256–2048 | Face size the super-res pass should aim for. |
| sr_max_short_edge | INT | 2048512–4096 | Cap on the SR short edge. 2048 is the proven SeedVR2 7b setting on 16GB. |
| stage | STRING | s1 | Label for the log / CSV row. Must end in the stage number (s1, s2, s3) for 'force' to work. |
| force | COMBO | off | Manual override, ignores all thresholds. accept = keep as-is at stage 1; refine_1x = one SR pass then keep; refine_2x = two SR passes then keep. Wire one primitive into every stage so they agree. |
| accepted_inopt | BOOLEAN | false | — |
| hard_reject_inopt | BOOLEAN | false | — |
| filenameopt | STRING | — | |
| csv_pathopt | STRING | Append one row per scoring here (empty = off). | |
| min_source_grainopt | FLOAT | 0.150–5 | Stage 1 only. Skin noise below this = no sensor noise at all: illustration, airbrushed export, AI image. Hard reject, no SR (it would only produce wax). 0 = off. |
| min_texture_after_sropt | FLOAT | 0.600–5 | Stages 2+. Skin texture below this after super-res = waxy (the source had no real pores to recover). Rejected with reason. 0 = off. |
| review_texture_belowopt | FLOAT | 0.850–5 | Stages 2+. Texture between min_texture_after_sr and this = accepted but the suffix output becomes '_rev' so you can eyeball those files. 0 = off. |
| suffix_inopt | STRING | Previous stage's suffix output; carried through on passthrough so a '_rev' raised at s2 reaches the filename. |
Outputs (8)
| Name | Type | Description |
|---|---|---|
| image_pass | IMAGE | — |
| image_reject | IMAGE | — |
| accepted | BOOLEAN | — |
| hard_reject | BOOLEAN | — |
| needs_refine | BOOLEAN | — |
| sr_short_edge | INT | — |
| report | STRING | — |
| suffix | STRING | — |