{"id":7,"date":"2026-10-14T20:39:26","date_gmt":"2026-10-14T11:39:26","guid":{"rendered":"https:\/\/tech.swallow-incubate.com\/en\/?p=7"},"modified":"2026-10-14T20:40:30","modified_gmt":"2026-10-14T11:40:30","slug":"face-recognition-photo-attack","status":"publish","type":"post","link":"https:\/\/tech.swallow-incubate.com\/en\/liveness\/face-recognition-photo-attack\/","title":{"rendered":"We Built Face Recognition with OpenCV \u2014 and a Printed Photo Got Right Through (C++)"},"content":{"rendered":"<p>Suppose you have built a face recognition pipeline using nothing but OpenCV&#8217;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.<\/p>\n<p>Now, what happens if the thing in front of the camera is not the enrolled user, but <strong>a printed photo of the enrolled user&#8217;s face<\/strong>?<\/p>\n<p>Short answer: <strong>it gets through<\/strong>. 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.<\/p>\n<p>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).<\/p>\n<hr \/>\n<h2>The experiment<\/h2>\n<p>We create a feature vector from an enrollment image, then keep matching whatever face appears in the camera feed against it.<\/p>\n<table>\n<thead>\n<tr>\n<th>Case<\/th>\n<th>What is in front of the camera<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>1<\/td>\n<td>The enrolled person (live)<\/td>\n<\/tr>\n<tr>\n<td>2<\/td>\n<td>A <strong>printed photo<\/strong> of the enrolled person&#8217;s face<\/td>\n<\/tr>\n<tr>\n<td>3<\/td>\n<td>A <strong>smartphone screen<\/strong> showing the enrolled person&#8217;s face<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<h3>Code (C++)<\/h3>\n<pre><code class=\"language-cpp\">#include &lt;opencv2\/opencv.hpp&gt;\n#include &lt;iostream&gt;\n\nint main()\n{\n    auto detector = cv::FaceDetectorYN::create(\n        &quot;face_detection_yunet_2023mar.onnx&quot;, &quot;&quot;, cv::Size(320, 320));\n    auto recognizer = cv::FaceRecognizerSF::create(\n        &quot;face_recognition_sface_2021dec.onnx&quot;, &quot;&quot;);\n\n    \/\/ Build a feature vector from the enrollment image (a frontal photo of the user)\n    cv::Mat enroll = cv::imread(&quot;enroll.jpg&quot;);\n    if (enroll.empty()) {\n        std::cerr &lt;&lt; &quot;Cannot read enroll.jpg&quot; &lt;&lt; std::endl;\n        return -1;\n    }\n    detector-&gt;setInputSize(enroll.size());\n    cv::Mat facesEnroll;\n    detector-&gt;detect(enroll, facesEnroll);\n    if (facesEnroll.rows &lt; 1) {\n        std::cerr &lt;&lt; &quot;No face found in the enrollment image&quot; &lt;&lt; std::endl;\n        return -1;\n    }\n    cv::Mat alignedE, featEnroll;\n    recognizer-&gt;alignCrop(enroll, facesEnroll.row(0), alignedE);\n    recognizer-&gt;feature(alignedE, featEnroll);\n    featEnroll = featEnroll.clone();\n\n    \/\/ Keep matching &quot;whatever&quot; is in front of the camera\n    cv::VideoCapture cap(0);\n    if (!cap.isOpened()) {\n        std::cerr &lt;&lt; &quot;Cannot open the camera&quot; &lt;&lt; std::endl;\n        return -1;\n    }\n    cv::Mat frame;\n    while (cap.read(frame)) {\n        detector-&gt;setInputSize(frame.size());\n        cv::Mat faces;\n        detector-&gt;detect(frame, faces);\n        if (faces.rows &gt; 0) {\n            cv::Mat aligned, feat;\n            recognizer-&gt;alignCrop(frame, faces.row(0), aligned);\n            recognizer-&gt;feature(aligned, feat);\n            double score = recognizer-&gt;match(featEnroll, feat,\n                                             cv::FaceRecognizerSF::DisType::FR_COSINE);\n            bool same = score &gt;= 0.363;\n            cv::Rect box(cv::Point((int)faces.at&lt;float&gt;(0, 0), (int)faces.at&lt;float&gt;(0, 1)),\n                         cv::Size((int)faces.at&lt;float&gt;(0, 2), (int)faces.at&lt;float&gt;(0, 3)));\n            cv::Scalar color = same ? cv::Scalar(0, 255, 0) : cv::Scalar(0, 0, 255);\n            cv::rectangle(frame, box, color, 2);\n            cv::putText(frame, same ? &quot;MATCH&quot; : &quot;NO MATCH&quot;, cv::Point(20, 40),\n                        cv::FONT_HERSHEY_SIMPLEX, 1.0, color, 2);\n        }\n        cv::imshow(&quot;face auth&quot;, frame);\n        if (cv::waitKey(1) == 'q') break;\n    }\n    return 0;\n}\n<\/code><\/pre>\n<p>The threshold of 0.363 is the default cosine-similarity threshold used in OpenCV&#8217;s face_detect sample (the sample&#8217;s L2-norm equivalent is 1.128).<\/p>\n<hr \/>\n<h2>Results<\/h2>\n<table>\n<thead>\n<tr>\n<th>Case<\/th>\n<th>Result<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>1. Enrolled person (live)<\/td>\n<td><strong>MATCH<\/strong> (as expected)<\/td>\n<\/tr>\n<tr>\n<td>2. Printed photo<\/td>\n<td><strong>MATCH<\/strong> \u2014 the score clears the 0.363 threshold<\/td>\n<\/tr>\n<tr>\n<td>3. Photo on a smartphone screen<\/td>\n<td><strong>MATCH<\/strong> \u2014 same as above<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>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\u00e9 on the phone screen, do not stop YuNet from detecting the face, and SFace extracts the enrolled person&#8217;s features without complaint.<\/p>\n<hr \/>\n<h2>Why does it get through? It is not a bug \u2014 the model answers a different question<\/h2>\n<p>This is neither an implementation mistake nor a flaw in the model.<\/p>\n<p>Face recognition answers one question: <strong>&#8220;Is this the same person as the one enrolled?&#8221;<\/strong> A printed photo and a phone screen both carry <strong>the enrolled person&#8217;s facial features, intact<\/strong>. So SFace correctly answers &#8220;yes, this is the same person&#8217;s face.&#8221; <strong>The system works exactly as designed \u2014 and authentication is defeated anyway.<\/strong><\/p>\n<p>&#8220;Is the enrolled person actually present, right now?&#8221; is a completely different question. The technology that answers it is called <strong>liveness detection<\/strong>, or <strong>PAD (Presentation Attack Detection)<\/strong>, and it requires its own dedicated classifier.<\/p>\n<pre><code class=\"language-text\">Face recognition:   \"Whose face is this?\"     -> a photo of the user still matches (correct behavior)\nLiveness detection: \"Live person or a fake?\"  -> tells photos, screens, and masks apart (a separate technology)\n<\/code><\/pre>\n<hr \/>\n<h2>Is this a realistic threat?<\/h2>\n<p>You might wonder whether anyone would really print a photo to defeat face recognition. But consider this: <strong>the attack material \u2014 a photo of the user&#8217;s face \u2014 is often published by the users themselves<\/strong> on social media and the web. The only tools needed are a printer or a phone. Print and screen attacks are therefore <strong>the cheapest attacks there are<\/strong>.<\/p>\n<p>That is why putting plain face recognition in front of any system where impersonation pays off \u2014 time and attendance, door access, identity verification \u2014 is risky. We break down the types of attacks, and how much defense each use case needs, in the next article:<\/p>\n<p>\u2192 <a href=\"https:\/\/tech.swallow-incubate.com\/en\/liveness\/presentation-attack-types\/\">Types of Presentation Attacks on Face Recognition \u2014 and Which Ones Are Realistic Threats<\/a><\/p>\n<p>Before you ship face recognition to production, see <a href=\"https:\/\/tech.swallow-incubate.com\/en\/liveness\/anti-spoofing-checklist\/\">Face Anti-Spoofing Design Checklist: 12 Items to Review Before Production<\/a><\/p>\n<hr \/>\n<h2>Summary<\/h2>\n<ul>\n<li>Face recognition built with OpenCV (YuNet + SFace) <strong>can be defeated with a printed photo or a smartphone screen<\/strong> showing the enrolled user. Anyone can reproduce this with the code above.<\/li>\n<li>It is not a bug. Face recognition answers &#8220;whose face is this?&#8221; \u2014 it <strong>never claimed to answer &#8220;is the person actually here?&#8221;<\/strong><\/li>\n<li>If you use face recognition for authentication, you need <strong>liveness detection (PAD)<\/strong> as a separate component.<\/li>\n<li>Photos of faces are easy to obtain from public sources. Print and screen attacks are <strong>the lowest-cost attacks<\/strong> and should be the first ones you defend against.<\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>OpenCV&#8217;s YuNet + SFace face recognition accepts a printed photo of the user. Reproducible C++ code, and why you need liveness detection on top.<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[4],"tags":[],"class_list":["post-7","post","type-post","status-publish","format-standard","hentry","category-liveness"],"_links":{"self":[{"href":"https:\/\/tech.swallow-incubate.com\/en\/wp-json\/wp\/v2\/posts\/7","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tech.swallow-incubate.com\/en\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tech.swallow-incubate.com\/en\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tech.swallow-incubate.com\/en\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/tech.swallow-incubate.com\/en\/wp-json\/wp\/v2\/comments?post=7"}],"version-history":[{"count":1,"href":"https:\/\/tech.swallow-incubate.com\/en\/wp-json\/wp\/v2\/posts\/7\/revisions"}],"predecessor-version":[{"id":12,"href":"https:\/\/tech.swallow-incubate.com\/en\/wp-json\/wp\/v2\/posts\/7\/revisions\/12"}],"wp:attachment":[{"href":"https:\/\/tech.swallow-incubate.com\/en\/wp-json\/wp\/v2\/media?parent=7"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tech.swallow-incubate.com\/en\/wp-json\/wp\/v2\/categories?post=7"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tech.swallow-incubate.com\/en\/wp-json\/wp\/v2\/tags?post=7"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}