mirror of
https://github.com/RfidResearchGroup/proxmark3.git
synced 2026-10-05 20:57:28 +00:00
154 lines
4.8 KiB
Python
154 lines
4.8 KiB
Python
"""Scan screen end to end: synthetic frames in, auto-filled BAC fields out.
|
|
|
|
Uses a fake camera so it runs on a build machine with no webcam, but exercises
|
|
the real OCR pipeline and the real acceptance rule.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import glob
|
|
import os
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
cv2 = pytest.importorskip("cv2")
|
|
numpy = pytest.importorskip("numpy")
|
|
|
|
from epassport.ocr import mrz_ocr # noqa: E402
|
|
|
|
FONT = (
|
|
Path(__file__).parent.parent
|
|
/ "epassport"
|
|
/ "assets"
|
|
/ "fonts"
|
|
/ "DejaVuSansMono.ttf"
|
|
)
|
|
L1 = "P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<"
|
|
L2 = "L898902C36UTO7408122F1204159ZE184226B<<<<<10"
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
not mrz_ocr.tesseract_available()[0], reason="the tesseract binary is not installed"
|
|
)
|
|
|
|
|
|
def render_page(width: int = 1280, height: int = 900):
|
|
"""A synthetic data page with the MRZ where the guide box expects it."""
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
img = Image.new("RGB", (width, height), (243, 238, 226))
|
|
draw = ImageDraw.Draw(img)
|
|
top = mrz_ocr.crop_to_guide # documents the coupling below
|
|
assert top is not None
|
|
band_top = int(height * 0.60)
|
|
draw.rectangle((0, band_top, width, int(height * 0.86)), fill=(250, 247, 240))
|
|
size = int(width / 44 * 1.45)
|
|
font = ImageFont.truetype(str(FONT), size)
|
|
char_w = (width * 0.94) / 44
|
|
for row, line in enumerate((L1, L2)):
|
|
x = width * 0.03
|
|
y = band_top + size * 0.35 + row * size * 1.35
|
|
for ch in line:
|
|
draw.text((x, y), ch, font=font, fill=(18, 18, 26))
|
|
x += char_w
|
|
return numpy.array(img)[:, :, ::-1].copy()
|
|
|
|
|
|
def test_the_pipeline_reads_a_frame_framed_by_the_guide() -> None:
|
|
from epassport.ui.scan import GUIDE
|
|
|
|
result = mrz_ocr.read_frame(render_page(), guide=GUIDE)
|
|
assert result.checks_ok, f"{result.reason} / {result.lines}"
|
|
# The dates and their check digits are fully verified.
|
|
assert result.mrz.date_of_birth.value == "740812"
|
|
assert result.mrz.date_of_expiry.value == "120415"
|
|
assert result.mrz.nationality == "UTO"
|
|
# The document number's shape is verified; individual characters are not -
|
|
# see test_check_digits_cannot_see_every_ocr_slip.
|
|
assert len(result.mrz.bac_document_number) == 9
|
|
|
|
|
|
def test_acceptance_needs_two_frames_then_yields_bac_input() -> None:
|
|
from epassport.pm3.client import BacInput
|
|
from epassport.ui.scan import GUIDE
|
|
|
|
frame = render_page()
|
|
acceptor = mrz_ocr.Acceptor()
|
|
first = acceptor.offer(mrz_ocr.read_frame(frame, guide=GUIDE))
|
|
assert not first.accepted
|
|
second = acceptor.offer(mrz_ocr.read_frame(frame, guide=GUIDE))
|
|
assert second.accepted
|
|
|
|
parsed = second.mrz
|
|
validated = BacInput(mrz_line2=parsed.mrz_line2).validated()
|
|
assert validated.args()[0] == "-m"
|
|
assert len(validated.args()[1]) == 44
|
|
assert validated.args()[1][13:19] == "740812"
|
|
assert validated.args()[1][21:27] == "120415"
|
|
|
|
|
|
def test_a_blank_frame_is_never_accepted() -> None:
|
|
from epassport.ui.scan import GUIDE
|
|
|
|
blank = numpy.full((900, 1280, 3), 235, dtype=numpy.uint8)
|
|
acceptor = mrz_ocr.Acceptor()
|
|
for _ in range(3):
|
|
assert not acceptor.offer(mrz_ocr.read_frame(blank, guide=GUIDE)).accepted
|
|
|
|
|
|
def test_ocr_scratch_files_stay_out_of_slash_tmp() -> None:
|
|
"""A picture of somebody's MRZ must not be written to a shared /tmp."""
|
|
original = tempfile.tempdir
|
|
try:
|
|
mrz_ocr.use_private_tempdir()
|
|
assert tempfile.tempdir is not None
|
|
scratch = Path(tempfile.tempdir)
|
|
assert scratch.is_dir()
|
|
assert scratch.stat().st_mode & 0o077 == 0, "OCR scratch dir must be owner-only"
|
|
|
|
before = set(glob.glob("/tmp/tess_*"))
|
|
mrz_ocr.read_frame(render_page())
|
|
after = set(glob.glob("/tmp/tess_*"))
|
|
assert after == before, "tesseract wrote scratch files into /tmp"
|
|
|
|
mrz_ocr.clear_private_tempdir()
|
|
assert not any(scratch.iterdir())
|
|
finally:
|
|
tempfile.tempdir = original
|
|
|
|
|
|
def test_camera_thread_stops_cleanly_without_a_camera(monkeypatch) -> None:
|
|
"""No camera must mean a clear message, not a hung thread."""
|
|
from epassport.ocr.camera import CameraThread
|
|
|
|
class DeadCapture:
|
|
def __init__(self, *_a, **_k) -> None:
|
|
pass
|
|
|
|
def isOpened(self) -> bool:
|
|
return False
|
|
|
|
def release(self) -> None:
|
|
pass
|
|
|
|
def set(self, *_a) -> None:
|
|
pass
|
|
|
|
def read(self):
|
|
return False, None
|
|
|
|
monkeypatch.setattr(cv2, "VideoCapture", DeadCapture)
|
|
errors: list[str] = []
|
|
camera = CameraThread(index=0, on_error=errors.append)
|
|
camera.start()
|
|
for _ in range(100):
|
|
if errors:
|
|
break
|
|
time.sleep(0.02)
|
|
camera.stop()
|
|
assert errors, "a missing camera must report an error"
|
|
assert "/dev/video" in errors[0]
|
|
assert not camera.running
|