JacobLinCool commited on
Commit
ffa6aac
0 Parent(s):

chore: init

Browse files
.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
.gitignore ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # poetry
98
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102
+ #poetry.lock
103
+
104
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
105
+ __pypackages__/
106
+
107
+ # Celery stuff
108
+ celerybeat-schedule
109
+ celerybeat.pid
110
+
111
+ # SageMath parsed files
112
+ *.sage.py
113
+
114
+ # Environments
115
+ .env
116
+ .venv
117
+ env/
118
+ venv/
119
+ ENV/
120
+ env.bak/
121
+ venv.bak/
122
+
123
+ # Spyder project settings
124
+ .spyderproject
125
+ .spyproject
126
+
127
+ # Rope project settings
128
+ .ropeproject
129
+
130
+ # mkdocs documentation
131
+ /site
132
+
133
+ # mypy
134
+ .mypy_cache/
135
+ .dmypy.json
136
+ dmypy.json
137
+
138
+ # Pyre type checker
139
+ .pyre/
140
+
141
+ # pytype static type analyzer
142
+ .pytype/
143
+
144
+ # Cython debug symbols
145
+ cython_debug/
146
+
147
+ # PyCharm
148
+ # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can
149
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
150
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
151
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
152
+ #.idea/
153
+
154
+ .DS_Store
155
+
156
+ data
.vscode/settings.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "python.formatting.provider": "black",
3
+ "[python]": {
4
+ "editor.formatOnSave": true
5
+ }
6
+ }
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2023 JacobLinCool
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1 @@
 
 
1
+ # captcha-recognizer
poetry.lock ADDED
The diff for this file is too large to render. See raw diff
 
