Nodes/ComfyUI CV/CV Embedding Match
ComfyUI Node

CV Embedding Match

Who is this face, and how sure are you?

By bmad4ever·Created 3 months ago·Updated 14 days ago· 1
CV Embedding Match
  • query
  • gallery
  • scores
  • best_index
  • best_score
  • is_match
  • any_match
◄metriccosine similarity (higher = same)►
◄threshold0.363►

Take a batch of face embeddings from the scene, take a gallery of embeddings of people you know, compare every row against every column, and return who each face is plus a yes/no you can branch on. That's the node. There is no model inside it, no download, no key - just the array maths cv2.FaceRecognizerSF.match would have done.

Why bother when the recognizer has a match function

Because writing it here means it works on anything. The node description makes the point directly: it needs no model file, so the embeddings can come from CV SFace Embeddings, from the backbone vectors of a CV Deep Features extractor, from DNN class scores, or from descriptors you built yourself. It's the identity-decision half of a face pipeline with the model-specific half removed, which is a genuinely useful thing to have as a separate node - you can tune the decision without re-running inference.

How it works

Both sides are L2-normalized first, which is what makes the two metrics the same geometry wearing different clothes. Cosine similarity is then the dot product of the normalized rows (the full (A, B) score matrix is one matmul), and normalized L2 is the distance between them. Best match is argmax for cosine, argmin for L2 - and the score at that position is what gets compared to threshold.

Then the outputs line up with the shapes you'd expect: scores is (A, B), best_index is (A,) int32 (-1 when the gallery is empty), best_score is (A,), and is_match is (A,) uint8 0/255. any_match is a single boolean for the whole call - handy for a top-level "did we recognize anybody" branch.

Empty in, empty out. If either input has no rows you get zero-filled arrays and False, not an exception. Dimension mismatch (query 128-D against a gallery 512-D) does raise, with a message telling you they must come from the same model - which is the error you'd actually want.

The one setting that matters

threshold is the whole decision, and it is always applied:

  • cosine similarity: match when score ≥ threshold
  • normalized L2: match when score ≤ threshold

The default of 0.363 is SFace's published cosine operating point, and 1.128 is SFace's normalized-L2 operating point - the node's tooltip tells you both, and tells you to change the threshold when you switch metric. Believe it. Keeping 0.363 while switching to L2 distance means "match if the distance is under 0.363" out of a possible range of about 0 to 2, which will reject nearly everything and quietly look like a broken gallery. It doesn't only pick how scores are computed; it's the accept/reject line, separately.

The right value depends on your embeddings and your tolerance for false accepts. For an unsupervised first pass, run the default, look at the scores matrix with Preview CV Array in heatmap mode, and read where genuine matches actually sit. Tune the threshold to that, not to a number from a paper about a different model.

Wiring

Typically query comes from CV SFace Embeddings on the crops of faces detected in the frame, gallery from the same node run over your known people (build it once, keep it in a note or a saved array). is_match is 0/255, so it works directly as labels for CV Draw Points, or as a mask to filter the query boxes. best_index tells you which known person matched. any_match goes into a control-flow branch.

Install

# ComfyUI Manager → search "ComfyUI CV" → install → restart
# or manually:
cd ComfyUI/custom_nodes
git clone https://github.com/bmad4ever/comfyui_cv
cd comfyui_cv && pip install -r requirements.txt

Python ≥ 3.12 and a recent ComfyUI (V3 node API) are required; the single runtime dependency is opencv-contrib-python-headless~=5.0.0.93. Keep the contrib wheel: installing plain opencv-python over it silently strips the contrib submodules and makes contrib nodes disappear - tools/repair_opencv_contrib.py --check then --apply diagnoses and fixes it.

Since this node does its own numpy work, the usual OpenCV-distribution caveats barely touch it - but its neighbours in the DNN family do. If CV SFace Embeddings throws a missing-model error, you're missing the SFace .onnx in ComfyUI/models/onnx; models aren't bundled, and the pack's model_sources.txt lists sources and licences.

When it misbehaves

  • Everything matches. Wrong metric/threshold pairing (L2 with a cosine threshold), or your embeddings aren't normalized by the model that made them - this node normalizes, but it can't fix a model that should have.
  • Nothing matches. Same problem in reverse, or a gallery of one person and a query crop that got the alignment wrong. Look at the heatmap before blaming the threshold.
  • Dimension error. Query and gallery from different models. Match the extractor.

bmad4ever is a small-tools ComfyUI author (a cartesian-product list node, an undo/redo extension) whose wider pack is a fork of geroldmeisinger's opencv-comfyui. He flags the whole thing as LLM-assisted and not production-ready - reasonable to read as "verify the logic before you ship a face-recognition product on it," less relevant when you're deciding which photo of your friend is which.

Categoryimage/CV/dnn

Inputs (4)

NameTypeDefaultDescription
queryNPARRAY(A, D) embeddings to identify - e.g. the faces found in the scene. A single (D,) vector is accepted as one row.
galleryNPARRAY(B, D) known reference embeddings, same dimension D. Each query row is compared against all of them.
metricCOMBOcosine similarity (higher = same)The two metrics cv2.FaceRecognizerSF.match implements. Cosine is a SIMILARITY (bigger is more alike, best match = maximum); normalized L2 is a DISTANCE (smaller is more alike, best match = minimum). This only chooses HOW scores are computed and compared - where the accept/reject line sits is entirely the 'threshold' input.
thresholdFLOAT0.363-1–100Decision threshold for 'is_match', always used: with cosine a match needs score >= threshold, with normalized L2 it needs score <= threshold. The right value depends on YOUR embeddings; as a reference, SFace's official face-identity operating points are 0.363 (cosine) and 1.128 (normalized L2), which is where the default comes from. Remember to change it when you switch metric.

Outputs (5)

NameTypeDescription
scoresNPARRAY(A, B) float32 score matrix, query rows x gallery columns - preview it with 'Preview CV Array' (heatmap) to see the whole comparison at once.
best_indexNPARRAY(A,) int32 index of the best gallery entry for each query row (-1 when the gallery is empty).
best_scoreNPARRAY(A,) float32 score of that best entry.
is_matchNPARRAY(A,) uint8 0/255 - whether the best score passes the threshold. Use it as labels for 'CV Draw Points' or to filter the query boxes.
any_matchBOOLEANTrue when at least one query row matched - branch on it with 'Basic data handling: IfElse'.