Nodes/Portrait Dataset Tools/Portrait Quality Score (face size / clarity / grain gate)
ComfyUI Node

Portrait Quality Score (face size / clarity / grain gate)

The gate that decides which of your photos deserve a LoRA

By BillyBGit·Created 23 days ago·Updated 22 days ago· 1
Portrait Quality Score (face size / clarity / grain gate)
  • image
  • image_pass
  • image_reject
  • accepted
  • hard_reject
  • needs_refine
  • sr_short_edge
  • report
  • suffix
◄hard_floor_face_px200►
◄min_face_px500►
◄min_clarity60►
◄max_grain2.5►
◄sr_face_target_px800►
◄sr_max_short_edge2048►
◄stages1►
◄forceoff►
◄accepted_infalse►
◄hard_reject_infalse►
◄filename►
◄csv_path►
◄min_source_grain0.15►
◄min_texture_after_sr0.60►
◄review_texture_below0.85►
◄suffix_in►

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.

Categoryimage/analysis

Inputs (17)

NameTypeDefaultDescription
imageIMAGE—
hard_floor_face_pxINT2000–4096Face short edge below this is rejected outright, no refine (0 = off). Set on the FIRST stage only.
min_face_pxINT5000–4096Face short edge needed to pass.
min_clarityFLOAT600–10000Variance of Laplacian on the face at normalised scale. ~20 = soft, ~60 = acceptable, 150+ = crisp.
max_grainFLOAT2.50–50Noise sigma on flat skin at normalised scale. <0.7 clean, >2.5 visibly grainy.
sr_face_target_pxINT800256–2048Face size the super-res pass should aim for.
sr_max_short_edgeINT2048512–4096Cap on the SR short edge. 2048 is the proven SeedVR2 7b setting on 16GB.
stageSTRINGs1Label for the log / CSV row. Must end in the stage number (s1, s2, s3) for 'force' to work.
forceCOMBOoffManual 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_inoptBOOLEANfalse—
hard_reject_inoptBOOLEANfalse—
filenameoptSTRING—
csv_pathoptSTRINGAppend one row per scoring here (empty = off).
min_source_grainoptFLOAT0.150–5Stage 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_sroptFLOAT0.600–5Stages 2+. Skin texture below this after super-res = waxy (the source had no real pores to recover). Rejected with reason. 0 = off.
review_texture_belowoptFLOAT0.850–5Stages 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_inoptSTRINGPrevious stage's suffix output; carried through on passthrough so a '_rev' raised at s2 reaches the filename.

Outputs (8)

NameTypeDescription
image_passIMAGE—
image_rejectIMAGE—
acceptedBOOLEAN—
hard_rejectBOOLEAN—
needs_refineBOOLEAN—
sr_short_edgeINT—
reportSTRING—
suffixSTRING—