mapreader 1.2.0__tar.gz → 1.3.0__tar.gz

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.
Files changed (48) hide show
  1. {mapreader-1.2.0 → mapreader-1.3.0}/PKG-INFO +1 -1
  2. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/__init__.py +10 -0
  3. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/_version.py +3 -3
  4. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/load/images.py +1 -1
  5. mapreader-1.3.0/mapreader/spot_text/deepsolo_runner.py +643 -0
  6. mapreader-1.3.0/mapreader/spot_text/dptext_detr_runner.py +434 -0
  7. mapreader-1.3.0/mapreader/utils/__init__.py +0 -0
  8. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader.egg-info/PKG-INFO +1 -1
  9. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader.egg-info/SOURCES.txt +3 -0
  10. {mapreader-1.2.0 → mapreader-1.3.0}/LICENSE +0 -0
  11. {mapreader-1.2.0 → mapreader-1.3.0}/README.md +0 -0
  12. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/annotate/__init__.py +0 -0
  13. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/annotate/annotator.py +0 -0
  14. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/annotate/utils.py +0 -0
  15. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/classify/__init__.py +0 -0
  16. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/classify/classifier.py +0 -0
  17. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/classify/custom_models.py +0 -0
  18. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/classify/datasets.py +0 -0
  19. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/classify/load_annotations.py +0 -0
  20. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/download/__init__.py +0 -0
  21. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/download/data_structures.py +0 -0
  22. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/download/downloader.py +0 -0
  23. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/download/downloader_utils.py +0 -0
  24. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/download/sheet_downloader.py +0 -0
  25. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/download/tile_loading.py +0 -0
  26. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/download/tile_merging.py +0 -0
  27. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/load/__init__.py +0 -0
  28. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/load/geo_utils.py +0 -0
  29. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/load/loader.py +0 -0
  30. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/process/__init__.py +0 -0
  31. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/process/post_process.py +0 -0
  32. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/process/process.py +0 -0
  33. {mapreader-1.2.0/mapreader/utils → mapreader-1.3.0/mapreader/spot_text}/__init__.py +0 -0
  34. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/utils/compute_and_save_stats.py +0 -0
  35. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader/utils/slice_parallel.py +0 -0
  36. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader.egg-info/dependency_links.txt +0 -0
  37. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader.egg-info/entry_points.txt +0 -0
  38. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader.egg-info/not-zip-safe +0 -0
  39. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader.egg-info/requires.txt +0 -0
  40. {mapreader-1.2.0 → mapreader-1.3.0}/mapreader.egg-info/top_level.txt +0 -0
  41. {mapreader-1.2.0 → mapreader-1.3.0}/setup.cfg +0 -0
  42. {mapreader-1.2.0 → mapreader-1.3.0}/setup.py +0 -0
  43. {mapreader-1.2.0 → mapreader-1.3.0}/tests/test_annotator.py +0 -0
  44. {mapreader-1.2.0 → mapreader-1.3.0}/tests/test_geo_pipeline.py +0 -0
  45. {mapreader-1.2.0 → mapreader-1.3.0}/tests/test_import.py +0 -0
  46. {mapreader-1.2.0 → mapreader-1.3.0}/tests/test_post_processing.py +0 -0
  47. {mapreader-1.2.0 → mapreader-1.3.0}/tests/test_sheet_downloader.py +0 -0
  48. {mapreader-1.2.0 → mapreader-1.3.0}/versioneer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: mapreader
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: A computer vision pipeline for the semantic exploration of maps/images at scale
5
5
  Home-page: https://github.com/Living-with-machines/MapReader
6
6
  Download-URL: https://github.com/Living-with-machines/MapReader/archive/refs/heads/main.zip
@@ -12,6 +12,16 @@ from mapreader.classify.datasets import PatchContextDataset
12
12
  from mapreader.classify.classifier import ClassifierContainer
13
13
  from mapreader.classify import custom_models
14
14
 
15
+ try:
16
+ from mapreader.spot_text.deepsolo_runner import DeepSoloRunner
17
+ except ImportError:
18
+ pass
19
+
20
+ try:
21
+ from mapreader.spot_text.dptext_detr_runner import DPTextDETRRunner
22
+ except ImportError:
23
+ pass
24
+
15
25
  from mapreader.process import process
16
26
 
17
27
  from mapreader.annotate.annotator import Annotator
@@ -8,11 +8,11 @@ import json
8
8
 
9
9
  version_json = '''
10
10
  {
11
- "date": "2024-04-08T09:00:00+0100",
11
+ "date": "2024-04-29T15:52:44+0100",
12
12
  "dirty": false,
13
13
  "error": null,
14
- "full-revisionid": "e9d119ffa6946e723f7977ddd7add0c9bcce34d8",
15
- "version": "1.2.0"
14
+ "full-revisionid": "f256ae9b73ae9dd14d8bd72b61a86088f8f1905a",
15
+ "version": "1.3.0"
16
16
  }
17
17
  ''' # END VERSION_JSON
18
18
 
@@ -1753,7 +1753,7 @@ See https://pillow.readthedocs.io/en/stable/handbook/concepts.html#modes for mor
1753
1753
  counter=-1,
1754
1754
  )
1755
1755
 
1756
- ax.set_title(image_id)
1756
+ ax.set_title(parent_id)
1757
1757
  figures.append(fig)
1758
1758
 
1759
1759
  if column_to_plot and plot_histogram:
