4 Коммиты 82ef7f2999 ... a62558add7

Автор SHA1 Сообщение Дата
  Andrew Klopper a62558add7 Add HTML output. лет назад: 6
  Andrew Klopper 6fdfd76066 Split extract_crossword into extract_crossword_grid and grid_to_png лет назад: 6
  Andrew Klopper 3de3b2f972 Added ability to filter out non-greys before trying to extract the лет назад: 6
  Andrew Klopper 8ab61aeb6d Added 'extract' command. лет назад: 6

+ 43 - 0
home/jinja2/home/grid.html

@@ -0,0 +1,43 @@
1
+<!DOCTYPE html>
2
+<html>
3
+    <head>
4
+        <meta charset="UTF-8">
5
+        <title>Crossword</title>
6
+        <style>
7
+            .crossword {
8
+                border-right: 1px solid;
9
+                border-bottom: 1px solid;
10
+            }
11
+            .crossword td {
12
+                border-left: 1px solid;
13
+                border-top: 1px solid;
14
+            }
15
+            .crossword td.filled {
16
+                background: black;
17
+            }
18
+        </style>
19
+    </head>
20
+    <body>
21
+        <table border=0 cellspacing=0 cellpadding=0 class="crossword">
22
+            {%- set tab_index = namespace(value=1) %}
23
+            {%- set blank = '&emsp;'|safe %}
24
+            {%- for row in range(num_rows) %}
25
+                <tr>
26
+                    {%- for col in range(num_cols) %}
27
+                        {%- if grid[row][col].filled %}
28
+                            <td class="filled">&emsp;</td>
29
+                        {%- else %}
30
+                            <td class="empty" tabindex="{{ tab_index.value }}">
31
+                                {{ grid[row][col].number|default(blank) }}
32
+                            </td>
33
+                            {%- set tab_index.value = tab_index.value + 1 %}
34
+                        {%- endif %}
35
+                    {%- endfor %}
36
+                </tr>
37
+            {%- endfor %}
38
+        </table>
39
+    </body>
40
+</html>
41
+<!--
42
+vim:ts=4:sw=4:expandtab
43
+-->

+ 16 - 0
home/jinja2/home/index.html

@@ -0,0 +1,16 @@
1
+<!DOCTYPE html>
2
+<html>
3
+    <head>
4
+        <meta charset="UTF-8">
5
+        <title>Crossword Extractor</title>
6
+    </head>
7
+    <body>
8
+        <form enctype="multipart/form-data" method="post" action="">
9
+            <div>{{ form }}</div>
10
+            <div><input type="submit" value="Upload"></div>
11
+        </form>
12
+    </body>
13
+</html>
14
+<!--
15
+vim:ts=4:sw=4:expandtab
16
+-->

+ 33 - 0
home/jinja2/home/output.html

@@ -0,0 +1,33 @@
1
+<!DOCTYPE html>
2
+<html>
3
+    <head>
4
+        <meta charset="UTF-8">
5
+        <title>Extracted Crossword</title>
6
+        <style>
7
+            img {
8
+                width: 600px;
9
+            }
10
+        </style>
11
+    </head>
12
+    <body>
13
+        {% if warnings %}
14
+        <div>
15
+            Warnings:
16
+            <ul>
17
+                {% for warning in warnings %}
18
+                    <li>{{ warning }}</li>
19
+                {% endfor %}
20
+            </ul>
21
+        </div>
22
+        {% endif %}
23
+        <div>
24
+            <a href="{{ image_url }}" download="{{ image_file_name }}">Download</a>
25
+        </div>
26
+        <div>
27
+            <img alt="Crossword" src="{{ image_url }}">
28
+        </div>
29
+    </body>
30
+</html>
31
+<!--
32
+vim:ts=4:sw=4:expandtab
33
+-->

+ 0 - 0
home/management/__init__.py


+ 0 - 0
home/management/commands/__init__.py


+ 104 - 0
home/management/commands/extract.py

