A face-swap tool can be pointed at the wrong content. That is not a hypothetical, it is the first thing some users try. So before the swap runs, there is a check: a small classifier looks at the target frame, and if it sees explicit content, the swap is skipped. The original frame goes through untouched.
This is a walkthrough of that check in an open-source repo, down to the labels and the number that decides. It is short code. The interesting part is where it sits in the pipeline and what it does when it fires.
The check sits in front of the swap, not after it. Nothing explicit ever gets a new face. Photo: Unsplash.
What the check actually is
The classifier is NudeNet, run as an ONNX model called filter.onnx. It is a single-stage object detector, the same family as YOLO: you give it an image, it gives you back boxes, each box with a class label and a confidence score. It has no academic paper, so I will describe it only from what the code does with it.
The model knows 18 labels. Here they are, straight from the file:
__labels = [
"FEMALE_GENITALIA_COVERED", "FACE_FEMALE", "BUTTOCKS_EXPOSED",
"FEMALE_BREAST_EXPOSED", "FEMALE_GENITALIA_EXPOSED", "MALE_BREAST_EXPOSED",
"ANUS_EXPOSED", "FEET_EXPOSED", "BELLY_COVERED", "FEET_COVERED",
"ARMPITS_COVERED", "ARMPITS_EXPOSED", "FACE_MALE", "BELLY_EXPOSED",
"MALE_GENITALIA_EXPOSED", "ANUS_COVERED", "FEMALE_BREAST_COVERED",
"BUTTOCKS_COVERED",
]
Notice the split: most labels come in COVERED and EXPOSED pairs. The model is not a single yes/no nudity switch. It localizes specific body parts and tells you whether each one is covered or exposed. That distinction is the whole basis of the gate, because the gate only cares about the exposed ones.
Preprocessing: letterbox to 320x320
The input side is plain object-detector plumbing, but it matters because the boxes have to map back to the original image. The model wants a square 320x320 input. An arbitrary photo is not square, so the image is resized to fit one dimension, then padded with black bars to fill the rest:
def _read_image(img, target_size=320):
img_height, img_width = img.shape[:2]
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
aspect = img_width / img_height
if img_height > img_width:
new_height = target_size
new_width = int(round(target_size * aspect))
else:
new_width = target_size
new_height = int(round(target_size / aspect))
resize_factor = math.sqrt(
(img_width**2 + img_height**2) / (new_width**2 + new_height**2)
)
img = cv2.resize(img, (new_width, new_height))
# ... pad with cv2.copyMakeBorder to reach target_size ...
image_data = img.astype("float32") / 255.0
image_data = np.transpose(image_data, (2, 0, 1)) # HWC -> CHW
image_data = np.expand_dims(image_data, axis=0) # add batch dim
return image_data, resize_factor, pad_left, pad_top
The three values returned alongside the tensor, resize_factor, pad_left, and pad_top, are the undo instructions. The detector finds boxes in the padded 320x320 space, and these let postprocessing put them back where they belong in the real image. Pixels are scaled to 0..1 and the layout is flipped from height-width-channels to channels-first, which is what the ONNX graph expects.
Postprocessing: two thresholds, then NMS
The model outputs one row per candidate box. Each row is four box coordinates followed by the per-class scores. Postprocessing keeps a candidate only if its best class scores at least 0.2, then maps the box back to image space:
for i in range(rows):
classes_scores = outputs[i][4:]
max_score = np.amax(classes_scores)
if max_score >= 0.2:
class_id = np.argmax(classes_scores)
x, y, w, h = outputs[i][0], outputs[i][1], outputs[i][2], outputs[i][3]
left = int(round((x - w * 0.5 - pad_left) * resize_factor))
top = int(round((y - h * 0.5 - pad_top) * resize_factor))
# ... collect boxes, scores, class_ids ...
indices = cv2.dnn.NMSBoxes(boxes, scores, 0.25, 0.45)
That 0.2 is a loose first pass: keep almost anything the model is not sure is nothing. The pad_left and pad_top subtractions strip off the black bars, and multiplying by resize_factor scales the box back up to the original resolution. Then non-maximum suppression (NMSBoxes with a 0.25 score cutoff and 0.45 IoU) throws away overlapping duplicate boxes so each region is reported once.
The output of detect() is a list of dicts, each {"class", "score", "box"}. That is the raw material. The gate is built on top of it.
The gate itself: status()
This is the method the pipeline calls. It runs detection, keeps only the labels it cares about above a strict score, and returns a single boolean:
def status(self, img, classes=None):
detections = self.detect(img)
if classes is None:
classes = [
"BUTTOCKS_EXPOSED",
"FEMALE_BREAST_EXPOSED",
"FEMALE_GENITALIA_EXPOSED",
#"MALE_BREAST_EXPOSED",
"ANUS_EXPOSED",
"MALE_GENITALIA_EXPOSED",
]
detections = [d for d in detections
if d["class"] in classes and d["score"] > 0.7]
if len(detections) > 0:
return False
return True
Read the return values carefully, because the polarity is easy to get backwards. True means clean, go ahead. False means a blocking detection was found, stop.
Three design choices live in those lines:
-
Only
EXPOSEDclasses block. The default list is five exposed body parts. TheCOVEREDvariants are not in it. A swimsuit or underwear photo, which the model would tag asCOVERED, does not trip the gate. The tool is filtering explicit nudity, not skin. - The threshold jumps from 0.2 to 0.7. Detection keeps candidates at 0.2 so nothing is missed early. The gate then demands 0.7 confidence before it will block. That gap is a deliberate bias toward not falsely rejecting clean content. A low-confidence guess does not stop a user's legitimate edit.
-
MALE_BREAST_EXPOSEDis commented out. A bare male chest is an everyday photo, so it was pulled from the block list. That one commented line is a small, honest record of a real product call: where to draw the line between explicit and ordinary.
There is also a sibling method, censor(), that uses the same class list and threshold but instead of returning a boolean it paints black rectangles over the detected boxes. The pipeline does not use censor() for the swap gate; it uses status(). Same detector, two policies, and the swap path chose the hard stop.
Where it is wired in
A detector that nobody calls is decoration. The point of this article is the wiring, so here is every place the swap pipeline consults the gate. It is constructed once, in the processor's __init__:
self.filter_model = NudeDetector(
providers=self.access_providers if self.device != "cpu" else None
)
Then it guards every swap path. For a single image:
def swap_image(self, target_frame, source_face, face_fields, save_dir, multiface=False):
save_file = os.path.join(save_dir, "swapped_image.png")
if self.filter_model.status(target_frame):
# ... detect faces, run face_swap_model.get(...) ...
else:
print("Face swap cannot be applied to nude or explicit content. "
"Please upload images with appropriate content.")
cv2.imwrite(save_file, target_frame) # save the ORIGINAL, unswapped
return target_frame
For video on the threaded CPU path, the same guard sits inside the per-frame worker:
def process_frame(self, args):
frame, face_det_result, source_face, ... = args
tmp_frame = frame.copy()
if self.filter_model.status(tmp_frame):
for face in face_det_result:
if face is None:
break
tmp_frame = self.face_swap_model.get(tmp_frame, face, source_face, paste_back=True)
else:
print("Face swap cannot be applied to nude or explicit content. ...")
cv2.imwrite(filename, tmp_frame) # explicit frame is written unmodified
return filename
And the CUDA video path has the identical check before its swap call. Three entry points, one rule: status() runs on the target frame first, and face_swap_model.get(...) only runs if it returns True.
Two details worth pulling out:
- The check runs on the target, per frame. The target is the content you are swapping a face into. For a video, that means the classifier runs on every frame independently. A clip that is clean for ten seconds and explicit for two will get faces swapped only on the clean frames. The block is granular, not all-or-nothing for the file.
-
A blocked frame is not dropped, it is passed through. Look at the
elsebranches: the original frame still gets written to disk and into the output video. The gate removes the swap, not the content. The user gets their video back, just without a new face on the frames that tripped the filter.
The face side, for context
The swap that the gate protects is built on InsightFace. Recognition uses the buffalo_l pack, which is an ArcFace embedding model (Deng et al., CVPR 2019). ArcFace turns a face into a vector where same-person faces sit close together, which is how the pipeline finds and aligns the right face to replace. The gate is upstream of all of it. The recognition and the swap model never even see a frame that status() rejected.
Gotchas if you reuse this
A few things that will bite you if you lift this pattern into your own pipeline.
Get the boolean polarity right. status() returns True for clean. I have seen people wire if status(): block() and silently invert the whole safety behavior. Test both cases with a known-clean and a known-explicit image before you trust it.
It runs on CPU by default, and per frame. In the constructor, the detector is given the CUDA provider only when a GPU device is set; otherwise it is CPU. On a long video the gate is real per-frame work. It is cheap next to the swap itself, but it is not free, and it is in the hot loop.
A detector is not a guarantee. This is a 320x320 model with a 0.7 threshold tuned to avoid false blocks. It will miss things, and it can be fooled. It is a reasonable first line, not a proof. If you ship a tool like this, the filter is one layer; clear terms of use and the open-source license are others.
The threshold and class list are policy, not magic numbers. The 0.7 cutoff and the five-item list are where "what counts as explicit" is actually decided in code. If you change them, you are changing the product's stance, not tuning performance. The commented-out male chest line is the example to study.
![]() |
About the author. I'm Wlad Radchenko, a software engineer. The code in this article comes from Wunjo Make (open source), local software for video makers, and Wunjo Design, an offline PWA for designers. Get in touch to find more on GitHub and LinkedIn. |
Takeaway
The safety gate is about thirty lines of real logic. A YOLO-style ONNX model detects body parts, status() keeps only the five exposed classes above 0.7 confidence and returns a boolean, and three swap entry points refuse to run the swap when that boolean is False, passing the original frame through instead. The check runs on the target content, per frame, before recognition or swapping touches it.
The numbers are the policy. The 0.7 threshold biases toward not blocking clean work, the exposed-only class list filters nudity rather than skin, and the commented MALE_BREAST_EXPOSED line marks one place the line was drawn by hand. The code is in visual_processing/face_swap/nudenet.py and swap.py in the Wunjo Make repo.
References
- Deng, Guo, Xue, Zafeiriou. "ArcFace: Additive Angular Margin Loss for Deep Face Recognition." CVPR 2019. arXiv:1801.07698 (InsightFace / buffalo_l recognition.)

Top comments (0)