Image.FaceDetection answers "where are the faces in this image?". It returns a list of bounding boxes, confidence scores, and five facial landmarks (right eye, left eye, nose tip, right mouth corner, left mouth corner) per detected face.

Basic detection

iex> image = Image.open!("group.jpg")
iex> faces = Image.FaceDetection.detect(image)
iex> hd(faces)
%{
  box: {412, 88, 96, 124},
  score: 0.94,
  landmarks: [{438.2, 130.1}, {478.7, 129.6}, {458.0, 152.3}, {442.1, 178.5}, {475.0, 178.2}]
}

Each detection is a map with:

  • :box{x, y, width, height} in pixel coordinates of the original image
  • :score — confidence score in [0.0, 1.0]
  • :landmarks — a list of five {x, y} tuples: right eye, left eye, nose tip, right mouth corner, left mouth corner — in that order

Results are sorted by descending confidence.

Filtering by confidence

The default minimum score is 0.6. Raise it for stricter detections:

iex> Image.FaceDetection.detect(image, min_score: 0.8)

:nms_iou (default 0.3) controls how aggressively overlapping boxes are collapsed by non-maximum suppression. Lower values keep fewer overlapping faces.

Boxes only

When landmarks aren't needed, boxes/2 skips them:

iex> Image.FaceDetection.boxes(image)
[{412, 88, 96, 124}, {612, 102, 84, 110}]

Drawing detections

draw_boxes/3 overlays bounding boxes, the score as a percentage label, and the five landmark dots:

iex> faces = Image.FaceDetection.detect(image)
iex> annotated = Image.FaceDetection.draw_boxes(faces, image)
iex> Image.write!(annotated, "annotated.jpg")

Pipeline form:

iex> image
...> |> Image.FaceDetection.detect()
...> |> Image.FaceDetection.draw_boxes(image)
...> |> Image.write!("annotated.jpg")

Drawing options include :color, :stroke_width, :landmark_radius, :font_size, and :show_landmarks? (set to false to skip the dots).

Face-aware crop

crop_largest/2 is a convenience for the common "crop to the most prominent face" case (the wire-in point for face-aware crop bias used by gravity: :face in image_plug, ImageKit z-, and Cloudflare face-zoom):

iex> {:ok, portrait} = Image.FaceDetection.crop_largest(image, padding: 0.2)

The largest face is chosen by bounding-box area. :padding is a fraction of each face dimension — 0.0 is a tight crop, 0.5 adds 50% on each side, 1.0 doubles the box. The expanded crop is clipped to the image bounds.

When no face meets the score threshold, crop_largest/2 returns {:error, :no_face_detected}.

Default model

YuNet (opencv/face_detection_yunet) — the OpenCV team's production face detector. Roughly 340 KB on disk, MIT licensed, real-time on CPU. The 2023-March export produces decoded boxes, keypoints, and scores directly.

Model weights are downloaded on first call and cached. Configure the cache directory with:

config :image_vision, :cache_dir, "/path/to/cache"

Using a different model

detect/2 accepts :repo and :model_file to swap in a different YuNet ONNX export:

iex> Image.FaceDetection.detect(image,
...>   repo: "opencv/face_detection_yunet",
...>   model_file: "face_detection_yunet_2023mar.onnx"
...> )

Caveat: post-processor is YuNet 2023-March specific

The output decoder assumes YuNet's 2023-March 12-tensor convention (cls_*, obj_*, bbox_*, kps_* at strides 8/16/32, fixed 640×640 input). SCRFD, BlazeFace, and other face-detector exports produce different output shapes and need a different post-processor — they will not work as a drop-in replacement.

Dependencies

Face detection requires :ortex. Add to mix.exs:

{:ortex, "~> 0.1"}