@@ -0,0 +1,104 @@
1
+import cv2
2
+from django.core.management.base import BaseCommand
3
+from django.template.loader import render_to_string
4
+from ...xword import extract_crossword_grid, draw_grid
5
+
6
+
7
+class Command(BaseCommand):
8
+    help = "Extracts a clean crossword image from a photograph."
9
+
10
+    def add_arguments(self, parser):
11
+        parser.add_argument('input_file_name')
12
+
13
+        parser.add_argument('--debug', action='store_true')
14
+
15
+        parser.add_argument('--remove-colours', action='store_true')
16
+        parser.add_argument('--colour-removal-threshold', type=int, default=48)
17
+
18
+        parser.add_argument('--gaussian-blur-size', type=int, default=11)
19
+        parser.add_argument('--adaptive-threshold-block-size', type=int, default=11)
20
+        parser.add_argument('--adaptive-threshold-mean-adjustment', type=int, default=2)
21
+
22
+        parser.add_argument('--not-square', action='store_true')
23
+
24
+        parser.add_argument('--num-dilations', type=int, default=1)
25
+
26
+        parser.add_argument('--contour-erosion-kernel-size', type=int, default=5)
27
+        parser.add_argument('--contour-erosion-iterations', type=int, default=5)
28
+        parser.add_argument('--line-detector-element-size', type=int, default=51)
29
+
30
+        parser.add_argument('--sampling-block-size-ratio', type=float, default=0.25)
31
+        parser.add_argument('--sampling-threshold-quantile', type=float, default=0.3)
32
+        parser.add_argument('--sampling-threshold', type=int)
33
+
34
+        parser.add_argument('--grid-line-thickness', type=int, default=4)
35
+        parser.add_argument('--grid-square-size', type=int, default=64)
36
+        parser.add_argument('--grid-border-size', type=int, default=20)
37
+
38
+        group = parser.add_mutually_exclusive_group()
39
+        group.add_argument('--out')
40
+        group.add_argument('--html')
41
+
42
+    def handle(self, *args, **options):
43
+        warnings, grid, num_rows, num_cols, block_img = extract_crossword_grid(
44
+            options['input_file_name'],
45
+            callback=debug_callback if options['debug'] else None,
46
+            remove_colours=options['remove_colours'],
47
+            colour_removal_threshold=options['colour_removal_threshold'],
48
+            gaussian_blur_size=options['gaussian_blur_size'],
49
+            adaptive_threshold_block_size=options['adaptive_threshold_block_size'],
50
+            adaptive_threshold_mean_adjustment=options['adaptive_threshold_mean_adjustment'],
51
+            square=not options['not_square'],
52
+            num_dilations=options['num_dilations'],
53
+            contour_erosion_kernel_size=options['contour_erosion_kernel_size'],
54
+            contour_erosion_iterations=options['contour_erosion_iterations'],
55
+            line_detector_element_size=options['line_detector_element_size'],
56
+            sampling_block_size_ratio=options['sampling_block_size_ratio'],
57
+            sampling_threshold_quantile=options['sampling_threshold_quantile'],
58
+            sampling_threshold=options['sampling_threshold']
59
+        )
60
+
61
+        if options['debug']:
62
+            debug_callback('square', block_img)
63
+
64
+        for warning in warnings:
65
+            print('WARNING: ' + warning)
66
+
67
+        if options['html'] is not None:
68
+            html = render_to_string('home/grid.html', {
69
+                'grid': grid,
70
+                'num_rows': num_rows,
71
+                'num_cols': num_cols,
72
+            })
73
+
74
+            with open(options['html'], 'w') as f:
75
+                f.write(html)
76
+
77
+        else:
78
+            image = draw_grid(
79
+                grid,
80
+                num_rows,
81
+                num_cols,
82
+                grid_line_thickness=options['grid_line_thickness'],
83
+                grid_square_size=options['grid_square_size'],
84
+                grid_border_size=options['grid_border_size']
85
+            )
86
+
87
+            if options['debug'] or (options['out'] is None):
88
+                debug_callback('output', image)
89
+
90
+            if options['out'] is not None:
91
+                _, png = cv2.imencode('.png', image)
92
+
93
+                with open(options['out'], 'wb') as f:
94
+                    f.write(png.tobytes())
95
+
96
+        if options['debug'] or (options['out'] is None):
97
+            while cv2.waitKey() & 0xFF != ord('q'):
98
+                pass
99
+            cv2.destroyAllWindows()
100
+
101
+
102
+def debug_callback(name, image):
103
+    cv2.namedWindow(name, cv2.WINDOW_NORMAL)
104
+    cv2.imshow(name, image)

+ 0 - 16
home/templates/home/index.html

