We Built Face Recognition with OpenCV — and a Printed Photo Got Right Through (C++)

Suppose you have built a face recognition pipeline using nothing but OpenCV’s FaceDetectorYN and FaceRecognizerSF classes, with the YuNet (face detection) and SFace (face recognition) models from the OpenCV Zoo. The enrolled user is accepted, other people are rejected, and everything works as expected.

Now, what happens if the thing in front of the camera is not the enrolled user, but a printed photo of the enrolled user’s face?

Short answer: it gets through. With an ordinary color printout, the cosine similarity clears the match threshold. Holding up a smartphone that shows the same photo works just as well.

We have measured this behavior many times while developing and evaluating our liveness detection technology at Swallow Incubate. The code below is a single file: download the two ONNX models from the OpenCV Zoo (face_detection_yunet_2023mar.onnx and face_recognition_sface_2021dec.onnx), and you can reproduce it with your own face. We used OpenCV 4.x (the 2023mar YuNet model requires OpenCV 4.8 or later).


The experiment

We create a feature vector from an enrollment image, then keep matching whatever face appears in the camera feed against it.

Case What is in front of the camera
1 The enrolled person (live)
2 A printed photo of the enrolled person’s face
3 A smartphone screen showing the enrolled person’s face

Code (C++)

#include <opencv2/opencv.hpp>
#include <iostream>

int main()
{
    auto detector = cv::FaceDetectorYN::create(
        "face_detection_yunet_2023mar.onnx", "", cv::Size(320, 320));
    auto recognizer = cv::FaceRecognizerSF::create(
        "face_recognition_sface_2021dec.onnx", "");

    // Build a feature vector from the enrollment image (a frontal photo of the user)
    cv::Mat enroll = cv::imread("enroll.jpg");
    if (enroll.empty()) {
        std::cerr << "Cannot read enroll.jpg" << std::endl;
        return -1;
    }
    detector->setInputSize(enroll.size());
    cv::Mat facesEnroll;
    detector->detect(enroll, facesEnroll);
    if (facesEnroll.rows < 1) {
        std::cerr << "No face found in the enrollment image" << std::endl;
        return -1;
    }
    cv::Mat alignedE, featEnroll;
    recognizer->alignCrop(enroll, facesEnroll.row(0), alignedE);
    recognizer->feature(alignedE, featEnroll);
    featEnroll = featEnroll.clone();

    // Keep matching "whatever" is in front of the camera
    cv::VideoCapture cap(0);
    if (!cap.isOpened()) {
        std::cerr << "Cannot open the camera" << std::endl;
        return -1;
    }
    cv::Mat frame;
    while (cap.read(frame)) {
        detector->setInputSize(frame.size());
        cv::Mat faces;
        detector->detect(frame, faces);
        if (faces.rows > 0) {
            cv::Mat aligned, feat;
            recognizer->alignCrop(frame, faces.row(0), aligned);
            recognizer->feature(aligned, feat);
            double score = recognizer->match(featEnroll, feat,
                                             cv::FaceRecognizerSF::DisType::FR_COSINE);
            bool same = score >= 0.363;
            cv::Rect box(cv::Point((int)faces.at<float>(0, 0), (int)faces.at<float>(0, 1)),
                         cv::Size((int)faces.at<float>(0, 2), (int)faces.at<float>(0, 3)));
            cv::Scalar color = same ? cv::Scalar(0, 255, 0) : cv::Scalar(0, 0, 255);
            cv::rectangle(frame, box, color, 2);
            cv::putText(frame, same ? "MATCH" : "NO MATCH", cv::Point(20, 40),
                        cv::FONT_HERSHEY_SIMPLEX, 1.0, color, 2);
        }
        cv::imshow("face auth", frame);
        if (cv::waitKey(1) == 'q') break;
    }
    return 0;
}

The threshold of 0.363 is the default cosine-similarity threshold used in OpenCV’s face_detect sample (the sample’s L2-norm equivalent is 1.128).


Results

Case Result
1. Enrolled person (live) MATCH (as expected)
2. Printed photo MATCH — the score clears the 0.363 threshold
3. Photo on a smartphone screen MATCH — same as above

In cases 2 and 3, simply holding the paper or the phone where the face would be produces the green MATCH box. The halftone texture of the print, and the reflections and moiré on the phone screen, do not stop YuNet from detecting the face, and SFace extracts the enrolled person’s features without complaint.


Why does it get through? It is not a bug — the model answers a different question

This is neither an implementation mistake nor a flaw in the model.

Face recognition answers one question: “Is this the same person as the one enrolled?” A printed photo and a phone screen both carry the enrolled person’s facial features, intact. So SFace correctly answers “yes, this is the same person’s face.” The system works exactly as designed — and authentication is defeated anyway.

“Is the enrolled person actually present, right now?” is a completely different question. The technology that answers it is called liveness detection, or PAD (Presentation Attack Detection), and it requires its own dedicated classifier.

Face recognition:   "Whose face is this?"     -> a photo of the user still matches (correct behavior)
Liveness detection: "Live person or a fake?"  -> tells photos, screens, and masks apart (a separate technology)

Is this a realistic threat?

You might wonder whether anyone would really print a photo to defeat face recognition. But consider this: the attack material — a photo of the user’s face — is often published by the users themselves on social media and the web. The only tools needed are a printer or a phone. Print and screen attacks are therefore the cheapest attacks there are.

That is why putting plain face recognition in front of any system where impersonation pays off — time and attendance, door access, identity verification — is risky. We break down the types of attacks, and how much defense each use case needs, in the next article:

→ Types of Presentation Attacks on Face Recognition — and Which Ones Are Realistic Threats

Before you ship face recognition to production, see Face Anti-Spoofing Design Checklist: 12 Items to Review Before Production


Summary

  • Face recognition built with OpenCV (YuNet + SFace) can be defeated with a printed photo or a smartphone screen showing the enrolled user. Anyone can reproduce this with the code above.
  • It is not a bug. Face recognition answers “whose face is this?” — it never claimed to answer “is the person actually here?”
  • If you use face recognition for authentication, you need liveness detection (PAD) as a separate component.
  • Photos of faces are easy to obtain from public sources. Print and screen attacks are the lowest-cost attacks and should be the first ones you defend against.

Add liveness detection with one REST API call

Our Liveness Detection API tells your face recognition or eKYC flow whether a live person is in front of the camera — from a single still image. Start with the Free plan (50 calls per month).

Try free Plans & pricing API docs