Annotate images and videos with YuNet and CLI automation
YuNet is a compact face detector available through OpenCV. A small CLI can use it to annotate local images and short constant-frame-rate videos, drawing green boxes around detected faces. The examples below preserve decoded image dimensions and give each batch input its own output. Detection finds face-like regions; it does not identify people or provide an authentication decision.
Understand YuNet
YuNet offers a lightweight CPU-oriented option for detecting faces and facial landmarks. Accuracy and speed depend on image size, face size, pose, lighting, threshold, and hardware. See the OpenCV Zoo model documentation for the model’s published evaluation. This walkthrough processes local files; it does not establish real-time camera performance.
Set up YuNet quickly
Use Bash on Linux, Python with venv and pip, cURL, and a filesystem that supports hard links. This
walkthrough was tested on Linux x86-64 with Python 3.14.7, the opencv-python-headless 5.0.0.93 wheel
(OpenCV 5.0.0), and NumPy 2.4.6. The Python patch and wheel build are tested pins; the video path
uses OpenCV 5’s boolean write status. Use a
supported Python release with a compatible wheel from the
package’s release page.
Other runtime and platform combinations need their own validation.
Start in an empty working directory. This CLI does not open GUI windows, so install the headless
wheel. OpenCV’s wheels share the cv2 namespace: install one variant per environment.
(
set -e
command -v python3 >/dev/null || { printf 'Missing prerequisite: python3\n' >&2; exit 1; }
if [ -e venv ] || [ -L venv ]; then
printf 'Choose a fresh directory; venv already exists\n' >&2
exit 1
fi
python3 -m venv venv
venv/bin/python -m pip install opencv-python-headless==5.0.0.93 numpy==2.4.6
)
The parentheses keep failure handling inside a subshell. A failed environment creation stops before
installation; a failed installation may leave a partial venv. Continue in a new empty directory
instead of reusing that environment. Each command below names its interpreter explicitly, so you do
not need to activate the environment.
Download the tested model bytes from the pinned OpenCV Zoo revision and verify their checksum before publishing the filename the scripts use:
(
set -e
for tool in curl mktemp ln rm; do
command -v "$tool" >/dev/null || { printf 'Missing prerequisite: %s\n' "$tool" >&2; exit 1; }
done
test -x venv/bin/python
if [ -e face_detection_yunet.onnx ] || [ -L face_detection_yunet.onnx ]; then
printf 'Model already exists; verify it or choose a fresh directory\n' >&2
exit 1
fi
model_tmp=$(mktemp './.yunet-model.XXXXXX')
trap 'rm -f "$model_tmp"' EXIT
curl -fsSLo "$model_tmp" \
https://media.githubusercontent.com/media/opencv/opencv_zoo/47534e27c9851bb1128ccc0102f1145e27f23f98/models/face_detection_yunet/face_detection_yunet_2023mar.onnx
venv/bin/python - "$model_tmp" <<'PY'
import hashlib
from pathlib import Path
import sys
expected = '8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4'
if hashlib.sha256(Path(sys.argv[1]).read_bytes()).hexdigest() != expected:
sys.exit('Unexpected model checksum')
PY
ln "$model_tmp" face_detection_yunet.onnx
)
The model is 232,589 bytes. A failed request or checksum check removes the temporary download and leaves the final filename absent; retry the same block after fixing the failure. An existing model is preserved. Keep it beside all three scripts below.
Automate single-image detection
Save this shared implementation as face_tools.py. OpenCV’s
FaceDetectorYN API expects its input
size to match the frame it receives. The helper resizes the inference frame, then scales the
detected boxes back to the original image for drawing.
from contextlib import contextmanager
import math
import os
from pathlib import Path
from tempfile import TemporaryDirectory
# Set before importing OpenCV so image decoding has an explicit pixel ceiling.
os.environ.setdefault('OPENCV_IO_MAX_IMAGE_PIXELS', '20000000')
import cv2
MODEL_PATH = Path(__file__).with_name('face_detection_yunet.onnx')
def create_detector():
return cv2.FaceDetectorYN.create(str(MODEL_PATH), '', (320, 320), 0.9, 0.3, 5000)
def annotate(detector, image, max_side=960):
height, width = image.shape[:2]
if width * height > 20_000_000:
raise ValueError('Frame exceeds the pixel limit')
scale = min(1.0, max_side / max(width, height))
small = cv2.resize(image, (max(1, round(width * scale)), max(1, round(height * scale))))
detector.setInputSize((small.shape[1], small.shape[0]))
_, faces = detector.detect(small)
count = 0 if faces is None else len(faces)
if faces is not None:
scale_x = width / small.shape[1]
scale_y = height / small.shape[0]
for face in faces:
x, y, w, h = face[:4]
left, top = max(0, round(x * scale_x)), max(0, round(y * scale_y))
right = min(width - 1, round((x + w) * scale_x))
bottom = min(height - 1, round((y + h) * scale_y))
cv2.rectangle(image, (left, top), (right, bottom), (0, 255, 0), 2)
return image, count
@contextmanager
def new_output(path):
output = Path(path).absolute()
if output.exists() or output.is_symlink():
raise ValueError('Output must not exist')
with TemporaryDirectory(prefix='.face-output-', dir=output.parent) as temporary:
candidate = Path(temporary) / ('result' + output.suffix)
yield candidate
if not candidate.is_file() or candidate.stat().st_size == 0:
raise ValueError('No nonempty output produced')
os.link(candidate, output)
def detect_image(detector, source, output):
source = Path(source)
if source.suffix.lower() not in {'.jpg', '.jpeg', '.png'}:
raise ValueError('Use a JPEG or PNG image')
if not source.is_file() or source.stat().st_size > 50 * 1024 * 1024:
raise ValueError('Use a regular image file no larger than 50 MiB')
image = cv2.imread(str(source))
if image is None:
raise ValueError('Cannot decode image')
annotated, count = annotate(detector, image)
with new_output(output) as candidate:
if not cv2.imwrite(str(candidate), annotated):
raise ValueError('Cannot encode output image')
return count
def detect_video(detector, source, output):
if not Path(source).is_file():
raise ValueError('Use a local video file')
capture = cv2.VideoCapture(str(Path(source).absolute()))
writer = None
frames = 0
detections = 0
try:
if not capture.isOpened():
raise ValueError('Cannot open video')
fps = capture.get(cv2.CAP_PROP_FPS)
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
if not math.isfinite(fps) or fps <= 0 or width <= 0 or height <= 0:
raise ValueError('Invalid video timing or dimensions')
if width * height > 20_000_000 or width % 2 or height % 2:
raise ValueError('Use bounded, even video dimensions')
with new_output(output) as candidate:
writer = cv2.VideoWriter(str(candidate), cv2.VideoWriter_fourcc(*'mp4v'), fps, (width, height))
if not writer.isOpened():
raise ValueError('Cannot open video encoder')
while True:
ok, frame = capture.read()
if not ok:
break
if frame.shape[:2] != (height, width):
raise ValueError('Frame dimensions changed')
annotated, count = annotate(detector, frame)
if not writer.write(annotated):
raise ValueError('Cannot encode video frame')
frames += 1
detections += count
writer.release()
writer = None
if frames == 0:
raise ValueError('No frames decoded')
return frames, detections
finally:
capture.release()
if writer is not None:
writer.release()
Save this caller as detect_faces.py in the same directory:
import argparse
import sys
from face_tools import create_detector, detect_image, detect_video
import cv2
parser = argparse.ArgumentParser()
parser.add_argument('mode', choices=['image', 'video'])
parser.add_argument('input')
parser.add_argument('output')
args = parser.parse_args()
try:
detector = create_detector()
if args.mode == 'image':
print(f'Detected {detect_image(detector, args.input, args.output)} faces')
else:
frames, detections = detect_video(detector, args.input, args.output)
print(f'Processed {frames} frames with {detections} face detections')
except (OSError, ValueError, cv2.error):
sys.exit(f'{args.input}: Face detection failed; check input, model, codec, and destination')
Run venv/bin/python detect_faces.py image photo.jpg annotated.jpg with your own JPEG or PNG.
Open the result and check that the green boxes surround the faces you expect. A valid image with no
detected faces still produces an output and reports zero detections. Use .jpg or .png for the
output, and create its parent directory first; existing destinations are refused.
OpenCV decodes these images as 8-bit color pixels. Transparency, higher bit depth, color profiles, and source metadata are not preserved. Dimensions refer to the decoded image, including OpenCV’s automatic EXIF orientation. JPEG output is lossy; use PNG when you need unchanged pixels outside the boxes. In testing, a JPEG with its final bytes removed still decoded successfully, with missing pixels filled in. Inspect the whole image, including its edges, and use intact, trusted inputs; this CLI does not validate input integrity.
Batch-process folders
Reuse one detector sequentially for the top-level JPEG and PNG files in a folder. Save this as
batch_detect.py:
import argparse
from pathlib import Path
import sys
from face_tools import create_detector, detect_image
import cv2
parser = argparse.ArgumentParser()
parser.add_argument('input_directory')
parser.add_argument('output_directory')
args = parser.parse_args()
try:
source = Path(args.input_directory)
images = sorted(path for path in source.iterdir() if path.is_file() and not path.is_symlink()
and path.suffix.lower() in {'.jpg', '.jpeg', '.png'})
if not images:
sys.exit('No supported images found')
detector = create_detector()
output = Path(args.output_directory)
output.mkdir(mode=0o700)
failures = 0
for image in images:
try:
count = detect_image(detector, image, output / (image.name + '.jpg'))
print(f'{image.name}: {count} faces')
except (OSError, ValueError, cv2.error):
failures += 1
print(f'Failed: {image.name}', file=sys.stderr)
sys.exit(1 if failures else 0)
except (OSError, ValueError, cv2.error):
sys.exit('Cannot prepare batch; check model and directories')
Run venv/bin/python batch_detect.py images new-results. The results directory must be new. Suffix
matching is case-insensitive, files are sorted by filename, and subfolders and symlink entries are
skipped. Full source filenames are retained before adding .jpg, so photo.jpg and photo.png
produce photo.jpg.jpg and photo.png.jpg. Failed files are named on stderr and count toward a
nonzero batch status; successful results remain available. Retry into another new results directory.
Quote paths containing spaces. For an input or destination name starting with a hyphen, pass a path
such as ./-photo.png so argparse does not interpret the name as an option.
Handle video streams
Run venv/bin/python detect_faces.py video input.mp4 annotated.mp4 for a short, local
constant-frame-rate video with even decoded dimensions. The output contains annotated video only:
audio is not copied. The detection total counts observations across frames, not distinct people.
Variable-frame-rate timing and live camera reconnection need a different workflow.
OpenCV’s failed frame read can mean end-of-file or a decoder problem. A test clip cut inside a late packet produced a shorter playable result and a success message. The pinned writer returns a status per frame, which this CLI checks, but some backends report only best-effort success. Compare the result’s decoded frame count and frame rate with the source, check that its duration is the frame count divided by the frame rate, and play the beginning and end. Its success message alone does not prove complete media integrity. Rotation metadata can change coded dimensions; compare the displayed, decoded frames, which the output encodes in their oriented form.
Tune for real-time speed
Reduce max_side in annotate to actually resize inference frames, then measure the tradeoff on
small and distant faces. Keep one detector per worker process; do not share it across simultaneous
calls. A CPU wheel does not automatically provide CUDA support. GPU acceleration requires a
compatible OpenCV build, hardware, and a separately validated backend configuration.
For untrusted media, use isolated workers and whole-job time, memory, and disk limits. The image byte and pixel checks do not bound every video-decoder allocation or total processing time.
Compare another detector on the same representative faces and hardware if YuNet misses your accuracy or latency target. Include decoding, resizing, detection, and encoding in elapsed time; inference time alone does not tell you whether the complete pipeline keeps up with a live source.
For managed vision workflows, see Transloadit’s Artificial Intelligence service.