@@ -1,16 +0,0 @@
1
-<!DOCTYPE html>
2
-<html>
3
-  <head>
4
-    <meta charset="UTF-8">
5
-    <title>Crossword Extractor</title>
6
-  </head>
7
-  <body>
8
-    <form enctype="multipart/form-data" method="post" action="">
9
-      <div>{{ form }}</div>
10
-      <div><input type="submit" value="Upload"></div>
11
-    </form>
12
-  </body>
13
-</html>
14
-<!--
15
-vim:ts=2:sw=2:expandtab
16
--->

+ 0 - 33
home/templates/home/output.html

@@ -1,33 +0,0 @@
1
-<!DOCTYPE html>
2
-<html>
3
-  <head>
4
-    <meta charset="UTF-8">
5
-    <title>Extracted Crossword</title>
6
-    <style>
7
-      img {
8
-        width: 600px;
9
-      }
10
-    </style>
11
-  </head>
12
-  <body>
13
-    {% if warnings %}
14
-    <div>
15
-      Warnings:
16
-      <ul>
17
-        {% for warning in warnings %}
18
-          <li>{{ warning }}</li>
19
-        {% endfor %}
20
-      </ul>
21
-    </div>
22
-    {% endif %}
23
-    <div>
24
-      <a href="{{ image_url }}" download="{{ image_file_name }}">Download</a>
25
-    </div>
26
-    <div>
27
-      <img alt="Crossword" src="{{ image_url }}">
28
-    </div>
29
-  </body>
30
-</html>
31
-<!--
32
-vim:ts=2:sw=2:expandtab
33
--->

+ 9 - 3
home/views.py

@@ -1,10 +1,11 @@
1 1
 from base64 import b64encode
2
+import cv2
2 3
 from datetime import datetime
3 4
 from django.http import HttpResponse, HttpResponseBadRequest
4 5
 from django.shortcuts import render
5 6
 from django.views import View
6 7
 from .forms import CrosswordForm
7
-from .xword import extract_crossword
8
+from .xword import extract_crossword_grid, draw_grid
8 9
 
9 10
 
10 11
 class HomeView(View):
@@ -15,9 +16,14 @@ class HomeView(View):
15 16
         form = CrosswordForm(request.POST, request.FILES)
16 17
         if not form.is_valid():
17 18
             return HttpResponseBadRequest('Invalid form data')
18
-        image, warnings = extract_crossword(form.cleaned_data['file'].temporary_file_path())
19
+        warnings, grid, num_rows, num_cols, block_img = extract_crossword_grid(
20
+            form.cleaned_data['file'].temporary_file_path(),
21
+            remove_colours=False
22
+        )
23
+        image = draw_grid(grid, num_rows, num_cols)
24
+        _, png = cv2.imencode('.png', image)
19 25
         return render(request, 'home/output.html', {
20 26
             'warnings': warnings,
21 27
             'image_file_name': 'xword_{}.png'.format(datetime.now().strftime('%Y%m%d_%H%M%S')),
22
-            'image_url': 'data:image/png;base64,' + b64encode(image).decode()
28
+            'image_url': 'data:image/png;base64,' + b64encode(png.tobytes()).decode()
23 29
         })

+ 67 - 27
home/xword.py

@@ -5,6 +5,25 @@ import copy
5 5
 import argparse
6 6
 
7 7
 
8
+def non_greys_to_white(img, threshold=48):
9
+    b, g, r = cv2.split(img)
10
+    rgb_diff = cv2.subtract(cv2.max(cv2.max(b, g), r), cv2.min(cv2.min(b, g), r))
11
+    filtered = img.copy()
12
+    filtered[np.where(rgb_diff > threshold)] = (255, 255, 255)
13
+    return filtered
14
+
15
+
16
+def load_image_as_greyscale(file_name, filter_colours, colour_filter_threshold):
17
+    img = cv2.imread(file_name)
18
+    if img is None:
19
+        raise RuntimeError("Failed to load image")
20
+
21
+    if filter_colours:
22
+        img = non_greys_to_white(img, colour_filter_threshold)
23
+
24
+    return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
25
+
26
+
8 27
 def preprocess_image(original, gaussian_blur_size, adaptive_threshold_block_size, adaptive_threshold_mean_adjustment, num_dilations):
9 28
     img = cv2.GaussianBlur(original, (gaussian_blur_size, gaussian_blur_size), 0)
10 29
     img = cv2.adaptiveThreshold(img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, adaptive_threshold_block_size, adaptive_threshold_mean_adjustment)
