From 6367a6e9dfd2ba08ea05b1c400fcf0d0e9925437 Mon Sep 17 00:00:00 2001 From: Vitus Date: Wed, 17 Nov 2021 18:54:16 +0100 Subject: [PATCH 01/19] Add first version of script to match name and dob between certificate and id card --- id_card_scanner.py | 126 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 id_card_scanner.py diff --git a/id_card_scanner.py b/id_card_scanner.py new file mode 100644 index 0000000..536f1c1 --- /dev/null +++ b/id_card_scanner.py @@ -0,0 +1,126 @@ +# -*- coding: UTF-8 -*- + +import sys +import cv2 +from pytesseract import pytesseract + +PYTESSERACT_LANGUAGE = 'deu' + +class IdCardScanner: + + def __init__(self): + pass + + def scan_for_id_cards(self, frame, data): + + # This script is currently optimised for german ID cards. + if data['co'] != 'DE': + print('Certificate not issued in germany, therefore probably no german passport') + + modified_frame = self.__prepare_frame(frame) + variants_dict = self.__generate_variants_dict(data) + + match_found = self.__find_matches(modified_frame, variants_dict) + + print('Match found:', match_found) + + # Do some magic to improve the readability of text in the frame + # TODO: Improve this and maybe offer multiple options + def __prepare_frame(self, frame): + frame_grey = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + + # TODO: Find better values and remove magic numbers + threshold = cv2.adaptiveThreshold(frame_grey, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 199, 17) + + cv2.imshow('Adaptive Gaussian Thresh', threshold) + + return threshold + + # Perform OCR on the frame and compare the found text with the strings from the dict + def __find_matches(self, frame, variants_dict): + raw_text = pytesseract.image_to_string(frame, lang=PYTESSERACT_LANGUAGE) + + first_name_found = False + last_name_found = False + dob_found = False + + for first_name in variants_dict['first_name']: + if first_name in raw_text: + print('First name matches!') + first_name_found = True + break + + for last_name in variants_dict['last_name']: + if last_name in raw_text: + print('Last name matches!') + last_name_found = True + break + + for dob in variants_dict['dob']: + if dob in raw_text: + print('Date of birth matches!') + dob_found = True + break + + return first_name_found and last_name_found and dob_found + + # Generate a dict of strings that we expect to find in the text on the ID card + # -> Different variants of first name, last name and date of birth + def __generate_variants_dict(self, data): + variants_dict = { + 'first_name': [data['gn'], data['gnt'], data['gn'][0] + data['gnt'][1:].lower(), data['gn'].upper()], + 'last_name': [data['fn'], data['fnt'], data['fn'][0] + data['fnt'][1:].lower(), data['fn'].upper()], + 'dob': self.__generate_possible_dob_variants(data['dob']) + } + + return variants_dict + + # The date of birth (dob) can appear in different variants on passports (dd.mm.yyyy, dd.mm.yy, yymmdd, ...) + # This method generates a list of all possible variants + def __generate_possible_dob_variants(self, dob): + + dob_variants = [dob] + + parts = dob.split('-') + yyyy = parts[0] + yy = parts[0][2:] + mm = parts[1] + dd = parts[2] + + dob_variants.append('{}.{}.{}'.format(dd, mm, yyyy)) + dob_variants.append('{}.{}.{}'.format(dd, mm, yy)) + dob_variants.append('{}{}{}'.format(yy, mm, dd)) + + return dob_variants + + +# EVERYTHING BELOW IS JUST FOR TESTING THE SCRIPT + + +def main(): + id_card_scanner = IdCardScanner() + + CAMERA = '/dev/video2' + CAM_WIDTH, CAM_HEIGHT = 1280, 720 + TEST_DATA = {'co': 'DE', 'dob': '2000-12-01', 'fn': 'Müller', 'gn': 'Max', 'fnt': 'MUELLER', 'gnt': 'MAX'} + + cap = cv2.VideoCapture(CAMERA) + cap.set(cv2.CAP_PROP_FRAME_WIDTH, CAM_WIDTH) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, CAM_HEIGHT) + while True: + ret, frame = cap.read() + + if frame is not None: + cv2.imshow('raw frame', frame) + id_card_scanner.scan_for_id_cards(frame, TEST_DATA) + + key = cv2.waitKey(1) + + # Press esc or 'q' to close the image window + if key & 0xFF == ord('q') or key == 27: + cv2.destroyAllWindows() + sys.exit(0) + + +if __name__ == '__main__': + main() From 85a745e974f86edaa96e95d54f09e5d18896e0e9 Mon Sep 17 00:00:00 2001 From: Vitus Date: Sun, 21 Nov 2021 17:24:51 +0100 Subject: [PATCH 02/19] Put processing of certificate in separate file --- covpass_scanner.py | 262 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 covpass_scanner.py diff --git a/covpass_scanner.py b/covpass_scanner.py new file mode 100644 index 0000000..0f6f290 --- /dev/null +++ b/covpass_scanner.py @@ -0,0 +1,262 @@ +import sys +import zlib +import logging +from typing import Dict, Tuple, Optional +from datetime import datetime +import pyzbar.pyzbar +import json +import base45 +import base64 +import cbor2 +from cose.headers import Algorithm, KID +from cose.messages import CoseMessage +from cose.keys import cosekey, ec2, keyops, curves + +from cryptography import x509 +from cryptography import hazmat +from pyasn1.codec.ber import decoder as asn1_decoder +from cryptojwt import jwk as cjwtk +from cryptojwt import utils as cjwt_utils + + +DEFAULT_CERTIFICATE_DB_JSON = 'certs/Digital_Green_Certificate_Signing_Keys.json' + + +class CovpassScanner: + + def __init__(self, certs=DEFAULT_CERTIFICATE_DB_JSON): + + self.certs = certs + + self.log = logging.getLogger(__name__) + self.__setup_logger() + + def __setup_logger(self) -> None: + log_formatter = logging.Formatter("%(asctime)s [%(levelname)-5.5s] %(message)s") + console_handler = logging.StreamHandler(sys.stderr) + console_handler.setFormatter(log_formatter) + console_handler.propagate = False + logging.getLogger().addHandler(console_handler) + self.log.setLevel(logging.DEBUG) + # log.setLevel(logging.INFO) + + def process_frame(self, frame): + barcodes = pyzbar.pyzbar.decode(frame) + found_certificate = len(barcodes) == 1 + + if found_certificate: + data = barcodes[0].data.decode() + if data.startswith("HC1:"): + # try: + parsed_covid_cert_data = self.output_covid_cert_data(data, self.certs) + is_valid = parsed_covid_cert_data['verified'][1] + + return found_certificate, is_valid, parsed_covid_cert_data + # except: + # log.info("no certificate in QR code") + + return found_certificate, False, None + + def output_covid_cert_data(self, cert: str, keys_file: str) -> dict: + # Code adapted from: + # https://alphalist.com/blog/the-use-of-blockchain-for-verification-eu-vaccines-passport-program-and-more + signature_verified = False + + # Strip the first characters to form valid Base45-encoded data + b45data = cert[4:] + + # Decode the data + zlibdata = base45.b45decode(b45data) + + # Uncompress the data + decompressed = zlib.decompress(zlibdata) + + self.log.debug(decompressed) + + # decode COSE message (no signature verification done yet) + cose_msg = CoseMessage.decode(decompressed) + + # decode the CBOR encoded payload and print as json + # for some reason, some certificates store the KID in the protected header, some in the unprotected header + # (e.g., current German vaccination passports) + if KID in cose_msg.phdr: + key_header = cose_msg.phdr[KID] + self.log.debug("KID in cose_msg.phdr: " + str(cose_msg.phdr)) + elif KID in cose_msg.uhdr: + key_header = cose_msg.uhdr[KID] + self.log.debug("KID in cose_msg.uhdr: " + str(cose_msg.uhdr)) + else: + key_header = None + if key_header: + self.log.info("COVID certificate signed with X.509 certificate.") + self.log.info("X.509 in DER form has SHA-256 beginning with: {0}".format( + key_header.hex())) + key = self.find_key(key_header, keys_file) + if key: + signature_verified = self.verify_signature(cose_msg, key) + else: + self.log.info("Skip verify as no key found from database") + else: + self.log.debug("KID not in cose_msg.phdr or cose_msg.uhdr: " + str(KID)) + self.log.info("Certificate is not signed") + self.log.debug(cose_msg.key) + cbor = cbor2.loads(cose_msg.payload) + cbor['verified'] = True if signature_verified else False + # Note: Some countries have hour:minute:second for sc-field (Date/Time of Sample Collection). + # If used, this will decode as a datetime. A datetime cannot be JSON-serialized without hints (use str as default). + # Note 2: Names may contain non-ASCII characters in UTF-8 + self.log.info("Certificate as JSON: {0}".format(json.dumps(cbor, indent=2, default=str, ensure_ascii=False))) + return self.print_cert_data(cbor) + + def find_key(self, key: Algorithm, keys_file: str) -> Optional[cosekey.CoseKey]: + if False: + # Test read a PEM-key + jwt_key = read_cosekey_from_pem_file("certs/Finland.pem") + # pprint(jwt_key) + # pprint(jwt_key.kid.decode()) + + # Read the JSON-database of all known keys + with open(keys_file, encoding='utf-8') as f: + known_keys = json.load(f) + + jwt_key = None + for key_id, key_data in known_keys.items(): + key_id_binary = base64.b64decode(key_id) + if key_id_binary == key: + self.log.info("Found the key from DB!") + # check if the point is uncompressed rather than compressed + x, y = self.public_ec_key_points(base64.b64decode(key_data['publicKeyPem'])) + key_dict = {'crv': key_data['publicKeyAlgorithm']['namedCurve'], # 'P-256' + 'kid': key_id_binary.hex(), + 'kty': key_data['publicKeyAlgorithm']['name'][:2], # 'EC' + 'x': x, # 'eIBWXSaUgLcxfjhChSkV_TwNNIhddCs2Rlo3tdD671I' + 'y': y, # 'R1XB4U5j_IxRgIOTBUJ7exgz0bhen4adlbHkrktojjo' + } + jwt_key = self.cosekey_from_jwk_dict(key_dict) + break + + if not jwt_key: + return None + + if jwt_key.kid.decode() != key.hex(): + raise RuntimeError("Internal: No key for {0}!".format(key.hex())) + + return jwt_key + + def verify_signature(self, cose_msg: CoseMessage, key: cosekey.CoseKey) -> bool: + cose_msg.key = key + if not cose_msg.verify_signature(): + self.log.warning("Signature does not verify with key ID {0}!".format(key.kid.decode())) + return False + self.log.info("Signature verified ok") + return cose_msg.verify_signature() + + def print_cert_data(self, d) -> dict: + print(f"Issuer: {d[1]}") + print(f"Issue Date: {datetime.fromtimestamp(int(d[6]))}") + print(f"Expiration Date: {datetime.fromtimestamp(int(d[4]))}") + data = d[-260][1] + data = self.flatten(data) + data['verified'] = d['verified'] + data['issuer'] = d[1] + data['issue date'] = datetime.fromtimestamp(int(d[6])) + data['expiration date'] = datetime.fromtimestamp(int(d[4])) + + translated = {} + for k in data.keys(): + translated[k] = (self.translate(k), self.translate(data[k])) + self.log.info(f"{self.translate(k)}: {self.translate(data[k])}") + return translated + + def flatten(self, dic): + items = {} + for item in dic.keys(): + if type(dic[item]) == dict: + for k, v in self.flatten(dic[item]).items(): + items[k] = v + elif type(dic[item]) == list: + for d in dic[item]: + for k, v in self.flatten(d).items(): + items[k] = v + else: + items[item] = dic[item] + return items + + def translate(self, abbreviation): + abbr_dict = json.load(open("Digital_Green_Certificate_Value_Sets.json")) + abbreviations = self.flatten(abbr_dict) + if abbreviation in abbreviations.keys(): + return abbreviations[abbreviation] + else: + return abbreviation + + def public_ec_key_points(self, public_key: bytes) -> Tuple[str, str]: + # This code adapted from: https://stackoverflow.com/a/59537764/1548275 + public_key_asn1, _remainder = asn1_decoder.decode(public_key) + public_key_bytes = public_key_asn1[1].asOctets() + + off = 0 + if public_key_bytes[off] != 0x04: + raise ValueError("EC public key is not an uncompressed point") + off += 1 + + size_bytes = (len(public_key_bytes) - 1) // 2 + + x_bin = public_key_bytes[off:off + size_bytes] + x = int.from_bytes(x_bin, 'big', signed=False) + off += size_bytes + + y_bin = public_key_bytes[off:off + size_bytes] + y = int.from_bytes(y_bin, 'big', signed=False) + off += size_bytes + + bl = (x.bit_length() + 7) // 8 + bytes_val = x.to_bytes(bl, 'big') + x_str = base64.b64encode(bytes_val, altchars='-_'.encode()).decode() + + bl = (y.bit_length() + 7) // 8 + bytes_val = y.to_bytes(bl, 'big') + y_str = base64.b64encode(bytes_val, altchars='-_'.encode()).decode() + + return x_str, y_str + + # Create CoseKey from JWK + def cosekey_from_jwk_dict(self, jwk_dict: Dict) -> cosekey.CoseKey: + # Read key and return CoseKey + if jwk_dict["kty"] != "EC": + raise ValueError("Only EC keys supported") + if jwk_dict["crv"] != "P-256": + raise ValueError("Only P-256 supported") + + key = ec2.EC2( + crv=curves.P256, + x=cjwt_utils.b64d(jwk_dict["x"].encode()), + y=cjwt_utils.b64d(jwk_dict["y"].encode()), + ) + key.key_ops = [keyops.VerifyOp] + if "kid" in jwk_dict: + key.kid = bytes(jwk_dict["kid"], "UTF-8") + + return key + + # Create JWK and calculate KID from Public Signing Certificate + def read_cosekey_from_pem_file(self, cert_file: str) -> cosekey.CoseKey: + # Read certificate, calculate kid and return EC CoseKey + if not cert_file.endswith(".pem"): + raise ValueError("Unknown key format. Use .pem keyfile") + + with open(cert_file, 'rb') as f: + cert_data = f.read() + # Calculate Hash from the DER format of the Certificate + cert = x509.load_pem_x509_certificate(cert_data, hazmat.backends.default_backend()) + keyidentifier = cert.fingerprint(hazmat.primitives.hashes.SHA256()) + f.close() + key = cert.public_key() + + jwk = cjwtk.ec.ECKey() + jwk.load_key(key) + # Use first 8 bytes of the hash as Key Identifier (Hex as UTF-8) + jwk.kid = keyidentifier[:8].hex() + jwk_dict = jwk.serialize(private=False) + + return self.cosekey_from_jwk_dict(jwk_dict) From bc959bddf6ba24397ae5e4dded2986c9c2b7fe1b Mon Sep 17 00:00:00 2001 From: Vitus Date: Sun, 21 Nov 2021 20:33:22 +0100 Subject: [PATCH 03/19] Cleanup id card scanner script --- id_card_scanner.py | 81 ++++++++++++++++++++++++++-------------------- 1 file changed, 46 insertions(+), 35 deletions(-) diff --git a/id_card_scanner.py b/id_card_scanner.py index 536f1c1..a8b9f23 100644 --- a/id_card_scanner.py +++ b/id_card_scanner.py @@ -1,11 +1,14 @@ # -*- coding: UTF-8 -*- import sys +import time + import cv2 from pytesseract import pytesseract PYTESSERACT_LANGUAGE = 'deu' + class IdCardScanner: def __init__(self): @@ -13,16 +16,19 @@ def __init__(self): def scan_for_id_cards(self, frame, data): + variants_dict = self.__generate_variants_dict(data) + # This script is currently optimised for german ID cards. - if data['co'] != 'DE': - print('Certificate not issued in germany, therefore probably no german passport') + if data['co'][1] != 'DE': + print('Certificate not issued in Germany, therefore probably also no german passport') + # Step 1 modified_frame = self.__prepare_frame(frame) - variants_dict = self.__generate_variants_dict(data) match_found = self.__find_matches(modified_frame, variants_dict) print('Match found:', match_found) + return match_found # Do some magic to improve the readability of text in the frame # TODO: Improve this and maybe offer multiple options @@ -36,9 +42,15 @@ def __prepare_frame(self, frame): return threshold + def __get_text_from_frame(self, frame): + raw_text = pytesseract.image_to_string(frame, lang=PYTESSERACT_LANGUAGE) + + print(raw_text) + return raw_text + # Perform OCR on the frame and compare the found text with the strings from the dict def __find_matches(self, frame, variants_dict): - raw_text = pytesseract.image_to_string(frame, lang=PYTESSERACT_LANGUAGE) + raw_text = self.__get_text_from_frame(frame) first_name_found = False last_name_found = False @@ -68,9 +80,9 @@ def __find_matches(self, frame, variants_dict): # -> Different variants of first name, last name and date of birth def __generate_variants_dict(self, data): variants_dict = { - 'first_name': [data['gn'], data['gnt'], data['gn'][0] + data['gnt'][1:].lower(), data['gn'].upper()], - 'last_name': [data['fn'], data['fnt'], data['fn'][0] + data['fnt'][1:].lower(), data['fn'].upper()], - 'dob': self.__generate_possible_dob_variants(data['dob']) + 'first_name': [data['gn'][1], data['gnt'][1], data['gn'][1][0] + data['gnt'][1][1:].lower(), data['gn'][1].upper()], + 'last_name': [data['fn'][1], data['fnt'][1], data['fn'][1][0] + data['fnt'][1][1:].lower(), data['fn'][1].upper()], + 'dob': self.__generate_possible_dob_variants(data['dob'][1]) } return variants_dict @@ -96,31 +108,30 @@ def __generate_possible_dob_variants(self, dob): # EVERYTHING BELOW IS JUST FOR TESTING THE SCRIPT - -def main(): - id_card_scanner = IdCardScanner() - - CAMERA = '/dev/video2' - CAM_WIDTH, CAM_HEIGHT = 1280, 720 - TEST_DATA = {'co': 'DE', 'dob': '2000-12-01', 'fn': 'Müller', 'gn': 'Max', 'fnt': 'MUELLER', 'gnt': 'MAX'} - - cap = cv2.VideoCapture(CAMERA) - cap.set(cv2.CAP_PROP_FRAME_WIDTH, CAM_WIDTH) - cap.set(cv2.CAP_PROP_FRAME_HEIGHT, CAM_HEIGHT) - while True: - ret, frame = cap.read() - - if frame is not None: - cv2.imshow('raw frame', frame) - id_card_scanner.scan_for_id_cards(frame, TEST_DATA) - - key = cv2.waitKey(1) - - # Press esc or 'q' to close the image window - if key & 0xFF == ord('q') or key == 27: - cv2.destroyAllWindows() - sys.exit(0) - - -if __name__ == '__main__': - main() +# def main(): +# id_card_scanner = IdCardScanner() +# +# CAMERA = '/dev/video2' +# CAM_WIDTH, CAM_HEIGHT = 1280, 720 +# TEST_DATA = {'co': 'DE', 'dob': '2000-12-01', 'fn': 'Müller', 'gn': 'Max', 'fnt': 'MUELLER', 'gnt': 'MAX'} +# +# cap = cv2.VideoCapture(CAMERA) +# cap.set(cv2.CAP_PROP_FRAME_WIDTH, CAM_WIDTH) +# cap.set(cv2.CAP_PROP_FRAME_HEIGHT, CAM_HEIGHT) +# while True: +# ret, frame = cap.read() +# +# if frame is not None: +# cv2.imshow('raw frame', frame) +# id_card_scanner.scan_for_id_cards(frame, TEST_DATA) +# +# key = cv2.waitKey(1) +# +# # Press esc or 'q' to close the image window +# if key & 0xFF == ord('q') or key == 27: +# cv2.destroyAllWindows() +# sys.exit(0) +# +# +# if __name__ == '__main__': +# main() From db693e0ecd2fb33d51b924dca234086f605b5739 Mon Sep 17 00:00:00 2001 From: Vitus Date: Sun, 21 Nov 2021 20:34:00 +0100 Subject: [PATCH 04/19] Hide testing logging information --- covpass_scanner.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/covpass_scanner.py b/covpass_scanner.py index 0f6f290..7dcceb0 100644 --- a/covpass_scanner.py +++ b/covpass_scanner.py @@ -21,7 +21,6 @@ DEFAULT_CERTIFICATE_DB_JSON = 'certs/Digital_Green_Certificate_Signing_Keys.json' - class CovpassScanner: def __init__(self, certs=DEFAULT_CERTIFICATE_DB_JSON): @@ -37,8 +36,9 @@ def __setup_logger(self) -> None: console_handler.setFormatter(log_formatter) console_handler.propagate = False logging.getLogger().addHandler(console_handler) - self.log.setLevel(logging.DEBUG) - # log.setLevel(logging.INFO) + self.log.setLevel(logging.ERROR) + # self.log.setLevel(logging.DEBUG) + # self.log.setLevel(logging.INFO) def process_frame(self, frame): barcodes = pyzbar.pyzbar.decode(frame) @@ -70,7 +70,6 @@ def output_covid_cert_data(self, cert: str, keys_file: str) -> dict: # Uncompress the data decompressed = zlib.decompress(zlibdata) - self.log.debug(decompressed) # decode COSE message (no signature verification done yet) @@ -152,9 +151,9 @@ def verify_signature(self, cose_msg: CoseMessage, key: cosekey.CoseKey) -> bool: return cose_msg.verify_signature() def print_cert_data(self, d) -> dict: - print(f"Issuer: {d[1]}") - print(f"Issue Date: {datetime.fromtimestamp(int(d[6]))}") - print(f"Expiration Date: {datetime.fromtimestamp(int(d[4]))}") + # print(f"Issuer: {d[1]}") + # print(f"Issue Date: {datetime.fromtimestamp(int(d[6]))}") + # print(f"Expiration Date: {datetime.fromtimestamp(int(d[4]))}") data = d[-260][1] data = self.flatten(data) data['verified'] = d['verified'] From 61aeda66de9de61b928fe3a952b6ef3b47514590 Mon Sep 17 00:00:00 2001 From: Vitus Date: Sun, 21 Nov 2021 20:34:38 +0100 Subject: [PATCH 05/19] Add main script to coordinate processing steps and UI output --- main.py | 224 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 main.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..7a9702b --- /dev/null +++ b/main.py @@ -0,0 +1,224 @@ +# -*- coding: UTF-8 -*- + +import argparse +import sys +import time + +import cv2 +import numpy as np +import PIL.Image +from PIL.ImageOps import pad +from PIL import Image + +from pygame import mixer + +from covpass_scanner import CovpassScanner +from id_card_scanner import IdCardScanner + +DEFAULT_CERTIFICATE_DB_JSON = 'certs/Digital_Green_Certificate_Signing_Keys.json' + +CAMERA_ID = 7 +CAM_WIDTH, CAM_HEIGHT = 640, 480 + +TIME_WAIT_AFTER_CERTIFICATE_FOUND_SEC = 3 +TIME_WAIT_FOR_ID_CARD_SEC = 30 + +TIME_SHOW_INVALID_CERTIFICATE_MESSAGE_SEC = 10 +TIME_SHOW_SUCCESSFUL_VERIFICATION_MESSAGE_SEC = 5 + +BORDER_PERCENTAGE = 0.2 + +class Main: + + active_certificate_data = None + last_certificate_found_timestamp = 0 + id_card_matches_certificate = False + invalid_certificate_found = False + + def __init__(self): + # parser = argparse.ArgumentParser(description='EU COVID Vaccination Passport Verifier') + # parser.add_argument('--image-file', metavar="IMAGE-FILE", + # help='Image to read QR-code from') + # parser.add_argument('--raw-string', metavar="RAW-STRING", + # help='Contents of the QR-code as string') + # parser.add_argument('image_file_positional', metavar="IMAGE-FILE", nargs="?", + # help='Image to read QR-code from') + # parser.add_argument('--certificate-db-json-file', default=DEFAULT_CERTIFICATE_DB_JSON, + # help="Default: {0}".format(DEFAULT_CERTIFICATE_DB_JSON)) + # parser.add_argument('--camera', metavar="CAMERA-FILE", + # help='camera path') + # + # args = parser.parse_args() + # + # covid_cert_data = None + # image_file = None + # if args.image_file_positional: + # image_file = args.image_file_positional + # elif args.image_file: + # image_file = args.image_file + # + # if image_file: + # data = pyzbar.pyzbar.decode(PIL.Image.open(image_file)) + # covid_cert_data = data[0].data.decode() + # elif args.raw_string: + # covid_cert_data = args.raw_string + # elif args.camera: + # run_interactive(args.camera, args.certificate_db_json_file) + # sys.exit(0) + # else: + # log.error("Input parameters: Need either --camera, --image-file or --raw-string QR-code content.") + # exit(2) + # + # # Got the data, output + # log.debug("Cert data: '{0}'".format(covid_cert_data)) + # output_covid_cert_data(covid_cert_data, args.certificate_db_json_file) + + self.capture = cv2.VideoCapture(CAMERA_ID) + self.capture.set(cv2.CAP_PROP_FRAME_WIDTH, CAM_WIDTH) + self.capture.set(cv2.CAP_PROP_FRAME_HEIGHT, CAM_HEIGHT) + + self.covpass_scanner = CovpassScanner() + self.id_card_scanner = IdCardScanner() + + # cv2.namedWindow("Camera", cv2.WND_PROP_FULLSCREEN) + # cv2.setWindowProperty("Camera", cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN) + self.font_title = PIL.ImageFont.truetype("fonts/Roboto-Regular.ttf", 80) + self.font_subtitle = PIL.ImageFont.truetype("fonts/Roboto-Regular.ttf", 50) + + self.invalid_certificate_image = cv2.imread("img/failure.png") + self.successful_verification_image = cv2.imread("img/success.png") + + self.run_interactive() + + def run_interactive(self): + while True: + ret, frame = self.capture.read() + if frame is None: + print('No frame from camera') + continue + + now = time.time() + + # Check if a certificate is found in the frame + found_certificate, is_valid, parsed_covid_cert_data = self.covpass_scanner.process_frame(frame) + + if found_certificate: + already_scanned_certificate = self.active_certificate_data == parsed_covid_cert_data + if not already_scanned_certificate: # Only continue if it is new certificate + #frame[:] = (0, 255, 255) + #cv2.imshow("Camera", frame) + + if is_valid: + self.active_certificate_data = parsed_covid_cert_data + self.last_certificate_found_timestamp = now + + else: + self.invalid_certificate_found = True + + else: # Only check for ID card if no certificate is found in the current frame + if self.active_certificate_data is not None: + + # Wait at least XX seconds after certificate has been detected in frame + # This should at least somewhat prevent detecting text from the certificate itself while we have no + # proper verification of an ID card + if now - self.last_certificate_found_timestamp >= TIME_WAIT_AFTER_CERTIFICATE_FOUND_SEC: + self.id_card_matches_certificate = self.id_card_scanner.scan_for_id_cards(frame, self.active_certificate_data) + + # Delete saved certificate data after XX seconds + if now - self.last_certificate_found_timestamp > TIME_WAIT_AFTER_CERTIFICATE_FOUND_SEC + TIME_WAIT_FOR_ID_CARD_SEC: + self.active_certificate_data = None + + self.update_ui(frame) + + if self.invalid_certificate_found: + self.on_invalid_certificate(frame) + key = cv2.waitKey(TIME_SHOW_INVALID_CERTIFICATE_MESSAGE_SEC * 1000) # sec to ms + elif self.id_card_matches_certificate: + self.on_successful_verification(frame) + key = cv2.waitKey(TIME_SHOW_SUCCESSFUL_VERIFICATION_MESSAGE_SEC * 1000) # sec to ms + else: + key = cv2.waitKey(1) + + # Press esc or 'q' to close the image window + if key & 0xFF == ord('q') or key == 27: + cv2.destroyAllWindows() + sys.exit(0) + + def update_ui(self, frame): + old_shape = frame.shape # Remember to resize later after adding borders to the frame + + frame = self.add_borders_to_frame(frame) + frame = self.add_text_to_frame(frame) + frame = cv2.resize(frame, (old_shape[1], old_shape[0])) + cv2.imshow("Camera", frame) + + def add_borders_to_frame(self, frame): + # Add small black border around camera preview + frame = cv2.copyMakeBorder(frame, 3, 3, 3, 3, cv2.BORDER_CONSTANT, value=(0, 0, 0)) + # Add large white border + frame = cv2.copyMakeBorder(frame, + int(BORDER_PERCENTAGE * frame.shape[1]), int(BORDER_PERCENTAGE * frame.shape[1]), + int(BORDER_PERCENTAGE * frame.shape[0]), int(BORDER_PERCENTAGE * frame.shape[0]), + cv2.BORDER_CONSTANT, value=(255, 255, 255)) + + return frame + + def add_text_to_frame(self, frame): + title = 'Step 1: Scan COVPASS Certificate:' + subtitle = '' + + if self.active_certificate_data is not None: + title = 'Step 2: Scan ID card:' + last_name = self.active_certificate_data['fn'][1] + first_name = self.active_certificate_data['gn'][1] + subtitle = 'Name: {} {}'.format(first_name, last_name) + + pil_image = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) + draw = PIL.ImageDraw.Draw(pil_image) + + title_width = max(draw.textsize(title, font=self.font_title), draw.textsize(title, font=self.font_title))[0] + subtitle_width = max(draw.textsize(title, font=self.font_subtitle), draw.textsize(title, font=self.font_subtitle))[0] + + # TODO: make drawing code independent of screen size + draw.text(xy=((int((frame.shape[1] - title_width) / 2)), 10), text=title, fill=(0, 0, 0), font=self.font_title) + draw.text(xy=((int((frame.shape[1] - subtitle_width) / 2)), frame.shape[0] - 100), text=subtitle, + fill=(0, 0, 0), font=self.font_subtitle) + + frame[:] = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR) + + return frame + + def reset(self): + self.active_certificate_data = None + self.last_certificate_found_timestamp = 0 + self.id_card_matches_certificate = False + self.invalid_certificate_found = False + + def on_successful_verification(self, frame): + mixer.init() + mixer.music.load("sounds/complete.oga") + mixer.music.play() + + output = cv2.resize(self.successful_verification_image, (frame.shape[1], frame.shape[0])) + cv2.imshow('Camera', output) + + self.reset() + + def on_invalid_certificate(self, frame): + mixer.init() + mixer.music.load("sounds/dialog-error.oga") + mixer.music.play() + + output = cv2.resize(self.invalid_certificate_image, (frame.shape[1], frame.shape[0])) + cv2.imshow('Camera', output) + + self.reset() + + +def main(): + Main() + sys.exit() + + +if __name__ == '__main__': + main() From b041a61085739bf3ff0e2839db8eebf137ba8a86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitus=20Maierh=C3=B6fer?= <1371724+VitusMa@users.noreply.github.com> Date: Sun, 21 Nov 2021 20:37:45 +0100 Subject: [PATCH 06/19] Create .gitignore --- .gitignore | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6e4761 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ From cba88e7721ab2b0873de19478ff621f70ab2c3f7 Mon Sep 17 00:00:00 2001 From: Vitus Date: Sun, 21 Nov 2021 20:41:03 +0100 Subject: [PATCH 07/19] Update .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index b6e4761..872c981 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,5 @@ dmypy.json # Pyre type checker .pyre/ + +.idea From 4063420f526e4af8d08ec9316659020f0d0d6c97 Mon Sep 17 00:00:00 2001 From: Vitus Date: Sun, 21 Nov 2021 20:50:07 +0100 Subject: [PATCH 08/19] work on correct UI scaling --- main.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/main.py b/main.py index 7a9702b..c91873a 100644 --- a/main.py +++ b/main.py @@ -82,8 +82,8 @@ def __init__(self): # cv2.namedWindow("Camera", cv2.WND_PROP_FULLSCREEN) # cv2.setWindowProperty("Camera", cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN) - self.font_title = PIL.ImageFont.truetype("fonts/Roboto-Regular.ttf", 80) - self.font_subtitle = PIL.ImageFont.truetype("fonts/Roboto-Regular.ttf", 50) + self.font_title = PIL.ImageFont.truetype("fonts/Roboto-Regular.ttf", 45) + self.font_subtitle = PIL.ImageFont.truetype("fonts/Roboto-Regular.ttf", 40) self.invalid_certificate_image = cv2.imread("img/failure.png") self.successful_verification_image = cv2.imread("img/success.png") @@ -177,12 +177,16 @@ def add_text_to_frame(self, frame): draw = PIL.ImageDraw.Draw(pil_image) title_width = max(draw.textsize(title, font=self.font_title), draw.textsize(title, font=self.font_title))[0] + title_height = max(draw.textsize(title, font=self.font_title), draw.textsize(title, font=self.font_title))[1] subtitle_width = max(draw.textsize(title, font=self.font_subtitle), draw.textsize(title, font=self.font_subtitle))[0] # TODO: make drawing code independent of screen size - draw.text(xy=((int((frame.shape[1] - title_width) / 2)), 10), text=title, fill=(0, 0, 0), font=self.font_title) - draw.text(xy=((int((frame.shape[1] - subtitle_width) / 2)), frame.shape[0] - 100), text=subtitle, - fill=(0, 0, 0), font=self.font_subtitle) + title_x = (int((frame.shape[1] - title_width) / 2)) + title_y = int((BORDER_PERCENTAGE * frame.shape[0] - title_height) / 2) + subtitle_x = int((frame.shape[1] - subtitle_width) / 2) + subtitle_y = frame.shape[0] - 100 + draw.text(xy=(title_x, title_y), text=title, fill=(0, 0, 0), font=self.font_title) + draw.text(xy=(subtitle_x, subtitle_y), text=subtitle, fill=(0, 0, 0), font=self.font_subtitle) frame[:] = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR) From c170c0070652725c3f0670fb22a4c6265fd5191f Mon Sep 17 00:00:00 2001 From: Vitus Date: Sun, 21 Nov 2021 20:52:33 +0100 Subject: [PATCH 09/19] Rename old script --- covpass-scanner => covpass-scanner_old | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename covpass-scanner => covpass-scanner_old (100%) diff --git a/covpass-scanner b/covpass-scanner_old similarity index 100% rename from covpass-scanner rename to covpass-scanner_old From 91647d45d2b2fb64cc517cc6e20a4d5ed24fe5a2 Mon Sep 17 00:00:00 2001 From: Vitus Date: Wed, 24 Nov 2021 18:04:15 +0100 Subject: [PATCH 10/19] Very WIP version of faster ID card scanning --- id_card_scanner.py | 165 ++++++++++++++++++++++++++++++++++++--------- main.py | 22 +++--- 2 files changed, 142 insertions(+), 45 deletions(-) diff --git a/id_card_scanner.py b/id_card_scanner.py index a8b9f23..455180a 100644 --- a/id_card_scanner.py +++ b/id_card_scanner.py @@ -1,21 +1,46 @@ # -*- coding: UTF-8 -*- - +import datetime import sys import time import cv2 +import numpy as np from pytesseract import pytesseract +import imutils PYTESSERACT_LANGUAGE = 'deu' class IdCardScanner: + last_frame = None + last_movement_timestamp = 0 + def __init__(self): pass def scan_for_id_cards(self, frame, data): + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + + frame_center = self.__extract_center_of_frame(frame) + + movement = self.__detect_movement(frame_center, 40, 100) + + now = time.time_ns() // 1_000_000 + print('movement', movement) + if movement: + self.last_movement_timestamp = now + return False + + if now - self.last_movement_timestamp < 500: + return False + + edges_present = self.__detect_edges(frame_center) + + if not edges_present: + return False + variants_dict = self.__generate_variants_dict(data) # This script is currently optimised for german ID cards. @@ -30,22 +55,41 @@ def scan_for_id_cards(self, frame, data): print('Match found:', match_found) return match_found + def __detect_edges(self, frame): + blurred = cv2.GaussianBlur(frame, (3, 3), 0) + canny = cv2.Canny(blurred, 50, 130) + cv2.imshow("Canny Edge Map", canny) + + edges_percentage = cv2.countNonZero(canny) / (frame.shape[0] * frame.shape[1]) * 100 + + MIN_NUM_EDGES_PERCENTAGE = 2 + + print('Edges: {}%'.format(edges_percentage)) + + if edges_percentage > MIN_NUM_EDGES_PERCENTAGE: + return True + return False + # Do some magic to improve the readability of text in the frame # TODO: Improve this and maybe offer multiple options def __prepare_frame(self, frame): - frame_grey = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # TODO: Find better values and remove magic numbers - threshold = cv2.adaptiveThreshold(frame_grey, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 199, 17) + threshold = cv2.adaptiveThreshold(frame, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 199, 17) cv2.imshow('Adaptive Gaussian Thresh', threshold) return threshold def __get_text_from_frame(self, frame): + # Windows Workaround + # pytesseract.tesseract_cmd = 'C:\\Program Files\\Tesseract-OCR\\tesseract.exe' + raw_text = pytesseract.image_to_string(frame, lang=PYTESSERACT_LANGUAGE) + raw_text = ' '.join(raw_text.split()) # Remove new lines and double spaces print(raw_text) + return raw_text # Perform OCR on the frame and compare the found text with the strings from the dict @@ -58,19 +102,19 @@ def __find_matches(self, frame, variants_dict): for first_name in variants_dict['first_name']: if first_name in raw_text: - print('First name matches!') + # print('First name matches!') first_name_found = True break for last_name in variants_dict['last_name']: if last_name in raw_text: - print('Last name matches!') + # print('Last name matches!') last_name_found = True break for dob in variants_dict['dob']: if dob in raw_text: - print('Date of birth matches!') + # print('Date of birth matches!') dob_found = True break @@ -105,33 +149,88 @@ def __generate_possible_dob_variants(self, dob): return dob_variants + def __extract_center_of_frame(self, frame): + width = frame.shape[1] + height = frame.shape[0] + center_x = int(width/2) + center_y = int(height/2) + size = int((0.5 * height) / 2) + frame = frame[center_y - size:center_y+size, center_x - size:center_x+size] + + cv2.imshow('center', frame) + + return frame + + # Check if movement was detected within the frame + # Based on https://www.pyimagesearch.com/2015/05/25/basic-motion-detection-and-tracking-with-python-and-opencv/ + def __detect_movement(self, frame, movement_threshold, min_area_for_movement_px): + movement = False + + if self.last_frame is None: + self.last_frame = frame + return True + + try: + # Compute the absolute difference between the current frame and first frame + frame_delta = cv2.absdiff(self.last_frame, frame) + + # Now threshold the difference image + thresh = cv2.threshold(frame_delta, movement_threshold, 255, cv2.THRESH_BINARY)[1] + + # dilate the thresholded image to fill in holes, then find contours on thresholded image + # TODO: Check if this is still needed + thresh = cv2.dilate(thresh, None, iterations=2) + + # Find contours in the thresholded image (Those are areas where movement has been detected) + # RETR_EXTERNAL: Return only outer contours. All child contours are left behind + # CV_CHAIN_APPROX_SIMPLE: Contour approximation method: compresses horizontal, vertical, and diagonal + # segments and leaves only their end points + contours = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + contours = imutils.grab_contours(contours) + + # Loop over the contours + for contour in contours: + + # If the contour is too small, ignore it. + # Otherwise we accept it as an area where movement has been detected + if cv2.contourArea(contour) >= min_area_for_movement_px: + movement = True + break + + except Exception as e: + print('[ImageAnalyzer]: Error on detecting movement:', e) + + self.last_frame = frame + + return movement + # EVERYTHING BELOW IS JUST FOR TESTING THE SCRIPT -# def main(): -# id_card_scanner = IdCardScanner() -# -# CAMERA = '/dev/video2' -# CAM_WIDTH, CAM_HEIGHT = 1280, 720 -# TEST_DATA = {'co': 'DE', 'dob': '2000-12-01', 'fn': 'Müller', 'gn': 'Max', 'fnt': 'MUELLER', 'gnt': 'MAX'} -# -# cap = cv2.VideoCapture(CAMERA) -# cap.set(cv2.CAP_PROP_FRAME_WIDTH, CAM_WIDTH) -# cap.set(cv2.CAP_PROP_FRAME_HEIGHT, CAM_HEIGHT) -# while True: -# ret, frame = cap.read() -# -# if frame is not None: -# cv2.imshow('raw frame', frame) -# id_card_scanner.scan_for_id_cards(frame, TEST_DATA) -# -# key = cv2.waitKey(1) -# -# # Press esc or 'q' to close the image window -# if key & 0xFF == ord('q') or key == 27: -# cv2.destroyAllWindows() -# sys.exit(0) -# -# -# if __name__ == '__main__': -# main() +def main(): + id_card_scanner = IdCardScanner() + + CAMERA = 2 + CAM_WIDTH, CAM_HEIGHT = 640, 480 + TEST_DATA = {} # INSERT HERE + + cap = cv2.VideoCapture(CAMERA) + cap.set(cv2.CAP_PROP_FRAME_WIDTH, CAM_WIDTH) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, CAM_HEIGHT) + while True: + ret, frame = cap.read() + + if frame is not None: + cv2.imshow('raw frame', frame) + id_card_scanner.scan_for_id_cards(frame, TEST_DATA) + + key = cv2.waitKey(1) + + # Press esc or 'q' to close the image window + if key & 0xFF == ord('q') or key == 27: + cv2.destroyAllWindows() + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/main.py b/main.py index c91873a..c73236a 100644 --- a/main.py +++ b/main.py @@ -17,8 +17,8 @@ DEFAULT_CERTIFICATE_DB_JSON = 'certs/Digital_Green_Certificate_Signing_Keys.json' -CAMERA_ID = 7 -CAM_WIDTH, CAM_HEIGHT = 640, 480 +CAMERA_ID = 2 +CAM_WIDTH, CAM_HEIGHT = 1280, 720 TIME_WAIT_AFTER_CERTIFICATE_FOUND_SEC = 3 TIME_WAIT_FOR_ID_CARD_SEC = 30 @@ -27,6 +27,8 @@ TIME_SHOW_SUCCESSFUL_VERIFICATION_MESSAGE_SEC = 5 BORDER_PERCENTAGE = 0.2 +TEXT_COLOR = (255, 0, 0) + class Main: @@ -105,13 +107,9 @@ def run_interactive(self): if found_certificate: already_scanned_certificate = self.active_certificate_data == parsed_covid_cert_data if not already_scanned_certificate: # Only continue if it is new certificate - #frame[:] = (0, 255, 255) - #cv2.imshow("Camera", frame) - if is_valid: self.active_certificate_data = parsed_covid_cert_data self.last_certificate_found_timestamp = now - else: self.invalid_certificate_found = True @@ -156,10 +154,10 @@ def add_borders_to_frame(self, frame): # Add small black border around camera preview frame = cv2.copyMakeBorder(frame, 3, 3, 3, 3, cv2.BORDER_CONSTANT, value=(0, 0, 0)) # Add large white border - frame = cv2.copyMakeBorder(frame, - int(BORDER_PERCENTAGE * frame.shape[1]), int(BORDER_PERCENTAGE * frame.shape[1]), - int(BORDER_PERCENTAGE * frame.shape[0]), int(BORDER_PERCENTAGE * frame.shape[0]), - cv2.BORDER_CONSTANT, value=(255, 255, 255)) + # frame = cv2.copyMakeBorder(frame, + # int(BORDER_PERCENTAGE * frame.shape[1]), int(BORDER_PERCENTAGE * frame.shape[1]), + # int(BORDER_PERCENTAGE * frame.shape[0]), int(BORDER_PERCENTAGE * frame.shape[0]), + # cv2.BORDER_CONSTANT, value=(255, 255, 255)) return frame @@ -185,8 +183,8 @@ def add_text_to_frame(self, frame): title_y = int((BORDER_PERCENTAGE * frame.shape[0] - title_height) / 2) subtitle_x = int((frame.shape[1] - subtitle_width) / 2) subtitle_y = frame.shape[0] - 100 - draw.text(xy=(title_x, title_y), text=title, fill=(0, 0, 0), font=self.font_title) - draw.text(xy=(subtitle_x, subtitle_y), text=subtitle, fill=(0, 0, 0), font=self.font_subtitle) + draw.text(xy=(title_x, title_y), text=title, fill=TEXT_COLOR, font=self.font_title) + draw.text(xy=(subtitle_x, subtitle_y), text=subtitle, fill=TEXT_COLOR, font=self.font_subtitle) frame[:] = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR) From 840ef5f3281506aacd408f6eda9550b4b8ca5ab9 Mon Sep 17 00:00:00 2001 From: Vitus Date: Thu, 25 Nov 2021 16:07:49 +0100 Subject: [PATCH 11/19] Add levenshtein algorythm to allow for some wrongly detected chars --- id_card_scanner.py | 120 ++++++++++++++++++++++++++++++++------------- 1 file changed, 85 insertions(+), 35 deletions(-) diff --git a/id_card_scanner.py b/id_card_scanner.py index 455180a..8477b2f 100644 --- a/id_card_scanner.py +++ b/id_card_scanner.py @@ -1,5 +1,4 @@ # -*- coding: UTF-8 -*- -import datetime import sys import time @@ -10,6 +9,13 @@ PYTESSERACT_LANGUAGE = 'deu' +# Check if at least X% of the center of the image are edges +# (Many edges -> Probably text -> High possibility that an ID card is present) +MIN_NUM_EDGES_PERCENTAGE = 1.5 + +USE_MOVEMENT_DETECTOR = True +USE_LEVENSHTEIN = True + class IdCardScanner: @@ -22,19 +28,19 @@ def __init__(self): def scan_for_id_cards(self, frame, data): frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) - frame_center = self.__extract_center_of_frame(frame) - movement = self.__detect_movement(frame_center, 40, 100) + if USE_MOVEMENT_DETECTOR: + movement = self.__detect_movement(frame_center, 40, 300) - now = time.time_ns() // 1_000_000 - print('movement', movement) - if movement: - self.last_movement_timestamp = now - return False + now = time.time_ns() // 1_000_000 + if movement: + print('Movement detected') + self.last_movement_timestamp = now + return False - if now - self.last_movement_timestamp < 500: - return False + if now - self.last_movement_timestamp < 400: + return False edges_present = self.__detect_edges(frame_center) @@ -47,7 +53,6 @@ def scan_for_id_cards(self, frame, data): if data['co'][1] != 'DE': print('Certificate not issued in Germany, therefore probably also no german passport') - # Step 1 modified_frame = self.__prepare_frame(frame) match_found = self.__find_matches(modified_frame, variants_dict) @@ -55,16 +60,15 @@ def scan_for_id_cards(self, frame, data): print('Match found:', match_found) return match_found - def __detect_edges(self, frame): + @staticmethod + def __detect_edges(frame): blurred = cv2.GaussianBlur(frame, (3, 3), 0) canny = cv2.Canny(blurred, 50, 130) - cv2.imshow("Canny Edge Map", canny) + # cv2.imshow("Canny Edge Map", canny) edges_percentage = cv2.countNonZero(canny) / (frame.shape[0] * frame.shape[1]) * 100 - MIN_NUM_EDGES_PERCENTAGE = 2 - - print('Edges: {}%'.format(edges_percentage)) + print('Edges Percentage: {}%'.format(edges_percentage)) if edges_percentage > MIN_NUM_EDGES_PERCENTAGE: return True @@ -72,54 +76,98 @@ def __detect_edges(self, frame): # Do some magic to improve the readability of text in the frame # TODO: Improve this and maybe offer multiple options - def __prepare_frame(self, frame): - + @staticmethod + def __prepare_frame(frame): # TODO: Find better values and remove magic numbers - threshold = cv2.adaptiveThreshold(frame, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 199, 17) - - cv2.imshow('Adaptive Gaussian Thresh', threshold) - + threshold = cv2.adaptiveThreshold(frame, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 41, 16) + # cv2.imshow('Adaptive Gaussian Thresh', threshold) return threshold - def __get_text_from_frame(self, frame): + @staticmethod + def __get_text_from_frame(frame): # Windows Workaround - # pytesseract.tesseract_cmd = 'C:\\Program Files\\Tesseract-OCR\\tesseract.exe' + pytesseract.tesseract_cmd = 'C:\\Program Files\\Tesseract-OCR\\tesseract.exe' raw_text = pytesseract.image_to_string(frame, lang=PYTESSERACT_LANGUAGE) raw_text = ' '.join(raw_text.split()) # Remove new lines and double spaces - print(raw_text) - return raw_text # Perform OCR on the frame and compare the found text with the strings from the dict def __find_matches(self, frame, variants_dict): raw_text = self.__get_text_from_frame(frame) + detected_word_list = raw_text.split(' ') + first_name_found = False last_name_found = False dob_found = False for first_name in variants_dict['first_name']: if first_name in raw_text: - # print('First name matches!') first_name_found = True break + if not first_name_found and USE_LEVENSHTEIN: + first_name_found = self.match_witch_levenshtein(variants_dict['first_name'], detected_word_list) + for last_name in variants_dict['last_name']: if last_name in raw_text: - # print('Last name matches!') last_name_found = True break + if not last_name_found and USE_LEVENSHTEIN: + last_name_found = self.match_witch_levenshtein(variants_dict['last_name'], detected_word_list) + for dob in variants_dict['dob']: if dob in raw_text: - # print('Date of birth matches!') dob_found = True break + if not dob_found and USE_LEVENSHTEIN: + dob_found = self.match_witch_levenshtein(variants_dict['dob'], detected_word_list) + return first_name_found and last_name_found and dob_found + def match_witch_levenshtein(self, variants, detected_word_list): + for variant in variants: + for word in detected_word_list: + if len(word) == len(variant): + word_distance = self.levenshtein_distance(word, variant) + # If just one char is different + if word_distance == 1: + print('{} vs. {} -> Just one char is different'.format(variant, word, word_distance)) + return True + return False + + # Based on: https://blog.paperspace.com/implementing-levenshtein-distance-word-autocomplete-autocorrect/ + def levenshtein_distance(self, token1, token2): + distances = np.zeros((len(token1) + 1, len(token2) + 1)) + + for t1 in range(len(token1) + 1): + distances[t1][0] = t1 + + for t2 in range(len(token2) + 1): + distances[0][t2] = t2 + + for t1 in range(1, len(token1) + 1): + for t2 in range(1, len(token2) + 1): + if token1[t1 - 1] == token2[t2 - 1]: + distances[t1][t2] = distances[t1 - 1][t2 - 1] + else: + a = distances[t1][t2 - 1] + b = distances[t1 - 1][t2] + c = distances[t1 - 1][t2 - 1] + + if a <= b and a <= c: + distances[t1][t2] = a + 1 + elif b <= a and b <= c: + distances[t1][t2] = b + 1 + else: + distances[t1][t2] = c + 1 + + return distances[len(token1)][len(token2)] + # Generate a dict of strings that we expect to find in the text on the ID card # -> Different variants of first name, last name and date of birth def __generate_variants_dict(self, data): @@ -133,7 +181,8 @@ def __generate_variants_dict(self, data): # The date of birth (dob) can appear in different variants on passports (dd.mm.yyyy, dd.mm.yy, yymmdd, ...) # This method generates a list of all possible variants - def __generate_possible_dob_variants(self, dob): + @staticmethod + def __generate_possible_dob_variants(dob): dob_variants = [dob] @@ -149,7 +198,8 @@ def __generate_possible_dob_variants(self, dob): return dob_variants - def __extract_center_of_frame(self, frame): + @staticmethod + def __extract_center_of_frame(frame): width = frame.shape[1] height = frame.shape[0] center_x = int(width/2) @@ -157,7 +207,7 @@ def __extract_center_of_frame(self, frame): size = int((0.5 * height) / 2) frame = frame[center_y - size:center_y+size, center_x - size:center_x+size] - cv2.imshow('center', frame) + # cv2.imshow('center', frame) return frame @@ -198,7 +248,7 @@ def __detect_movement(self, frame, movement_threshold, min_area_for_movement_px) break except Exception as e: - print('[ImageAnalyzer]: Error on detecting movement:', e) + print('Error on detecting movement:', e) self.last_frame = frame @@ -211,8 +261,8 @@ def main(): id_card_scanner = IdCardScanner() CAMERA = 2 - CAM_WIDTH, CAM_HEIGHT = 640, 480 - TEST_DATA = {} # INSERT HERE + CAM_WIDTH, CAM_HEIGHT = 1280, 720 + TEST_DATA = {} # INSERT HERE cap = cv2.VideoCapture(CAMERA) cap.set(cv2.CAP_PROP_FRAME_WIDTH, CAM_WIDTH) From 4a98f58da3249f17b7817d38a488ac436de8f749 Mon Sep 17 00:00:00 2001 From: Vitus Date: Thu, 25 Nov 2021 16:08:11 +0100 Subject: [PATCH 12/19] Trim success/failure images --- img/failure.png | Bin 4657 -> 12538 bytes img/success.png | Bin 4024 -> 10468 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/img/failure.png b/img/failure.png index 567b984f6ae22b4dc772fa054345ed453036edc6..246ee3240532f3fdbf5879990715875c618ca193 100644 GIT binary patch literal 12538 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 QIsgCw07*qoM6N<$g7y%bQvd(} literal 4657 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 From f3e8508b92b5c33e7d553ae0f766b44bcc08b3e5 Mon Sep 17 00:00:00 2001 From: Vitus Date: Thu, 25 Nov 2021 16:08:40 +0100 Subject: [PATCH 13/19] Rework preview window and add more constants for settings --- main.py | 74 ++++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/main.py b/main.py index c73236a..c333fe5 100644 --- a/main.py +++ b/main.py @@ -24,10 +24,19 @@ TIME_WAIT_FOR_ID_CARD_SEC = 30 TIME_SHOW_INVALID_CERTIFICATE_MESSAGE_SEC = 10 -TIME_SHOW_SUCCESSFUL_VERIFICATION_MESSAGE_SEC = 5 +TIME_SHOW_SUCCESSFUL_VERIFICATION_MESSAGE_SEC = 3 -BORDER_PERCENTAGE = 0.2 -TEXT_COLOR = (255, 0, 0) +BORDER_PERCENTAGE = 0.15 +TEXT_COLOR = (255, 255, 255) +BORDER_COLOR = (0, 0, 0) +PREVIEW_BORDER_COLOR = (120, 120, 120) + +FONT_SIZE = 90 + +OUTPUT_DISPLAY_RESOLUTION = (1024, 600) + +STEP_1_TEXT = 'Step 1: Scan COVPASS Certificate:' +STEP_2_TEXT = 'Step 2: Scan ID card:' class Main: @@ -82,15 +91,22 @@ def __init__(self): self.covpass_scanner = CovpassScanner() self.id_card_scanner = IdCardScanner() - # cv2.namedWindow("Camera", cv2.WND_PROP_FULLSCREEN) - # cv2.setWindowProperty("Camera", cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN) - self.font_title = PIL.ImageFont.truetype("fonts/Roboto-Regular.ttf", 45) - self.font_subtitle = PIL.ImageFont.truetype("fonts/Roboto-Regular.ttf", 40) + cv2.namedWindow("Camera", cv2.WND_PROP_FULLSCREEN) + cv2.setWindowProperty("Camera", cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN) + self.font_title = PIL.ImageFont.truetype("fonts/Roboto-Regular.ttf", FONT_SIZE) + self.font_subtitle = PIL.ImageFont.truetype("fonts/Roboto-Regular.ttf", FONT_SIZE) + self.__prepare_images() + self.run_interactive() + + def __prepare_images(self): self.invalid_certificate_image = cv2.imread("img/failure.png") - self.successful_verification_image = cv2.imread("img/success.png") + self.invalid_certificate_image = cv2.resize(self.invalid_certificate_image, (OUTPUT_DISPLAY_RESOLUTION[1], OUTPUT_DISPLAY_RESOLUTION[1])) + self.invalid_certificate_image = cv2.copyMakeBorder(self.invalid_certificate_image, 0, 0, int((OUTPUT_DISPLAY_RESOLUTION[0] - OUTPUT_DISPLAY_RESOLUTION[1]) / 2), int((OUTPUT_DISPLAY_RESOLUTION[0] - OUTPUT_DISPLAY_RESOLUTION[1]) / 2), cv2.BORDER_CONSTANT, value=(255, 255, 255)) - self.run_interactive() + self.successful_verification_image = cv2.imread("img/success.png") + self.successful_verification_image = cv2.resize(self.successful_verification_image, (OUTPUT_DISPLAY_RESOLUTION[1], OUTPUT_DISPLAY_RESOLUTION[1])) + self.successful_verification_image = cv2.copyMakeBorder(self.successful_verification_image, 0, 0, int((OUTPUT_DISPLAY_RESOLUTION[0] - OUTPUT_DISPLAY_RESOLUTION[1]) / 2), int((OUTPUT_DISPLAY_RESOLUTION[0] - OUTPUT_DISPLAY_RESOLUTION[1]) / 2), cv2.BORDER_CONSTANT, value=(255, 255, 255)) def run_interactive(self): while True: @@ -99,6 +115,8 @@ def run_interactive(self): print('No frame from camera') continue + frame = cv2.flip(frame, -1) + now = time.time() # Check if a certificate is found in the frame @@ -108,6 +126,8 @@ def run_interactive(self): already_scanned_certificate = self.active_certificate_data == parsed_covid_cert_data if not already_scanned_certificate: # Only continue if it is new certificate if is_valid: + # print(parsed_covid_cert_data) + self.on_valid_certificate() self.active_certificate_data = parsed_covid_cert_data self.last_certificate_found_timestamp = now else: @@ -129,10 +149,10 @@ def run_interactive(self): self.update_ui(frame) if self.invalid_certificate_found: - self.on_invalid_certificate(frame) + self.on_invalid_certificate() key = cv2.waitKey(TIME_SHOW_INVALID_CERTIFICATE_MESSAGE_SEC * 1000) # sec to ms elif self.id_card_matches_certificate: - self.on_successful_verification(frame) + self.on_successful_verification() key = cv2.waitKey(TIME_SHOW_SUCCESSFUL_VERIFICATION_MESSAGE_SEC * 1000) # sec to ms else: key = cv2.waitKey(1) @@ -143,30 +163,31 @@ def run_interactive(self): sys.exit(0) def update_ui(self, frame): - old_shape = frame.shape # Remember to resize later after adding borders to the frame + # old_shape = frame.shape # Remember to resize later after adding borders to the frame frame = self.add_borders_to_frame(frame) frame = self.add_text_to_frame(frame) - frame = cv2.resize(frame, (old_shape[1], old_shape[0])) + # frame = cv2.resize(frame, (old_shape[1], old_shape[0])) + frame = cv2.resize(frame, OUTPUT_DISPLAY_RESOLUTION) cv2.imshow("Camera", frame) def add_borders_to_frame(self, frame): # Add small black border around camera preview - frame = cv2.copyMakeBorder(frame, 3, 3, 3, 3, cv2.BORDER_CONSTANT, value=(0, 0, 0)) + frame = cv2.copyMakeBorder(frame, 3, 3, 3, 3, cv2.BORDER_CONSTANT, value=PREVIEW_BORDER_COLOR) # Add large white border - # frame = cv2.copyMakeBorder(frame, - # int(BORDER_PERCENTAGE * frame.shape[1]), int(BORDER_PERCENTAGE * frame.shape[1]), - # int(BORDER_PERCENTAGE * frame.shape[0]), int(BORDER_PERCENTAGE * frame.shape[0]), - # cv2.BORDER_CONSTANT, value=(255, 255, 255)) + frame = cv2.copyMakeBorder(frame, + 2 * int(BORDER_PERCENTAGE * frame.shape[0]), 0, + int(BORDER_PERCENTAGE * frame.shape[1]), int(BORDER_PERCENTAGE * frame.shape[1]), + cv2.BORDER_CONSTANT, value=BORDER_COLOR) return frame def add_text_to_frame(self, frame): - title = 'Step 1: Scan COVPASS Certificate:' + title = STEP_1_TEXT subtitle = '' if self.active_certificate_data is not None: - title = 'Step 2: Scan ID card:' + title = STEP_2_TEXT last_name = self.active_certificate_data['fn'][1] first_name = self.active_certificate_data['gn'][1] subtitle = 'Name: {} {}'.format(first_name, last_name) @@ -196,22 +217,27 @@ def reset(self): self.id_card_matches_certificate = False self.invalid_certificate_found = False - def on_successful_verification(self, frame): + def on_successful_verification(self): mixer.init() mixer.music.load("sounds/complete.oga") mixer.music.play() - output = cv2.resize(self.successful_verification_image, (frame.shape[1], frame.shape[0])) + output = cv2.resize(self.successful_verification_image, OUTPUT_DISPLAY_RESOLUTION) cv2.imshow('Camera', output) self.reset() - def on_invalid_certificate(self, frame): + def on_valid_certificate(self): + mixer.init() + mixer.music.load("sounds/message.oga") + mixer.music.play() + + def on_invalid_certificate(self): mixer.init() mixer.music.load("sounds/dialog-error.oga") mixer.music.play() - output = cv2.resize(self.invalid_certificate_image, (frame.shape[1], frame.shape[0])) + output = cv2.resize(self.invalid_certificate_image, OUTPUT_DISPLAY_RESOLUTION) cv2.imshow('Camera', output) self.reset() From 9d7fdaecc2cf1e1b09c76a90988960a7a4a55c42 Mon Sep 17 00:00:00 2001 From: Vitus Date: Thu, 25 Nov 2021 16:08:56 +0100 Subject: [PATCH 14/19] Update requirements.txt --- requirements.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/requirements.txt b/requirements.txt index 5a2eccb..9bce308 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,3 +25,7 @@ six==1.16.0 typing-extensions==3.10.0.0 urllib3==1.26.6 zbar-py==1.0.4 + +pyasn1~=0.4.8 +imutils~=0.5.4 +pytesseract~=0.3.8 \ No newline at end of file From 16fe1ebb2feccd0b519c0cec9375ca9bc6c5cc7d Mon Sep 17 00:00:00 2001 From: Vitus Date: Thu, 25 Nov 2021 16:11:45 +0100 Subject: [PATCH 15/19] Remove workaround for tesseract missing in PATH on Windows --- id_card_scanner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/id_card_scanner.py b/id_card_scanner.py index 8477b2f..850dc2d 100644 --- a/id_card_scanner.py +++ b/id_card_scanner.py @@ -86,7 +86,7 @@ def __prepare_frame(frame): @staticmethod def __get_text_from_frame(frame): # Windows Workaround - pytesseract.tesseract_cmd = 'C:\\Program Files\\Tesseract-OCR\\tesseract.exe' + # pytesseract.tesseract_cmd = 'C:\\Program Files\\Tesseract-OCR\\tesseract.exe' raw_text = pytesseract.image_to_string(frame, lang=PYTESSERACT_LANGUAGE) raw_text = ' '.join(raw_text.split()) # Remove new lines and double spaces From 8ee695bcd121fa0fafed2973540b4f27a3018db7 Mon Sep 17 00:00:00 2001 From: Vitus Date: Thu, 25 Nov 2021 16:13:11 +0100 Subject: [PATCH 16/19] Add script to test settings of Adaptive threshold --- value_tweaker_utility.py | 95 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 value_tweaker_utility.py diff --git a/value_tweaker_utility.py b/value_tweaker_utility.py new file mode 100644 index 0000000..0646f9a --- /dev/null +++ b/value_tweaker_utility.py @@ -0,0 +1,95 @@ +import sys +import cv2 +import numpy as np + +WINDOW_NAME = 'ID card scanner value tweaker utility' + +CAMERA = 2 +CAM_WIDTH, CAM_HEIGHT = 1280, 720 + + +# Based on: https://docs.opencv.org/3.4/da/d97/tutorial_threshold_inRange.html +class ValueTweakerUtility: + """ ValueTweakerUtility + """ + + max_value_name = 'Max value' + blocksize_name = 'BlockSize' + C_name = 'C' + + low_blocksize = 3 + high_blocksize = 255 + + low_C = 0 + high_C = 30 + + max_value = 255 + blocksize = 199 + C = 17 + + def __init__(self): + self.cap = cv2.VideoCapture(CAMERA) + self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, CAM_WIDTH) + self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, CAM_HEIGHT) + + cv2.namedWindow(WINDOW_NAME) + + cv2.createTrackbar(self.max_value_name, WINDOW_NAME, self.max_value, self.max_value, self.on_max_value_trackbar) + cv2.createTrackbar(self.blocksize_name, WINDOW_NAME, self.low_blocksize, self.high_blocksize, self.on_blocksize_trackbar) + cv2.createTrackbar(self.C_name, WINDOW_NAME, self.low_C, self.high_C, self.on_C_trackbar) + + cv2.setTrackbarPos(self.blocksize_name, WINDOW_NAME, self.blocksize) + cv2.setTrackbarPos(self.C_name, WINDOW_NAME, self.C) + + self.loop() + + def loop(self): + while True: + ret, frame = self.cap.read() + + # Only continue if needed frames are available + if frame is not None: + + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + + threshold = cv2.adaptiveThreshold(frame, self.max_value, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, self.blocksize, self.C) + + kernel = np.ones((2, 2), np.uint8) + threshold = cv2.erode(threshold, kernel) + + # cv2.imshow(WINDOW_NAME, combined_frames) + cv2.imshow(WINDOW_NAME, threshold) + + print('') + print('Max Value:', self.max_value, 'Blocksize:', self.blocksize, 'C:', self.C) + + key = cv2.waitKey(1) + # Press esc or 'q' to close the image window + if key & 0xFF == ord('q') or key == 27: + cv2.destroyAllWindows() + break + + def on_max_value_trackbar(self, val): + self.max_value = val + cv2.setTrackbarPos(self.max_value_name, WINDOW_NAME, self.max_value) + + def on_blocksize_trackbar(self, val): + if val % 2 != 1: + val = val - 1 + if val < 3: + val = 3 + self.blocksize = val + cv2.setTrackbarPos(self.blocksize_name, WINDOW_NAME, self.blocksize) + + def on_C_trackbar(self, val): + self.C = val + cv2.setTrackbarPos(self.C_name, WINDOW_NAME, self.C) + + +def main(): + ValueTweakerUtility() + sys.exit() + + +if __name__ == '__main__': + main() From 62593b34c38ce93d36904c7f4feb9e77e167de0d Mon Sep 17 00:00:00 2001 From: Vitus Date: Thu, 25 Nov 2021 16:13:44 +0100 Subject: [PATCH 17/19] Add erode to preprocessing of frame for OCR --- id_card_scanner.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/id_card_scanner.py b/id_card_scanner.py index 850dc2d..a722523 100644 --- a/id_card_scanner.py +++ b/id_card_scanner.py @@ -80,6 +80,11 @@ def __detect_edges(frame): def __prepare_frame(frame): # TODO: Find better values and remove magic numbers threshold = cv2.adaptiveThreshold(frame, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 41, 16) + + # Erode frame + kernel = np.ones((2, 2), np.uint8) + threshold = cv2.erode(threshold, kernel) + # cv2.imshow('Adaptive Gaussian Thresh', threshold) return threshold From 965728cbc32a6cb6f82c68bebe9a2f4e63a9f708 Mon Sep 17 00:00:00 2001 From: thomfisch <13784757+thomfischer@users.noreply.github.com> Date: Sat, 27 Nov 2021 20:43:30 +0100 Subject: [PATCH 18/19] make id verification optional --- main.py | 67 +++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/main.py b/main.py index c333fe5..7d85fbd 100644 --- a/main.py +++ b/main.py @@ -22,6 +22,7 @@ TIME_WAIT_AFTER_CERTIFICATE_FOUND_SEC = 3 TIME_WAIT_FOR_ID_CARD_SEC = 30 +TIME_WAIT_BETWEEN_SCANS_SEC = 0.100 TIME_SHOW_INVALID_CERTIFICATE_MESSAGE_SEC = 10 TIME_SHOW_SUCCESSFUL_VERIFICATION_MESSAGE_SEC = 3 @@ -47,19 +48,26 @@ class Main: invalid_certificate_found = False def __init__(self): - # parser = argparse.ArgumentParser(description='EU COVID Vaccination Passport Verifier') + parser = argparse.ArgumentParser(description='EU COVID Vaccination Passport Verifier') # parser.add_argument('--image-file', metavar="IMAGE-FILE", # help='Image to read QR-code from') # parser.add_argument('--raw-string', metavar="RAW-STRING", # help='Contents of the QR-code as string') # parser.add_argument('image_file_positional', metavar="IMAGE-FILE", nargs="?", # help='Image to read QR-code from') - # parser.add_argument('--certificate-db-json-file', default=DEFAULT_CERTIFICATE_DB_JSON, - # help="Default: {0}".format(DEFAULT_CERTIFICATE_DB_JSON)) - # parser.add_argument('--camera', metavar="CAMERA-FILE", - # help='camera path') - # - # args = parser.parse_args() + parser.add_argument('--certificate-db-json-file', default=DEFAULT_CERTIFICATE_DB_JSON, + help="Default: {0}".format(DEFAULT_CERTIFICATE_DB_JSON)) + parser.add_argument('--camera', metavar="CAMERA-FILE", + help='camera path') + parser.add_argument('--id-verification', action='store_true', + help='Verify vaccination certificate with personal ID') + + args = parser.parse_args() + self.id_verification = args.id_verification + if args.camera: + self.camera_device = args.camera + else: + self.camera_device = CAMERA_ID # # covid_cert_data = None # image_file = None @@ -84,7 +92,7 @@ def __init__(self): # log.debug("Cert data: '{0}'".format(covid_cert_data)) # output_covid_cert_data(covid_cert_data, args.certificate_db_json_file) - self.capture = cv2.VideoCapture(CAMERA_ID) + self.capture = cv2.VideoCapture(self.camera_device) self.capture.set(cv2.CAP_PROP_FRAME_WIDTH, CAM_WIDTH) self.capture.set(cv2.CAP_PROP_FRAME_HEIGHT, CAM_HEIGHT) @@ -109,6 +117,7 @@ def __prepare_images(self): self.successful_verification_image = cv2.copyMakeBorder(self.successful_verification_image, 0, 0, int((OUTPUT_DISPLAY_RESOLUTION[0] - OUTPUT_DISPLAY_RESOLUTION[1]) / 2), int((OUTPUT_DISPLAY_RESOLUTION[0] - OUTPUT_DISPLAY_RESOLUTION[1]) / 2), cv2.BORDER_CONSTANT, value=(255, 255, 255)) def run_interactive(self): + previous_scan_timestamp = 0 while True: ret, frame = self.capture.read() if frame is None: @@ -120,20 +129,24 @@ def run_interactive(self): now = time.time() # Check if a certificate is found in the frame - found_certificate, is_valid, parsed_covid_cert_data = self.covpass_scanner.process_frame(frame) + found_certificate, is_valid, parsed_covid_cert_data = None, None, None + if previous_scan_timestamp + TIME_WAIT_BETWEEN_SCANS_SEC < now: + found_certificate, is_valid, parsed_covid_cert_data = self.covpass_scanner.process_frame(frame) + previous_scan_timestamp = time.time() if found_certificate: already_scanned_certificate = self.active_certificate_data == parsed_covid_cert_data if not already_scanned_certificate: # Only continue if it is new certificate if is_valid: # print(parsed_covid_cert_data) - self.on_valid_certificate() + # self.on_valid_certificate() self.active_certificate_data = parsed_covid_cert_data self.last_certificate_found_timestamp = now + else: self.invalid_certificate_found = True - else: # Only check for ID card if no certificate is found in the current frame + elif self.id_verification: # Only check for ID card if no certificate is found in the current frame if self.active_certificate_data is not None: # Wait at least XX seconds after certificate has been detected in frame @@ -151,6 +164,10 @@ def run_interactive(self): if self.invalid_certificate_found: self.on_invalid_certificate() key = cv2.waitKey(TIME_SHOW_INVALID_CERTIFICATE_MESSAGE_SEC * 1000) # sec to ms + elif self.active_certificate_data and not self.id_verification: + self.on_successful_verification() + key = cv2.waitKey(TIME_SHOW_SUCCESSFUL_VERIFICATION_MESSAGE_SEC * 1000) # sec to ms + elif self.id_card_matches_certificate: self.on_successful_verification() key = cv2.waitKey(TIME_SHOW_SUCCESSFUL_VERIFICATION_MESSAGE_SEC * 1000) # sec to ms @@ -164,6 +181,12 @@ def run_interactive(self): def update_ui(self, frame): # old_shape = frame.shape # Remember to resize later after adding borders to the frame + if self.active_certificate_data and not self.id_verification: + frame = cv2.resize(self.successful_verification_image, OUTPUT_DISPLAY_RESOLUTION) + elif self.id_card_matches_certificate: + frame = cv2.resize(self.successful_verification_image, OUTPUT_DISPLAY_RESOLUTION) + elif self.invalid_certificate_found: + frame = cv2.resize(self.invalid_certificate_image, OUTPUT_DISPLAY_RESOLUTION) frame = self.add_borders_to_frame(frame) frame = self.add_text_to_frame(frame) @@ -186,8 +209,9 @@ def add_text_to_frame(self, frame): title = STEP_1_TEXT subtitle = '' - if self.active_certificate_data is not None: - title = STEP_2_TEXT + if self.active_certificate_data: + if self.id_verification: + title = STEP_2_TEXT last_name = self.active_certificate_data['fn'][1] first_name = self.active_certificate_data['gn'][1] subtitle = 'Name: {} {}'.format(first_name, last_name) @@ -217,14 +241,22 @@ def reset(self): self.id_card_matches_certificate = False self.invalid_certificate_found = False + # Hotfix hack + # without multithreading, the code will block and use buffered frames from several seconds ago + # when image analysis finishes. + # this results in analysing phantom barcodes, that are no longer physically present + # restarting the camera clears the buffer. + # TODO: implement this properly + self.capture.release() + self.capture = cv2.VideoCapture(self.camera_device) + self.capture.set(cv2.CAP_PROP_FRAME_WIDTH, CAM_WIDTH) + self.capture.set(cv2.CAP_PROP_FRAME_HEIGHT, CAM_HEIGHT) + def on_successful_verification(self): mixer.init() mixer.music.load("sounds/complete.oga") mixer.music.play() - output = cv2.resize(self.successful_verification_image, OUTPUT_DISPLAY_RESOLUTION) - cv2.imshow('Camera', output) - self.reset() def on_valid_certificate(self): @@ -237,9 +269,6 @@ def on_invalid_certificate(self): mixer.music.load("sounds/dialog-error.oga") mixer.music.play() - output = cv2.resize(self.invalid_certificate_image, OUTPUT_DISPLAY_RESOLUTION) - cv2.imshow('Camera', output) - self.reset() From ea2ffdfe93e8aa7ecdb59e3fdc503be7f6e73836 Mon Sep 17 00:00:00 2001 From: thomfischer <13784757+thomfischer@users.noreply.github.com> Date: Sun, 28 Nov 2021 00:05:52 +0100 Subject: [PATCH 19/19] fix text on feedback screen --- main.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/main.py b/main.py index 7d85fbd..ebe1ad2 100644 --- a/main.py +++ b/main.py @@ -29,6 +29,7 @@ BORDER_PERCENTAGE = 0.15 TEXT_COLOR = (255, 255, 255) +SUBTEXT_COLOR = (0, 0, 0) BORDER_COLOR = (0, 0, 0) PREVIEW_BORDER_COLOR = (120, 120, 120) @@ -108,13 +109,14 @@ def __init__(self): self.run_interactive() def __prepare_images(self): + short_side = min(CAM_HEIGHT, CAM_WIDTH) self.invalid_certificate_image = cv2.imread("img/failure.png") - self.invalid_certificate_image = cv2.resize(self.invalid_certificate_image, (OUTPUT_DISPLAY_RESOLUTION[1], OUTPUT_DISPLAY_RESOLUTION[1])) - self.invalid_certificate_image = cv2.copyMakeBorder(self.invalid_certificate_image, 0, 0, int((OUTPUT_DISPLAY_RESOLUTION[0] - OUTPUT_DISPLAY_RESOLUTION[1]) / 2), int((OUTPUT_DISPLAY_RESOLUTION[0] - OUTPUT_DISPLAY_RESOLUTION[1]) / 2), cv2.BORDER_CONSTANT, value=(255, 255, 255)) + self.invalid_certificate_image = cv2.resize(self.invalid_certificate_image, (short_side, short_side)) + self.invalid_certificate_image = cv2.copyMakeBorder(self.invalid_certificate_image, 0, 0, int((CAM_WIDTH - CAM_HEIGHT) / 2), int((CAM_WIDTH - CAM_HEIGHT) / 2), cv2.BORDER_CONSTANT, value=(255, 255, 255)) self.successful_verification_image = cv2.imread("img/success.png") - self.successful_verification_image = cv2.resize(self.successful_verification_image, (OUTPUT_DISPLAY_RESOLUTION[1], OUTPUT_DISPLAY_RESOLUTION[1])) - self.successful_verification_image = cv2.copyMakeBorder(self.successful_verification_image, 0, 0, int((OUTPUT_DISPLAY_RESOLUTION[0] - OUTPUT_DISPLAY_RESOLUTION[1]) / 2), int((OUTPUT_DISPLAY_RESOLUTION[0] - OUTPUT_DISPLAY_RESOLUTION[1]) / 2), cv2.BORDER_CONSTANT, value=(255, 255, 255)) + self.successful_verification_image = cv2.resize(self.successful_verification_image, (short_side, short_side)) + self.successful_verification_image = cv2.copyMakeBorder(self.successful_verification_image, 0, 0, int((CAM_WIDTH - CAM_HEIGHT) / 2), int((CAM_WIDTH - CAM_HEIGHT) / 2), cv2.BORDER_CONSTANT, value=(255, 255, 255)) def run_interactive(self): previous_scan_timestamp = 0 @@ -167,7 +169,6 @@ def run_interactive(self): elif self.active_certificate_data and not self.id_verification: self.on_successful_verification() key = cv2.waitKey(TIME_SHOW_SUCCESSFUL_VERIFICATION_MESSAGE_SEC * 1000) # sec to ms - elif self.id_card_matches_certificate: self.on_successful_verification() key = cv2.waitKey(TIME_SHOW_SUCCESSFUL_VERIFICATION_MESSAGE_SEC * 1000) # sec to ms @@ -182,11 +183,11 @@ def run_interactive(self): def update_ui(self, frame): # old_shape = frame.shape # Remember to resize later after adding borders to the frame if self.active_certificate_data and not self.id_verification: - frame = cv2.resize(self.successful_verification_image, OUTPUT_DISPLAY_RESOLUTION) + frame = self.successful_verification_image elif self.id_card_matches_certificate: - frame = cv2.resize(self.successful_verification_image, OUTPUT_DISPLAY_RESOLUTION) + frame = self.successful_verification_image elif self.invalid_certificate_found: - frame = cv2.resize(self.invalid_certificate_image, OUTPUT_DISPLAY_RESOLUTION) + frame = self.invalid_certificate_image frame = self.add_borders_to_frame(frame) frame = self.add_text_to_frame(frame) @@ -218,18 +219,16 @@ def add_text_to_frame(self, frame): pil_image = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) draw = PIL.ImageDraw.Draw(pil_image) - title_width = max(draw.textsize(title, font=self.font_title), draw.textsize(title, font=self.font_title))[0] title_height = max(draw.textsize(title, font=self.font_title), draw.textsize(title, font=self.font_title))[1] subtitle_width = max(draw.textsize(title, font=self.font_subtitle), draw.textsize(title, font=self.font_subtitle))[0] - # TODO: make drawing code independent of screen size title_x = (int((frame.shape[1] - title_width) / 2)) title_y = int((BORDER_PERCENTAGE * frame.shape[0] - title_height) / 2) - subtitle_x = int((frame.shape[1] - subtitle_width) / 2) + subtitle_x = int((frame.shape[1] - subtitle_width)) subtitle_y = frame.shape[0] - 100 draw.text(xy=(title_x, title_y), text=title, fill=TEXT_COLOR, font=self.font_title) - draw.text(xy=(subtitle_x, subtitle_y), text=subtitle, fill=TEXT_COLOR, font=self.font_subtitle) + draw.text(xy=(subtitle_x, subtitle_y), text=subtitle, fill=SUBTEXT_COLOR, font=self.font_subtitle) frame[:] = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)