topo_map_processor 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1844 @@
1
+
2
+ import os
3
+ import time
4
+ import json
5
+ import pickle
6
+ import shutil
7
+ import subprocess
8
+
9
+ from pathlib import Path
10
+ from functools import cmp_to_key
11
+ from collections import namedtuple
12
+
13
+ import cv2
14
+ import numpy as np
15
+
16
+ import pytesseract
17
+ import easyocr
18
+ from PIL import Image
19
+ from imgcat import imgcat
20
+
21
+ from shapely.ops import unary_union, nearest_points
22
+ from shapely.geometry import (
23
+ LineString, Polygon, box, LinearRing,
24
+ CAP_STYLE, JOIN_STYLE, Point
25
+ )
26
+ from shapely.affinity import translate
27
+ from rasterio.control import GroundControlPoint
28
+ from rasterio.transform import GCPTransformer
29
+
30
+ # remove decompression_bomb_check
31
+ Image.MAX_IMAGE_PIXELS = None
32
+
33
+ easy_ocr_reader = None
34
+
35
+ # rotation_reversal_params
36
+ RotationReversalParams = namedtuple('RotationReversalParams', ['rotated', 'angle', 'rotated_shape', 'original_shape'])
37
+
38
+ LineRemovalParams = namedtuple('LineRemovalParams', ['line_buf_ratio', 'blur_buf_ratio', 'blur_kern_ratio', 'blur_repeat'])
39
+
40
+ class GCPBasedTransformer:
41
+ def __init__(self, gcps):
42
+ self.pixel_map = {}
43
+ self.cooord_map = {}
44
+ gcp_objs = []
45
+
46
+ for gcp in gcps:
47
+ corner = gcp[0]
48
+ idx = gcp[1]
49
+ #print(f'adding gcp: {corner} -> {idx}')
50
+ self.pixel_map[(corner[1], corner[0])] = (idx[0], idx[1])
51
+ self.cooord_map[(idx[0], idx[1])] = (corner[0], corner[1])
52
+ gcp_obj = GroundControlPoint(row=corner[1], col=corner[0], x=idx[0], y=idx[1])
53
+ gcp_objs.append(gcp_obj)
54
+
55
+ self.transformer = GCPTransformer(gcp_objs, tps=True)
56
+
57
+
58
+ def xy(self, row, col, offset='center'):
59
+ if (row, col) in self.pixel_map:
60
+ return self.pixel_map[(row, col)]
61
+
62
+ x, y = self.transformer.xy([row], [col], offset=offset)
63
+ return (x[0].item(), y[0].item())
64
+
65
+ def rowcol(self, x, y):
66
+ if (x, y) in self.cooord_map:
67
+ return self.cooord_map[(x, y)]
68
+ rs, cs = self.transformer.rowcol([x], [y])
69
+ return (cs[0].item(), rs[0].item())
70
+
71
+
72
+ class TopoMapProcessor:
73
+ def __init__(self, filepath, extra, index_map):
74
+ self.filepath = filepath
75
+ self.index_map = index_map
76
+
77
+ self.SHOW_IMG = os.getenv('SHOW_IMG', '0') == '1'
78
+ self.INSPECT = os.getenv('INSPECT', '0') == '1'
79
+
80
+ self.full_img = None
81
+ self.small_img = None
82
+ self.mapbox_corners = None
83
+ self.rotation_reversal_params = None
84
+
85
+ self.extents = extra.get('extents', None)
86
+ self.pixel_cutlines = extra.get('pixel_cutlines', [])
87
+
88
+ # rotation related
89
+ self.auto_rotate_thresh = extra.get('auto_rotate_thresh', 0.0)
90
+
91
+ # shrinking related
92
+ self.resize_factor = extra.get('resize_factor', 0.25)
93
+ self.resize_width = extra.get('resize_width', None)
94
+ self.resize_height = extra.get('resize_height', None)
95
+ if self.resize_width is not None and self.resize_height is not None:
96
+ raise ValueError("Either resize_width or resize_height should be set, not both.")
97
+ if self.resize_width is not None or self.resize_height is not None:
98
+ self.resize_factor = None
99
+
100
+ # mapframe location related
101
+ self.band_color = extra.get('band_color', 'black')
102
+ self.band_color_choices = []
103
+ self.collar_erode = extra.get('collar_erode', -2)
104
+ self.use_bbox_area = extra.get('use_bbox_area', True)
105
+ self.shrunk_map_area_corners = extra.get('shrunk_map_area_corners', None)
106
+ self.poly_approx_factor = extra.get('poly_approx_factor', 0.001)
107
+
108
+ # corners related
109
+ self.corner_ratio = extra.get('corner_ratio', 400.0 / 9000.0)
110
+ self.corner_overrides = extra.get('corner_overrides', None)
111
+
112
+ # text removal related
113
+ self.text_removal_engine = extra.get('text_removal_engine', 'tesseract')
114
+ self.text_removal_border = extra.get('text_removal_border', 0)
115
+ self.text_removal_iterations = extra.get('text_removal_iterations', 1)
116
+ self.text_removal_char_size_cutoff = extra.get('text_removal_char_size_cutoff', 30)
117
+ self.text_removal_confidence_cutoff = extra.get('text_removal_confidence_cutoff', 20)
118
+ self.text_removal_word_char_overlap_size_ratio_cutoff = extra.get('text_removal_word_char_overlap_size_ratio_cutoff', 0.8)
119
+
120
+ # grid lines related
121
+ self.should_remove_grid_lines = extra.get('should_remove_grid_lines', False)
122
+ # leaving it around for what a sample config looks like
123
+ #self.remove_line_buf_ratio = extra.get('remove_line_buf_ratio', 2.0 / 6500.0)
124
+ #self.remove_line_blur_buf_ratio = extra.get('remove_line_blur_buf_ratio', 14.0 / 6500.0)
125
+ #self.remove_line_blur_kern_ratio = extra.get('remove_line_blur_kern_ratio', 9.0 / 6500.0)
126
+ #self.remove_line_blur_repeat = extra.get('remove_line_blur_repeat', 2)
127
+
128
+ # georef related
129
+ self.jpeg_export_quality = extra.get('jpeg_export_quality', 75)
130
+ self.warp_jpeg_export_quality = extra.get('warp_jpeg_export_quality', 75)
131
+ self.warp_output_height = extra.get('warp_output_height', 6500)
132
+
133
+ self.color_map = {
134
+ 'black': ((0, 0, 0), (179, 255, 130)),
135
+ 'greyish': ((0, 0, 50), (179, 150, 192)),
136
+ 'white': ((0, 0, 200), (179, 60, 255)),
137
+ }
138
+
139
+
140
+ def get_id(self):
141
+ ext = self.filepath.suffix
142
+ return self.filepath.name.replace(ext, '')
143
+
144
+ def get_sheet_ibox(self, sheet_id=None):
145
+ if sheet_id is None:
146
+ sheet_id = self.get_id()
147
+
148
+ return self.index_map[sheet_id]
149
+
150
+ def get_data_dir(self):
151
+ return Path('data')
152
+
153
+ def get_inter_dir(self):
154
+ return self.get_data_dir() / 'inter'
155
+
156
+ def get_workdir(self):
157
+ return self.get_inter_dir() / self.get_id()
158
+
159
+ def get_export_dir(self):
160
+ return Path('export/')
161
+
162
+ def get_gtiff_dir(self):
163
+ return self.get_export_dir() / 'gtiffs'
164
+
165
+ def get_bounds_dir(self):
166
+ return self.get_export_dir() / 'bounds'
167
+
168
+ def get_export_file(self, extra=None):
169
+ export_dir = self.get_gtiff_dir()
170
+ sheet_no = self.get_id()
171
+ return export_dir / f'{sheet_no}.tif'
172
+
173
+ def ensure_dir(self, d):
174
+ d.mkdir(parents=True, exist_ok=True)
175
+
176
+ def get_bbox_area_simple(self, contour):
177
+ bbox = cv2.boundingRect(contour)
178
+ return bbox[2] * bbox[3]
179
+
180
+ def get_bbox_area(self, ctuple):
181
+ return self.get_bbox_area_simple(ctuple[0])
182
+
183
+ def get_distance(self, p1, p2):
184
+ p1 = np.array(p1)
185
+ p2 = np.array(p2)
186
+ return np.linalg.norm(p2 - p1)
187
+
188
+ def get_angle(self, p1, p2):
189
+ p1 = np.array(p1)
190
+ p2 = np.array(p2)
191
+ vector = p2 - p1
192
+ angle_radians = np.arctan2(vector[1], vector[0])
193
+ return np.degrees(angle_radians)
194
+
195
+ def crop_img(self, img, bbox):
196
+ x, y, w, h = bbox
197
+ return img[y:y+h, x:x+w]
198
+
199
+ def prompt(self):
200
+ if not self.INSPECT:
201
+ return
202
+ print('Press Enter to continue...')
203
+ input()
204
+
205
+ def prompt1(self):
206
+ self.prompt()
207
+
208
+ def prompt2(self):
209
+ self.prompt()
210
+
211
+ def run_external(self, cmd):
212
+ print(f'running cmd - {cmd}')
213
+ start = time.time()
214
+ res = subprocess.run(cmd, shell=True, capture_output=True, text=True)
215
+ end = time.time()
216
+ print(f'STDOUT: {res.stdout}')
217
+ print(f'STDERR: {res.stderr}')
218
+ print(f'command took {end - start} secs to run')
219
+ if res.returncode != 0:
220
+ raise Exception(f'command {cmd} failed with exit code: {res.returncode}')
221
+
222
+
223
+ # display methods
224
+ def show_image(self, img):
225
+ if not self.SHOW_IMG:
226
+ return
227
+ # check if it is a numpy array
228
+ if not isinstance(img, (Image.Image, np.ndarray)):
229
+ raise ValueError("Input must be a PIL Image or a numpy array")
230
+
231
+ if isinstance(img, np.ndarray):
232
+ pil_img = Image.fromarray(img)
233
+ else:
234
+ pil_img = img
235
+ imgcat(pil_img)
236
+
237
+ def show_contours(self, binary_img, contours, color=(0, 255, 0)):
238
+ if not self.SHOW_IMG:
239
+ return
240
+ # check if image is uint8
241
+ if binary_img.dtype != 'uint8':
242
+ raise ValueError("Input image must be a boolean array")
243
+
244
+ rgb = cv2.merge([binary_img, binary_img, binary_img])
245
+ cv2.drawContours(rgb, contours, -1, color, 2, cv2.LINE_AA)
246
+
247
+ self.show_image(rgb)
248
+
249
+ def show_points(self, points, img_grey, color=(0, 255, 0), radius=5):
250
+ if not self.SHOW_IMG:
251
+ return
252
+
253
+ # check if image is uint8
254
+ if img_grey.dtype != 'uint8':
255
+ raise ValueError("Input image must be a boolean array")
256
+
257
+ img_rgb = np.stack([img_grey, img_grey, img_grey], axis=-1)
258
+
259
+ h, w = img_rgb.shape[:2]
260
+
261
+ for point in points:
262
+ center_x = point[0]
263
+ center_y = point[1]
264
+ y_coords, x_coords = np.ogrid[:h, :w]
265
+ distances = np.sqrt((x_coords - center_x)**2 + (y_coords - center_y)**2)
266
+ circle_mask = distances <= radius
267
+ img_rgb[circle_mask] = color
268
+
269
+ self.show_image(img_rgb)
270
+
271
+ def save_with_points(self, points_and_colors, img, out_file, radius=2):
272
+ if not isinstance(img, np.ndarray):
273
+ raise ValueError("Input image must be a numpy array")
274
+
275
+ h, w = img.shape[:2]
276
+
277
+ img = img.copy()
278
+
279
+ for point,color in points_and_colors:
280
+ center_x = int(point[0])
281
+ center_y = int(point[1])
282
+
283
+ # Define a bounding box around the point
284
+ x_start = max(0, center_x - radius)
285
+ y_start = max(0, center_y - radius)
286
+ x_end = min(w, center_x + radius + 1)
287
+ y_end = min(h, center_y + radius + 1)
288
+
289
+ if x_start >= x_end or y_start >= y_end:
290
+ continue
291
+
292
+ # Create a smaller ogrid within the bounding box
293
+ y_coords, x_coords = np.ogrid[y_start:y_end, x_start:x_end]
294
+
295
+ # Calculate distances and the circle_mask only for this smaller region
296
+ distances = np.sqrt((x_coords - center_x)**2 + (y_coords - center_y)**2)
297
+ circle_mask = distances <= radius
298
+
299
+ # Apply the circle_mask to the corresponding slice of the image
300
+ img_slice = img[y_start:y_end, x_start:x_end]
301
+ img_slice[circle_mask] = color
302
+
303
+ cv2.imwrite(str(out_file), img)
304
+
305
+ def save_with_lines(self, line_and_color_pairs, img, out_file, thickness=2):
306
+ if not isinstance(img, np.ndarray):
307
+ raise ValueError("Input image must be a numpy array")
308
+
309
+ img = img.copy()
310
+
311
+ for line, color in line_and_color_pairs:
312
+ p1 = (int(line[0][0]), int(line[0][1]))
313
+ p2 = (int(line[1][0]), int(line[1][1]))
314
+ cv2.line(img, p1, p2, color, thickness)
315
+ cv2.circle(img, p1, thickness * 2, color, -1)
316
+ cv2.circle(img, p2, thickness * 2, color, -1)
317
+
318
+ cv2.imwrite(str(out_file), img)
319
+
320
+ def save_cutline(self, multipolygon):
321
+ line_and_color_pairs = []
322
+ colors = [(0, 0, 255), (0, 255, 0), (255, 0, 0), (255, 255, 0), (0, 255, 255), (255, 0, 255)] # B, G, R, Y, C, M
323
+ color_idx = 0
324
+ for polygon in multipolygon.geoms:
325
+ # Exterior ring
326
+ exterior_points = list(polygon.exterior.coords)
327
+ current_color = colors[color_idx % len(colors)]
328
+ color_idx += 1
329
+ for i in range(len(exterior_points) - 1):
330
+ line_and_color_pairs.append(((exterior_points[i], exterior_points[i+1]), current_color))
331
+
332
+ # Interior rings
333
+ for interior_ring in polygon.interiors:
334
+ interior_points = list(interior_ring.coords)
335
+ current_color = colors[color_idx % len(colors)]
336
+ color_idx += 1
337
+ for i in range(len(interior_points) - 1):
338
+ line_and_color_pairs.append(((interior_points[i], interior_points[i+1]), current_color))
339
+ self.save_with_lines(line_and_color_pairs,
340
+ self.get_full_img(),
341
+ self.get_workdir() / 'with_full_cutline.jpg',
342
+ thickness=2)
343
+
344
+
345
+
346
+
347
+ # translation methods
348
+ def scale_bbox(self, bbox, rw, rh):
349
+ b = bbox
350
+ return (int(b[0]*rw), int(b[1]*rh), int(b[2]*rw), int(b[3]*rh))
351
+
352
+ def scale_point(self, point, rw, rh):
353
+ return [int(point[0]*rw), int(point[1]*rh)]
354
+
355
+
356
+ def translate_point(self, point, bbox):
357
+ b = bbox
358
+ px = point[0]
359
+ py = point[1]
360
+ return (b[0] + px, b[1] + py)
361
+
362
+ # copied form imutils and modified to take fill
363
+ def rotate_image_bound(self, img, angle, fill_color=(255, 255, 255)):
364
+ """
365
+ Rotate an image by a given angle and return the rotated image.
366
+ The image is rotated around its center.
367
+ """
368
+ if angle == 0:
369
+ return img
370
+
371
+ (h, w) = img.shape[:2]
372
+ (cX, cY) = (w / 2, h / 2)
373
+
374
+ M = cv2.getRotationMatrix2D((cX, cY), -angle, 1.0)
375
+ cos = np.abs(M[0, 0])
376
+ sin = np.abs(M[0, 1])
377
+
378
+ nW = int((h * sin) + (w * cos))
379
+ nH = int((h * cos) + (w * sin))
380
+
381
+ M[0, 2] += (nW / 2) - cX
382
+ M[1, 2] += (nH / 2) - cY
383
+
384
+ rotated_img = cv2.warpAffine(img, M, (nW, nH),
385
+ borderMode=cv2.BORDER_CONSTANT,
386
+ borderValue=fill_color)
387
+
388
+ return rotated_img
389
+
390
+ def get_original_pixel_coordinate_internal(self, rotated_point, rotation_reversal_params):
391
+ """
392
+ Given a pixel coordinate in the rotated image, gives the pixel coordinate in the original image.
393
+ """
394
+
395
+ angle = rotation_reversal_params.angle
396
+ rotated_shape = rotation_reversal_params.rotated_shape
397
+ original_shape = rotation_reversal_params.original_shape
398
+
399
+ (h, w) = original_shape
400
+ (nH, nW) = rotated_shape
401
+ (cX, cY) = (w / 2, h / 2)
402
+
403
+ # Recreate the forward transformation matrix M
404
+ M = cv2.getRotationMatrix2D((cX, cY), -angle, 1.0)
405
+ M[0, 2] += (nW / 2) - cX
406
+ M[1, 2] += (nH / 2) - cY
407
+
408
+ # Invert the transformation matrix
409
+ inv_M = cv2.invertAffineTransform(M)
410
+
411
+ # Apply the inverse transformation to the rotated point
412
+ x_rot, y_rot = rotated_point
413
+ x_orig = inv_M[0, 0] * x_rot + inv_M[0, 1] * y_rot + inv_M[0, 2]
414
+ y_orig = inv_M[1, 0] * x_rot + inv_M[1, 1] * y_rot + inv_M[1, 2]
415
+
416
+ x_orig = int(round(x_orig))
417
+ y_orig = int(round(y_orig))
418
+
419
+ if not (0 <= x_orig < w and 0 <= y_orig < h):
420
+ raise ValueError(f"Calculated original coordinates for {rotated_point} are out of bounds")
421
+
422
+ return (x_orig, y_orig)
423
+
424
+ def get_color_ranges(self, color):
425
+ if color not in self.color_map:
426
+ raise ValueError(f"Color '{color}' not found in color map")
427
+
428
+ lower, upper = self.color_map[color]
429
+ lower = np.array(lower, dtype=np.uint8)
430
+ upper = np.array(upper, dtype=np.uint8)
431
+
432
+ return upper, lower
433
+
434
+ def get_color_mask(self, img, color):
435
+ img_hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
436
+
437
+ if not isinstance(color, list):
438
+ colors = [color]
439
+ else:
440
+ colors = color
441
+
442
+ # https://colorizer.org/ for what the HSV values look like..
443
+ # N.B: the scale there is H:0-359 S:0-99 V:0-99
444
+ # in opencv it is H:0-179 S:0-255 V:0-255
445
+ img_masks = []
446
+ for color in colors:
447
+ invert = False
448
+ if color.startswith('not_'):
449
+ invert = True
450
+ color = color[4:]
451
+
452
+ upper, lower = self.get_color_ranges(color)
453
+
454
+ img_mask = cv2.inRange(img_hsv, lower, upper)
455
+ if invert:
456
+ # if 255 chenge to 0 and vice versa
457
+ img_mask ^= 255
458
+
459
+ img_masks.append(img_mask)
460
+
461
+ final_mask = img_masks[0]
462
+ for img_mask in img_masks[1:]:
463
+ orred = np.logical_or(final_mask, img_mask)*255
464
+ final_mask = orred
465
+
466
+ return final_mask.astype(np.uint8)
467
+
468
+
469
+ def remove_line(self, line, map_img, removal_params):
470
+ line_buf_ratio = removal_params.line_buf_ratio
471
+ blur_buf_ratio = removal_params.blur_buf_ratio
472
+ blur_kern_ratio = removal_params.blur_kern_ratio
473
+ blur_repeat = removal_params.blur_repeat
474
+
475
+ h, w = map_img.shape[:2]
476
+
477
+ line_buf = round(line_buf_ratio * w)
478
+ blur_buf = round(blur_buf_ratio * w)
479
+ blur_kern = round(blur_kern_ratio * w)
480
+ if blur_kern % 2 == 0:
481
+ blur_kern += 1
482
+
483
+ limits = Polygon([(w,0), (w,h), (0,h), (0,0), (w,0)])
484
+
485
+ ls = LineString(line)
486
+ line_poly = ls.buffer(line_buf, resolution=1, cap_style=CAP_STYLE.flat).intersection(limits)
487
+ blur_poly = ls.buffer(blur_buf, resolution=1, cap_style=CAP_STYLE.flat).intersection(limits)
488
+ bb = blur_poly.bounds
489
+ bb = [ round(x) for x in bb ]
490
+ # restrict to a small img strip to make things less costly
491
+ img_strip = map_img[bb[1]:bb[3], bb[0]:bb[2]]
492
+ sh, sw = img_strip.shape[:2]
493
+ #cv2.imwrite('temp.jpg', img_strip)
494
+
495
+ line_poly_t = translate(line_poly, xoff=-bb[0], yoff=-bb[1])
496
+ mask = np.zeros(img_strip.shape[:2], dtype=np.uint8)
497
+ poly_coords = np.array([ [int(x[0]), int(x[1])] for x in line_poly_t.exterior.coords ])
498
+ cv2.fillPoly(mask, pts=[poly_coords], color=1)
499
+
500
+ #img_blurred = cv2.medianBlur(img_strip, blur_kern)
501
+ pad = int(blur_kern/2)
502
+ img_strip_padded = cv2.copyMakeBorder(img_strip, pad, pad, pad, pad, cv2.BORDER_REFLECT_101)
503
+
504
+ img_blurred_padded = cv2.medianBlur(img_strip_padded, blur_kern)
505
+ for i in range(blur_repeat):
506
+ img_blurred_padded = cv2.medianBlur(img_blurred_padded, blur_kern)
507
+ img_blurred = img_blurred_padded[pad:pad+sh, pad:pad+sw]
508
+ #cv2.imwrite('temp.jpg', img_blurred)
509
+
510
+ img_strip[mask == 1] = img_blurred[mask == 1]
511
+
512
+
513
+
514
+ # from camelot.. this has served me well
515
+ # https://github.com/camelot-dev/camelot/blob/master/camelot/image_processing.py
516
+ def find_lines(self, threshold, direction, line_scale, iterations):
517
+ if direction not in ["vertical", "horizontal"]:
518
+ raise ValueError("Direction must be either 'vertical' or 'horizontal'")
519
+
520
+ lines = []
521
+
522
+ if direction == "vertical":
523
+ size = threshold.shape[0] // line_scale
524
+ el = cv2.getStructuringElement(cv2.MORPH_RECT, (1, size))
525
+ elif direction == "horizontal":
526
+ size = threshold.shape[1] // line_scale
527
+ el = cv2.getStructuringElement(cv2.MORPH_RECT, (size, 1))
528
+ elif direction is None:
529
+ raise ValueError("Specify direction as either 'vertical' or 'horizontal'")
530
+
531
+ threshold = cv2.erode(threshold, el)
532
+ #imgcat(Image.fromarray(threshold))
533
+ threshold = cv2.dilate(threshold, el)
534
+ dmask = cv2.dilate(threshold, el, iterations=iterations)
535
+ #imgcat(Image.fromarray(dmask))
536
+
537
+ contours, _ = cv2.findContours(
538
+ threshold.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
539
+ )
540
+
541
+ for c in contours:
542
+ x, y, w, h = cv2.boundingRect(c)
543
+ x1, x2 = x, x + w
544
+ y1, y2 = y, y + h
545
+ if direction == "vertical":
546
+ lines.append(((x1 + x2) // 2, y2, (x1 + x2) // 2, y1))
547
+ elif direction == "horizontal":
548
+ lines.append((x1, (y1 + y2) // 2, x2, (y1 + y2) // 2))
549
+
550
+ return dmask, lines
551
+
552
+ def get_text_mask_easyocr(self, img, border, iterations):
553
+ global easy_ocr_reader
554
+
555
+ if easy_ocr_reader is None:
556
+ easy_ocr_reader = easyocr.Reader(['en'], gpu=False)
557
+
558
+ mask = np.zeros(img.shape[:2], dtype=np.uint8)
559
+
560
+ # Detect text in the image
561
+ print("Detecting text...")
562
+ results = easy_ocr_reader.readtext(img, ycenter_ths=0.0, width_ths=0.0, link_threshold=100.0)
563
+
564
+ if not results:
565
+ return mask
566
+
567
+ print(f"Found {len(results)} text regions:")
568
+
569
+ for (bbox, text, confidence) in results:
570
+ print(f"Text: '{text}' (Confidence: {confidence:.2f})")
571
+
572
+ # Convert bbox to integer coordinates
573
+ points = np.array(bbox, dtype=np.int32)
574
+
575
+ # Create a filled polygon on the mask
576
+ cv2.fillPoly(mask, [points], 255)
577
+
578
+ if border == 0:
579
+ continue
580
+ # Optional: Add some padding around text regions
581
+ # This helps remove text more completely
582
+ if border > 0:
583
+ kernel = np.ones((border, border), np.uint8)
584
+ mask = cv2.dilate(mask, kernel, iterations=iterations)
585
+ else:
586
+ kernel = np.ones((-border, -border), np.uint8)
587
+ mask = cv2.erode(mask, kernel, iterations=iterations)
588
+
589
+ return mask
590
+
591
+
592
+ def get_text_mask_tesseract(self, img,
593
+ border, iterations,
594
+ char_size_cutoff, confidence_cutoff,
595
+ word_char_overlap_size_ratio_cutoff):
596
+ #display = img.copy()
597
+ h = img.shape[0]
598
+ mask = np.zeros(img.shape[:2], dtype=np.uint8)
599
+ # get word bboxes
600
+ word_shapes = []
601
+ d = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT)
602
+ n_boxes = len(d['level'])
603
+ for i in range(n_boxes):
604
+ # Extract bounding box coordinates and text
605
+ (x, y, ww, wh) = (d['left'][i], d['top'][i], d['width'][i], d['height'][i])
606
+ text = d['text'][i]
607
+ conf = int(float(d['conf'][i]))
608
+ print(f"Text: '{text}', x: {x}, y: {y}, height: {wh}, width: {ww}, confidence: {conf}")
609
+ if text.strip() == "":
610
+ continue
611
+ if ww == 0 or wh == 0:
612
+ continue
613
+ if conf > confidence_cutoff:
614
+ #cv2.rectangle(display, (x, y), (x + ww, y + wh), (0, 255, 0), 2)
615
+ word_shapes.append(box(x, y, x+ww, y+wh))
616
+
617
+
618
+ # get char bboxes
619
+ d = pytesseract.image_to_boxes(img, output_type=pytesseract.Output.DICT)
620
+ if 'char' not in d:
621
+ return mask
622
+ print(f"Found {len(d['char'])} char regions:")
623
+
624
+ char_bboxes = []
625
+ for i in range(len(d['char'])):
626
+ (char,x1,y2,x2,y1) = (d['char'][i], d['left'][i], d['top'][i], d['right'][i], d['bottom'][i])
627
+
628
+ cw = x2 - x1
629
+ ch = y2 - y1
630
+ print(f"Text: '{char}', height: {ch}, width: {cw}, x1: {x1}, y1: {y1}, x2: {x2}, y2: {y2}")
631
+
632
+ if ch > char_size_cutoff or cw > char_size_cutoff:
633
+ continue
634
+ if char.strip() == "":
635
+ continue
636
+
637
+ if cw == 0 or ch == 0:
638
+ continue
639
+
640
+ char_shape = box(x1, h - y1, x2, h - y2)
641
+ carea = char_shape.area
642
+ found_word = None
643
+ for i, word_shape in enumerate(word_shapes):
644
+ warea = word_shape.area
645
+ if carea / warea > 1.1:
646
+ continue
647
+ if char_shape.intersects(word_shape):
648
+ overlap = char_shape.intersection(word_shape)
649
+ if overlap.area / carea > word_char_overlap_size_ratio_cutoff:
650
+ found_word = word_shape
651
+ break
652
+ if found_word is None:
653
+ continue
654
+
655
+ #cv2.rectangle(display, (x1, h - y1), (x2, h - y2), (255, 0, 0), 2)
656
+ char_bboxes.append((x1, y1, cw, ch))
657
+ # Create a rectangle on the mask
658
+ cv2.rectangle(mask, (x1, h - y1), (x2, h - y2), 255, thickness=cv2.FILLED)
659
+
660
+ if len(char_bboxes) > 0:
661
+ self.show_image(mask)
662
+ #self.show_image(display)
663
+
664
+ if border == 0:
665
+ return mask
666
+
667
+ # Optional: Add some padding around text regions
668
+ # This helps remove text more completely
669
+ if border > 0:
670
+ kernel = np.ones((border, border), np.uint8)
671
+ mask = cv2.dilate(mask, kernel, iterations=iterations)
672
+ else:
673
+ kernel = np.ones((-border, -border), np.uint8)
674
+ mask = cv2.erode(mask, kernel, iterations=iterations)
675
+
676
+ return mask
677
+
678
+ def get_transformer_from_gcps(self, gcps):
679
+ return GCPBasedTransformer(gcps)
680
+
681
+ def get_full_file_path(self):
682
+ workdir = self.get_workdir()
683
+ rotated_img_file = workdir / 'full.rotated.jpg'
684
+ if rotated_img_file.exists():
685
+ return rotated_img_file
686
+
687
+ full_img_file = workdir / 'full.jpg'
688
+ if full_img_file.exists():
689
+ return full_img_file
690
+
691
+ return self.filepath
692
+
693
+ def get_full_img(self):
694
+ if self.full_img is not None:
695
+ return self.full_img
696
+
697
+ print('loading full image')
698
+ start = time.time()
699
+
700
+ full_file = self.get_full_file_path()
701
+ self.full_img = cv2.imread(str(full_file))
702
+
703
+ end = time.time()
704
+ print(f'loading image took {end - start} secs')
705
+
706
+ return self.full_img
707
+
708
+ def get_shrunk_img(self):
709
+ if self.small_img is not None:
710
+ return self.small_img
711
+
712
+ workdir = self.get_workdir()
713
+
714
+ small_img_file = workdir.joinpath('small.jpg')
715
+ if small_img_file.exists():
716
+ self.small_img = cv2.imread(str(small_img_file))
717
+ return self.small_img
718
+
719
+
720
+ img = self.get_full_img()
721
+ h, w = img.shape[:2]
722
+
723
+ r = self.resize_factor
724
+ if r is None:
725
+ if self.resize_width is not None:
726
+ r = float(self.resize_width) / w
727
+ elif self.resize_height is not None:
728
+ r = float(self.resize_height) / h
729
+ else:
730
+ raise ValueError("Either resize_factor, resize_width or resize_height should be set.")
731
+
732
+ self.ensure_dir(workdir)
733
+
734
+ dim = (int(w*r), int(h*r))
735
+ small_img = cv2.resize(img, dim, interpolation=cv2.INTER_AREA)
736
+ cv2.imwrite(str(small_img_file), small_img)
737
+
738
+ # for some reason this fixes some of the issues
739
+ self.small_img = cv2.imread(str(small_img_file))
740
+ return self.small_img
741
+
742
+ def get_biggest_contour(self, img_mask, erode_size, use_bbox_area, max_corner_contour_area_ratio):
743
+
744
+ h, w = img_mask.shape[:2]
745
+ area = h * w
746
+
747
+ if erode_size > 0:
748
+ el1 = cv2.getStructuringElement(cv2.MORPH_RECT, (erode_size, erode_size))
749
+ img_mask = cv2.erode(img_mask, el1)
750
+ elif erode_size < 0:
751
+ el1 = cv2.getStructuringElement(cv2.MORPH_RECT, (-erode_size, -erode_size))
752
+ img_mask = cv2.dilate(img_mask, el1)
753
+
754
+ contours, hierarchy = cv2.findContours(
755
+ img_mask, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE
756
+ )
757
+ ctuples = list(zip(list(contours), list(hierarchy[0])))
758
+
759
+ self.show_contours(img_mask, [x[0] for x in ctuples])
760
+
761
+ ctuples = [ c for c in ctuples if self.get_bbox_area(c) < max_corner_contour_area_ratio*area ]
762
+
763
+ if use_bbox_area:
764
+ ctuples_s = sorted(ctuples, key=self.get_bbox_area, reverse=True)
765
+ map_contour = ctuples_s[0][0]
766
+ else:
767
+ ctuples_s = sorted(ctuples, key=lambda x: cv2.contourArea(x[0]), reverse=True)
768
+ map_inner_contour_idx = ctuples_s[0][1][2]
769
+ map_contour = ctuples[map_inner_contour_idx][0]
770
+
771
+ return map_contour
772
+
773
+ def get_maparea(self):
774
+ workdir = self.get_workdir()
775
+
776
+ maparea_info_file = workdir.joinpath('maparea_info.pkl')
777
+ if maparea_info_file.exists():
778
+ with open(maparea_info_file, 'rb') as f:
779
+ return pickle.load(f)
780
+
781
+ img = self.get_shrunk_img()
782
+
783
+ band_color_choices = self.band_color_choices
784
+ if self.band_color is not None:
785
+ band_color_choices = [ self.band_color ]
786
+
787
+ for band_color in band_color_choices:
788
+ try:
789
+ print(f'getting {band_color} contours')
790
+ img_mask = self.get_color_mask(img, band_color)
791
+
792
+ if self.shrunk_map_area_corners is not None:
793
+ map_contour = np.array(self.shrunk_map_area_corners).reshape((-1,1,2)).astype(np.int32)
794
+ else:
795
+ print(f'getting {band_color} contours for whole image')
796
+ map_contour = self.get_biggest_contour(img_mask, self.collar_erode, self.use_bbox_area, 1.0)
797
+
798
+ self.show_contours(img_mask, [map_contour])
799
+
800
+ map_bbox = cv2.boundingRect(map_contour)
801
+ map_min_rect = cv2.minAreaRect(map_contour)
802
+ map_area = map_bbox[2] * map_bbox[3]
803
+ print(f'{map_bbox=}')
804
+ print(f'{map_min_rect=}')
805
+ print(f'{map_area=}')
806
+
807
+ h, w = img.shape[:2]
808
+ total_area = w * h
809
+ if total_area / map_area > 2:
810
+ raise Exception(f'map area less than expected, {map_area=}, {total_area=}')
811
+
812
+ self.show_contours(img_mask, [map_contour])
813
+
814
+ self.ensure_dir(workdir)
815
+ with open(maparea_info_file, 'wb') as f:
816
+ pickle.dump((map_bbox, map_min_rect, map_contour), f)
817
+
818
+ return map_bbox, map_min_rect, map_contour
819
+ except Exception as e:
820
+ print(f'Error getting map area for {band_color}: {e}')
821
+
822
+ raise Exception(f'no map area found for {self.filepath}, band_color_choices: {band_color_choices}')
823
+
824
+ def rotate(self):
825
+ workdir = self.get_workdir()
826
+
827
+ rotated_info_file = workdir.joinpath('rotated_info.txt')
828
+ if rotated_info_file.exists():
829
+ print('already rotated.. skipping rotation')
830
+ return
831
+
832
+
833
+ map_bbox, map_min_rect, _ = self.get_maparea()
834
+ _, _, angle = map_min_rect
835
+ if angle > 45:
836
+ angle = angle - 90
837
+
838
+ self.ensure_dir(workdir)
839
+
840
+ if abs(angle) < self.auto_rotate_thresh:
841
+ print(f'not rotated because angle: {angle}')
842
+ rotated_info_file.write_text(f'{angle}, not_rotated')
843
+ return
844
+
845
+ img = self.get_full_img()
846
+
847
+ print(f'rotating image by {angle}')
848
+ rotated_file = workdir.joinpath('full.rotated.jpg')
849
+
850
+ img_rotated = self.rotate_image_bound(img, -angle)
851
+ cv2.imwrite(str(rotated_file), img_rotated)
852
+
853
+ rotated_info_file.write_text(f'{angle}, rotated')
854
+
855
+ workdir.joinpath('small.jpg').unlink()
856
+ workdir.joinpath('maparea_info.pkl').unlink()
857
+ self.small_img = None
858
+ self.full_img = None
859
+
860
+
861
+ def locate_corners(self, img, corner_overrides):
862
+
863
+ w = img.shape[1]
864
+ h = img.shape[0]
865
+ cw = round(self.corner_ratio * w)
866
+ ch = round(self.corner_ratio * h)
867
+ y = h - 1 - ch
868
+ x = w - 1 - cw
869
+
870
+ print(f'main img dim: {w=}, {h=}')
871
+ # take the four corners
872
+ corner_boxes = []
873
+ corner_boxes.append(((0, 0), (cw, ch)))
874
+ corner_boxes.append(((0, y), (cw, ch)))
875
+ corner_boxes.append(((x, y), (cw, ch)))
876
+ corner_boxes.append(((x, 0), (cw, ch)))
877
+
878
+ directions = [
879
+ (+1,+1),
880
+ (+1,-1),
881
+ (-1,-1),
882
+ (-1,+1),
883
+ ]
884
+ anchor_angles = [
885
+ 45,
886
+ -45,
887
+ -135,
888
+ 135,
889
+ ]
890
+
891
+ # get intersection points
892
+ points = []
893
+ for i, corner_box in enumerate(corner_boxes):
894
+ corner_override = corner_overrides[i]
895
+ if corner_override is not None:
896
+ points.append(corner_override)
897
+ continue
898
+ bx, by = corner_box[0]
899
+ bw, bh = corner_box[1]
900
+ c_img = img[by:by+bh, bx:bx+bw]
901
+ print(f'{corner_box=}')
902
+ ipoint = self.get_intersection_point(c_img, directions[i], anchor_angles[i])
903
+ ipoint = bx + ipoint[0], by + ipoint[1]
904
+ points.append(ipoint)
905
+ return points
906
+
907
+ def reorder_poly_points(self, poly_points):
908
+ # sort points in anti clockwise order
909
+ num_corners = len(poly_points)
910
+ box = LinearRing(poly_points + [poly_points[0]])
911
+ if not box.is_ccw:
912
+ poly_points = poly_points.copy()
913
+ poly_points.reverse()
914
+
915
+ center = box.centroid.coords[0]
916
+ #print(center)
917
+ indices = range(0, num_corners)
918
+ indices = [ i for i in indices if poly_points[i][0] < center[0] and poly_points[i][1] < center[1] ]
919
+ def cmp(ci1, ci2):
920
+ c1 = poly_points[ci1]
921
+ c2 = poly_points[ci2]
922
+ if c1[1] == c2[1]:
923
+ return c2[0] - c1[0]
924
+ else:
925
+ return c2[1] - c1[1]
926
+
927
+ s_indices = sorted(indices, key=cmp_to_key(cmp), reverse=True)
928
+ #print(f'{s_indices=}')
929
+ first = s_indices[0]
930
+ poly_reordered = [ poly_points[first] ]
931
+ for i in range(1, num_corners):
932
+ idx = (first - i) % num_corners
933
+ poly_reordered.append(poly_points[idx])
934
+ #print(f'{poly_reordered=}')
935
+ return poly_reordered
936
+
937
+ def remove_text(self, img_mask):
938
+
939
+ if self.text_removal_engine == 'tesseract':
940
+ txt_mask = self.get_text_mask_tesseract(img_mask, self.text_removal_border,
941
+ self.text_removal_iterations,
942
+ self.text_removal_char_size_cutoff,
943
+ self.text_removal_confidence_cutoff,
944
+ self.text_removal_word_char_overlap_size_ratio_cutoff)
945
+ else:
946
+ txt_mask = self.get_text_mask_easyocr(img_mask,
947
+ self.text_removal_border,
948
+ self.text_removal_iterations)
949
+
950
+
951
+
952
+ self.show_image(txt_mask)
953
+ img_mask[txt_mask > 0] = 0
954
+
955
+
956
+ def get_ext_count(self, point, img_mask, ext_thresh, factor, cwidth):
957
+ x, y = point
958
+ h, w = img_mask.shape[:2]
959
+ uc = 0
960
+ ext_length = 10*factor
961
+ ye = min(y+ext_length, h - 1)
962
+ print(ye)
963
+ uc += np.count_nonzero(img_mask[y:ye, x])
964
+ for i in range(cwidth):
965
+ uc += np.count_nonzero(img_mask[y:ye, x+i])
966
+ uc += np.count_nonzero(img_mask[y:ye, x-i])
967
+
968
+ dc = 0
969
+ ye = max(y-ext_length, 0)
970
+ dc += np.count_nonzero(img_mask[ye:y, x])
971
+ for i in range(cwidth):
972
+ dc += np.count_nonzero(img_mask[ye:y, x+i])
973
+ dc += np.count_nonzero(img_mask[ye:y, x-i])
974
+
975
+ lc = 0
976
+ xe = min(x+ext_length, w - 1)
977
+ lc += np.count_nonzero(img_mask[y, x:xe])
978
+ for i in range(cwidth):
979
+ lc += np.count_nonzero(img_mask[y+i, x:xe])
980
+ lc += np.count_nonzero(img_mask[y-i, x:xe])
981
+
982
+ rc = 0
983
+ xe = max(x-ext_length, 0)
984
+ rc += np.count_nonzero(img_mask[y, xe:x])
985
+ for i in range(cwidth):
986
+ rc += np.count_nonzero(img_mask[y+i, xe:x])
987
+ rc += np.count_nonzero(img_mask[y-i, xe:x])
988
+
989
+ counts = [ uc, dc, rc, lc ]
990
+ print(f'{point=}, {counts=}, {ext_thresh=} {factor=}')
991
+ exts = [ c > ext_thresh*factor*(2*cwidth + 1)/3 for c in counts ]
992
+ return exts.count(True)
993
+
994
+ def get_line_intersections(self, img_mask, find_line_scale, find_line_iter, print_lines=True):
995
+ h, w = img_mask.shape[:2]
996
+
997
+ v_mask, v_lines = self.find_lines(img_mask, direction='vertical', line_scale=find_line_scale, iterations=find_line_iter)
998
+ h_mask, h_lines = self.find_lines(img_mask, direction='horizontal', line_scale=find_line_scale, iterations=find_line_iter)
999
+ if print_lines:
1000
+ print(f'{v_lines=}')
1001
+ print(f'{h_lines=}')
1002
+
1003
+ ips = []
1004
+ only_lines = np.multiply(v_mask, h_mask)
1005
+ jcs, _ = cv2.findContours(only_lines, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)
1006
+ for j in jcs:
1007
+ jx, jy, jw, jh = cv2.boundingRect(j)
1008
+ c1, c2 = (2 * jx + jw) // 2, (2 * jy + jh) // 2
1009
+ if 0 < c1 < w - 1 and 0 < c2 < h - 1:
1010
+ ips.append((c1, c2))
1011
+
1012
+ return ips
1013
+
1014
+ def get_nearest_intersection_point(self, img,
1015
+ direction, anchor_angle,
1016
+ line_color, remove_text, expect_band_count,
1017
+ find_line_scale, find_line_iter,
1018
+ corner_max_dist_ratio, corner_min_dist_ratio,
1019
+ min_expected_points, max_corner_angle_diff,
1020
+ max_corner_angle_diff_cutoff):
1021
+
1022
+ img_mask = self.get_color_mask(img, line_color)
1023
+ h, w = img_mask.shape[:2]
1024
+ diag_len = self.get_distance((0,0), (h,w))
1025
+
1026
+ if remove_text:
1027
+ self.remove_text(img_mask)
1028
+ anchor_corner = [
1029
+ 0 + w * ( 1 if direction[0] < 0 else 0),
1030
+ 0 + h * ( 1 if direction[1] < 0 else 0),
1031
+ ]
1032
+
1033
+ self.show_image(img_mask)
1034
+
1035
+ ips = self.get_line_intersections(img_mask, find_line_scale, find_line_iter)
1036
+ print(f'{ips=}')
1037
+
1038
+ self.show_points(ips, img_mask, [255,0,0])
1039
+
1040
+ sorted_ips = sorted(ips, key=lambda p: (self.get_distance(p, anchor_corner), p[0]*direction[0], p[1]*direction[1]))
1041
+ if expect_band_count > 0:
1042
+ anchor_point = sorted_ips[0]
1043
+ diag_len = abs(diag_len - self.get_distance(anchor_corner, anchor_point))
1044
+ self.show_points([anchor_point], img_mask, [0,0,255])
1045
+ remaining = sorted_ips[expect_band_count:]
1046
+ else:
1047
+ remaining = sorted_ips
1048
+ anchor_point = anchor_corner
1049
+
1050
+ angles_before = [ abs(self.get_angle(anchor_point, r) - anchor_angle) for r in remaining ]
1051
+ distances_before = [ self.get_distance(anchor_point, r)/diag_len for r in remaining ]
1052
+ print(f'{angles_before=}')
1053
+ print(f'{distances_before=}')
1054
+
1055
+ remaining = [ r for r in remaining
1056
+ if corner_max_dist_ratio > self.get_distance(anchor_point, r)/diag_len > corner_min_dist_ratio and
1057
+ abs(self.get_angle(anchor_point, r) - anchor_angle) < max_corner_angle_diff_cutoff ]
1058
+
1059
+ if len(remaining) < min_expected_points:
1060
+ raise Exception('too few remaining points')
1061
+
1062
+ angles = [ abs(self.get_angle(anchor_point, r) - anchor_angle) for r in remaining ]
1063
+ distances = [ self.get_distance(anchor_point, r)/diag_len for r in remaining ]
1064
+ print(f'{angles=}')
1065
+ print(f'{distances=}')
1066
+
1067
+ dist_min_index = np.argmin(np.array(distances))
1068
+
1069
+ ip = remaining[dist_min_index]
1070
+ self.show_points([ip], img_mask, [0,255,0])
1071
+ angle = angles[dist_min_index]
1072
+ distance = distances[dist_min_index]
1073
+ if angle > max_corner_angle_diff:
1074
+ raise Exception(f'angle too high: {angle}')
1075
+ print(f'angle: {angle}, distance: {distance}')
1076
+
1077
+ return ip
1078
+
1079
+ def get_4way_intersection_point(self, img, line_color,
1080
+ find_line_scale, find_line_iter,
1081
+ cwidth, ext_thresh):
1082
+ img_mask = self.get_color_mask(img, line_color)
1083
+
1084
+ self.remove_text(img_mask)
1085
+
1086
+ h, w = img_mask.shape[:2]
1087
+ self.show_image(img_mask)
1088
+
1089
+ ips = self.get_line_intersections(img_mask, find_line_scale, find_line_iter)
1090
+ print(f'{ips=}')
1091
+
1092
+ self.show_points(ips, img_mask, [255, 0, 0])
1093
+
1094
+ for pix_factor in [1, 2, 4, 8]:
1095
+ four_corner_ips = []
1096
+ for ip in ips:
1097
+ ext_count = self.get_ext_count(ip, img_mask, ext_thresh, pix_factor, cwidth)
1098
+ print(f'{ip=}, {ext_count}')
1099
+ if ext_count == 4:
1100
+ four_corner_ips.append(ip)
1101
+
1102
+ if len(four_corner_ips) == 0:
1103
+ raise Exception(f'no intersection points found - {len(four_corner_ips)}')
1104
+
1105
+ if len(four_corner_ips) == 1:
1106
+ break
1107
+
1108
+ if len(four_corner_ips) > 1:
1109
+ continue
1110
+
1111
+ self.show_points(four_corner_ips, img_mask, [0, 255, 0])
1112
+ if len(four_corner_ips) > 1:
1113
+ raise Exception(f'multiple intersection points found - {len(four_corner_ips)}')
1114
+
1115
+ return four_corner_ips[0]
1116
+
1117
+ def remove_corner_edges(self, img_mask, direction, remove_corner_edges_ratio):
1118
+
1119
+ h, w = img_mask.shape[:2]
1120
+ print(f'{w=} {h=}')
1121
+ ratio = remove_corner_edges_ratio
1122
+ wdelta = int(w * ratio)
1123
+ hdelta = int(h * ratio)
1124
+ if direction[0] > 0:
1125
+ xrange = [0, wdelta - 1]
1126
+ else:
1127
+ xrange = [w - wdelta + 1, w - 1]
1128
+
1129
+ if direction[1] > 0:
1130
+ yrange = [0, hdelta + 1]
1131
+ else:
1132
+ yrange = [h - hdelta + 1, h - 1]
1133
+
1134
+ print(f'{xrange=} {yrange=}')
1135
+
1136
+ img_mask[yrange[0]:yrange[1], :] = 0
1137
+ img_mask[:, xrange[0]:xrange[1]] = 0
1138
+
1139
+ def get_biggest_contour_corner(self, img,
1140
+ direction, anchor_angle,
1141
+ line_color, remove_text, remove_corner_edges_ratio,
1142
+ corner_erode, max_corner_contour_area_ratio,
1143
+ min_corner_contour_area_ratio,
1144
+ min_corner_dist_ratio, max_corner_angle_diff,
1145
+ pixel_adjustment, picked_corner_max_dist_from_contour_ratio):
1146
+ img_mask = self.get_color_mask(img, line_color)
1147
+
1148
+
1149
+ h, w = img_mask.shape[:2]
1150
+ area = h * w
1151
+ diag_len = self.get_distance((0,0), (h,w))
1152
+
1153
+ if remove_text:
1154
+ self.remove_text(img_mask)
1155
+
1156
+ ax = w * (0 if direction[0] > 0 else 1)
1157
+ ay = h * (0 if direction[1] > 0 else 1)
1158
+
1159
+ anchor_corner = (ax, ay)
1160
+
1161
+ self.remove_corner_edges(img_mask, direction, remove_corner_edges_ratio)
1162
+
1163
+ frame_contour = self.get_biggest_contour(img_mask, corner_erode, True, max_corner_contour_area_ratio)
1164
+
1165
+ self.show_contours(img_mask, [frame_contour])
1166
+
1167
+ frame_contour_area = self.get_bbox_area_simple(frame_contour)
1168
+ print(f'{area=}, {frame_contour_area=}, {frame_contour_area/area=}')
1169
+
1170
+ if frame_contour_area < min_corner_contour_area_ratio*area:
1171
+ self.show_contours(img_mask, [frame_contour[0]])
1172
+ raise Exception(f'corner too small, {frame_contour_area/area}')
1173
+
1174
+ bbox = cv2.boundingRect(frame_contour)
1175
+ x_factor = 0 if direction[0] > 0 else 1
1176
+ y_factor = 0 if direction[1] > 0 else 1
1177
+ ip = [bbox[0] + (x_factor*bbox[2]), bbox[1] + (y_factor*bbox[3])]
1178
+ ip = [ ip[0] + pixel_adjustment*direction[0], ip[1] + pixel_adjustment*direction[1] ]
1179
+ self.show_points([ip], img_mask, [255,0,0])
1180
+
1181
+ dist = self.get_distance(anchor_corner, ip)
1182
+ angle = self.get_angle(anchor_corner, ip)
1183
+ dist_ratio = dist/diag_len
1184
+ print(f'{dist_ratio=} {angle=}')
1185
+ if dist_ratio < min_corner_dist_ratio:
1186
+ raise Exception(f'{dist_ratio=} smaller than {min_corner_dist_ratio=}')
1187
+
1188
+ angle_delta = abs(angle - anchor_angle)
1189
+
1190
+ if angle_delta > max_corner_angle_diff:
1191
+ raise Exception(f'{angle_delta=} too big')
1192
+
1193
+ # check if the intersection point is on the contour
1194
+ dist_from_contour = cv2.pointPolygonTest(frame_contour, (ip[0], ip[1]), True)
1195
+ dist_from_contour = -dist_from_contour
1196
+ print(f'{dist_from_contour=}')
1197
+ max_dist = picked_corner_max_dist_from_contour_ratio * diag_len
1198
+ if dist_from_contour > max_dist:
1199
+ raise Exception(f'corner point {ip} too far from contour {dist_from_contour=} > {max_dist=}')
1200
+ elif dist_from_contour > 0:
1201
+ adjustment = dist_from_contour
1202
+ ip = [ ip[0] + adjustment*direction[0], ip[1] + adjustment*direction[1] ]
1203
+ #contour_poly = Polygon(frame_contour.reshape(-1, 2))
1204
+ #picked_point = Point(ip[0], ip[1])
1205
+ #p1, _ = nearest_points(contour_poly, picked_point)
1206
+ #ip = [int(p1.x), int(p1.y)]
1207
+ self.show_points([ip], img_mask, [0,255,0])
1208
+
1209
+ return ip
1210
+
1211
+ def get_nearest_intersection_point_from_biggest_corner_contour(self, img,
1212
+ direction, anchor_angle,
1213
+ line_color, corner_contour_color,
1214
+ remove_corner_edges_ratio, corner_erode,
1215
+ max_corner_contour_area_ratio, min_corner_contour_area_ratio,
1216
+ find_line_scale, find_line_iter,
1217
+ max_corner_angle_diff_cutoff, max_corner_angle_diff,
1218
+ corner_max_dist_ratio):
1219
+
1220
+ h,w = img.shape[:2]
1221
+ area = h * w
1222
+
1223
+ img_c_mask = self.get_color_mask(img, corner_contour_color)
1224
+ self.remove_text(img_c_mask)
1225
+ self.remove_corner_edges(img_c_mask, direction, self.remove_corner_edges_ratio)
1226
+ frame_contour = self.get_biggest_contour(img_c_mask, self.corner_erode, True, max_corner_contour_area_ratio)
1227
+
1228
+ frame_contour_area = self.get_bbox_area_simple(frame_contour)
1229
+ print(f'{area=}, {frame_contour_area=}, {frame_contour_area/area=}')
1230
+ self.show_contours(img_c_mask, [frame_contour])
1231
+
1232
+ if frame_contour_area < min_corner_contour_area_ratio * area:
1233
+ raise Exception(f'corner too small, {frame_contour_area/area}')
1234
+
1235
+ frame_bbox = cv2.boundingRect(frame_contour)
1236
+ fx = frame_bbox[0]
1237
+ fy = frame_bbox[1]
1238
+ fw = frame_bbox[2]
1239
+ fh = frame_bbox[3]
1240
+
1241
+ frame_anchor_point = [
1242
+ fx + fw * ( 1 if direction[0] < 0 else 0),
1243
+ fy + fh * ( 1 if direction[1] < 0 else 0),
1244
+ ]
1245
+ frame_diag_len = self.get_distance((0,0), (fh,fw))
1246
+ self.show_points([frame_anchor_point], img_c_mask, [0,0,255])
1247
+
1248
+
1249
+ img_mask = self.get_color_mask(img, line_color)
1250
+ #self.remove_text(img_mask)
1251
+ ips = self.get_line_intersections(img_mask, find_line_scale, find_line_iter)
1252
+ ips = [ ip for ip in ips if fx < ip[0] < fx + fw and fy < ip[1] < fy + fh ]
1253
+ self.show_points(ips, img_mask, [255,0,0])
1254
+
1255
+ angles_before = [ abs(self.get_angle(frame_anchor_point, r) - anchor_angle) for r in ips ]
1256
+ distances_before = [ self.get_distance(frame_anchor_point, r)/frame_diag_len for r in ips ]
1257
+ print(f'{angles_before=}')
1258
+ print(f'{distances_before=}')
1259
+ ips = [ ip for ip in ips if abs(self.get_angle(frame_anchor_point, ip) - anchor_angle) < max_corner_angle_diff_cutoff ]
1260
+
1261
+ #sorted_ips = sorted(ips, key=lambda p: (p[0]*direction[0], p[1]*direction[1]))
1262
+ angles = [ abs(self.get_angle(frame_anchor_point, r) - anchor_angle) for r in ips ]
1263
+ distances = [ self.get_distance(frame_anchor_point, r)/frame_diag_len for r in ips ]
1264
+ print(f'{angles=}')
1265
+ print(f'{distances=}')
1266
+ dist_min_index = np.argmin(np.array(distances))
1267
+ distance = distances[dist_min_index]
1268
+ angle = angles[dist_min_index]
1269
+ ip = ips[dist_min_index]
1270
+ if distance > corner_max_dist_ratio or angle > max_corner_angle_diff:
1271
+ raise Exception(f'corner too far or angle too far, {distance=}, {angle=}, {corner_max_dist_ratio=}, {max_corner_angle_diff=}')
1272
+ self.show_points([ip], img_mask, [0,255,0])
1273
+ return ip
1274
+
1275
+
1276
+ def get_intersection_point(self, img, direction, anchor_angle):
1277
+ raise NotImplementedError("This method should be implemented in a subclass")
1278
+
1279
+
1280
+ def locate_corners_generic(self, img, map_poly_points, corner_overrides):
1281
+ w = img.shape[1]
1282
+ #h = img_hsv.shape[0]
1283
+ cw = round(self.corner_ratio * w)
1284
+ #ch = round(corner_ratio * h)
1285
+
1286
+ map_poly_points = self.reorder_poly_points(map_poly_points)
1287
+ print(f'{map_poly_points=}')
1288
+
1289
+ #num_corners = len(map_poly_points)
1290
+ box = LinearRing(map_poly_points + [map_poly_points[0]])
1291
+ buffered_poly = box.buffer(-cw/2, single_sided=True, join_style=JOIN_STYLE.mitre)
1292
+ inner_ring = buffered_poly.interiors[0]
1293
+ corner_centers = list(inner_ring.coords)
1294
+ corner_centers.reverse()
1295
+ print(f'{corner_centers=}')
1296
+
1297
+ corner_boxes = []
1298
+ directions = []
1299
+ anchor_angles = []
1300
+ # get the corner centers and directions
1301
+ angle_map = {
1302
+ (+1,+1): 45,
1303
+ (+1,-1): -45,
1304
+ (-1,-1): -135,
1305
+ (-1,+1): 135
1306
+ }
1307
+ for i,c in enumerate(corner_centers[:-1]):
1308
+ c = (int(c[0]), int(c[1]))
1309
+ p = map_poly_points[i]
1310
+ direction = (+1 if c[0] > p[0] else -1, +1 if c[1] > p[1] else -1)
1311
+ anchor_angle = angle_map[direction]
1312
+ directions.append(direction)
1313
+ anchor_angles.append(anchor_angle)
1314
+ corner_box = ((int(c[0] - cw/2), int(c[1] - cw/2)), (cw, cw))
1315
+ corner_boxes.append(corner_box)
1316
+
1317
+ print(f'{corner_boxes=}')
1318
+
1319
+ points = []
1320
+ for i, corner_box in enumerate(corner_boxes):
1321
+ corner_override = corner_overrides[i]
1322
+ if corner_override is not None:
1323
+ points.append(corner_override)
1324
+ continue
1325
+ print(f'{corner_box=}')
1326
+ bx, by = corner_box[0]
1327
+ bw, bh = corner_box[1]
1328
+ c_img = img[by:by+bh, bx:bx+bw]
1329
+ ipoint = self.get_intersection_point(c_img, directions[i], anchor_angles[i])
1330
+ ipoint = bx + ipoint[0], by + ipoint[1]
1331
+ points.append(ipoint)
1332
+ return points
1333
+
1334
+
1335
+
1336
+ def get_poly_points(self, map_contour, poly_approx_factor):
1337
+ perimeter_len = cv2.arcLength(map_contour, True)
1338
+ print(f'{perimeter_len=}')
1339
+ epsilon = poly_approx_factor * perimeter_len
1340
+ map_poly = cv2.approxPolyDP(map_contour, epsilon, True)
1341
+ map_poly_points = [ list(p[0]) for p in map_poly ]
1342
+ return map_poly_points
1343
+
1344
+
1345
+ def get_corners(self):
1346
+ if self.mapbox_corners is not None:
1347
+ return self.mapbox_corners
1348
+
1349
+ workdir = self.get_workdir()
1350
+ corners_file = workdir.joinpath('corners.json')
1351
+ if corners_file.exists():
1352
+ with open(corners_file, 'r') as f:
1353
+ corners = json.load(f)
1354
+
1355
+ self.mapbox_corners = corners
1356
+ return corners
1357
+
1358
+ map_bbox, _, map_contour = self.get_maparea()
1359
+ map_poly_points = self.get_poly_points(map_contour, self.poly_approx_factor)
1360
+ #if self.extents is None and len(map_poly_points) != 4:
1361
+ # raise Exception(f'extents is None and map_poly_points has {len(map_poly_points)} points, expected 4')
1362
+
1363
+
1364
+ full_img = self.get_full_img()
1365
+ small_img = self.get_shrunk_img()
1366
+
1367
+ fh, fw = full_img.shape[:2]
1368
+ sh, sw = small_img.shape[:2]
1369
+ rh, rw = float(fh)/float(sh), float(fw)/float(sw)
1370
+
1371
+ map_bbox_scaled = self.scale_bbox(map_bbox, rw, rh)
1372
+ map_poly_points_scaled = [ self.scale_point(p, rw, rh) for p in map_poly_points ]
1373
+
1374
+ map_img = self.crop_img(full_img, map_bbox_scaled)
1375
+ corner_overrides_full = self.corner_overrides
1376
+ if corner_overrides_full is None:
1377
+ if self.extents is not None:
1378
+ corner_overrides_full = [ None ] * len(map_poly_points)
1379
+ else:
1380
+ corner_overrides_full = [ None ] * 4
1381
+
1382
+ corner_overrides = [ (c[0] - map_bbox_scaled[0], c[1] - map_bbox_scaled[1]) if c is not None else None for c in corner_overrides_full ]
1383
+ map_poly_points_scaled = [ (p[0] - map_bbox_scaled[0], p[1] - map_bbox_scaled[1]) for p in map_poly_points_scaled ]
1384
+
1385
+ if self.extents is None:
1386
+ corners = self.locate_corners(map_img, corner_overrides)
1387
+ else:
1388
+ corners = self.locate_corners_generic(map_img, map_poly_points_scaled, corner_overrides)
1389
+
1390
+ corners = [ (c[0] + map_bbox_scaled[0], c[1] + map_bbox_scaled[1]) for c in corners ]
1391
+ self.mapbox_corners = corners
1392
+ self.ensure_dir(workdir)
1393
+ with open(corners_file, 'w') as f:
1394
+ json.dump(corners, f, indent = 4)
1395
+
1396
+ return corners
1397
+
1398
+ def get_between(self, start, end, divisible_by):
1399
+ """
1400
+ Get a list of numbers between start and end that are divisible by divisible_by.
1401
+ The numbers are multiplied by factor.
1402
+ """
1403
+
1404
+ if start % divisible_by != 0:
1405
+ start += divisible_by - (start % divisible_by)
1406
+ if end % divisible_by != 0:
1407
+ end -= end % divisible_by
1408
+
1409
+ return [ (i * divisible_by ) for i in range(start // divisible_by, end // divisible_by + 1) ]
1410
+
1411
+ def correct_intersection(self, point,
1412
+ line_color_choices, context_dim,
1413
+ find_line_scale, find_line_iter):
1414
+
1415
+ ch, cw = context_dim
1416
+ p = (int(point[0]), int(point[1]))
1417
+ full_img = self.get_full_img()
1418
+ # context dim is the size of the image around the intersection point
1419
+ # lets crop to that size
1420
+ bbox = (p[0] - cw, p[1] - ch, cw * 2, ch * 2)
1421
+ img = self.crop_img(full_img, bbox)
1422
+ if img.size == 0 or img.shape[0] == 0 or img.shape[1] == 0:
1423
+ return { 'point': p, 'type': 'invalid' }
1424
+
1425
+ ip = None
1426
+ for line_color in line_color_choices:
1427
+ img_mask = self.get_color_mask(img, line_color)
1428
+ # get the intersection point in the cropped image
1429
+ ips = self.get_line_intersections(img_mask, find_line_scale, find_line_iter, print_lines=False)
1430
+ #self.show_points(ips, img_mask, [255,0,0], radius=5)
1431
+ if len(ips) > 1:
1432
+ return { 'point': point, 'type': 'too_many_choices' }
1433
+
1434
+ if len(ips) == 0:
1435
+ continue
1436
+
1437
+ ip = ips[0]
1438
+
1439
+ if ip is None:
1440
+ return { 'point': point, 'type': 'no_choices' }
1441
+
1442
+ ip = self.translate_point(ip, bbox)
1443
+
1444
+ return { 'point': ip, 'type': 'unchanged' if p == ip else 'corrected' }
1445
+
1446
+ def get_grid_line_corrections(self, points, bounds_check_buffer,
1447
+ line_color_choices, context_dim,
1448
+ find_line_scale, find_line_iter):
1449
+
1450
+ full_pixel_cutline = self.get_full_pixel_cutline()
1451
+ cutline_poly = Polygon([tuple(p) for p in full_pixel_cutline])
1452
+ cutline_poly_buffered = cutline_poly.buffer(bounds_check_buffer, join_style=JOIN_STYLE.mitre)
1453
+
1454
+ corrections = {}
1455
+
1456
+ for p in points:
1457
+
1458
+ if not cutline_poly_buffered.contains(Point(p)):
1459
+ corrections[p] = { 'point': p, 'type': 'invalid' }
1460
+ continue
1461
+
1462
+ correction = self.correct_intersection(p, line_color_choices, context_dim, find_line_scale, find_line_iter)
1463
+ corrections[p] = correction
1464
+
1465
+ return corrections
1466
+
1467
+
1468
+
1469
+ def locate_grid_lines_using_trasformer(self, transformer, factor, divisible_by, bounds_check_buffer):
1470
+
1471
+ full_pixel_cutline = self.get_full_pixel_cutline()
1472
+ cutline_poly = Polygon([tuple(p) for p in full_pixel_cutline])
1473
+ cutline_poly_buffered = cutline_poly.buffer(bounds_check_buffer, join_style=JOIN_STYLE.mitre)
1474
+
1475
+ full_pixel_cutline_xy = []
1476
+ for p in full_pixel_cutline:
1477
+ x, y = transformer.xy(p[1], p[0])
1478
+ full_pixel_cutline_xy.append((x, y))
1479
+
1480
+ cutline_poly_xy = Polygon(full_pixel_cutline_xy)
1481
+
1482
+ cutline_poly_xy_bbox = list(cutline_poly_xy.bounds)
1483
+
1484
+ cutline_poly_xy_bbox_rounded_scaled = [ round(c * factor) for c in cutline_poly_xy_bbox ]
1485
+ xmin_scaled = cutline_poly_xy_bbox_rounded_scaled[0]
1486
+ ymin_scaled = cutline_poly_xy_bbox_rounded_scaled[1]
1487
+ xmax_scaled = cutline_poly_xy_bbox_rounded_scaled[2]
1488
+ ymax_scaled = cutline_poly_xy_bbox_rounded_scaled[3]
1489
+
1490
+ print(f'cutline_poly_xy_bbox: {cutline_poly_xy_bbox}')
1491
+
1492
+ xlocs_scaled = self.get_between(xmin_scaled, xmax_scaled, divisible_by)
1493
+ ylocs_scaled = self.get_between(ymin_scaled, ymax_scaled, divisible_by)
1494
+
1495
+ xlocs = [ (x / factor) for x in xlocs_scaled ]
1496
+ ylocs = [ (y / factor) for y in ylocs_scaled ]
1497
+
1498
+ print(f'xlocs: {xlocs}')
1499
+ print(f'ylocs: {ylocs}')
1500
+
1501
+ intersections_scaled = []
1502
+ for x in xlocs_scaled:
1503
+ for y in ylocs_scaled:
1504
+ intersections_scaled.append((x, y))
1505
+
1506
+ lines_xy_scaled = []
1507
+ for inter in intersections_scaled:
1508
+ x, y = inter
1509
+ line1 = (
1510
+ (x, y),
1511
+ (x + divisible_by, y)
1512
+ )
1513
+ line2 = (
1514
+ (x - divisible_by, y),
1515
+ (x, y)
1516
+ )
1517
+ line3 = (
1518
+ (x, y),
1519
+ (x, y + divisible_by)
1520
+ )
1521
+ line4 = (
1522
+ (x, y - divisible_by),
1523
+ (x, y)
1524
+ )
1525
+ lines_xy_scaled += [line1, line2, line3, line4]
1526
+
1527
+ lines_xy = [
1528
+ (
1529
+ (x1 / factor, y1 / factor),
1530
+ (x2 / factor, y2 / factor)
1531
+ )
1532
+ for (x1, y1), (x2, y2) in lines_xy_scaled
1533
+ ]
1534
+ lines_xy = set(lines_xy)
1535
+
1536
+ lines = []
1537
+ lines_xy_filtered = []
1538
+ for line in lines_xy:
1539
+ p1 = transformer.rowcol(line[0][0], line[0][1])
1540
+ p2 = transformer.rowcol(line[1][0], line[1][1])
1541
+ point1 = Point(p1)
1542
+ point2 = Point(p2)
1543
+ if cutline_poly_buffered.contains(point1) or cutline_poly_buffered.contains(point2):
1544
+ lines.append((p1, p2))
1545
+ lines_xy_filtered.append(line)
1546
+
1547
+ return lines, lines_xy_filtered
1548
+
1549
+ def locate_grid_lines(self):
1550
+ raise NotImplementedError("This method should be implemented in a subclass")
1551
+
1552
+ def remove_grid_lines(self):
1553
+ workdir = self.get_workdir()
1554
+
1555
+ nogrid_file = workdir.joinpath('nogrid.jpg')
1556
+ if nogrid_file.exists():
1557
+ print(f'{nogrid_file} file exists.. skipping')
1558
+ return
1559
+
1560
+ grid_lines = self.locate_grid_lines()
1561
+ if len(grid_lines) == 0:
1562
+ return
1563
+
1564
+ full_img = self.get_full_img()
1565
+ full_img = full_img.copy()
1566
+ print('dropping grid lines')
1567
+ for line, params in grid_lines:
1568
+ self.remove_line(line, full_img, params)
1569
+
1570
+ self.ensure_dir(workdir)
1571
+ cv2.imwrite(str(nogrid_file), full_img)
1572
+
1573
+ def georeference(self):
1574
+ workdir = self.get_workdir()
1575
+
1576
+ georef_file = workdir.joinpath('georef.tif')
1577
+ final_file = workdir.joinpath('final.tif')
1578
+ if georef_file.exists() or final_file.exists():
1579
+ print(f'{georef_file} or {final_file} exists.. skipping')
1580
+ return
1581
+
1582
+ from_file = self.get_full_file_path()
1583
+
1584
+ if self.should_remove_grid_lines:
1585
+ self.remove_grid_lines()
1586
+ from_file = workdir.joinpath('nogrid.jpg')
1587
+
1588
+ ibox = self.get_sheet_ibox()
1589
+ print(ibox)
1590
+
1591
+ crs_proj = self.get_crs_proj()
1592
+
1593
+ gcps = self.get_gcps()
1594
+
1595
+ gcp_str = ''
1596
+ for gcp in gcps:
1597
+ corner = gcp[0]
1598
+ idx = gcp[1]
1599
+ gcp_str += f' -gcp {corner[0]} {corner[1]} {idx[0]} {idx[1]}'
1600
+
1601
+ creation_options = '-co TILED=YES -co COMPRESS=DEFLATE -co PREDICTOR=2'
1602
+ perf_options = '--config GDAL_CACHEMAX 128 --config GDAL_NUM_THREADS ALL_CPUS'
1603
+
1604
+ translate_cmd = f'gdal_translate {creation_options} {perf_options} {gcp_str} -a_srs "{crs_proj}" -of GTiff {str(from_file)} {str(georef_file)}'
1605
+ self.run_external(translate_cmd)
1606
+
1607
+ def get_cutline_props(self):
1608
+ crs_proj = self.get_crs_proj()
1609
+
1610
+ props = {
1611
+ 'id': self.get_id(),
1612
+ 'crs': crs_proj,
1613
+ 'gcps': self.get_gcps(pre_rotated=True),
1614
+ 'pixel_cutline': self.get_full_pixel_cutline(pre_rotated=True),
1615
+ }
1616
+ return props
1617
+
1618
+ def create_cutline(self, ibox, file):
1619
+ cutline_data = {
1620
+ "type": "FeatureCollection",
1621
+ "name": "CUTLINE",
1622
+ "features": [{
1623
+ "type": "Feature",
1624
+ "properties": self.get_cutline_props(),
1625
+ "geometry": {
1626
+ "type": "Polygon",
1627
+ "coordinates": [ibox]
1628
+ }
1629
+ }]
1630
+ }
1631
+
1632
+ self.ensure_dir(file.parent)
1633
+ with open(file, 'w') as f:
1634
+ json.dump(cutline_data, f, indent=4)
1635
+
1636
+ def get_scale(self):
1637
+ raise NotImplementedError("This method should be implemented in a subclass")
1638
+
1639
+ def get_crs_proj(self):
1640
+ raise NotImplementedError("This method should be implemented in a subclass")
1641
+
1642
+ def get_resolution(self):
1643
+ # 1:25000 is 2.1166 for 300 dpi like effect?
1644
+ # TODO: explain the calculation
1645
+ scale = self.get_scale()
1646
+ ratio = scale / 25000
1647
+ return round(2.1166 * ratio, 4)
1648
+
1649
+ def get_full_pixel_cutline(self, pre_rotated=False):
1650
+
1651
+ corners = self.get_corners()
1652
+ corners = corners + [corners[0]] # close the polygon
1653
+
1654
+ corners_poly = Polygon([tuple(p) for p in corners])
1655
+ extra_polys = []
1656
+ for pixel_cutline in self.pixel_cutlines:
1657
+ extra_poly = Polygon([tuple(p) for p in pixel_cutline])
1658
+ extra_polys.append(extra_poly)
1659
+
1660
+ full_poly = unary_union([corners_poly] + extra_polys)
1661
+ if full_poly.geom_type != 'Polygon':
1662
+ for poly in full_poly.geoms:
1663
+ print(f'poly: {poly}')
1664
+ self.save_cutline(full_poly)
1665
+ raise Exception(f'expected full_poly to be a Polygon, got {full_poly.geom_type}')
1666
+ full_poly_points = list(full_poly.exterior.coords)
1667
+ full_poly_points.reverse()
1668
+ full_poly_points = [ (int(p[0]), int(p[1])) for p in full_poly_points ]
1669
+ if pre_rotated:
1670
+ full_poly_points = [ self.get_original_pixel_coordinate((p[0], p[1])) for p in full_poly_points ]
1671
+
1672
+ return full_poly_points
1673
+
1674
+ def get_rotation_reversal_params(self):
1675
+ if self.rotation_reversal_params is not None:
1676
+ return self.rotation_reversal_params
1677
+
1678
+ workdir = self.get_workdir()
1679
+ rotated_info_file = workdir.joinpath('rotated_info.txt')
1680
+ if not rotated_info_file.exists():
1681
+ raise Exception(f'rotated_info.txt does not exist in {workdir}')
1682
+
1683
+ parts = rotated_info_file.read_text().strip().split(',')
1684
+ angle = float(parts[0])
1685
+ rotated = parts[1].strip() == 'rotated'
1686
+
1687
+ if not rotated:
1688
+ self.rotation_reversal_params = RotationReversalParams(False, -angle, None, None)
1689
+ return self.rotation_reversal_params
1690
+
1691
+ rotated_shape = self.get_full_img().shape[:2]
1692
+ orig_file = workdir / 'full.jpg'
1693
+ if not orig_file.exists():
1694
+ orig_file = self.filepath
1695
+ original_shape = cv2.imread(str(orig_file)).shape[:2]
1696
+ self.rotation_reversal_params = RotationReversalParams(True, -angle, rotated_shape, original_shape)
1697
+
1698
+ return self.rotation_reversal_params
1699
+
1700
+ def get_original_pixel_coordinate(self, p):
1701
+ rotation_reversal_params = self.get_rotation_reversal_params()
1702
+
1703
+ if not rotation_reversal_params.rotated:
1704
+ return p
1705
+
1706
+ return self.get_original_pixel_coordinate_internal(p, rotation_reversal_params)
1707
+
1708
+ def get_gcps(self, pre_rotated=False):
1709
+ corners = self.get_corners()
1710
+ if pre_rotated:
1711
+ corners = [ self.get_original_pixel_coordinate((c[0], c[1])) for c in corners ]
1712
+
1713
+ ibox = self.get_sheet_ibox()
1714
+
1715
+ if len(ibox) - 1 != len(corners):
1716
+ raise Exception(f'{len(ibox) - 1=} != {len(corners)=}')
1717
+
1718
+ gcps = []
1719
+ for i, corner in enumerate(corners):
1720
+ idx = ibox[i]
1721
+ gcp = [[corner[0], corner[1]], [idx[0], idx[1]]]
1722
+ gcps.append(gcp)
1723
+
1724
+ return gcps
1725
+
1726
+ def warp_file(self, box, cline_file, georef_file, f_file, jpeg_quality, set_resolution=True):
1727
+ img_quality_config = {
1728
+ 'COMPRESS': 'JPEG',
1729
+ #'PHOTOMETRIC': 'YCBCR',
1730
+ 'JPEG_QUALITY': f'{jpeg_quality}'
1731
+ }
1732
+
1733
+ crs_proj = self.get_crs_proj()
1734
+
1735
+ self.create_cutline(box, cline_file)
1736
+
1737
+ cutline_options = f'-cutline {str(cline_file)} -cutline_srs "{crs_proj}" -crop_to_cutline --config GDALWARP_IGNORE_BAD_CUTLINE YES -wo CUTLINE_ALL_TOUCHED=TRUE'
1738
+
1739
+ warp_quality_config = img_quality_config.copy()
1740
+ warp_quality_config.update({'TILED': 'YES'})
1741
+ warp_quality_options = ' '.join([ f'-co {k}={v}' for k,v in warp_quality_config.items() ])
1742
+ if not set_resolution:
1743
+ reproj_options = f'-tps -ts 0 {self.warp_output_height} -r bilinear -t_srs "EPSG:3857"'
1744
+ else:
1745
+ res = self.get_resolution()
1746
+ reproj_options = f'-tps -tr {res} {res} -r bilinear -t_srs "EPSG:3857"'
1747
+ #nodata_options = '-dstnodata 0'
1748
+ nodata_options = '-dstalpha'
1749
+ perf_options = '-multi -wo NUM_THREADS=ALL_CPUS --config GDAL_CACHEMAX 1024 -wm 1024'
1750
+
1751
+ warp_cmd = f'gdalwarp -overwrite {perf_options} {nodata_options} {reproj_options} {warp_quality_options} {cutline_options} {str(georef_file)} {str(f_file)}'
1752
+ self.run_external(warp_cmd)
1753
+
1754
+ def get_updated_sheet_ibox(self):
1755
+ if len(self.pixel_cutlines) == 0:
1756
+ return self.get_sheet_ibox()
1757
+
1758
+ gcps = self.get_gcps()
1759
+ transformer = self.get_transformer_from_gcps(gcps)
1760
+
1761
+ sheet_ibox = []
1762
+ full_pixel_cutline = self.get_full_pixel_cutline()
1763
+ for p in full_pixel_cutline:
1764
+ x, y = transformer.xy(p[1], p[0])
1765
+ sheet_ibox.append((x, y))
1766
+
1767
+ return sheet_ibox
1768
+
1769
+ def export_bounds_file(self):
1770
+ bounds_dir = self.get_bounds_dir()
1771
+
1772
+ bounds_file = bounds_dir.joinpath(f'{self.get_id()}.geojsonl')
1773
+ if bounds_file.exists():
1774
+ print(f'{bounds_file} exists.. overwriting')
1775
+ bounds_file.unlink()
1776
+
1777
+ self.ensure_dir(bounds_dir)
1778
+
1779
+ workdir = self.get_workdir()
1780
+ cutline_file = workdir.joinpath('cutline.geojson')
1781
+ crs_proj = self.get_crs_proj()
1782
+
1783
+ self.run_external(f'ogr2ogr -t_srs EPSG:4326 -s_srs "{crs_proj}" -f GeoJSONSeq {str(bounds_file)} {cutline_file}')
1784
+
1785
+ def warp(self):
1786
+ workdir = self.get_workdir()
1787
+
1788
+ cutline_file = workdir.joinpath('cutline.geojson')
1789
+ georef_file = workdir.joinpath('georef.tif')
1790
+ final_file = workdir.joinpath('final.tif')
1791
+ if final_file.exists():
1792
+ print(f'{final_file} exists.. skipping')
1793
+ return
1794
+
1795
+ sheet_ibox = self.get_updated_sheet_ibox()
1796
+
1797
+ self.warp_file(sheet_ibox, cutline_file, georef_file, final_file, self.warp_jpeg_export_quality)
1798
+
1799
+ def export_gtiff(self, filename, out_filename, jpeg_export_quality):
1800
+ if Path(out_filename).exists():
1801
+ print(f'{out_filename} exists.. skipping export')
1802
+ return
1803
+ creation_opts = f'-co TILING_SCHEME=GoogleMapsCompatible -co COMPRESS=JPEG -co QUALITY={jpeg_export_quality}'
1804
+ mask_options = '--config GDAL_TIFF_INTERNAL_MASK YES -b 1 -b 2 -b 3 -mask 4'
1805
+ perf_options = '--config GDAL_CACHEMAX 512'
1806
+ cog_options = '-of COG'
1807
+ cmd = f'gdal_translate {perf_options} {mask_options} {creation_opts} {cog_options} {filename} {out_filename}'
1808
+ self.run_external(cmd)
1809
+
1810
+ def export(self):
1811
+
1812
+ self.export_bounds_file()
1813
+
1814
+ export_file = self.get_export_file()
1815
+
1816
+ self.ensure_dir(export_file.parent)
1817
+
1818
+ final_file = self.get_workdir().joinpath('final.tif')
1819
+
1820
+ self.export_gtiff(str(final_file), str(export_file), self.jpeg_export_quality)
1821
+
1822
+
1823
+ def process(self):
1824
+ export_file = self.get_export_file()
1825
+ if export_file.exists():
1826
+ return True
1827
+
1828
+ self.rotate()
1829
+
1830
+ # pause to debug
1831
+ self.prompt1()
1832
+
1833
+ self.georeference()
1834
+ self.warp()
1835
+ self.export()
1836
+
1837
+ # pause to debug
1838
+ self.prompt2()
1839
+
1840
+ # cleanup
1841
+ shutil.rmtree(self.get_workdir(), ignore_errors=True)
1842
+
1843
+ return True
1844
+