@@ -136,25 +155,41 @@ def extract_grid_colours(img, num_rows, num_cols, sampling_block_size_ratio):
136 155
 def grid_colours_to_blocks(grid_colours, num_rows, num_cols, sampling_threshold):
137 156
     grid = copy.deepcopy(grid_colours)
138 157
     warning = False
139
-    for row in range(round(num_rows / 2)):
158
+
159
+    midpoint = num_rows // 2 + (0 if num_rows % 2 == 0 else 1)
160
+    for row in range(midpoint):
140 161
         for col in range(num_cols):
162
+            # If there is an odd number of rows then row and row2 will point to
163
+            # the same row when we reach the middle. Doesn't seem worth adding a
164
+            # special case.
141 165
             row2 = num_rows - row - 1
142 166
             col2 = num_cols - col - 1
143 167
             delta1 = grid_colours[row][col] - sampling_threshold
144 168
             delta2 = grid_colours[row2][col2] - sampling_threshold
145 169
 
146 170
             if (delta1 > 0) and (delta2 > 0):
147
-                block = 0
171
+                filled = False
148 172
             elif (delta1 < 0) and (delta2 < 0):
149
-                block = 1
173
+                filled = True
150 174
             else:
151 175
                 warning = True
152 176
                 if abs(delta1) > abs(delta2):
153
-                    block = 1 if delta1 < 0 else 0
177
+                    filled = delta1 < 0
154 178
                 else:
155
-                    block = 1 if delta2 < 0 else 0
179
+                    filled = delta2 < 0
180
+
181
+            grid[row][col] = {'filled': filled}
182
+            grid[row2][col2] = {'filled': filled}
156 183
 
157
-            grid[row][col] = grid[row2][col2] = block
184
+    number = 1
185
+    for row in range(num_rows):
186
+        for col in range(num_cols):
187
+            if (not grid[row][col]['filled'] and (
188
+                (((col == 0) or grid[row][col - 1]['filled']) and (col < num_cols - 1) and not grid[row][col + 1]['filled']) or
189
+                (((row == 0) or grid[row - 1][col]['filled']) and (row < num_rows - 1) and not grid[row + 1][col]['filled'])
190
+            )):
191
+                grid[row][col]['number'] = number
192
+                number += 1
158 193
 
159 194
     return warning, grid
160 195
 
@@ -169,38 +204,32 @@ def draw_point(image, point, colour):
169 204
                 image[y, x] = colour
170 205
 
171 206
 
