From f9968ae8b3f4abc989633d0d1647b573e69daa5b Mon Sep 17 00:00:00 2001 From: 283375 Date: Sat, 3 Jun 2023 20:26:53 +0800 Subject: [PATCH] init --- .editorconfig | 9 + .gitignore | 163 +++++++++++++++++++ README.md | 34 ++++ build_template.py | 26 +++ pyproject.toml | 32 ++++ requirements.txt | 5 + src/arcaea_offline_ocr/__init__.py | 0 src/arcaea_offline_ocr/_builtin_templates.py | 2 + src/arcaea_offline_ocr/crop.py | 42 +++++ src/arcaea_offline_ocr/device.py | 32 ++++ src/arcaea_offline_ocr/mask.py | 64 ++++++++ src/arcaea_offline_ocr/ocr.py | 158 ++++++++++++++++++ src/arcaea_offline_ocr/recognize.py | 67 ++++++++ src/arcaea_offline_ocr/template.py | 163 +++++++++++++++++++ 14 files changed, 797 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build_template.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 src/arcaea_offline_ocr/__init__.py create mode 100644 src/arcaea_offline_ocr/_builtin_templates.py create mode 100644 src/arcaea_offline_ocr/crop.py create mode 100644 src/arcaea_offline_ocr/device.py create mode 100644 src/arcaea_offline_ocr/mask.py create mode 100644 src/arcaea_offline_ocr/ocr.py create mode 100644 src/arcaea_offline_ocr/recognize.py create mode 100644 src/arcaea_offline_ocr/template.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b1ae5f4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.py] +indent_size = 4 +indent_style = space diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..786de0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,163 @@ +__debug* +.vscode/ + +# 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/ +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/ +cover/ + +# 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 +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .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 + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__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/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec4279e --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Arcaea Offline OCR + +## Example + +```py +import json +import pytesseract + +pytesseract.pytesseract.tesseract_cmd = r'D:/path/to/your/tesseract.exe' + +from arcaea_offline_ocr import device, recognize + +with open("./assets/devices.json", "r", encoding="utf-8") as file: + my_device = device.Device.from_json_object(json.loads(file.read())[0]) +print(recognize.recognize('./assets/screenshots/RMX3370_byd_1.jpg', my_device)) +``` + +![RMX_3370_byd_1.jpg](./assets/screenshots/RMX3370_byd_1.jpg "Screenshot of Arcaea play result: RMX_3370_byd_1.jpg") + +``` +RecognizeResult(pure=38, far=2, lost=7, score=347938, max_recall=21, rating_class=3, title='Kanagawa Cybe') +``` + +
+ +```py +print(recognize.recognize('./assets/screenshots/RMX3370_ftr_1.jpg', my_device)) +``` + +![RMX_3370_ftr_1.jpg](./assets/screenshots/RMX3370_ftr_1.jpg "Screenshot of Arcaea play result: RMX_3370_ftr_1.jpg") + +``` +RecognizeResult(pure=1344, far=42, lost=6, score=9807234, max_recall=490, rating_class=2, title='To the Milkv') +``` diff --git a/build_template.py b/build_template.py new file mode 100644 index 0000000..d4f301b --- /dev/null +++ b/build_template.py @@ -0,0 +1,26 @@ +import base64 +import json + +import cv2 +from src.arcaea_offline_ocr.template import load_digit_template + +TEMPLATES = [ + ("GeoSansLight_Regular", "./assets/templates/GeoSansLightRegular.png"), + ("GeoSansLight_Italic", "./assets/templates/GeoSansLightItalic.png"), +] + +OUTPUT_FILE = "_builtin_templates.py" +output = "" + +for name, file in TEMPLATES: + template_res = load_digit_template(file) + template_res_b64 = { + key: base64.b64encode(cv2.imencode(".png", template_img)[1]).decode("utf-8") + for key, template_img in template_res.items() + } + # jpg_as_text = base64.b64encode(buffer) + output += f"{name} = {json.dumps(template_res_b64)}" + output += "\n" + +with open(OUTPUT_FILE, "w", encoding="utf-8") as of: + of.write(output) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..85543d8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "arcaea-offline-ocr" +version = "0.1.0" +authors = [{ name = "283375", email = "log_283375@163.com" }] +description = "Extract Arcaea play result from your screenshot." +readme = "README.md" +requires-python = ">=3.8" +dependencies = [ + "imutils==0.5.4", + "numpy==1.24.3", + "opencv-python==4.7.0.72", + "pytesseract==0.3.10", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Programming Language :: Python :: 3", +] + +[project.urls] +"Homepage" = "https://github.com/283375/arcaea-offline-ocr" +"Bug Tracker" = "https://github.com/283375/arcaea-offline-ocr/issues" + +[tool.isort] +profile = "black" +src_paths = ["src/arcaea_offline_ocr"] + +[tool.pyright] +ignore = ["**/__debug*.*"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..13aff3e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +black==23.3.0 +imutils==0.5.4 +numpy==1.24.3 +opencv-python==4.7.0.72 +pytesseract==0.3.10 diff --git a/src/arcaea_offline_ocr/__init__.py b/src/arcaea_offline_ocr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/arcaea_offline_ocr/_builtin_templates.py b/src/arcaea_offline_ocr/_builtin_templates.py new file mode 100644 index 0000000..24f1b8a --- /dev/null +++ b/src/arcaea_offline_ocr/_builtin_templates.py @@ -0,0 +1,2 @@ +GeoSansLight_Regular = {"0": "iVBORw0KGgoAAAANSUhEUgAAAGgAAACVCAAAAACRe+C6AAADzElEQVRoBbXBCYLaMAAEwe7/P3oCHhnMuRCkKvlKOJAvyKfCU/IZ+UR4Rz4gfwt/kr/IX8JH5D15L3xM3pF3wjfkDXkjPCGb8IS8JC+FO/Ig3JJX5JVwQ14IN+Q5eSEcyFvhQJ6S58KV/ClcyTPyVLiSD4QreUKeCRfyoXAhj+SJcCEfCxfyQB6FC/lCuJB78iBcyHfCIPfkXriQb4VB7sidcCHfC4PckjthJ/8h7OSG3Ao7+S9hkBtyI+zkP4VBjuRGGOS/hUEO5CgM8oNQciAHYSc/CINcyUEY5CdhkAu5CoP8KJRcyFUY5EdhkJ1chEF+Fkp2chEG+VkYZJBdGGSCUDLILpTMEAYpGcIgU4SSkiGUzBFKSoZQMkko2UiFQSYJJRupUDJNKDmTTRhkmlByJptQMk8oOZNNKJkolJzIWRhkolByImehZKZQciJnoWSqUAJyEgaZKpSAnISSuUIJyEkomSxsBOQklEwWShAIJbOFEgRCyWyhBIFQMl3YCAKhZLpQIoSS+UKJEErmCyVCKFkgbEQIJQuEUgglC4RSCCULhFJCyQqhlFCyQigllCwRNkooWSJslFCyRNgooWSJUBJKlggloWSJUBI2skYoCRtZI5ShZI1QhpI1QhlK1ghlKFkjlKFkjVCGkjVCGUrWCGUoWSOUoWSNUIaSNUIZStYIZShZI5ShZI1QhpI1QhlK1gglYSNrhJJQskQoCSVLhJJQskQoCSVLhJJQskTYKKFkibBRQskKoZRQskIoJZSsEEohlCwQSiGULBBKIZQsEEohlCwQNiKEkvlCiUDYyHyhRCCUTBdKBELJdKFEIJTMFkoQCCWzhRLkJJRMFkqQk1AyWShBTkLJXKEE5CQMMlUoATkLJVOFEpCzUDJTKDmRszDIRKHkRDahZKJQciKbUDJPKDmTCiXThJIzqVAySxjkTCoMMkko2cgQSuYIg2xkCINMEUpKdqFkhjBIyS4MMkEYpGQXBvldGGSQizDIz8Igg1yFkl+FQXZyFQb5URhkJweh5DdhkAs5CIP8IuzkQo7CID8Ig1zJURjk/4VBDuRGGOR/hZ0cyK0wyP8JOzmSW2En/yPs5IbcCTv5D2EnN+Re2MnXwk5uyb1wIV8KO7kjD8KFfCNcyD15FC7kc+FK7skT4UI+Fa7kgTwTruQj4UoeyVPhSv4WDuQJeS4cyHvhSJ6RF8KRvBaO5Dl5JdySZ8IteUFeCw/kIDyQV+SN8B15Td4KX5A35L3wKXlL/hI+IX+QD4Q/yJ/kM+El+YR8LDyST8n3gnztH/UCG4yiCY1RAAAAAElFTkSuQmCC", "1": "iVBORw0KGgoAAAANSUhEUgAAAB8AAACOCAAAAADs/4wDAAABZ0lEQVRYCX3BwRHAQBACIOy/aJPZvwfxqymoLagt1EOoh6iX1FMMdWKoE0OdGOrEUCeGOjHUiaFODHViqBNDnRjqxFAnhjox1ImhTgx1YqgTQ50Y6sRQJ4Y6MdSJoU4MdWKoE0OdGOrEUCeGOjHUiaFODHViqBNDnRjqxFAnhjox1ImhTgx1YqgTQ50Y6sRQJ4Y6MdSJoU4MdWKoE0OdGOrEUCeGOjHUiaFODHViqBNDnRjqxFAnhjox1ImhTgx1YqgTQ50Y6sRQJ4Y6MdSJoU4MdWKoE0OdGOrEUCeGOjHUiaFODHViqBNDnRjqxFAnhjox1ImhTgx1YqgTQ50Y6sRQJ4Y6MdSJoU4MdWKoE0OdGOrEUCeGOjHUiaFODHViqBNDnRjqxFAnhjox1ImhTgx1YqgTQ50Y6sRQJ4Y6MdSJoU4MdWKoE0OdGOrEUCeGOjHUiaFODHViqBNDnRjqxFAnhjox1PkAQ52OAdot6jUAAAAASUVORK5CYII=", "2": "iVBORw0KGgoAAAANSUhEUgAAAFUAAACQCAAAAABcu3iBAAACyklEQVRoBbXBCYKjMBAEwcz/P7rWg9QYH5hD2gg5L8g5cih8kp/kp7BPdsm+cEB2yJ5whnwjX4XT5JN8E66Qd/IpXCRv5EN4I2/CO3kh78KW7AgvZEtehQ35JWzJhrwIT3IkbMiTbIUnOSE8yUo2wkpOCispshGKnBZW0slTKHJBWEkjq1DkmlBkISUUuSoU+SNdKHJd6OSPdKGTO0InD9KETm4JnTxIExq5KXQCsgid3BU6Qf6ETu4LjSB/Qif3hUaQh9DJiNCIPIRORoRG5CE0MiQ0IhA6GRMaBUIjg0KjQGhkUGgUQiejwkIhNDIsLJSZwkKZKTQyU2hkptDITKGRmUIjM4VGZgqNzBQamSk0MlNYKDOFhTJTWCgzhYUyUWiUicJCZKKwEJknNCLzhEZknrAQZJrQCDJNaASZJTQCMktoBGSWsJAHmSQ08iBzhE4eZI7QyB+ZInTyR6YIjSxkhtBIIxOEThoZFzrpZFgo0smoUKTIqNDJSgaFIisZE4o8yZBQZENGhCJbMiAUeSH3hZW8kNvCSl7JbaHIG7krFHknN4UiH+SesJIPcktYySe5I6zkC7khrOQbuSEU+UquC0W+k8tCkR1yVSiyRy4KK9kj14SV7JJLwkr2ySWhyA9yRSjyi1wQivwk54Uiv8lpocgBOSus5ICcFFZyRM4JKzkk54Qix+SUUOQEOSMUOUNOCEVOkWOhyDlyKBQ5SY6ElZwkR0KRs+RAKHKa/BaKnCc/hSIXyC+hyBXyQyhyiewLRa6RXWEl18iuUOQi2ROKXCU7QpHL5LtQ5Dr5KhS5Qb4JRe6QL0KRW+SL0Mk98ikUuUc+hCI3ybtQ5C55E4rcJq9CkfvkRSgyQF6ETkbIVuhkiGyETsbIUygyRlahyCApocgo6UKRYdKEIuOkCZ1MIIswlfwJc8lDmEwgzCaE6STMJ2E+w3/wD3z/qYDrb9jOAAAAAElFTkSuQmCC", "3": "iVBORw0KGgoAAAANSUhEUgAAAE8AAACTCAAAAADqoxsOAAAC+0lEQVRoBa3BCWLCMBAEwe7/P3oCaOUD7PhSlZwT5Az5V/gl/5BdYZfskW3hgGySLeEE2SC/wknyQ36E8+SLfAmXyJqshS+yEr7JkqyEJdkUVmRBlsKC7ApLMpOFMJN/hQWZyCxM5FCYSSeTMJETwkyKdGEip4SZNFLCRE4KM/mQEjo5LUzkQ5rQyQVhIm/yETq5JHTyJh+hk2tCJy/yFjq5KhR5kbdQ5LLQCchbKHJdKALyEorcEDpBXkKRO0IR5CUUuSMUQSAUuSV0IhCK3BOKCIQi94QiQihyUygihCI3hSJCKHJTKCKEIneFoowQijJCKMoIoSgjhKKMEIoyQijKCKEoI4SijBAakQFCERkgFJEBQhEZIBSR50In8lwogjwXiiCPhU6Qx0IRkKdCJyBPhSIv8lDo5EWeCZ28ySNhIm/yRJjIhzwQJtLIfWEiRe4KM+nkpjCTidwRlmQml4U1WZALwi9ZkSPhH/JFjoRd8kOOhB2yQY6ELbJNjoRvsk+OhG+yT46EDbJDjoRtskWOhD3yS46EffJNjoT/yJpcFNZkRa4LK7Igd4Qlmck9YUEmclNYkE5uCzMpcl+YSSMPhJl8yBNhIh/ySJjImzwTJvIiD4VOXuSp0AnIY6EIyGOhE+S5UAR5LhRBBghFZIBQREYIjcgIoSgjhKIMERpliNAoQ4RGGSIUGSIUGSM0MkZoZIzQyBihkTFCI2OERsYIjYwRGhkjNDJGaGSIUGSIUGSI0ChDhEZDI0+ERkMjD4SihEbuC0UJjdwXGpHQyG2hiBAauSsUEUIjN4UiCKHIPaEIAqHIHaEICIQiN4ROQF5CketCJyAvoZOrQicv8hY6uSZ08iZvYSJXhIm8yUeYyHlhIh/ShImcFSbSSAkTOSdMpEgJC3IsLEiRLizIkbAgnUzCkvwnLMlEZmFF9oQlWZCFsCYbwposyVL4IUvhm6zIWrhG1uRLuEC+yY9wkvySDeEE2SKbwgHZJnvCLtkl/wk/5F9yRpBz/gBeLcOGGg0NjQAAAABJRU5ErkJggg==", "4": "iVBORw0KGgoAAAANSUhEUgAAAGEAAACPCAAAAADPI+qNAAADUklEQVRoBbXBCWLiQBAEwcz/P7qWpWwDzSWJIULWigyyVmSQpYIMslJABlkpIIMsFEAGWSecyCDrhBMZZJnwnwyySjiTQRYJJYMsEkoGWSP8kEGWCL9kkCXCLxlkhfBHBlkgXMggC4QLGeRz4YoM8rFwTQb5VLghg3wq3JBBPhRuySCfCWUoGeQzoQwlg3wklISSQT4RSgglg3wilBBKBvlAKIFQMshxoeQklAxyXCg5CSWDHBZK/gslgxwVSs5CySAHhZIKJYMcFEoqlAxyTCj5EUoGOSSU/AolgxwSSn6FkkGOCCV/QskgB4SSi1AyyAGh5CKUDLJfKLkSSgbZLZRcCyWD7BVKboSSQfYKJTdCySA7hZJboWSQfULJEEoG2SeUDKFkkF1CyRRKBtkjlNwJJYPsEUruhJJBdggl90LJINuFkgdCySDbhZIHQskgm4WSR0LJIFuFkodCySAbhZLHQskgG4WSx0LJINuEkidCySCbhJJnQskgm4SSZ0LJIFuEkqdCySAbhJLnQskgG4SS50LJIO+FkhdCySBvhZJXQskgb4UzeSmUDPJOKHkplAzyRih5LZQM8looeSOUDPJaKHkjlAzyUih5J5QM8kooeSuUDPJKKHkrlAzyQih5L5QM8lwo2SCUDPJcOJMtQskgT4WSLULJIM+Ekk1CySBPhJJtQskgT4SSbULJII+Fko1CySAPhZKtQskgD4WSrULJII+Eks1CySAPhJLtQskgD4Qz2SGUDHIvlOwQSga5E0r2CCWD3AlnsksoGWQKJbuEkkGGULJPKBnkVijZKZQMciuU7BRKBrkRSvYKJYNcCyW7hZJBroUz2S+UDHIllOwXSga5CCUHhJJBLsKZHBFKBvkTSo4IJYP8CiWHhJJBfoSSY0LJID9CyTGhZJAKJQeFkkHOQslRoWSQs3Amh4WSQf4LJYeFkkFOwhfJSfgigfBNQvgqIXyVhO8yfJksEkoGWSSUDLJIKBlkkVAyyCKhZJBFQskgi4SSQRYJJYMsEkoGWSSUDLJIKBlkkVAyyCKhZJBFQskgi4SSQRYJJYMsEkoGWSSUDLJIKBlkkVAyyCKhZJBFQskgi4SSQRYJJYMsEkoGWSSUDLJIKBlkkVAy/AOsQeSJwKM2UQAAAABJRU5ErkJggg==", "5": "iVBORw0KGgoAAAANSUhEUgAAAGAAAACQCAAAAADSYTH9AAAC3klEQVRoBbXBC6KjIBQFwe79L/pMnhfyRScRqZJj4RTp5Fg4RTo5Fk6RTo6FU6STQ+Ec6eRQOEc6ORTOkU4OhY2cJkdCkdPkSChymhwJGzlPjoSNnCcHQpHz5EAocp4cCBuZIAfCRibIvlBkguwLRSbIvlBkguwKRWbIrlBkhuwKRWbIrlBkhuwJRabInlBkiuwJRabInlBkiuwIRebIjlBkjuwIRebIjlBkjoyFIpNkLBSZJGOhyCQZC0UmyVBoZJIMhSKzZCgUmSVDocgsGQmNzJKRUGSajIQi02QkFJkmA6GRLryR78hAaAxH5P9kIHxPjslA+IUckU/hR7JPPoWfyR75FE6QMfkUBuQuDMmIfAidHAjvZEA+BJCvhFfyQT5EvhdeyDuZFp7IG7lAeJBXcoXwIC/kGuFBnshFwoM8yGVCJw9yndDJnVwodNLJlUInjVwqdFLkWqGRIhcLjWzkaqGRP3K10MgfuVxo5EauF4rcyPVCIyALhCIgC4QiICuEIsgKoQiyRCgiS4QiskQoImuEjcgaoShrhKKsEYqySNgoi4SNskjYKIuEjbJIKLJIKLJIKLJIKLJIKLJIKLJIKLJIKLJIKLJIKLJIKLJI2CiLhI2ySNgoi4SNskYoyhqhKGuEoqwRNiJLhCKyRCgiS4QiskIogqwQiiArhCLIAqEIyE1ALhSKgIQ/cp3QCBiKXCYUuTEUuUpo5MbQyEVCkT8SilwjNPJHQiNXCI1shNDIvNDJRiAUmRcaKQKhkVmhkUZuQiNzQieN3IROZoROOvkTOjkvdHInm9DJWeFO7qSETs4Jd/IgJdzJGeFOnkgT7uRn4UGeSRfu5EfhQV7IXXiQH4Qn8koewhP5Vngib+RJeCbfCM/knTwLL+Q/wgv5JK/CK9kV3siAvAmf5F34JCPyIZwhY/Ip/Ez2yEj4ieyTHeFbckR2hW/IMTkSjsl/yX+FIfmK/CDIr/4BB5euhg0haIUAAAAASUVORK5CYII=", "6": "iVBORw0KGgoAAAANSUhEUgAAAGEAAACRCAAAAAD2/4lmAAADbUlEQVRoBa3BCYKCMBQFwe77H/qNkx8EFGRLlZwXilwhp4VOrpDTQpFL5KzQySVyVihyjZwUOrlGTgpFLpJzQpGr5JxQ5Co5JRS5TM4InVwmZ4Qi18kJoZPr5IRQ5AY5Fjq5QY6FInfIodDJHXIoFLlFjoQi98iB0Mk9ciAUuUl+C53cJL+FInfJT6GTu+SnUOQ2+SV0cpv8EorcJz+ETu6TfaGTB2RfKPKE7AqdPCG7QpFHZE/o5BHZE4o8IztCJ8/IjlDkIdkWOnlINoVOnpJNochjsiV08phsCUWekw2hk+dkQygygHwLnQwg30KREeRL6GQE+RQ6GUI+hU6GkA+hk6XwRU6RD6HILOyRQ7IWOinhgPwma6HIv3CK/CAroRMIp8kuWQqdEC6RHbIUOsNlskkWwj5ZC19kiyyEbbIjrMk3mYUt8ktYkS8yC9/kUFiQT/IWvsgpYUHW5C18kLPCgqzIJKzJFWEmS9KFNbkmzGRBurAil4U3mUkJS3JHeJM3KWFB7gkTeZMmLMhdYSITacJM7gsT6eRfmMkTYSJF/oU3eSZ0UuRfeJOHQieNvIQ3eSx08k9ewkSeC538EwgTGSF08iIQJjJEKPIiEDoZI3QCQpjIIKEICKGTUUIREEInw4QiCKHIOKEIEjoZKBSRUGSkUERCkaFCIxKKDBUaMRQZKxQNRcYKRUORwUKjochgodHQyGih0dDIaKHR0MhooRgaGS40hkaGC42hkeFCY2hkuNAYGhkuNIZGhguNoZHhQmNoZLjQGBoZLjSGRoYLjaGR4UJjaGS0UAyNjBYaDY2MFhoNRQYLjRIaGSw0SmhkrFCU0MhYoSihyFChEQlFhgqNCKGRkUIRIRQZKDSCEIqME4ogEBoZJxRBIBQZJjQCAqHIKKEIyEsoMkYo8iIvoZMhQpEX+ReKjBA6eZF/oZMBQpF/0oROHgtFGmlCJ0+FThopoZNnQidFutDJE6GTTrowkfvCRDqZhIncFSYykbcwkXvCRN5kFiZyR5jITGZhJpeFiSzIQpjJReFNFmQpzOSKMJMlWQkLclaYyZqshSU5Jczkg3wKS3IkLMkn+RLW5IewIl/kW/gkW8IH2SBbwhZ5Cxtki2wKl8k22RGukR2yK5wm++SXcIb8IgfCb3JAjoU9ckzOCkty1h/OquuFNmc+yQAAAABJRU5ErkJggg==", "7": "iVBORw0KGgoAAAANSUhEUgAAAGMAAACPCAAAAADL1jqwAAAClklEQVRoBbXBC0LbUBAEwe77H3oSeBMC2LL1WVVJuJuEu0m4mRLuJRLuJRJuJUi4k4DcIpSA3CGU/CV3CIt8kBuEkg8yL5R8knlhkUXGhZJFxoVFSqaFkpJhoeQfGRYW+SKzQskXGRVK/pNRYZFvZFIo+UYGhZLvZFBY5AeZE0p+kDGh5CcZExb5RaaEkl9kSCj5TYaERR7IjFDyQEaEkkcyIizyhEwIJU/IgFDyjAwIizwl14WSp+SyUPKcXBYW2SBXhZINclEo2SIXhUU2yTWhZJNcEkq2ySVhkRfkilDyglwQSl6RC0LJK3JeKHlJTgslr8lpoeQ1OSuUvCEnhZJ35KRQ8o6cE0reklNCyXtySih5T84IJTvIGWGRPeSEULKHHBdKdpHjwiL7yGGhZB85KpTsJEeFRfaSg0LJXnJMKNlNjgmL7CeHhJL95IhQcoAcERY5Qg4IJUfIfqHkENkvLHKM7BZKjpG9QslBsldY5CjZKZQcJfuEksNkn1BymOwSSo6TPULJCbJHKDlBdgglZ8h7oeQUeS+UnCJvhZJz5J1QcpK8E0pOkjdCyVnyWig5TV4LJafJS6HkPHkllFwgr4SSC+SFUHKFbAsll8i2UHKJbAol18iWUHKRbAklF8mGUHKVbAiLXCbPhZLL5KlQcp08FRYZIM+EkgHyRCiZIE+ERUbIo1AyQh6EkhnyIJTMkN9CyRD5JZRMkV9CyRT5KZSMkR9CyRz5IZTMke9CySD5JpRMkm9CyST5L5SMki+hZJZ8CSWz5J9QMkwqlEyTCiXTZAkl4+RTKJknn0LJPPkQSm4gf4WSO8hfoeQOAqHkFkIouYcQSu4hoeQmhpK7GEruIuGT3EYIH+Q2AgHkPvIhcqM/fDCPjtTB+fYAAAAASUVORK5CYII=", "8": "iVBORw0KGgoAAAANSUhEUgAAAFIAAACTCAAAAAA48xFWAAADzUlEQVRoBaXBCYKCQBAEwcz/P7oWuodDRZcjQk4Ig5wgv4VP8pN8F76Tr+Sb8Jt8IcfC/+SQHAnnyAE5EM6ST/IhfJJZ+CTv5F14Ie/CC3kjb8KOHAs78kpehY18FzbyQl6ElfwUNrIne2El/wkr2ZGdsJATwko2sgkLOSWsZCWbMMhZYZCVrMIg54VBFrIIg1wQBlnIIgxyRRhkkCEMck0YpMkQmlwVmjRpYZCrQpMmLTS5LjQp0kKT60KTIiU0uSMUKVJCkztCk5mUUOSW0GQmJRS5JxSZySw0uScUmcksFLkpNJnILBS5KxSZyCwUuSsUmcgsFLkrFJnILBS5KxSZyCwUuSsUmcgsFLkrFJnILBS5KxSZyCwUuSsUmcgsFLkrFJnILBS5KxSZyCwUuSsUmcgsFLkrFJnILBS5KxSZyCwUuSk0mUgJRe4JRWZSQpF7QpGZlFDknlBkJiU0uSM0mUkLRe4IRYq00OS60KRIC02uC0WaDKHJVaFJkyE0uSo0abIITa4JTQZZhEGuCIMMsgqDXBCaLGQTmpwXmqxkEwY5Kwyykp0wyDlhkI3shYWcEAbZkb2wkv+EhezJi7CS38JCXsirsJHvwkpeyZuwI8fCRt7Ih7Ajn8KOvJNP4YXshT35JEfCOXJADoUT5JB8E36Sb+Sr8IN8Jd+E3+QLORROkENyIJwkB+RT2JMX4YV8kHdhRw6EHXknb8JGvgkbeSUvwkZ+CSt5IXthJf8IG9mRnbCS/4WVbGQTVnJGWMlKVmEhZ4WFLGQRFnJeWMggQ1jIFWEhTVpYyDVhkCYtDHJVGKRICYNcFwaZySwMckcYZCKz0OSe0GQikzDIPWEQkElocldoAgKhyX2hCQKhyX2hCUJo8kRoIoQmT4QmQijyTCgiockzoSmhyTOhKaHIU6EoochToSihyFOhGZo8FZqhyHOhGIo8F4qhyHOhGIo8F4qhyHOhGIo8F4qhyHOhGIo8F4qhyHOhGIo8F4qhyHOhGIo8F4qhyVOhGZo8FZqEIk+FooQiT4WihCYPhaKEJs+EphCKPBOKCKHJE6GJEJo8EZoIhCb3hSYIhCb3hSYIhEHuCoMgk9DkrtAEZBIGuScMAjILg9wRBplICYNcFwaZSQkLuSosZCYtLOSasJAiQ1jIFWEhTYawkvPCSposwkrOCisZZBVWck5YyUI2YSP/CxtZyU7Ykd/CjmxkL+zJd2FPduRFeCVHwivZkzfhneyFd/JK3oVr5I18CufJBzkSzpEDciz8Tw7JN+E3+UJ+CN/Id/Kf8Er+8Qdy0hGTyHtqHwAAAABJRU5ErkJggg==", "9": "iVBORw0KGgoAAAANSUhEUgAAAGIAAACQCAAAAADWlOHAAAADdElEQVRoBa3BCYKCMBQFwe77H/qNkx8QZZElVXJOWJNT5LewT36SH8JPckiOhHPkgOwL58ku2ROukR2yLVwnm2RTWJEvYUU2yIbwSfaET7Ima2FJjoUlWZGVsCC/hSX5It/Cm5wTFuSTfAkzOS+8yQf5EN7kkjCTJVkKM7kqzGRBlsJErgszeZOFMJFbwkRm8hYmclPoZCazMJHbQicTmYSJPBA66WQSOnkkdFKkC508FIoUKaGTp0InjZRQ5LlQpJEmdDJAKPJPmlBkhNDJi/wLRcYIRV7kXygySGjkRV5CkVFCEZCXUGSY0AgIhCLjhCIIhCIDhUYQCI2MFBpBCEVGCkWEUGSo0IgQGhkrNCKERsYKjQihkbFCUUKRwUKjhEZGC40SGhktNEpoZLTQKKGR0UKjhEZGC0VCI6OFIqGR4UIjoZHhQmMoMlxoDEWGC42ERoYLjYRGhguNhEaGC42ERkYLRUIjo4UioZHRQqOERkYLjRIaGS00SmhktNAoochgoVEIjYwVikJoZKxQFEKRoUJRCEVGCkUEQiMjhSICochAoYhAKDJO6EReQpFhQhHkJRQZJXSCvIROBgmdIP9CkTHCRJB/oZMhQicgTSgyQpgISBM6eS5M5EVK6OSxMJEXKWEiD4WZvEgXOnkmzOSfTEInT4Q3+SeTMJH7wps0MgsTuSssSCNvYSL3hAUpshAmckdYkiJLYSaXhSXp5EOYyTXhk3TyKczkivBJJvIlvMlZ4ZtM5FtYkDPCisxkJSzJL2FN3mQtfJIDYYu8yZbwRbaEHbIgm8IWmYUjsiA7wkUSOlmSXeECgdDJkhwIJ8lL6OSDHAonSBM6+SC/hEPShU4+yRlhkyyEIl/ktPAm30InX2SQUOSbjBE6+SZjhCIrMkToZEVGCJ2syQihkzUZIHSyQQYIRbbIc6GTLfJcKLJJHgudbJLHQpFt8lToZJs8FDrZIQ+FTnbIM6GTPfJMKLJLHgmd7JJHQpF98kToZJ88EYockAdCJwfkvtDJEbkvdHJEbgudHJLbQpFjclfo5JjcFYr8IDeFTn6Qm0KRX+Se0Mkvckvo5Ce5JRT5Te4Infwmd4QiJ8gNoZMT5IZQ5Ay5LnRyhlwXipwil4VOTpHLQpFz5KpQ5CS5KHRyklwUipwlF4UiZ8ll4UVOkxsCctofcVzqhuhX5pAAAAAASUVORK5CYII="} +GeoSansLight_Italic = {"0": "iVBORw0KGgoAAAANSUhEUgAAAGoAAACVCAAAAACVjjCHAAAD0UlEQVRoBbXBCYKCQADEwOT/j+5VelC8cR2q5J/Clnwm3wvPyVvypfCOvCbfCB/JK7Jf2EWek73CbvKM7BO+Io9kj/CMLMIT8kB2CHfkQbgl9+SzsCWvhC25I5+ELXknbMgt+SBsyCfhSm7Ie+FK9ggXsiVvhQvZKVzIhrwTLmS3cCFX8kZYyTfChVzIa2El3wkruZCXwkq+FVayklfCSr4XVjLIC2El/xEGGeS5sJL/CYOUPBVW8k9hkJKnwiD/FgZZyDNhkP8LgyzkiTDIL8IgZ/IoDPKTMMiZPAqD/CaUnMmDMMiPwiAnci8M8rNQciL3QsnvwiAgd8IgE4QSkDuhZIZQAnIrlEwRSkBuhEHmCCXIjVAySShBtsIgk4QSZCuUzBIGkY1QMk8okY1QMk8okatQMlEokatQMlEokYtQMlUo5SKUTBVKWYWSuUIpq1AyVyhlFUrmCqUMoWS2sFCGUDJbWCgVSqYLJRVKpgslFUqmCyWLUDJfKFmEkvlCySKUzBdKzkLJAULJWSg5QCg5CyUHCCUnoeQIoeQklBwhlJyEkiOEkpNQcoRQAqHkEKEEQskhQgmEkkOEEgglhwglhJJjhBJCyTFCCaHkGKGEUHKMsFAIJccIC4WwkIOEhRJKjhFKCSXHCKWEkmOEUkLJMUIpoeQYoZRQcoywEAkLOUhYiKHkGKHEUHKMUGIoOUYoMZQcI5QYSo4RFoKh5BChBEPJIUIJhpJDhBIMJYcICwFDySHCQsBQcoRQAoaSI4QSMJQcISzkxFBygFByYig5QCg5MZQcIJScGEoOEBZyZiiZL5ScGUrmCyVnhpLpQsnCUDJdKFkYSqYLJQtDyWyhpAwls4WSMpTMFhYyGAaZK5QMEkrmCiWDhJKpQslKQslUoWQloWSmUHIhoWSmUHIhYZB5QsmVEErmCSVXQiiZJpRsCGGQWULJhhAGmSSUbAmEkjnCIFsCoWSOUHJDIAwyQxjkhkAYZIZQcktOwiC/C4PckpMwyM/CIHfkLJT8KgxyT87CID8Kg9yTszDIb8IgD2QRBvlFGOSRLMIgPwiDPCEVBvm3sJInpMIg/xVW8owMYZD/CSt5Soawkv8IK3lOVmEl3wsX8pyswkq+Fi7kBbkIK/lSuJBX5Cqs5CvhQl6Sq3AhXwgX8ppshAvZK1zJG7IVrmSXcCXvyI1wJZ+FDXlLboUNeS9syAdyJ2zJS+GGfCL3wi15ItyRj+RBeCAb4YHsII/Cl2QPeSZ8QfaRp8Jespe8EPaQ/eSl8IF8Rd4Jr8mX5JPwSP5B9grIL/4A+3kbkdu/NscAAAAASUVORK5CYII=", "1": "iVBORw0KGgoAAAANSUhEUgAAAB8AAACOCAAAAADs/4wDAAACMklEQVRYCX3BiYHcMADEMLL/oifrkR9Jdg6QQ/gf+Qn/JRD+Twh/kPAXCX8x/Em+hVK+hVK+hVK+hVK+hVI+hUH5FAblUyiRT6FEPoUS+RRK5FMokS9hEPkSBpEvoQT5EkqQL6EE+RJKkC+hBPkQBkE+hEGQD6EE5EMoAfkQSkA+hBKQD6EE5C0MAvIWBgF5CyU/8hZKfuQtlPzIWyj5kbdQ8iMvYZAfeQmD/MhLKDnISyg5yEsoOchLKDnISyg5yC4McpBdGOQguzDIQXahpGQXSkp2oaRkF0pKdqGkZBMGKdmEQUo2oWSQTSgZZBNKBtmEkkE2oWSQVRhkkFUYZJBVKDnJKpScZBVKTrIKJSdZhZKTLMIgJ1mEQU6yCCUXWYSSiyxCyUUWoeQii1BykVkY5CKzMMhFZqHkJrNQcpNZKLnJLJTcZBZKbjIJg9xkEga5ySSUPGQSSh4yCSUPmYSSh0xCyUMeYZCHPMIgD3mEkok8QslEHqFkIo9QMpFHKJnII5RM5BYGmcgtDDKRWyiZyS2UzOQWSmZyCyUzuYWSmVzCIDO5hEFmcgklC7mEkoVcQslCLqFkIZdQspBTGGQhpzDIQk6hZCWnULKSUyhZySmUrOQUSlYyhEFWMoRBVjKEko0MoWQjQyjZyBBKNjKEko1UGGQjFQbZSIWSnVQo2UmFkp1UKNlJhZKdHMIgOzmEQXZyCCUvcgglL3IIJS9yCCUvcgglL/ITBnmRnzDIyz8ZrY6Ir0CsMAAAAABJRU5ErkJggg==", "2": "iVBORw0KGgoAAAANSUhEUgAAAGUAAACQCAAAAAA0SPq5AAAC7UlEQVRoBbXBC6KiMBQFwe79L/rMG8JFUZBfUiUPBTkkt4RvsksuC/tkk1wTjsg3uSKcIZ/ktHCarMlZ4QJZkXPCRfJGzggf5FP4IC9yQngnO8KKLORYeCM/hBWZyaGwkCPhnTRyILzICeFFGvktLOSc8CIT+Sks5LSwkP/kl1DkirCQP/JDKHJNKPJH9oUiV4UiIPvCTK4LRZBdYSY3hCLInjCTW8JMkB1hJjeFmciO0MhdYSayLTRyX2hENoWZ3BdmyqbQyBOhUbaEmTwRGmVLaOSR0CgbQiMPhYmyITTyUJgo30IjT4VGvoVGngqNfAuNPBUaGSk0MlKYKCOFiTJSmCgjhYkyUpgoI4WJMlBolIHCRGSgMBEZKExExgmNyDhhIsg4YSLIMKERZJgwEZBhwkRARgmNgIwSJvJHBgmN/JFBwkT+kzFCI//JEKGRiQwRGpnICKGRRkYIE5nJAKGRmfQXGinSXZhJkd7CTBbSW2jkRToLjbyRvsJM3khXYSbvpKcwkxXpKMxkTfoJM/kg3YQiH6SXUOSTdBKKfJE+QpFv0kUoskF6CEW2SAehyCZ5LhTZJo+FIjvkqVBkjzwUiuySZ0KRffJIKPKDPBGK/CIPhCI/yX2hyG9yWyhyQO4KRY7ITaHIIbknFDkmt4QiJ8gNYSFnyA2hyClyWVjIOXJVWMhJclFYyFlyTVjIaXJJWMh5ckVYyAVyQVjIFXJeWMglclpYyDVyVljIRXJSWMhVck5YyGVySljIdXJGWMgNckJYyB1yLCzkFjkUitwkR0KRu+RAKHKb/BaK3Cc/hSIPyC+hyBPyQyjyiOwLRZ6RXaHIQ7InFHlKdoQij8m2UOQ52RSKdCBbQpEeZEMo0oV8C0X6kC+hSCfyKRTpRT6EIt3IWijSj6yEIh3Ju1CkJ3kTinQlL6FIX7IIRTqTEor0JrNQpDtpQleyIpPQl6zIf6EzWZE/oTdZEQi9yZoQupM1Cf3JmmEAWfsHZwSpjZrtzb8AAAAASUVORK5CYII=", "3": "iVBORw0KGgoAAAANSUhEUgAAAFkAAACTCAAAAADAE+qhAAADHElEQVRoBa3BAYKiMBQFwe77H/rtrPmBgKCAqZJngnwmt4Q3ckwuC6fknVwTvpAduSJcIBvyXbhGRvJNuE5W8lm4RRbyUdiRjbAjnXwSRnIkbEkj58JAzoWRvMipsJKPwkBe5ExYyFdhJf/JibCQC8JK/sixsJBLwkpADoWFXBQWAnIkdHJdWAhyJBS5I3SCHAid3BEWIu9CJ/eETuRdKHJX6JQ3ochtoVP2Qie3hU7ZC0UeCEXZCUWeCJ3shCJPhE62QpFnQpGtUOSZUGQrNPJQKLIRijwUimyEIg+FIhuhkadCkVEo8lQoMlkoMlkoMlkoMlkoMlcoylyhKHOFoswVijJXKMpUoYhMFYrIVKGIzBSKIDOFIshEoRNkolAEZJ7QCcg8ocgfmSZ08kdmCZ38J7OEIi8ySejkReYInTQyQ1hIkQnCQjr5XVjIQn4VVrKS34SBDOQnYSUb8lwYyZY8FDZkT+4Le/JOLgqn5IhcFE7IMbkmHJJTck14Ix/JNeGNfCTXhAPygVwTjskZuSackWNySfhAjsgDYUsOyCNhQ97JQ2FD9uSxMJIdeS6MZEt+EQayIT8JAxnJb8JKRvKjsJKB/CosZCA/CwtZye/CQhYyQehkIROEThYyQ+ikkylCkU6mCEU6mSIU6WSOUKTIHKFIkTlCkSJzhCJFJgmNFJkkNFJkktBIkUlCI0UmCY0UmSQ0UmSS0EiRSUIjRSYJjRSZJDRSZJLQSJFJQiNF5ghFiswRGulkjtBIJ3OERjqZIzTSyRShkYVMERpZyBShkYXhRX4RiiwMjfwgNLIyNPJcKLIyNPJcaGRgaOSxUGRgKPJUKDKQ0MhDochIQpFHQicjCUWeCJ1sSOjkgVBkSwhF7gudbAmhk7tCJzsCoZN7Qid7AmEhd4RO3sif0MkNYSFv5E9YyUVhJe/kv7CSS8JKDsh/YSAXhJUckZcwkG/CQA5JE0bySRjICSlhQ86EkZyRLmzJgbAlp2QR9mQU9uQDWYV75BMZhBvkM9kIF8k3shMukO/kTfhCrpAD4ZxcJCfCO7lBvgnyxD8/nMOHlHQurQAAAABJRU5ErkJggg==", "4": "iVBORw0KGgoAAAANSUhEUgAAAGEAAACOCAAAAAAEfzkoAAADfElEQVRoBbXBAYKCMBAEwe7/P3pON4kHggoaquRSEblSELlQQORCAZHrBBC5TLgRuUq4EeQi4U6Qa4QiyDVCEeQSoRHkCqET5AJhEGS+MAjIdOFBQKYLDwIyW/gnIJOFBQGZKywJyFRhRUBmCityIzOFFbmRicKa3Mg8oTMUuZFpQiehyI3MEjohFLmRScIghCI3MknoBEKRG5kjdAKhyJ1METq5CUXuZIbQyV0ocicThE5KKHInvwuDlFDkTn4XOmlCkTv5WeikCY3cya9CJ10oUuRHoZMhFCnym9DJQyhS5CdhkIdQpMgvwiD/QpEivwidLIQiRX4QOlkIjRT5XuhkKRRp5Guhk5VQpJFvhUFWQpFGvhQGWQtFGvlS6ORJKNLId0Inz0KRRr4SOnkWGmnkG6GTjVCkky+EQTZCkU7OC4NshSKdnBc62RGKdHJa6GRPKNLJWaGTXaFIJyeFQfaERjo5JwyyKxQZ5JQwyL5QZJBTQicvhCKDnBE6eSUUGeSE0MlLocggx4VBXgpFBjksDPJSaGSQo8Igr4UiD3JU6OSNUORBDgqdvBOKPMgxoZO3QpEHOSQM8lYo8iBHhEHeC0Ue5IAwyHuhkQc5IHTyQSjyTz4LnXwSivyTj0InH4Ui/+STMMhHocg/+SAM8lko8k/eC4N8Fhr5J++FTg4IjfyTt0InR4QiC/JO6OSQUGRB3giDHBKKLMhrYZBjQpEFeSkMclAosiAvhU4OCo0syCuhk6NCIwvyQujksFBkSfaFQQ4LRZZkVxjkuFBkSfaEQU4IRZZkRxjkjFBkSXaETs4IjSzJVhjkjNDIkmyEQU4JjSzJszDIOaHIijwJg5wUiqzIWhjkrFBkRdZCJ6eFIiuyEgY5KzSyIkthkNNCIyuyEAY5LzSyIv/CIF8IRdbkIQzyjVBkTR5CJ18JRdZkCIN8JRRZky4M8pXQyJo0YZDvhEbWpIRBvhQaWZO7MMi3QpEncheuIzfhQgLhSkK4khIupeFaGq6lTBKKPJNJQpFnMkdo5JnMERp5JnOERp7JHKGRZzJHKLIhc4QiGzJHKLIhU4RGNmSK0MiGTBEa2ZApQiMbMkUosiVThCJbMkUosiUzhEa2ZIbQyJbMEBrZkhlCI1syQyiyQ2YIRXbIDKHIDpkhFNkhE4RGdsgEoZEdMkFoZIdMEIrs+QMn9uKMnhmc8gAAAABJRU5ErkJggg==", "5": "iVBORw0KGgoAAAANSUhEUgAAAGYAAACQCAAAAADff0G6AAAC8ElEQVRoBbXBCYLCIBQFwe77H/qN8hO30QRQqmRQmCGDwgwZE6bImDBFhoQ5MiRMUYaEKcqIUGSQjAiNjJIBocgoGRCKjJIBoZFh0i8UGSb9QiPjpFsoMk66hSLjpFtoZIL0CkUmSK9QZIJ0CkVmSKdQZIZ0Co1MkT6hyBTpE4pMkS6hyBzpEorMkS6hyBzpEYpMkh6hyCTpERqZJR1CkVnSIRSZJedCkWlyLhSZJudCkWlyKhSZJ6dCkXlyJhT5gpwJRb4gZ0KRL8iJUOQbciIU+YYcCxv5hhwLRW7CMzknx0KRcEQOyaHQTz6TQ2GEfCJHwiB5T46EYfKOHAkT5D85EN6RXXhL/pEDYSNHwit5IZ+FC+kRnskz+SzSLzyRJ/Iz4ZE8kh8KD+SB/FS4kzv5rXAnN/Jr4UZ28nPhRjbye+FGiiwQdlJkhbCTRpYIG2lkibCTK1kj7ORCFgkbuZBFwkYuZJWwEZBlQhGQZUIRkGXCRpB1QhFknVAEWScUQRYKRWShUEQWCkVkoVBEFgpFZKXQiKwUGpGVQiOyUmhEVgqNyEqhEVkpNCIrhUZkpdCILBSKyEKhiCwUGkEWCo0gC4VGkIVCI8hCoRFknVAEWSc0ArJOaARkndAIyDKhCMgyoZELWSUUuZBVQpELWSU0ciWLhCJXskgociVrhCKNrBGKNLJEKFJkiVCkyAqhyEaugvxSKLIRwpX8TiiyMxT5mbCRnWEjvxKK3Ego8iOhyJ2EjfxE2MidhI38QtjIAyFs5HthI4+EsJGvhZ08Eggb+VLYyROBsJOvhJ08k4uwkW+EnbyQi7CTeWEnr+Qq7GRWuJFXchVuZE64kX+kCTcyIdzJf1LCjQwLd/KGbMKNjAl38pZswp2MCHfynuzCA+kVHsgHchMeSY/wSD6Ru/BEToQn8pk8CC/ko/BCDsiT8J+8Cv/IIXkWpsgxeRWGyRn5LwyRc/JO6CVd5L3QQzrJR+GY9JND4S0ZJL2CTPsDi1yujJ1qEYsAAAAASUVORK5CYII=", "6": "iVBORw0KGgoAAAANSUhEUgAAAGMAAACRCAAAAADyCllbAAADe0lEQVRoBa3BAYKCMBAEwe7/P3ruzAZERSGQKrksFDkgV4UiR+Si0MkRuSgUOSTXhCLH5JJQ5AS5JBQ5Qa4IRc6QC0KRU2Rc6OQUGReKnCPDQpGTZFTo5CQZFYqcJYNCkdNkTOjkNBkTipwnQ0KRATIidDJABoRORsiAUGSInBeKjJHTQidj5LRQZJCcFYqMkpNCJ6PkpFBkmJwTioyTU0In4+SM0MkFckYocoWcEDq5Qo6FTi6RY6HINXIoFLlIjoROLpIjochVciAUuUx+C51cJr+FItfJT6HIDfJL6OQG+SF0cof8EIrcIt+FTm6Rr0In98hXochN8k3o5Cb5InSyFT7IAfkiFFmFr+QH2ReKdOGAfCO7QicP4RTZJXtCJ//CabJD9oQiEIbIB9kROgmj5J18Ct/Jq/BJXsmnsE/2hTfyQj6EPfJLeCFb8i58kkNhSzbkXXgnp4QNeZI34Y2cFp5kJa/CKxkRnmQhL8IrGROepJMXYUvGhZUU2QpbckVYSJGtsCHXhIU0shE25KKwkgd5ChtyWVjIgzyFJ7khLOSfrMJK7gmd/JNFWMldoROQRVjJXaETkC6s5L7QCdKFhUwQOkFKWMgUoRMpoZM5QidSQieThCLShE5mCUWkCUXmCUV5CJ3ME4ryEIpMFIryEIpMFIryLxSZKjTKv1BkqtAoEIrMFYpAKDJXKAKhkclCEUKRyUIRQpHJQhFCI7OFIoRGZgtFCI3MFoqEIrOFIqGR6UKjhEamC40SGpkuNEpoZLrQKKGR6UKjhEamC40SGpktFA1FZguNGIrMFhoxFJktNGIoMltoxFBkstAIhiKThUYwFJksNIKhyFyhETAUmSs0AhIamSoUAQmNTBUa+SehkZlCkX8SikwUGnmQUGSeUORBQpF5QiONhE5mCUUaIRSZJBQpQigySShShNDJFKFIJxCKzBCKLARCJ/eFThYCoZP7QpGV/Aud3BWKPMm/sJB7QidP8hAWckfoZEMewkJuCJ1sSRMWclno5IU0YSXXhIW8khJWckVYyBvpwkrGhYW8k0VYyaCwkg+yCBsyIDzJJ1mFDTktrGSPPIUtOSU8yS7ZCC/kSNiSffIivJBfwpZ8I6/CO9kT3shX8ibskVXYIT/IhzBMfpFPYYz8JnvCaXJI9oVT5AT5KhyQc+Sn8IWcJyeELRn0B8NR64lwFbLnAAAAAElFTkSuQmCC", "7": "iVBORw0KGgoAAAANSUhEUgAAAHcAAACPCAAAAADlkxsiAAACrUlEQVR4Ab3BCULjMAAEwe7/P3oW0MByOIkPyVWyISwnG8JqyoawmMiGsJjIhrCWIBvCUgKyIawkb+QmoeSd3CSUvJN7hJIPcotQMsgtwiAldwiDfJIbhJJPsl4o+SLLhZL/ZLkwyDeyWhjkO1kslHwna4WSH2SpUPKTLBUG+UVWCoP8JguFkt9knVDyhywTSv6SZcIgG2SVMMgWWSSUbJE1QskmWSKUbJMlwiAPyAphkEdkgVDyiMwXSh6S6ULJYzJdGOQJmS0M8oxMFkqekblCyVMyVSh5TqYKg7wgM4VBXpGJQskrMk8oeUmmCSWvyTRhkB1kljDIHjJJKNlD5gglu8gUoWQfmSIMspPMEEp2kglCyV5yXSjZTS4LJfvJZWGQA+SqUHKAXBRKjpBrQskhckkoOUYuCYMcJFeEkoPkglBylJwXSg6T00LJcXJaGOQEOSuUnCAnhZIz5JxQcoqcEkrOkVPCICfJGaHkJDkhlJwlx4WS0+SwUHKeHBYGuUCOCiUXyEGh5Ao5JpRcIoeEkmvkkDDIRXJEKLlIDgglV8l+oeQy2S2UXCe7hUEmkL1CyQSyUyiZQfYJJVPILqFkDtklDDKJ7BFKJpEdQsks8loomUZeCiXzyEthkInklVAykbwQSmaS50LJVPJUKJlLngqDTCbPhJLJ5IlQMps8Fkqmk4dCyXzyUBhkAXkklCwgD4SSFWRbKFlCNoWSNWRTGGQR2RJKFpENoWQV+SuULCN/hJJ15I9Qso78FkoWkl9CyUryUyhZSn4IJWvJD6FkLfkulCwm34SS1eS/ULKcfAkl68mXULKefAolN5AKJXeQIZTcQoYwyD3kQyi5h7wLJTeRN6HkLgKh5DYCYZD7CKHkPhJKbmQouZOED3IrIbyRewkEkHvJmyA3+wcCy4+Pd6wJvwAAAABJRU5ErkJggg==", "8": "iVBORw0KGgoAAAANSUhEUgAAAFgAAACTCAAAAAAv0YGfAAAD4UlEQVRoBaXBCWKDMBAEwe7/P3pCVgJz2iCqZEDo5JI8Ek7ICbkvXJI9uSn8IBtyS7hBVuSGcI98yG/hNpnJL+FASjiSTn4IG7ITtqSR78KKnAprUuSb8CHXwor8ky/Ch3wVPmQi18JCfgkLmcilMJMbwkJAroSZ3BIWglwJndwUZoJcCJ3cFmYi50InD4RO5FTo5JHQKadCI8+ETjkTGnkqNMqJ0MhjoVFOhEaeC40chUYGhEaOQpERoZGjUGRIKHIQiowJRQ5CkTGhyF5oZEwosheKDApF9kKRQaHIXigyKBTZCUVGhSI7ocioUGQnFBkViuyEIqNCkZ1QZFQoshWKDAtFtkKRYaHIVigyKhRlKxQZFYqyFYqMCkXZCkVGhaJshSKDQhHZCkUGhSKyFRoZEhqRrdDIkFAE2QlFhoQiyE4oMiIUAdkJjTwXGgHZCY08F4pMZC8UeSw0MpG90MhDoZF/shcaeSg08k8OQiOPhEaKHIROHgiNNHIUOrktNNLJUejkrtBJJydCJ/eETmZyIszkhjCThZwJM/kpzORDToWFfBUWsiLnwkK+CDPZkHNhRS6EhWzJlbAiR2FFduRS2JC1sCYHci3cIyfkm/CbnJLvwndyQX4IX8gl+S58Jxfki3CDnJJL4SY5IRfChmyEDTmQc2FFToQV2ZMz4UOuhA/ZkRNhId+ED9mQg7CQH8KHrMleWMhvYSErshNmcktYyIdshZncFBaykI3QyX1hJgtZC508EWYyk5XQyTNhJp18hE6eCp10sgidPBc6aWQWOhkRGmlkFhoZEjop0oVGBoVGijShkVGhk3/ShEaGhUb+SQmNjAuN/JMSirwRGpnIv9DIG6GRifwLRV4JjUxkEoq8FIpMZBKKvBSKTARCIy+FIhOBUOStUGQiEIq8FRoBIRR5LTQCQijyWmgEhFDktdAICKHIa6HIREKR90KRiYQi74UiEwlF3gtFJhKKvBeKTCQUeS8UmUgo8l4oMpFQ5LVQ5J+hkddCkX+GRl4LRf4ZGnktFPlnaOStUKQYGnkrFCmGRl4KjRQJRV4KRRoJRV4KRRoJjbwSGmkkNPJKKNJJaOSN0EgnoZEXQiMzITQyLjQyE0Ijw0IjCyF0Mig08iEQOhkSOvkQCJ2MCJ2sCISZPBc6WZNJmMlTYSZrMgkzeSjMZEP+hZk8EmayJSXM5IEwkx0pYSF3hYXsSRM+5JawkAPpwof8Fj7kSGZhRb4LK3JCFmFNroUVOSUfYUPOhC05JyvhQFbCnlyRtfCMXJKtcJ98IXvhHvlKjsJv8oOcCV/Jb3IhXJBb5IuwJff9AXvjEZJKODL+AAAAAElFTkSuQmCC", "9": "iVBORw0KGgoAAAANSUhEUgAAAGIAAACQCAAAAADWlOHAAAADbElEQVRoBa3BCYKDIBQFwe77H/pNho/G7KBUyZTwSr6TUeEL+UyGhJ/kA/ktjJG35JcwTt6Q78IceSHfhHnyRD4LL+RZeCaP5KPwSD4Jj+RIPggP5KvwQA7kvXAgA8KB3Mlb4U7GhAPZyRvhTsaFO9nIq7CTOWEnnbwIO5kVdlLkWdjICWEjRZ6EjZwSNtLIo7CRk8JG/smDsJHTQif/5Chs5ILQyY0chSKXhI2AHIQiF4VOQO5CJ1eFTpBd6OSy0AmyC0UWCEWQTSiyRCgiXSiyRigiXWhkkdApJRRZJRSlhEaWCUVpQpFlQlGa0MhCoci/0MhKoci/0MhKochNKLJSKHITGlkrNAKhyFqhEQiNLBYagdDIYqERQpHFQiOERlYLjRAaWS00QmhktdBIaGS50EhoZLnQSGhkudBIaGS50EhoZLnQGIosFxpDI8uFYmhkudBoaGS50GhoZLnQaGhkudBoaGS1UDQ0slpoxNDIaqERQyOrhUYMjSwWihgaWSw0gqHIUqEIhiJLhSIYiqwUioChyEqhCEhoZKVQBCQUWScUuZFQZJnQyY2EIsuEIv8kdLJI6OSfEIqsETpphNDJEqGTRgidrBA20giETq4LGykCoZPLwk6K3IROLgo76eQmbOSSsJON/AudXBHuZCP/wkbOC3eykyZs5KxwIDspYSPnhAO5kxJ2ckI4kgPpwk6mhQdyIJuwkznhkRzJLtzJhPBIHshduJNR4Zk8kINwICPCC3kkR+FIfgmv5Ik8CA/ki/COPJNH4Zm8Ez6QZ/IkvCO78I28kBdhkoROXsmrMEMgdPJK3gmj5CZ08oZ8EAbIv9DJO/JR+Eo2oZN35KvwntyFTt6S38KdPAudvCdXhSIfyEWhkw/kmtDJJ3JNKPKRXBI6+UiuCJ18JheETr6QC0InX8h5oZNv5LTQyVdyWijynZwVOvlOTgqd/CAnhSK/yDmhk1/klNDJT3JKKPKbnBE6+U1OCJ0MkHmhkxEyLxQZItNCJ0NkVuhkjMwKRQbJpNDJIJkTOhklc0KRYTIldDJMZoROxsmMUGSCTAhFZsi40MkMGRY6mSLDQpE5Mip0MkcGhU4myaBQZJaMCZ3MkiGhk2kyJBSZJyNCkRNkQOjkBBkQipwhv4Uip8hvocgpMiLcyDkyJshJfy6z6n6ddyR7AAAAAElFTkSuQmCC"} diff --git a/src/arcaea_offline_ocr/crop.py b/src/arcaea_offline_ocr/crop.py new file mode 100644 index 0000000..0a3aedd --- /dev/null +++ b/src/arcaea_offline_ocr/crop.py @@ -0,0 +1,42 @@ +from typing import Tuple + +from cv2 import Mat + +from .device import Device + + +def crop_img(img: Mat, *, top: int, left: int, bottom: int, right: int): + return img[top:bottom, left:right] + + +def crop_from_device_attr(img: Mat, rect: Tuple[int, int, int, int]): + x, y, w, h = rect + return crop_img(img, top=y, left=x, bottom=y + h, right=x + w) + + +def crop_to_pure(screenshot: Mat, device: Device): + return crop_from_device_attr(screenshot, device.pure) + + +def crop_to_far(screenshot: Mat, device: Device): + return crop_from_device_attr(screenshot, device.far) + + +def crop_to_lost(screenshot: Mat, device: Device): + return crop_from_device_attr(screenshot, device.lost) + + +def crop_to_max_recall(screenshot: Mat, device: Device): + return crop_from_device_attr(screenshot, device.max_recall) + + +def crop_to_rating_class(screenshot: Mat, device: Device): + return crop_from_device_attr(screenshot, device.rating_class) + + +def crop_to_score(screenshot: Mat, device: Device): + return crop_from_device_attr(screenshot, device.score) + + +def crop_to_title(screenshot: Mat, device: Device): + return crop_from_device_attr(screenshot, device.title) diff --git a/src/arcaea_offline_ocr/device.py b/src/arcaea_offline_ocr/device.py new file mode 100644 index 0000000..6af8f3f --- /dev/null +++ b/src/arcaea_offline_ocr/device.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from typing import Any, Dict, Tuple + + +@dataclass(kw_only=True) +class Device: + version: int + uuid: str + name: str + pure: Tuple[int, int, int, int] + far: Tuple[int, int, int, int] + lost: Tuple[int, int, int, int] + max_recall: Tuple[int, int, int, int] + rating_class: Tuple[int, int, int, int] + score: Tuple[int, int, int, int] + title: Tuple[int, int, int, int] + + @classmethod + def from_json_object(cls, json_dict: Dict[str, Any]): + if json_dict["version"] == 1: + return cls( + version=1, + uuid=json_dict["uuid"], + name=json_dict["name"], + pure=json_dict["pure"], + far=json_dict["far"], + lost=json_dict["lost"], + max_recall=json_dict["max_recall"], + rating_class=json_dict["rating_class"], + score=json_dict["score"], + title=json_dict["title"], + ) diff --git a/src/arcaea_offline_ocr/mask.py b/src/arcaea_offline_ocr/mask.py new file mode 100644 index 0000000..2bf7be4 --- /dev/null +++ b/src/arcaea_offline_ocr/mask.py @@ -0,0 +1,64 @@ +from cv2 import BORDER_CONSTANT, BORDER_ISOLATED, Mat, bitwise_or, dilate, inRange +from numpy import array, uint8 + +GRAY_MIN_HSV = array([0, 0, 70], uint8) +GRAY_MAX_HSV = array([0, 70, 200], uint8) + +WHITE_MIN_HSV = array([0, 0, 240], uint8) +WHITE_MAX_HSV = array([179, 10, 255], uint8) + +PST_MIN_HSV = array([100, 50, 80], uint8) +PST_MAX_HSV = array([100, 255, 255], uint8) + +PRS_MIN_HSV = array([43, 40, 75], uint8) +PRS_MAX_HSV = array([50, 155, 190], uint8) + +FTR_MIN_HSV = array([149, 30, 0], uint8) +FTR_MAX_HSV = array([155, 181, 150], uint8) + +BYD_MIN_HSV = array([170, 50, 50], uint8) +BYD_MAX_HSV = array([179, 210, 198], uint8) + + +def mask_gray(img_hsv: Mat): + mask = inRange(img_hsv, GRAY_MIN_HSV, GRAY_MAX_HSV) + mask = dilate(mask, (2, 2)) + return mask + + +def mask_white(img_hsv: Mat): + mask = inRange(img_hsv, WHITE_MIN_HSV, WHITE_MAX_HSV) + mask = dilate(mask, (5, 5), borderType=BORDER_CONSTANT | BORDER_ISOLATED) + return mask + + +def mask_pst(img_hsv: Mat): + mask = inRange(img_hsv, PST_MIN_HSV, PST_MAX_HSV) + mask = dilate(mask, (1, 1)) + return mask + + +def mask_prs(img_hsv: Mat): + mask = inRange(img_hsv, PRS_MIN_HSV, PRS_MAX_HSV) + mask = dilate(mask, (1, 1)) + return mask + + +def mask_ftr(img_hsv: Mat): + mask = inRange(img_hsv, FTR_MIN_HSV, FTR_MAX_HSV) + mask = dilate(mask, (1, 1)) + return mask + + +def mask_byd(img_hsv: Mat): + mask = inRange(img_hsv, BYD_MIN_HSV, BYD_MAX_HSV) + mask = dilate(mask, (2, 2)) + return mask + + +def mask_rating_class(img_hsv: Mat): + pst = mask_pst(img_hsv) + prs = mask_prs(img_hsv) + ftr = mask_ftr(img_hsv) + byd = mask_byd(img_hsv) + return bitwise_or(byd, bitwise_or(ftr, bitwise_or(pst, prs))) diff --git a/src/arcaea_offline_ocr/ocr.py b/src/arcaea_offline_ocr/ocr.py new file mode 100644 index 0000000..b67e95d --- /dev/null +++ b/src/arcaea_offline_ocr/ocr.py @@ -0,0 +1,158 @@ +import re +from typing import Dict, List + +from cv2 import Mat +from imutils import resize +from pytesseract import image_to_string + +from .template import ( + MatchTemplateMultipleResult, + load_builtin_digit_template, + matchTemplateMultiple, +) + + +def group_numbers(numbers: List[int], threshold: int) -> List[List[int]]: + """ + ``` + numbers = [26, 189, 303, 348, 32, 195, 391, 145, 77] + group_numbers(numbers, 10) -> [[26, 32], [77], [145], [189, 195], [303], [348], [391]] + group_numbers(numbers, 5) -> [[26], [32], [77], [145], [189], [195], [303], [348], [391]] + group_numbers(numbers, 50) -> [[26, 32, 77], [145, 189, 195], [303, 348, 391]] + # from Bing AI + ``` + """ + numbers.sort() + # Initialize an empty list of groups + groups = [] + # Initialize an empty list for the current group + group = [] + # Loop through the numbers + for number in numbers: + # If the current group is empty or the number is within the threshold of the last number in the group + if not group or number - group[-1] <= threshold: + # Append the number to the current group + group.append(number) + # Otherwise + else: + # Append the current group to the list of groups + groups.append(group) + # Start a new group with the number + group = [number] + # Append the last group to the list of groups + groups.append(group) + # Return the list of groups + return groups + + +class FilterDigitResultDict(MatchTemplateMultipleResult): + digit: int + + +def filter_digit_results( + results: Dict[int, List[MatchTemplateMultipleResult]], threshold: int +): + result_sorted_by_x_pos: Dict[ + int, List[FilterDigitResultDict] + ] = {} # dict[x_pos, dict[int, list[result]]] + for digit, match_results in results.items(): + if match_results: + for result in match_results: + x_pos = result["xywh"][0] + _dict = {**result, "digit": digit} + if result_sorted_by_x_pos.get(x_pos) is None: + result_sorted_by_x_pos[x_pos] = [_dict] + else: + result_sorted_by_x_pos[x_pos].append(_dict) + + x_poses_grouped: List[List[int]] = group_numbers( + list(result_sorted_by_x_pos), threshold + ) + + final_result: Dict[ + int, List[MatchTemplateMultipleResult] + ] = {} # dict[digit, list[Results]] + for x_poses in x_poses_grouped: + possible_results = [] + for x_pos in x_poses: + possible_results.extend(result_sorted_by_x_pos.get(x_pos, [])) + result = sorted(possible_results, key=lambda d: d["max_val"], reverse=True)[0] + result_digit = result["digit"] + result.pop("digit", None) + if final_result.get(result_digit) is None: + final_result[result_digit] = [result] + else: + final_result[result_digit].append(result) + return final_result + + +def ocr_digits( + img: Mat, + templates: Dict[int, Mat], + template_threshold: float, + filter_threshold: int, +): + results: Dict[int, List[MatchTemplateMultipleResult]] = {} + for digit, template in templates.items(): + template = resize(template, height=img.shape[0]) + results[digit] = matchTemplateMultiple(img, template, template_threshold) + results = filter_digit_results(results, filter_threshold) + result_x_digit_map = {} + for digit, match_results in results.items(): + if match_results: + for result in match_results: + result_x_digit_map[result["xywh"][0]] = digit + digits_sorted_by_x = dict(sorted(result_x_digit_map.items())) + joined_str = "".join([str(digit) for digit in digits_sorted_by_x.values()]) + return int(joined_str) if joined_str else None + + +def ocr_pure(img_masked: Mat): + templates = load_builtin_digit_template("GeoSansLight-Regular") + return ocr_digits(img_masked, templates, template_threshold=0.6, filter_threshold=3) + + +def ocr_far_lost(img_masked: Mat): + templates = load_builtin_digit_template("GeoSansLight-Italic") + return ocr_digits(img_masked, templates, template_threshold=0.6, filter_threshold=3) + + +def ocr_score(img_cropped: Mat): + templates = load_builtin_digit_template("GeoSansLight-Regular") + return ocr_digits( + img_cropped, templates, template_threshold=0.5, filter_threshold=10 + ) + + +def ocr_max_recall(img_cropped: Mat): + try: + texts = image_to_string(img_cropped).split(" ") # type: List[str] + texts.reverse() + for text in texts: + if re.match(r"^[0-9]+$", text): + return int(text) + except Exception as e: + return None + + +def ocr_rating_class(img_cropped: Mat): + try: + text = image_to_string(img_cropped) # type: str + text = text.lower() + if "past" in text: + return 0 + elif "present" in text: + return 1 + elif "future" in text: + return 2 + elif "beyond" in text: + return 3 + except Exception as e: + return None + + +def ocr_title(img_cropped: Mat): + try: + return image_to_string(img_cropped).replace("\n", "") + except Exception as e: + return "" diff --git a/src/arcaea_offline_ocr/recognize.py b/src/arcaea_offline_ocr/recognize.py new file mode 100644 index 0000000..eb295a1 --- /dev/null +++ b/src/arcaea_offline_ocr/recognize.py @@ -0,0 +1,67 @@ +from dataclasses import dataclass +from typing import Optional + +from cv2 import COLOR_BGR2HSV, GaussianBlur, cvtColor, imread + +from .crop import * +from .device import Device +from .mask import * +from .ocr import * + + +@dataclass(kw_only=True) +class RecognizeResult: + pure: Optional[int] + far: Optional[int] + lost: Optional[int] + score: Optional[int] + max_recall: Optional[int] + rating_class: Optional[int] + title: str + + +def recognize(img_filename: str, device: Device): + img = imread(img_filename) + img_hsv = cvtColor(img, COLOR_BGR2HSV) + + pure_roi = crop_to_pure(img_hsv, device) + pure_roi = mask_gray(pure_roi) + pure_roi = GaussianBlur(pure_roi, (3, 3), 0) + pure = ocr_pure(pure_roi) + + far_roi = crop_to_far(img_hsv, device) + far_roi = mask_gray(far_roi) + far_roi = GaussianBlur(far_roi, (3, 3), 0) + far = ocr_far_lost(far_roi) + + lost_roi = crop_to_lost(img_hsv, device) + lost_roi = mask_gray(lost_roi) + lost_roi = GaussianBlur(lost_roi, (3, 3), 0) + lost = ocr_far_lost(lost_roi) + + score_roi = crop_to_score(img_hsv, device) + score_roi = mask_white(score_roi) + score_roi = GaussianBlur(score_roi, (3, 3), 0) + score = ocr_score(score_roi) + + max_recall_roi = crop_to_max_recall(img_hsv, device) + max_recall_roi = mask_gray(max_recall_roi) + max_recall = ocr_max_recall(max_recall_roi) + + rating_class_roi = crop_to_rating_class(img_hsv, device) + rating_class_roi = mask_rating_class(rating_class_roi) + rating_class = ocr_rating_class(rating_class_roi) + + title_roi = crop_to_title(img_hsv, device) + title_roi = mask_white(title_roi) + title = ocr_title(title_roi) + + return RecognizeResult( + pure=pure, + far=far, + lost=lost, + score=score, + max_recall=max_recall, + rating_class=rating_class, + title=title, + ) diff --git a/src/arcaea_offline_ocr/template.py b/src/arcaea_offline_ocr/template.py new file mode 100644 index 0000000..2d5c2b9 --- /dev/null +++ b/src/arcaea_offline_ocr/template.py @@ -0,0 +1,163 @@ +from base64 import b64decode +from time import sleep +from typing import Dict, List, Literal, Tuple, TypedDict + +from cv2 import ( + CHAIN_APPROX_SIMPLE, + COLOR_BGR2GRAY, + COLOR_GRAY2BGR, + FONT_HERSHEY_SIMPLEX, + IMREAD_GRAYSCALE, + RETR_EXTERNAL, + THRESH_BINARY_INV, + TM_CCOEFF_NORMED, + Mat, + boundingRect, + cvtColor, + destroyAllWindows, + findContours, + imdecode, + imread, + imshow, + matchTemplate, + minMaxLoc, + putText, + rectangle, + threshold, + waitKey, +) +from imutils import contours, grab_contours +from numpy import frombuffer as np_frombuffer +from numpy import uint8 + +from ._builtin_templates import GeoSansLight_Italic, GeoSansLight_Regular + + +def load_digit_template(filename: str) -> Dict[int, Mat]: + """ + Arguments: + filename -- An image with white background and black "0 1 2 3 4 5 6 7 8 9" text. + + Returns: + dict[int, cv2.Mat] + """ + # https://pyimagesearch.com/2017/07/17/credit-card-ocr-with-opencv-and-python/ + ref = imread(filename) + ref = cvtColor(ref, COLOR_BGR2GRAY) + ref = threshold(ref, 10, 255, THRESH_BINARY_INV)[1] + refCnts = findContours(ref.copy(), RETR_EXTERNAL, CHAIN_APPROX_SIMPLE) + refCnts = grab_contours(refCnts) + refCnts = contours.sort_contours(refCnts, method="left-to-right")[0] + digits = {} + for i, cnt in enumerate(refCnts): + (x, y, w, h) = boundingRect(cnt) + roi = ref[y : y + h, x : x + w] + digits[i] = roi + return digits + + +def load_builtin_digit_template( + name: Literal["GeoSansLight-Regular", "GeoSansLight-Italic"] +): + name_builtin_template_b64_map = { + "GeoSansLight-Regular": GeoSansLight_Regular, + "GeoSansLight-Italic": GeoSansLight_Italic, + } + template_b64 = name_builtin_template_b64_map[name] + return { + int(key): imdecode(np_frombuffer(b64decode(b64str), uint8), IMREAD_GRAYSCALE) + for key, b64str in template_b64.items() + } + + +class MatchTemplateMultipleResult(TypedDict): + max_val: float + xywh: Tuple[int, int, int, int] + + +def matchTemplateMultiple( + src: Mat, template: Mat, threshold: float = 0.1 +) -> List[MatchTemplateMultipleResult]: + """ + Returns: + A list of tuple[x, y, w, h] representing the matched rectangle + """ + template_result = matchTemplate(src, template, TM_CCOEFF_NORMED) + min_val, max_val, min_loc, max_loc = minMaxLoc(template_result) + template_h, template_w = template.shape[:2] + results = [] + + # debug + # imshow("templ", template) + # waitKey(750) + # destroyAllWindows() + + # https://stackoverflow.com/a/66848923/16484891 + # CC BY-SA 4.0 + prev_min_val, prev_max_val, prev_min_loc, prev_max_loc = None, None, None, None + while max_val > threshold: + min_val, max_val, min_loc, max_loc = minMaxLoc(template_result) + + # Prevent infinite loop. If those 4 values are the same as previous ones, break the loop. + if ( + prev_min_val == min_val + and prev_max_val == max_val + and prev_min_loc == min_loc + and prev_max_loc == max_loc + ): + break + else: + prev_min_val, prev_max_val, prev_min_loc, prev_max_loc = ( + min_val, + max_val, + min_loc, + max_loc, + ) + + if max_val > threshold: + # Prevent start_row, end_row, start_col, end_col be out of range of image + start_row = max(0, max_loc[1] - template_h // 2) + start_col = max(0, max_loc[0] - template_w // 2) + end_row = min(template_result.shape[0], max_loc[1] + template_h // 2 + 1) + end_col = min(template_result.shape[1], max_loc[0] + template_w // 2 + 1) + + template_result[start_row:end_row, start_col:end_col] = 0 + results.append( + { + "max_val": max_val, + "xywh": ( + max_loc[0], + max_loc[1], + max_loc[0] + template_w + 1, + max_loc[1] + template_h + 1, + ), + } + ) + + # debug + # src_dbg = cvtColor(src, COLOR_GRAY2BGR) + # src_dbg = rectangle( + # src_dbg, + # (max_loc[0], max_loc[1]), + # ( + # max_loc[0] + template_w + 1, + # max_loc[1] + template_h + 1, + # ), + # (0, 255, 0), + # thickness=3, + # ) + # src_dbg = putText( + # src_dbg, + # f"{max_val:.5f}", + # (5, src_dbg.shape[0] - 5), + # FONT_HERSHEY_SIMPLEX, + # 1, + # (0, 255, 0), + # thickness=2, + # ) + # imshow("src_rect", src_dbg) + # imshow("templ", template) + # waitKey(750) + # destroyAllWindows() + + return results