@@ -0,0 +1,643 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import pathlib
5
+ import pickle
6
+
7
+ try:
8
+ import adet
9
+ except ImportError:
10
+ raise ImportError(
11
+ "Please install DeepSolo from the following link: https://github.com/rwood-97/DeepSolo"
12
+ )
13
+
14
+ import geopandas as geopd
15
+ import numpy as np
16
+ import pandas as pd
17
+ from adet.config import get_cfg
18
+
19
+ try:
20
+ from detectron2.engine import DefaultPredictor
21
+ except ImportError:
22
+ raise ImportError("Please install Detectron2")
23
+
24
+ import matplotlib.patches as patches
25
+ import matplotlib.pyplot as plt
26
+ from PIL import Image
27
+ from shapely import LineString, Polygon
28
+
29
+ # first assert we are using the deep solo version of adet
30
+ if adet.__version__ != "0.2.0-deepsolo":
31
+ raise ImportError(
32
+ "Please install DeepSolo from the following link: https://github.com/rwood-97/DeepSolo"
33
+ )
34
+
35
+
36
+ class DeepSoloRunner:
37
+ def __init__(
38
+ self,
39
+ patch_df: pd.DataFrame = None,
40
+ parent_df: pd.DataFrame = None,
41
+ cfg_file: str
42
+ | pathlib.Path = "./DeepSolo/configs/R_50/IC15/finetune_150k_tt_mlt_13_15_textocr.yaml",
43
+ weights_file: str
44
+ | pathlib.Path = "./ic15_res50_finetune_synth-tt-mlt-13-15-textocr.pth",
45
+ device: str = "cpu",
46
+ ) -> None:
47
+ # setup the dataframes
48
+ self.patch_df = patch_df
49
+ self.parent_df = parent_df
50
+
51
+ # set up predictions as dictionaries
52
+ self.patch_predictions = {}
53
+ self.parent_predictions = {}
54
+ self.geo_predictions = {}
55
+
56
+ # setup the config
57
+ cfg = get_cfg() # get a fresh new config
58
+ cfg.merge_from_file(cfg_file)
59
+ cfg.MODEL.WEIGHTS = weights_file
60
+ cfg.MODEL.DEVICE = device
61
+
62
+ self.voc_size = cfg.MODEL.TRANSFORMER.VOC_SIZE
63
+ self.use_customer_dictionary = cfg.MODEL.TRANSFORMER.CUSTOM_DICT
64
+ if self.voc_size == 96:
65
+ self.CTLABELS = [
66
+ " ",
67
+ "!",
68
+ '"',
69
+ "#",
70
+ "$",
71
+ "%",
72
+ "&",
73
+ "'",
74
+ "(",
75
+ ")",
76
+ "*",
77
+ "+",
78
+ ",",
79
+ "-",
80
+ ".",
81
+ "/",
82
+ "0",
83
+ "1",
84
+ "2",
85
+ "3",
86
+ "4",
87
+ "5",
88
+ "6",
89
+ "7",
90
+ "8",
91
+ "9",
92
+ ":",
93
+ ";",
94
+ "<",
95
+ "=",
96
+ ">",
97
+ "?",
98
+ "@",
99
+ "A",
100
+ "B",
101
+ "C",
102
+ "D",
103
+ "E",
104
+ "F",
105
+ "G",
106
+ "H",
107
+ "I",
108
+ "J",
109
+ "K",
110
+ "L",
111
+ "M",
112
+ "N",
113
+ "O",
114
+ "P",
115
+ "Q",
116
+ "R",
117
+ "S",
118
+ "T",
119
+ "U",
120
+ "V",
121
+ "W",
122
+ "X",
123
+ "Y",
124
+ "Z",
125
+ "[",
126
+ "\\",
127
+ "]",
128
+ "^",
129
+ "_",
130
+ "`",
131
+ "a",
132
+ "b",
133
+ "c",
134
+ "d",
135
+ "e",
136
+ "f",
137
+ "g",
138
+ "h",
139
+ "i",
140
+ "j",
141
+ "k",
142
+ "l",
143
+ "m",
144
+ "n",
145
+ "o",
146
+ "p",
147
+ "q",
148
+ "r",
149
+ "s",
150
+ "t",
151
+ "u",
152
+ "v",
153
+ "w",
154
+ "x",
155
+ "y",
156
+ "z",
157
+ "{",
158
+ "|",
159
+ "}",
160
+ "~",
161
+ ]
162
+ elif self.voc_size == 37:
163
+ self.CTLABELS = [
164
+ "a",
165
+ "b",
166
+ "c",
167
+ "d",
168
+ "e",
169
+ "f",
170
+ "g",
171
+ "h",
172
+ "i",
173
+ "j",
174
+ "k",
175
+ "l",
176
+ "m",
177
+ "n",
178
+ "o",
179
+ "p",
180
+ "q",
181
+ "r",
182
+ "s",
183
+ "t",
184
+ "u",
185
+ "v",
186
+ "w",
187
+ "x",
188
+ "y",
189
+ "z",
190
+ "0",
191
+ "1",
192
+ "2",
193
+ "3",
194
+ "4",
195
+ "5",
196
+ "6",
197
+ "7",
198
+ "8",
199
+ "9",
200
+ ]
201
+ else:
202
+ with open(self.use_customer_dictionary, "rb") as fp:
203
+ self.CTLABELS = pickle.load(fp)
204
+ # voc_size includes the unknown class, which is not in self.CTABLES
205
+ assert int(self.voc_size - 1) == len(
206
+ self.CTLABELS
207
+ ), f"voc_size is not matched dictionary size, got {int(self.voc_size - 1)} and {len(self.CTLABELS)}."
208
+
209
+ # setup the predictor
210
+ self.predictor = DefaultPredictor(cfg)
211
+
212
+ def run_all(
213
+ self,
214
+ patch_df: pd.DataFrame = None,
215
+ return_dataframe: bool = False,
216
+ ) -> dict | pd.DataFrame:
217
+ """Run the model on all images in the patch dataframe.
218
+
219
+ Parameters
220
+ ----------
221
+ patch_df : pd.DataFrame, optional
222
+ Dataframe containing patch information, by default None.
223
+ return_dataframe : bool, optional
224
+ Whether to return the predictions as a pandas DataFrame, by default False
225
+
226
+ Returns
227
+ -------
228
+ dict or pd.DataFrame
229
+ A dictionary of predictions for each patch image or a DataFrame if `as_dataframe` is True.
230
+ """
231
+ if patch_df is None:
232
+ if self.patch_df is not None:
233
+ patch_df = self.patch_df
234
+ else:
235
+ raise ValueError("[ERROR] Please provide a `patch_df`")
236
+ img_paths = patch_df["image_path"].to_list()
237
+
238
+ patch_predictions = self.run_on_images(
239
+ img_paths, return_dataframe=return_dataframe
240
+ )
241
+ return patch_predictions
242
+
243
+ def run_on_images(
244
+ self,
245
+ img_paths: str | pathlib.Path | list,
246
+ return_dataframe: bool = False,
247
+ ) -> dict | pd.DataFrame:
248
+ """Run the model on a list of images.
249
+
250
+ Parameters
251
+ ----------
252
+ img_paths : str, pathlib.Path or list
253
+ A list of image paths to run the model on.
254
+ return_dataframe : bool, optional
255
+ Whether to return the predictions as a pandas DataFrame, by default False
256
+
257
+ Returns
258
+ -------
259
+ dict or pd.DataFrame
260
+ A dictionary of predictions for each image or a DataFrame if `as_dataframe` is True.
261
+ """
262
+
263
+ if isinstance(img_paths, (str, pathlib.Path)):
264
+ img_paths = [img_paths]
265
+
266
+ for img_path in img_paths:
267
+ _ = self.run_on_image(img_path, return_outputs=False)
268
+
269
+ if return_dataframe:
270
+ return self._dict_to_dataframe(self.patch_predictions, geo=False)
271
+ return self.patch_predictions
272
+
273
+ def run_on_image(
274
+ self,
275
+ img_path: str | pathlib.Path,
276
+ return_outputs=False,
277
+ return_dataframe: bool = False,
278
+ ) -> dict | pd.DataFrame:
279
+ """Run the model on a single image.
280
+
281
+ Parameters
282
+ ----------
283
+ img_path : str or pathlib.Path
284
+ The path to the image to run the model on.
285
+ return_outputs : bool, optional
286
+ Whether to return the outputs direct from the model, by default False
287
+ return_dataframe : bool, optional
288
+ Whether to return the predictions as a pandas DataFrame, by default False
289
+
290
+ Returns
291
+ -------
292
+ dict or pd.DataFrame
293
+ The predictions for the image or the outputs from the model if `return_outputs` is True.
294
+ """
295
+ # load image
296
+ img = Image.open(img_path).convert("RGB")
297
+ img_array = np.array(img)
298
+
299
+ # run inference
300
+ outputs = self.predictor(img_array)
301
+ outputs["image_id"] = os.path.basename(img_path)
302
+ outputs["img_path"] = img_path
303
+
304
+ if return_outputs:
305
+ return outputs
306
+
307
+ self.get_patch_predictions(outputs)
308
+
309
+ if return_dataframe:
310
+ return self._dict_to_dataframe(self.patch_predictions, patch=True)
311
+ return self.patch_predictions
312
+
313
+ def get_patch_predictions(
314
+ self,
315
+ outputs: dict,
316
+ return_dataframe: bool = False,
317
+ ) -> dict | pd.DataFrame:
318
+ """Post process the model outputs to get patch predictions.
319
+
320
+ Parameters
321
+ ----------
322
+ outputs : dict
323
+ The outputs from the model.
324
+ return_dataframe : bool, optional
325
+ Whether to return the predictions as a pandas DataFrame, by default False
326
+
327
+ Returns
328
+ -------
329
+ dict or pd.DataFrame
330
+ A dictionary containing the patch predictions or a DataFrame if `as_dataframe` is True.
331
+ """
332
+ # key for predictions
333
+ image_id = outputs["image_id"]
334
+ self.patch_predictions[image_id] = []
335
+
336
+ # get instances
337
+ instances = outputs["instances"].to("cpu")
338
+ ctrl_pnts = instances.ctrl_points.numpy()
339
+ scores = instances.scores.tolist()
340
+ recs = instances.recs
341
+ bd_pts = np.asarray(instances.bd)
342
+
343
+ self._post_process(image_id, ctrl_pnts, scores, recs, bd_pts)
344
+
345
+ if return_dataframe:
346
+ return self._dict_to_dataframe(self.patch_predictions, geo=False)
347
+ return self.patch_predictions
348
+
349
+ def _process_ctrl_pnt(self, pnt):
350
+ points = pnt.reshape(-1, 2)
351
+ return points
352
+
353
+ def _ctc_decode_recognition(self, rec):
354
+ last_char = "###"
355
+ s = ""
356
+ for c in rec:
357
+ c = int(c)
358
+ if c < self.voc_size - 1:
359
+ if last_char != c:
360
+ if self.voc_size == 37 or self.voc_size == 96:
361
+ s += self.CTLABELS[c]
362
+ last_char = c
363
+ else:
364
+ s += str(chr(self.CTLABELS[c]))
365
+ last_char = c
366
+ else:
367
+ last_char = "###"
368
+ return s
369
+
370
+ def _post_process(self, image_id, ctrl_pnts, scores, recs, bd_pnts, alpha=0.4):
371
+ for ctrl_pnt, score, rec, bd in zip(ctrl_pnts, scores, recs, bd_pnts):
372
+ # draw polygons
373
+ if bd is not None:
374
+ bd = np.hsplit(bd, 2)
375
+ bd = np.vstack([bd[0], bd[1][::-1]])
376
+ polygon = Polygon(bd)
377
+
378
+ # draw center lines
379
+ line = self._process_ctrl_pnt(ctrl_pnt)
380
+ line = LineString(line)
381
+
382
+ # draw text
383
+ text = self._ctc_decode_recognition(rec)
384
+ if self.voc_size == 37:
385
+ text = text.upper()
386
+ # text = "{:.2f}: {}".format(score, text)
387
+ text = f"{text}"
388
+ score = f"{score:.2f}"
389
+
390
+ self.patch_predictions[image_id].append([polygon, text, score])
391
+
392
+ def convert_to_parent_pixel_bounds(
393
+ self,
394
+ patch_df: pd.DataFrame = None,
395
+ return_dataframe: bool = False,
396
+ ) -> dict | pd.DataFrame:
397
+ """Convert the patch predictions to parent predictions by converting pixel bounds.
398
+
399
+ Parameters
400
+ ----------
401
+ patch_df : pd.DataFrame, optional
402
+ Dataframe containing patch information, by default None
403
+ return_dataframe : bool, optional
404
+ Whether to return the predictions as a pandas DataFrame, by default False
405
+
406
+ Returns
407
+ -------
408
+ dict or pd.DataFrame
409
+ A dictionary of predictions for each parent image or a DataFrame if `as_dataframe` is True.
410
+
411
+ Raises
412
+ ------
413
+ ValueError
414
+ If `patch_df` is not available.
415
+ """
416
+ if patch_df is None:
417
+ if self.patch_df is not None:
418
+ patch_df = self.patch_df
419
+ else:
420
+ raise ValueError("[ERROR] Please provide a `patch_df`")
421
+
422
+ for image_id, prediction in self.patch_predictions.items():
423
+ parent_id = patch_df.loc[image_id, "parent_id"]
424
+ if parent_id not in self.parent_predictions.keys():
425
+ self.parent_predictions[parent_id] = []
426
+
427
+ for instance in prediction:
428
+ polygon = instance[0]
429
+
430
+ xx, yy = (np.array(i) for i in polygon.exterior.xy)
431
+ xx = xx + patch_df.loc[image_id, "pixel_bounds"][0] # add min_x
432
+ yy = yy + patch_df.loc[image_id, "pixel_bounds"][1] # add min_y
433
+
434
+ parent_polygon = Polygon(zip(xx, yy))
435
+ self.parent_predictions[parent_id].append(
436
+ [parent_polygon, instance[1], instance[2]]
437
+ )
438
+
439
+ if return_dataframe:
440
+ return self._dict_to_dataframe(self.parent_predictions, geo=False)
441
+ return self.parent_predictions
442
+
443
+ def convert_to_coords(
444
+ self,
445
+ parent_df: pd.DataFrame = None,
446
+ return_dataframe: bool = False,
447
+ ) -> dict | pd.DataFrame:
448
+ """Convert the parent predictions to georeferenced predictions by converting pixel bounds to coordinates.
449
+
450
+ Parameters
451
+ ----------
452
+ parent_df : pd.DataFrame, optional
453
+ Dataframe containing parent image information, by default None
454
+ return_dataframe : bool, optional
455
+ Whether to return the predictions as a pandas DataFrame, by default False
456
+
457
+ Returns
458
+ -------
459
+ dict or pd.DataFrame
460
+ A dictionary of predictions for each parent image or a DataFrame if `as_dataframe` is True.
461
+
462
+ Raises
463
+ ------
464
+ ValueError
465
+ If `parent_df` is not available.
466
+ """
467
+ if parent_df is None:
468
+ if self.parent_df is not None:
469
+ parent_df = self.parent_df
470
+ else:
471
+ raise ValueError("[ERROR] Please provide a `parent_df`")
472
+
473
+ if self.parent_predictions == {}:
474
+ print("[INFO] Converting patch pixel bounds to parent pixel bounds.")
475
+ _ = self.convert_to_parent_pixel_bounds()
476
+
477
+ for parent_id, prediction in self.parent_predictions.items():
478
+ if parent_id not in self.geo_predictions.keys():
479
+ self.geo_predictions[parent_id] = []
480
+
481
+ for instance in prediction:
482
+ polygon = instance[0]
483
+
484
+ xx, yy = (np.array(i) for i in polygon.exterior.xy)
485
+ xx = (
486
+ xx * parent_df.loc[parent_id, "dlon"]
487
+ + parent_df.loc[parent_id, "coordinates"][0]
488
+ )
489
+ yy = (
490
+ parent_df.loc[parent_id, "coordinates"][3]
491
+ - yy * parent_df.loc[parent_id, "dlat"]
492
+ )
493
+
494
+ crs = parent_df.loc[parent_id, "crs"]
495
+
496
+ parent_polygon_geo = Polygon(zip(xx, yy))
497
+ self.geo_predictions[parent_id].append(
498
+ [parent_polygon_geo, crs, instance[1], instance[2]]
499
+ )
500
+
501
+ if return_dataframe:
502
+ return self._dict_to_dataframe(self.geo_predictions, geo=True)
503
+ return self.geo_predictions
504
+
505
+ @staticmethod
506
+ def _dict_to_dataframe(
507
+ preds: dict,
508
+ geo: bool = False,
509
+ ) -> pd.DataFrame:
510
+ """Convert the predictions dictionary to a pandas DataFrame.
511
+
512
+ Parameters
513
+ ----------
514
+ preds : dict
515
+ A dictionary of predictions.
516
+ geo : bool, optional
517
+ Whether the dictionary is georeferenced coords (or pixel bounds), by default True
518
+
519
+ Returns
520
+ -------
521
+ pd.DataFrame
522
+ A pandas DataFrame containing the predictions.
523
+ """
524
+ if geo:
525
+ columns = ["polygon", "crs", "text", "score"]
526
+ else:
527
+ columns = ["polygon", "text", "score"]
528
+
529
+ preds_df = pd.concat(
530
+ pd.DataFrame(
531
+ preds[k],
532
+ index=np.full(len(preds[k]), k),
533
+ columns=columns,
534
+ )
535
+ for k in preds.keys()
536
+ )
537
+ preds_df.index.name = "image_id"
538
+ preds_df.reset_index(inplace=True) # reset index to get image_id as a column
539
+ return preds_df
540
+
541
+ def save_to_geojson(
542
+ self,
543
+ save_path: str | pathlib.Path = "./deepsolo_text_outputs.geojson",
544
+ ) -> None:
545
+ """Save the georeferenced predictions to a GeoJSON file.
546
+
547
+ Parameters
548
+ ----------
549
+ save_path : str | pathlib.Path, optional
550
+ Path to save the GeoJSON file, by default "./deepsolo_text_outputs.geojson"
551
+ """
552
+
553
+ geo_df = self._dict_to_dataframe(self.geo_predictions, geo=True)
554
+
555
+ # get the crs (should be the same for all polygons)
556
+ assert geo_df["crs"].nunique() == 1
557
+ crs = geo_df["crs"].unique()[0]
558
+
559
+ geo_df = geopd.GeoDataFrame(geo_df, geometry="polygon", crs=crs)
560
+ geo_df.to_file(save_path, driver="GeoJSON")
561
+
562
+ def show(
563
+ self,
564
+ image_id: str,
565
+ figsize: tuple | None = (10, 10),
566
+ border_color: str | None = "r",
567
+ text_color: str | None = "b",
568
+ image_width_resolution: int | None = None,
569
+ return_fig: bool = False,
570
+ ) -> None:
571
+ """Show the predictions on an image.
572
+
573
+ Parameters
574
+ ----------
575
+ image_id : str
576
+ The image ID to show the predictions on.
577
+ figsize : tuple | None, optional
578
+ The size of the figure, by default (10, 10)
579
+ border_color : str | None, optional
580
+ The color of the border of the polygons, by default "r"
581
+ text_color : str | None, optional
582
+ The color of the text, by default "b"
583
+ image_width_resolution : int | None, optional
584
+ The maximum resolution of the image width, by default None
585
+ return_fig : bool, optional
586
+ Whether to return the figure, by default False
587
+
588
+ Returns
589
+ -------
590
+ fig
591
+ The matplotlib figure if `return_fig` is True.
592
+
593
+ Raises
594
+ ------
595
+ ValueError
596
+ If the image ID is not found in the patch or parent predictions.
597
+ """
598
+
599
+ if image_id in self.patch_predictions.keys():
600
+ preds = self.patch_predictions
601
+ image_path = self.patch_df.loc[image_id, "image_path"]
602
+
603
+ elif image_id in self.parent_predictions.keys():
604
+ preds = self.parent_predictions
605
+ image_path = self.parent_df.loc[image_id, "image_path"]
606
+
607
+ else:
608
+ raise ValueError(
609
+ f"[ERROR] {image_id} not found in patch or parent predictions."
610
+ )
611
+
612
+ img = Image.open(image_path)
613
+
614
+ # if image_width_resolution is specified, resize the image
615
+ if image_width_resolution:
616
+ new_width = int(image_width_resolution)
617
+ rescale_factor = new_width / img.width
618
+ new_height = int(img.height * rescale_factor)
619
+ img = img.resize((new_width, new_height), Image.LANCZOS)
620
+
621
+ fig = plt.figure(figsize=figsize)
622
+ ax = plt.gca()
623
+
624
+ # check if grayscale
625
+ if len(img.getbands()) == 1:
626
+ ax.imshow(img, cmap="gray", vmin=0, vmax=255, zorder=1)
627
+ else:
628
+ ax.imshow(img, zorder=1)
629
+
630
+ for instance in preds[image_id]:
631
+ polygon = np.array(instance[0].exterior.coords.xy)
632
+ center = instance[0].centroid.coords.xy
633
+ patch = patches.Polygon(polygon.T, edgecolor=border_color, facecolor="none")
634
+ ax.add_patch(patch)
635
+ ax.text(
636
+ center[0][0], center[1][0], instance[1], fontsize=8, color=text_color
637
+ )
638
+
639
+ plt.axis("off")
640
+ plt.title(image_id)
641
+
642
+ if return_fig:
643
+ return fig
@@ -0,0 +1,434 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import pathlib
5
+
6
+ try:
7
+ import adet
8
+ except ImportError:
9
+ raise ImportError(
10
+ "Please install DPText-DETR from the following link: https://github.com/rwood-97/DPText-DETR"
11
+ )
12
+
13
+ import geopandas as geopd
14
+ import numpy as np
15
+ import pandas as pd
16
+ from adet.config import get_cfg
17
+
18
+ try:
19
+ from detectron2.engine import DefaultPredictor
20
+ except ImportError:
21
+ raise ImportError("Please install Detectron2")
22
+
23
+ import matplotlib.patches as patches
24
+ import matplotlib.pyplot as plt
25
+ from PIL import Image
26
+ from shapely import Polygon
27
+
28
+ # first assert we are using the dptext detr version of adet
29
+ if adet.__version__ != "0.2.0-dptext-detr":
30
+ raise ImportError(
31
+ "Please install DPText-DETR from the following link: https://github.com/rwood-97/DPText-DETR"
32
+ )
33
+
34
+
35
+ class DPTextDETRRunner:
36
+ def __init__(
37
+ self,
38
+ patch_df: pd.DataFrame = None,
39
+ parent_df: pd.DataFrame = None,
40
+ cfg_file: str
41
+ | pathlib.Path = "./DPText-DETR/configs/DPText_DETR/ArT/R_50_poly.yaml",
42
+ weights_file: str | pathlib.Path = "./art_final.pth",
43
+ device: str = "cpu",
44
+ ) -> None:
45
+ # first assert we are using the deep solo version of adet
46
+ assert adet.__version__ == "0.2.0-dptext-detr"
47
+
48
+ # setup the dataframes
49
+ self.patch_df = patch_df
50
+ self.parent_df = parent_df
51
+
52
+ # set up predictions as dictionaries
53
+ self.patch_predictions = {}
54
+ self.parent_predictions = {}
55
+ self.geo_predictions = {}
56
+
57
+ # setup the config
58
+ cfg = get_cfg() # get a fresh new config
59
+ cfg.merge_from_file(cfg_file)
60
+ cfg.MODEL.WEIGHTS = weights_file
61
+ cfg.MODEL.DEVICE = device
62
+
63
+ # setup the predictor
64
+ self.predictor = DefaultPredictor(cfg)
65
+
66
+ def run_all(
67
+ self,
68
+ patch_df: pd.DataFrame = None,
69
+ return_dataframe: bool = False,
70
+ ) -> dict | pd.DataFrame:
71
+ """Run the model on all images in the patch dataframe.
72
+
73
+ Parameters
74
+ ----------
75
+ patch_df : pd.DataFrame, optional
76
+ Dataframe containing patch information, by default None.
77
+ return_dataframe : bool, optional
78
+ Whether to return the predictions as a pandas DataFrame, by default False
79
+
80
+ Returns
81
+ -------
82
+ dict or pd.DataFrame
83
+ A dictionary of predictions for each patch image or a DataFrame if `as_dataframe` is True.
84
+ """
85
+ if patch_df is None:
86
+ if self.patch_df is not None:
87
+ patch_df = self.patch_df
88
+ else:
89
+ raise ValueError("[ERROR] Please provide a `patch_df`")
90
+ img_paths = patch_df["image_path"].to_list()
91
+
92
+ patch_predictions = self.run_on_images(
93
+ img_paths, return_dataframe=return_dataframe
94
+ )
95
+ return patch_predictions
96
+
97
+ def run_on_images(
98
+ self,
99
+ img_paths: str | pathlib.Path | list,
100
+ return_dataframe: bool = False,
101
+ ) -> dict | pd.DataFrame:
102
+ """Run the model on a list of images.
103
+
104
+ Parameters
105
+ ----------
106
+ img_paths : str, pathlib.Path or list
107
+ A list of image paths to run the model on.
108
+ return_dataframe : bool, optional
109
+ Whether to return the predictions as a pandas DataFrame, by default False
110
+
111
+ Returns
112
+ -------
113
+ dict or pd.DataFrame
114
+ A dictionary of predictions for each image or a DataFrame if `as_dataframe` is True.
115
+ """
116
+
117
+ if isinstance(img_paths, (str, pathlib.Path)):
118
+ img_paths = [img_paths]
119
+
120
+ for img_path in img_paths:
121
+ _ = self.run_on_image(img_path, return_outputs=False)
122
+
123
+ if return_dataframe:
124
+ return self._dict_to_dataframe(self.patch_predictions, geo=False)
125
+ return self.patch_predictions
126
+
127
+ def run_on_image(
128
+ self,
129
+ img_path: str | pathlib.Path,
130
+ return_outputs=False,
131
+ return_dataframe: bool = False,
132
+ ) -> dict | pd.DataFrame:
133
+ """Run the model on a single image.
134
+
135
+ Parameters
136
+ ----------
137
+ img_path : str or pathlib.Path
138
+ The path to the image to run the model on.
139
+ return_outputs : bool, optional
140
+ Whether to return the outputs direct from the model, by default False
141
+ return_dataframe : bool, optional
142
+ Whether to return the predictions as a pandas DataFrame, by default False
143
+
144
+ Returns
145
+ -------
146
+ dict or pd.DataFrame
147
+ The predictions for the image or the outputs from the model if `return_outputs` is True.
148
+ """
149
+ # load image
150
+ img = Image.open(img_path).convert("RGB")
151
+ img_array = np.array(img)
152
+
153
+ # run inference
154
+ outputs = self.predictor(img_array)
155
+ outputs["image_id"] = os.path.basename(img_path)
156
+ outputs["img_path"] = img_path
157
+
158
+ if return_outputs:
159
+ return outputs
160
+
161
+ self.get_patch_predictions(outputs)
162
+
163
+ if return_dataframe:
164
+ return self._dict_to_dataframe(self.patch_predictions, patch=True)
165
+ return self.patch_predictions
166
+
167
+ def get_patch_predictions(
168
+ self,
169
+ outputs: dict,
170
+ return_dataframe: bool = False,
171
+ ) -> dict | pd.DataFrame:
172
+ """Post process the model outputs to get patch predictions.
173
+
174
+ Parameters
175
+ ----------
176
+ outputs : dict
177
+ The outputs from the model.
178
+ return_dataframe : bool, optional
179
+ Whether to return the predictions as a pandas DataFrame, by default False
180
+
181
+ Returns
182
+ -------
183
+ dict or pd.DataFrame
184
+ A dictionary containing the patch predictions or a DataFrame if `as_dataframe` is True.
185
+ """
186
+ # key for predictions
187
+ image_id = outputs["image_id"]
188
+ self.patch_predictions[image_id] = []
189
+
190
+ # get instances
191
+ instances = outputs["instances"].to("cpu")
192
+ scores = instances.scores.tolist()
193
+ pred_classes = instances.pred_classes.tolist()
194
+ bd_pts = np.asarray(instances.polygons)
195
+
196
+ self._post_process(image_id, scores, pred_classes, bd_pts)
197
+
198
+ if return_dataframe:
199
+ return self._dict_to_dataframe(self.patch_predictions, geo=False)
200
+ return self.patch_predictions
201
+
202
+ def _post_process(self, image_id, scores, pred_classes, bd_pnts):
203
+ for score, _pred_class, bd in zip(scores, pred_classes, bd_pnts):
204
+ # draw polygons
205
+ if bd is not None:
206
+ bd = bd.reshape(-1, 2)
207
+ polygon = Polygon(bd)
208
+
209
+ score = f"{score:.2f}"
210
+
211
+ self.patch_predictions[image_id].append([polygon, score])
212
+
213
+ def convert_to_parent_pixel_bounds(
214
+ self,
215
+ patch_df: pd.DataFrame = None,
216
+ return_dataframe: bool = False,
217
+ ) -> dict | pd.DataFrame:
218
+ """Convert the patch predictions to parent predictions by converting pixel bounds.
219
+
220
+ Parameters
221
+ ----------
222
+ patch_df : pd.DataFrame, optional
223
+ Dataframe containing patch information, by default None
224
+ return_dataframe : bool, optional
225
+ Whether to return the predictions as a pandas DataFrame, by default False
226
+
227
+ Returns
228
+ -------
229
+ dict or pd.DataFrame
230
+ A dictionary of predictions for each parent image or a DataFrame if `as_dataframe` is True.
231
+
232
+ Raises
233
+ ------
234
+ ValueError
235
+ If `patch_df` is not available.
236
+ """
237
+ if patch_df is None:
238
+ if self.patch_df is not None:
239
+ patch_df = self.patch_df
240
+ else:
241
+ raise ValueError("[ERROR] Please provide a `patch_df`")
242
+
243
+ for image_id, prediction in self.patch_predictions.items():
244
+ parent_id = patch_df.loc[image_id, "parent_id"]
245
+ if parent_id not in self.parent_predictions.keys():
246
+ self.parent_predictions[parent_id] = []
247
+
248
+ for instance in prediction:
249
+ polygon = instance[0]
250
+
251
+ xx, yy = (np.array(i) for i in polygon.exterior.xy)
252
+ xx = xx + patch_df.loc[image_id, "pixel_bounds"][0] # add min_x
253
+ yy = yy + patch_df.loc[image_id, "pixel_bounds"][1] # add min_y
254
+
255
+ parent_polygon = Polygon(zip(xx, yy))
256
+ self.parent_predictions[parent_id].append([parent_polygon, instance[1]])
257
+
258
+ if return_dataframe:
259
+ return self._dict_to_dataframe(self.parent_predictions, geo=False)
260
+ return self.parent_predictions
261
+
262
+ def convert_to_coords(
263
+ self,
264
+ parent_df: pd.DataFrame = None,
265
+ return_dataframe: bool = False,
266
+ ) -> dict | pd.DataFrame:
267
+ """Convert the parent predictions to georeferenced predictions by converting pixel bounds to coordinates.
268
+
269
+ Parameters
270
+ ----------
271
+ parent_df : pd.DataFrame, optional
272
+ Dataframe containing parent image information, by default None
273
+ return_dataframe : bool, optional
274
+ Whether to return the predictions as a pandas DataFrame, by default False
275
+
276
+ Returns
277
+ -------
278
+ dict or pd.DataFrame
279
+ A dictionary of predictions for each parent image or a DataFrame if `as_dataframe` is True.
280
+
281
+ Raises
282
+ ------
283
+ ValueError
284
+ If `parent_df` is not available.
285
+ """
286
+ if parent_df is None:
287
+ if self.parent_df is not None:
288
+ parent_df = self.parent_df
289
+ else:
290
+ raise ValueError("[ERROR] Please provide a `parent_df`")
291
+
292
+ if self.parent_predictions == {}:
293
+ print("[INFO] Converting patch pixel bounds to parent pixel bounds.")
294
+ _ = self.convert_to_parent_pixel_bounds()
295
+
296
+ for parent_id, prediction in self.parent_predictions.items():
297
+ if parent_id not in self.geo_predictions.keys():
298
+ self.geo_predictions[parent_id] = []
299
+
300
+ for instance in prediction:
301
+ polygon = instance[0]
302
+
303
+ xx, yy = (np.array(i) for i in polygon.exterior.xy)
304
+ xx = (
305
+ xx * parent_df.loc[parent_id, "dlon"]
306
+ + parent_df.loc[parent_id, "coordinates"][0]
307
+ )
308
+ yy = (
309
+ parent_df.loc[parent_id, "coordinates"][3]
310
+ - yy * parent_df.loc[parent_id, "dlat"]
311
+ )
312
+
313
+ crs = parent_df.loc[parent_id, "crs"]
314
+
315
+ parent_polygon_geo = Polygon(zip(xx, yy))
316
+ self.geo_predictions[parent_id].append(
317
+ [parent_polygon_geo, crs, instance[1]]
318
+ )
319
+
320
+ if return_dataframe:
321
+ return self._dict_to_dataframe(self.geo_predictions, geo=True)
322
+ return self.geo_predictions
323
+
324
+ @staticmethod
325
+ def _dict_to_dataframe(
326
+ preds: dict,
327
+ geo: bool = False,
328
+ ) -> pd.DataFrame:
329
+ """Convert the predictions dictionary to a pandas DataFrame.
330
+
331
+ Parameters
332
+ ----------
333
+ preds : dict
334
+ A dictionary of predictions.
335
+ geo : bool, optional
336
+ Whether the dictionary is georeferenced coords (or pixel bounds), by default True
337
+
338
+ Returns
339
+ -------
340
+ pd.DataFrame
341
+ A pandas DataFrame containing the predictions.
342
+ """
343
+ if geo:
344
+ columns = ["polygon", "crs", "score"]
345
+ else:
346
+ columns = ["polygon", "score"]
347
+
348
+ preds_df = pd.concat(
349
+ pd.DataFrame(
350
+ preds[k],
351
+ index=np.full(len(preds[k]), k),
352
+ columns=columns,
353
+ )
354
+ for k in preds.keys()
355
+ )
356
+ preds_df.index.name = "image_id"
357
+ preds_df.reset_index(inplace=True)
358
+ return preds_df
359
+
360
+ def save_to_geojson(
361
+ self,
362
+ save_path: str | pathlib.Path = "./dptext-detr_text_outputs.geojson",
363
+ ) -> None:
364
+ """Save the georeferenced predictions to a GeoJSON file.
365
+
366
+ Parameters
367
+ ----------
368
+ save_path : str | pathlib.Path, optional
369
+ Path to save the GeoJSON file, by default "./deepsolo_text_outputs.geojson"
370
+ """
371
+
372
+ geo_df = self._dict_to_dataframe(self.geo_predictions, geo=True)
373
+
374
+ # get the crs (should be the same for all polygons)
375
+ assert geo_df["crs"].nunique() == 1
376
+ crs = geo_df["crs"].unique()[0]
377
+
378
+ geo_df = geopd.GeoDataFrame(geo_df, geometry="polygon", crs=crs)
379
+ geo_df.to_file(save_path, driver="GeoJSON")
380
+
381
+ def show(
382
+ self,
383
+ image_id: str,
384
+ figsize: tuple | None = (10, 10),
385
+ border_color: str | None = "r",
386
+ text_color: str | None = "b",
387
+ image_width_resolution: int | None = None,
388
+ return_fig: bool = False,
389
+ ) -> None:
390
+ if image_id in self.patch_predictions.keys():
391
+ preds = self.patch_predictions
392
+ image_path = self.patch_df.loc[image_id, "image_path"]
393
+
394
+ elif image_id in self.parent_predictions.keys():
395
+ preds = self.parent_predictions
396
+ image_path = self.parent_df.loc[image_id, "image_path"]
397
+
398
+ else:
399
+ raise ValueError(
400
+ f"[ERROR] {image_id} not found in patch or parent predictions."
401
+ )
402
+
403
+ img = Image.open(image_path)
404
+
405
+ # if image_width_resolution is specified, resize the image
406
+ if image_width_resolution:
407
+ new_width = int(image_width_resolution)
408
+ rescale_factor = new_width / img.width
409
+ new_height = int(img.height * rescale_factor)
410
+ img = img.resize((new_width, new_height), Image.LANCZOS)
411
+
412
+ fig = plt.figure(figsize=figsize)
413
+ ax = plt.gca()
414
+
415
+ # check if grayscale
416
+ if len(img.getbands()) == 1:
417
+ plt.imshow(img, cmap="gray", vmin=0, vmax=255, zorder=1)
418
+ else:
419
+ plt.imshow(img, zorder=1)
420
+
421
+ for instance in preds[image_id]:
422
+ polygon = np.array(instance[0].exterior.coords.xy)
423
+ center = instance[0].centroid.coords.xy
424
+ patch = patches.Polygon(polygon.T, edgecolor=border_color, facecolor="none")
425
+ ax.add_patch(patch)
426
+ ax.text(
427
+ center[0][0], center[1][0], instance[1], fontsize=8, color=text_color
428
+ )
429
+
430
+ plt.axis("off")
431
+ plt.title(image_id)
432
+
433
+ if return_fig:
434
+ return fig
File without changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: mapreader
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: A computer vision pipeline for the semantic exploration of maps/images at scale
5
5
  Home-page: https://github.com/Living-with-machines/MapReader
6
6
  Download-URL: https://github.com/Living-with-machines/MapReader/archive/refs/heads/main.zip
@@ -34,6 +34,9 @@ mapreader/load/loader.py
34
34
  mapreader/process/__init__.py
35
35
  mapreader/process/post_process.py
36
36
  mapreader/process/process.py
37
+ mapreader/spot_text/__init__.py
38
+ mapreader/spot_text/deepsolo_runner.py
39
+ mapreader/spot_text/dptext_detr_runner.py
37
40
  mapreader/utils/__init__.py
38
41
  mapreader/utils/compute_and_save_stats.py
39
42
  mapreader/utils/slice_parallel.py
File without changes
File without changes
File without changes
File without changes
File without changes