172
-def show_image(image):
173
-    cv2.namedWindow('xword', cv2.WINDOW_NORMAL)
174
-    cv2.imshow('xword', image)
175
-    while cv2.waitKey() & 0xFF != ord('q'):
176
-        pass
177
-    cv2.destroyAllWindows()
178
-
179
-
180
-def extract_crossword(
207
+def extract_crossword_grid(
181 208
     file_name,
209
+    callback=None,
210
+    remove_colours=False,
211
+    colour_removal_threshold=48,
182 212
     gaussian_blur_size=11,
183 213
     adaptive_threshold_block_size=11,
184 214
     adaptive_threshold_mean_adjustment=2,
185 215
     square=True,
186 216
     num_dilations=1,
187 217
     contour_erosion_kernel_size=5,
188
-    contour_erosion_iterations=6,
218
+    contour_erosion_iterations=5,
189 219
     line_detector_element_size=51,
190 220
     sampling_block_size_ratio=0.25,
191 221
     sampling_threshold_quantile=0.3,
192
-    sampling_threshold=None,
193
-    grid_line_thickness=4,
194
-    grid_square_size=64,
195
-    grid_border_size=20,
222
+    sampling_threshold=None
196 223
 ):
197 224
     warnings = []
198 225
 
199
-    original = cv2.imread(file_name, cv2.IMREAD_GRAYSCALE)
200
-    if original is None:
201
-        raise RuntimeError("Failed to load image")
226
+    original = load_image_as_greyscale(file_name, remove_colours, colour_removal_threshold)
227
+    if callback is not None:
228
+        callback('original', original)
202 229
 
203 230
     img = preprocess_image(original, gaussian_blur_size, adaptive_threshold_block_size, adaptive_threshold_mean_adjustment, num_dilations)
231
+    if callback is not None:
232
+        callback('preprocessed', img)
204 233
 
205 234
     biggest = find_biggest_contour(img)
206 235
     biggest = erode_contour(img.shape, biggest, contour_erosion_kernel_size, contour_erosion_iterations)
@@ -208,6 +237,8 @@ def extract_crossword(
208 237
     top_left, top_right, bottom_right, bottom_left = get_contour_corners(img, biggest)
209 238
 
210 239
     img = extract_square(img, top_left, top_right, bottom_right, bottom_left)
240
+    if callback is not None:
241
+        callback('pre-fft', img)
211 242
 
212 243
     num_rows = get_line_frequency(img, line_detector_element_size, 1)
213 244
     num_cols = get_line_frequency(img, line_detector_element_size, 0)
@@ -226,6 +257,17 @@ def extract_crossword(
226 257
     if warning:
227 258
         warnings.append("Some blocks may be the wrong colour")
228 259
 
260
+    return warnings, grid, num_rows, num_cols, block_img
261
+
262
+
263
+def draw_grid(
264
+    grid,
265
+    num_rows,
266
+    num_cols,
267
+    grid_line_thickness=4,
268
+    grid_square_size=64,
269
+    grid_border_size=20
270
+):
229 271
     step = grid_square_size + grid_line_thickness
230 272
     grid_height = num_rows * step + grid_line_thickness
231 273
     grid_width = num_cols * step + grid_line_thickness
@@ -234,9 +276,7 @@ def extract_crossword(
234 276
     for row in range(num_rows):
235 277
         y = row * step + grid_line_thickness + grid_border_size
236 278
         for col in range(num_cols):
237
-            if grid[row][col] == 0:
279
+            if not grid[row][col]['filled']:
238 280
                 x = col * step + grid_line_thickness + grid_border_size
239 281
                 cv2.rectangle(output, (x, y), (x + grid_square_size - 1, y + grid_square_size - 1), 255, -1)
240
-
241
-    _, png = cv2.imencode('.png', output)
242
-    return png.tobytes(), warnings
282
+    return output

+ 2 - 0
requirements.in

@@ -1,4 +1,6 @@
1 1
 chaussette
2 2
 django
3
+django-extensions
4
+jinja2
3 5
 opencv-python
4 6
 pip-tools

+ 4 - 1
requirements.txt

@@ -7,10 +7,13 @@
7 7
 asgiref==3.2.3            # via django
8 8
 chaussette==1.3.0
9 9
 click==7.0                # via pip-tools
10
+django-extensions==2.2.6
10 11
 django==3.0.2
12
+jinja2==2.11.0
13
+markupsafe==1.1.1         # via jinja2
11 14
 numpy==1.18.1             # via opencv-python
12 15
 opencv-python==4.1.2.30
13 16
 pip-tools==4.4.0
14 17
 pytz==2019.3              # via django
15
-six==1.14.0               # via chaussette, pip-tools
18
+six==1.14.0               # via chaussette, django-extensions, pip-tools
16 19
 sqlparse==0.3.0           # via django

+ 12 - 0
xword/jinja2.py

@@ -0,0 +1,12 @@
1
+#from django.contrib.staticfiles.storage import staticfiles_storage
2
+#from django.urls import reverse
3
+from jinja2 import Environment
4
+
5
+
6
+def environment(**options):
7
+    env = Environment(**options)
8
+    env.globals.update({
9
+        #‘static’: staticfiles_storage.url,
10
+        #‘url’: reverse,
11
+    })
12
+    return env

+ 3 - 1
xword/settings.py

@@ -11,6 +11,7 @@ DEBUG = False
11 11
 ALLOWED_HOSTS = OVERRIDE_ME
12 12
 
13 13
 INSTALLED_APPS = [
14
+    'django_extensions',
14 15
     'home.apps.HomeConfig',
15 16
 ]
16 17
 
@@ -24,10 +25,11 @@ ROOT_URLCONF = 'xword.urls'
24 25
 
25 26
 TEMPLATES = [
26 27
     {
27
-        'BACKEND': 'django.template.backends.django.DjangoTemplates',
28
+        'BACKEND': 'django.template.backends.jinja2.Jinja2',
28 29
         'DIRS': [],
29 30
         'APP_DIRS': True,
30 31
         'OPTIONS': {
32
+            'environment': 'xword.jinja2.environment',
31 33
             'context_processors': [
32 34
                 'django.template.context_processors.debug',
33 35
                 'django.template.context_processors.request',