pyproject.toml ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [tool.poetry]
2
+ name = "captcha-recognizer"
3
+ version = "0.0.0"
4
+ description = ""
5
+ authors = ["JacobLinCool <[email protected]>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+
9
+ [tool.poetry.dependencies]
10
+ python = "~3.10"
11
+ opencv-python = "^4.7.0.68"
12
+ gradio = "^3.18.0"
13
+ setuptools = "^67.3.2"
14
+ pytesseract = "^0.3.10"
15
+
16
+ [tool.poetry.group.dev.dependencies]
17
+ black = "^23.1.0"
18
+ poethepoet = "^0.18.1"
19
+
20
+ [tool.poe.tasks]
21
+ format = "black ."
22
+ collect = "python -m scripts/collect"
23
+ preprocess = "python -m scripts.preprocess"
24
+
25
+ [build-system]
26
+ requires = ["poetry-core"]
27
+ build-backend = "poetry.core.masonry.api"
scripts/__init__.py ADDED
File without changes
scripts/collect.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Description: Collects sample images for the website
2
+ import os
3
+ import time
4
+ import urllib.request
5
+ from src.shared import raw_dir
6
+
7
+ count = 100
8
+
9
+
10
+ def main():
11
+ print(f"Collecting {count} images to {raw_dir}")
12
+
13
+ for i in range(count):
14
+ url = "https://cos2s.ntnu.edu.tw/AasEnrollStudent/RandImage"
15
+ filename = os.path.join(raw_dir, f"{i}.jpg")
16
+ urllib.request.urlretrieve(url, filename)
17
+ print(f"Downloaded {i+1}/{count} {filename}")
18
+ time.sleep(0.1)
19
+
20
+ print("Done")
21
+
22
+
23
+ if __name__ == "__main__":
24
+ main()
scripts/generator.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from PIL import Image, ImageFont, ImageDraw
3
+ from random import choice, random
4
+ from string import ascii_lowercase
5
+ from src.shared import genereated_dir
6
+
7
+ WIDTH, HEIGHT = 108, 30
8
+
9
+ count = 100
10
+
11
+ font = ImageFont.truetype(
12
+ os.path.join(os.path.dirname(__file__), "..", "fonts", "NotoSans-Regular.ttf"), 18
13
+ )
14
+
15
+
16
+ def gen():
17
+ image = Image.new("RGB", (WIDTH, HEIGHT))
18
+
19
+ if random() > 0.5:
20
+ # alphabetical
21
+ a = choice(ascii_lowercase)
22
+ b = choice(ascii_lowercase)
23
+ c = choice(ascii_lowercase)
24
+ d = choice(ascii_lowercase)
25
+ ans = a + b + c + d
26
+
27
+ image = draw(image, [a, b, c, d])
28
+
29
+ else:
30
+ # arithmetic
31
+ a = choice(range(10))
32
+ b = choice(range(10))
33
+ op = choice(["+", "-", "x"])
34
+
35
+ if op == "+":
36
+ ans = f"{a}+{b}="
37
+ elif op == "-":
38
+ ans = f"{a}-{b}="
39
+ else:
40
+ ans = f"{a}x{b}="
41
+
42
+ if op == "x" and random() > 0.5:
43
+ op = "X"
44
+
45
+ image = draw(image, [str(a), op, str(b), "="])
46
+
47
+ return image, str(ans)
48
+
49
+
50
+ def draw(image: Image, text: list[str]) -> Image:
51
+ draw = ImageDraw.Draw(image)
52
+ draw.rectangle((0, 0, WIDTH, HEIGHT), fill=(255, 255, 255))
53
+
54
+ for i, t in enumerate(text):
55
+ txt = Image.new("RGBA", (30, 30))
56
+ d = ImageDraw.Draw(txt)
57
+ d.text(
58
+ (choice(range(0, 15)), -5 + choice(range(0, 15))),
59
+ t,
60
+ font=font,
61
+ fill=(255, 0, 0),
62
+ )
63
+ image.paste(txt, (14 + (i * 20), 0), txt)
64
+
65
+ # draw noise lines
66
+ for i in range(30):
67
+ fill = choice([120, 200])
68
+ x = random() * WIDTH
69
+ y = random() * HEIGHT
70
+
71
+ draw.line(
72
+ (
73
+ x,
74
+ y,
75
+ x + 15 * (random() - 1),
76
+ y + 15 * (random() - 1),
77
+ ),
78
+ fill=(fill, fill, fill),
79
+ width=1,
80
+ )
81
+
82
+ return image
83
+
84
+
85
+ if __name__ == "__main__":
86
+ for i in range(count):
87
+ image, ans = gen()
88
+ image.save(os.path.join(genereated_dir, f"{i}.png"))
89
+ with open(os.path.join(genereated_dir, f"{i}.txt"), "w") as f:
90
+ f.write(ans)
91
+ print(f"Generated {i}.png and {i}.txt")
92
+
93
+ print("Done")
scripts/preprocess.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Description: Preprocesses sample images
2
+ import os
3
+ import cv2
4
+ import numpy as np
5
+ from PIL import Image
6
+ from src.shared import raw_dir, preprocess_dir
7
+ from src.preprocess import preprocess
8
+
9
+
10
+ def main():
11
+ print(f"Preprocessing images in {raw_dir}")
12
+
13
+ for filename in os.listdir(raw_dir):
14
+ if not filename.endswith(".jpg"):
15
+ continue
16
+
17
+ raw_path = os.path.join(raw_dir, filename)
18
+ image = np.array(Image.open(raw_path))
19
+
20
+ image = preprocess(image)
21
+
22
+ # Save to preprocessed
23
+ preprocessed_path = os.path.join(preprocess_dir, filename)
24
+ cv2.imwrite(preprocessed_path, image)
25
+
26
+ print(f"Preprocessed {filename}")
27
+
28
+ print("Done")
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main()
src/__init__.py ADDED
File without changes
src/app.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import numpy as np
3
+ from preprocess import preprocess
4
+ from solve import solve
5
+
6
+
7
+ def run(img: np.ndarray) -> tuple[np.ndarray, str]:
8
+ preprocessed = preprocess(img)
9
+
10
+ solved = solve(preprocessed)
11
+
12
+ return preprocessed, solved
13
+
14
+
15
+ app = gr.Interface(
16
+ fn=run,
17
+ inputs=[
18
+ gr.Image(label="captcha image", shape=(108, 30)),
19
+ ],
20
+ outputs=[
21
+ gr.Image(label="preprocessed", shape=(108, 30)),
22
+ gr.Textbox(label="solved"),
23
+ ],
24
+ allow_flagging="never",
25
+ )
26
+
27
+ app.queue().launch()
src/preprocess.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+
4
+
5
+ def preprocess(image: np.ndarray) -> np.ndarray:
6
+ # Upscale, interpolation with nearest neighbor
7
+ image = cv2.resize(image, (0, 0), fx=3, fy=3, interpolation=cv2.INTER_NEAREST)
8
+
9
+ # Denoise gray-like pixels
10
+ hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
11
+ mask = cv2.inRange(hsv, (0, 70, 70), (255, 255, 255))
12
+ mask = cv2.bitwise_not(mask)
13
+ image[np.where(mask)] = 255
14
+
15
+ # Convert to binary
16
+ image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
17
+ _, image = cv2.threshold(image, 0, 255, cv2.THRESH_OTSU)
18
+
19
+ # Fix some holes
20
+ kernel = np.ones((3, 3), np.uint8)
21
+ image = cv2.morphologyEx(image, cv2.MORPH_OPEN, kernel, iterations=2)
22
+
23
+ # add padding
24
+ image = cv2.copyMakeBorder(
25
+ image, 5, 5, 5, 5, cv2.BORDER_CONSTANT, value=(255, 255, 255)
26
+ )
27
+
28
+ return image
src/shared.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ raw_dir = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "data", "raw"))
4
+
5
+ if not os.path.exists(raw_dir):
6
+ os.makedirs(raw_dir)
7
+
8
+ preprocess_dir = os.path.normpath(
9
+ os.path.join(os.path.dirname(__file__), "..", "data", "preprocessed")
10
+ )
11
+
12
+ if not os.path.exists(preprocess_dir):
13
+ os.makedirs(preprocess_dir)
14
+
15
+ genereated_dir = os.path.normpath(
16
+ os.path.join(os.path.dirname(__file__), "..", "data", "generated")
17
+ )
18
+
19
+ if not os.path.exists(genereated_dir):
20
+ os.makedirs(genereated_dir)
src/solve.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytesseract
2
+ import numpy as np
3
+
4
+
5
+ def solve(image: np.ndarray) -> str:
6
+ for mode in [7, 10, 11, 12, 13]:
7
+ result = normalize(
8
+ pytesseract.image_to_string(
9
+ image, lang="eng", config=f"--oem 3 --psm {mode}", timeout=0.5
10
+ ).strip()
11
+ )
12
+ if result != "":
13
+ return result
14
+
15
+ return "not sure"
16
+
17
+
18
+ def normalize(s: str) -> str:
19
+ print(s)
20
+ if "\n" in s:
21
+ return ""
22
+
23
+ s = s.replace(" ", "").lower()
24
+
25
+ # if first is number
26
+ if s[0].isdigit() and s[2].isdigit():
27
+ if s[1] in ["+", "4"]:
28
+ return str(int(s[0]) + int(s[2]))
29
+ elif s[1] in ["-", "_"]:
30
+ return str(int(s[0]) - int(s[2]))
31
+ else:
32
+ return str(int(s[0]) * int(s[2]))
33
+
34
+ # possible alphabet mapping
35
+ mapping = {
36
+ ")": "l",
37
+ "¥": "y",
38
+ "2": "z",
39
+ "é": "e",
40
+ }
41
+
42
+ for k, v in mapping.items():
43
+ s = s.replace(k, v)
44
+
45
+ # if not all alphabet
46
+ if not all([c.isalpha() for c in s]):
47
+ return ""
48
+
49
+ if len(s) != 4:
50
+ return ""
51
+
52
+ return s