archival-structures 0.1.0__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.
Files changed (43) hide show
  1. archival_structures/__init__.py +5 -0
  2. archival_structures/analysis/allen_interval.py +362 -0
  3. archival_structures/analysis/grid_analysis.py +254 -0
  4. archival_structures/analysis/line_clustering.py +104 -0
  5. archival_structures/analysis/neighbourhood_analysis.py +121 -0
  6. archival_structures/analysis/opening_detection.py +396 -0
  7. archival_structures/analysis/page.py +106 -0
  8. archival_structures/analysis/page_layout_clustering.py +52 -0
  9. archival_structures/analysis/region_calculus.py +81 -0
  10. archival_structures/analysis/sequence_patterns.py +230 -0
  11. archival_structures/analysis/stream_analysis.py +126 -0
  12. archival_structures/analysis/text_analysis.py +226 -0
  13. archival_structures/analysis/token_analysis.py +81 -0
  14. archival_structures/clustering/colour_clustering.py +1199 -0
  15. archival_structures/clustering/colour_quantisation.py +74 -0
  16. archival_structures/clustering/image_clustering.py +295 -0
  17. archival_structures/clustering/peaks.py +281 -0
  18. archival_structures/clustering/thumbnail_color_clustering.py +0 -0
  19. archival_structures/datasets/__init__.py +4 -0
  20. archival_structures/datasets/annotations.py +166 -0
  21. archival_structures/datasets/images_transcriptions.py +109 -0
  22. archival_structures/image/__init__.py +4 -0
  23. archival_structures/image/crop_borders.py +428 -0
  24. archival_structures/image/image_annotation.py +207 -0
  25. archival_structures/image/image_base.py +134 -0
  26. archival_structures/image/image_drawing.py +448 -0
  27. archival_structures/image/image_processing.py +475 -0
  28. archival_structures/image/pagexml_bridge.py +83 -0
  29. archival_structures/model/image.py +230 -0
  30. archival_structures/model/inventory_number.py +91 -0
  31. archival_structures/parsers/__init__.py +1 -0
  32. archival_structures/parsers/ead_parser.py +699 -0
  33. archival_structures/parsers/ead_start_end_year.py +91 -0
  34. archival_structures/parsers/mets_parser.py +86 -0
  35. archival_structures/parsers/read.py +188 -0
  36. archival_structures/stats/token_stats.py +4 -0
  37. archival_structures/utils/file.py +15 -0
  38. archival_structures/utils/image_utils.py +103 -0
  39. archival_structures/utils/wordlist.py +31 -0
  40. archival_structures-0.1.0.dist-info/METADATA +154 -0
  41. archival_structures-0.1.0.dist-info/RECORD +43 -0
  42. archival_structures-0.1.0.dist-info/WHEEL +4 -0
  43. archival_structures-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,5 @@
1
+ """Tools for analysing PageXML/ATR transcriptions and scan images of archival documents:
2
+ detecting and splitting two-page book openings, clustering text lines and page layouts,
3
+ mining cross-page document-element sequences, ink-colour and missing-transcription detection,
4
+ and parsing EAD/METS archival finding-aid metadata.
5
+ """
@@ -0,0 +1,362 @@
1
+ """Allen Interval Algebra relations between PageXML regions (lines/regions treated as 1D
2
+ horizontal intervals).
3
+
4
+ `get_allen_interval` classifies a pair of regions into one of the 13 base Allen relations
5
+ (e.g. ``before``, ``meets``, ``overlaps``, ``equal``). `get_extended_allen_interval` further
6
+ splits the asymmetric ``overlaps``/``contains``/``starts``/``finishes`` relations by how much
7
+ of each region is covered (``m`` = most, ``l`` = least), giving the finer-grained
8
+ `EXTENDED_ALLEN_INTERVALS` vocabulary -- useful for distinguishing e.g. a small marginal
9
+ annotation overlapping a body-text line from two lines of comparable width overlapping evenly.
10
+ """
11
+
12
+ from collections import Counter
13
+
14
+ import pagexml.model.physical_document_model as pdm
15
+ from pagexml.helper.pagexml_helper import horizontal_group_lines
16
+
17
+
18
+ ALLEN_INTERVALS = {
19
+ 'after', 'meets', 'o',
20
+ 'fi', 'di', 's', 'equal', 'si', 'd', 'f',
21
+ 'oi', 'met by', 'before'
22
+ }
23
+
24
+ EXTENDED_ALLEN_INTERVALS = {
25
+ 'after', 'meets',
26
+ 'mom', 'mol', 'lom', 'lol',
27
+ 'm', 'mfi', 'lfi',
28
+ 'ldi', 'cdi', 'rdi',
29
+ 'ms', 'ls', 'equal', 'lsi', 'msi',
30
+ 'ld', 'cd', 'rd', 'lf', 'mf',
31
+ 'momi', 'moli', 'lomi', 'loli',
32
+ 'met by', 'before'
33
+ }
34
+
35
+
36
+ def get_doc_allen_interval_profile(doc: pdm.PageXMLRegion, use_extended: bool = True):
37
+ """Frequency count of Allen interval relations between consecutive horizontally-grouped
38
+ lines of `doc` (each line compared against the previous and next group), giving a profile
39
+ of how `doc`'s lines relate to their immediate neighbours. Uses
40
+ `get_extended_allen_interval` if `use_extended`, else the base `get_allen_interval`."""
41
+ interval_func = get_extended_allen_interval if use_extended else get_allen_interval
42
+ hor_group_lines = horizontal_group_lines(doc.get_lines())
43
+ prev_group = None
44
+ ai_freq = Counter()
45
+ for ci, curr_group in enumerate(hor_group_lines):
46
+ if prev_group is not None:
47
+ ai_freq.update([interval_func(pl, cl) for cl in curr_group for pl in prev_group])
48
+ if ci < len(hor_group_lines) - 1:
49
+ next_group = hor_group_lines[ci+1]
50
+ ai_freq.update([interval_func(cl, nl) for cl in curr_group for nl in next_group])
51
+ prev_group = curr_group
52
+ return ai_freq
53
+
54
+
55
+ def get_normalised_diff(x: int, y: int, max_diff: int = 0) -> int:
56
+ """`x - y`, snapped to 0 if the difference is within `max_diff` (treats near-identical
57
+ boundaries as exactly aligned)."""
58
+ diff = x - y
59
+ return 0 if abs(diff) <= max_diff else diff
60
+
61
+
62
+ def determine_overlap_relation(region1: pdm.PageXMLDoc, region2: pdm.PageXMLDoc) -> tuple[str | None, str | None]:
63
+ """For each region, whether the horizontal overlap with the other covers at least half
64
+ of its own width (``'m'``, most) or less (``'l'``, least). Returns `(None, None)` if the
65
+ regions don't overlap horizontally at all."""
66
+ overlap = pdm.get_horizontal_overlap(region1, region2)
67
+ if overlap == 0:
68
+ return None, None
69
+ relation1 = 'm' if overlap / region1.coords.width >= 0.5 else 'l'
70
+ relation2 = 'm' if overlap / region2.coords.width >= 0.5 else 'l'
71
+ return relation1, relation2
72
+
73
+
74
+ def get_normalised_region_diffs(region1: pdm.PageXMLDoc, region2: pdm.PageXMLDoc) -> tuple[int, int, int, int]:
75
+ """Left/right boundary differences between `region1` and `region2` (left-vs-left,
76
+ left-vs-right, right-vs-left, right-vs-right), using baselines if both regions have one,
77
+ else bounding boxes. These four values are what `get_allen_interval`/
78
+ `get_extended_allen_interval` branch on to determine the interval relation."""
79
+ if pdm.has_baseline(region1) and pdm.has_baseline(region2):
80
+ # only use baseline when both have them, otherwise, use bounding boxes
81
+ coords1 = region1.baseline
82
+ coords2 = region2.baseline
83
+ else:
84
+ coords1, coords2 = region1.coords, region2.coords
85
+ l1l2 = get_normalised_diff(coords1.left, coords2.left)
86
+ l1r2 = get_normalised_diff(coords1.left, coords2.right)
87
+ r1l2 = get_normalised_diff(coords1.right, coords2.left)
88
+ r1r2 = get_normalised_diff(coords1.right, coords2.right)
89
+ return l1l2, l1r2, r1l2, r1r2
90
+
91
+
92
+ def get_allen_interval(region1: pdm.PageXMLDoc, region2: pdm.PageXMLDoc,
93
+ debug: int = 0):
94
+ """Determine the (Extended) Allen Interval.
95
+
96
+ Use `max_diff` to work with thick boundaries.
97
+
98
+ :param region1: a PageXML document with spatial coordinates
99
+ :param region2: a PageXML document with spatial coordinates
100
+ :param debug: print debugging info
101
+ """
102
+ l1l2, l1r2, r1l2, r1r2 = get_normalised_region_diffs(region1, region2)
103
+ overlap1, overlap2 = determine_overlap_relation(region1, region2)
104
+ if debug > 0:
105
+ print(f"allen_interval.get_extended_allen_interval\n\tl1: left: {pdm.coords_as_span(region1.coords)}")
106
+ print(f"allen_interval.get_extended_allen_interval\n\tl2: left: {pdm.coords_as_span(region2.coords)}")
107
+ print(f"allen_interval.get_extended_allen_interval\n\tl1l2: {l1l2} r1r2: {r1r2} "
108
+ f"r1l2: {r1l2}\n\toverlap1: {overlap1} overlap2: {overlap2}")
109
+ if l1l2 < 0:
110
+ if r1r2 < 0:
111
+ if r1l2 < 0:
112
+ # --
113
+ # --
114
+ return 'after'
115
+ elif r1l2 == 0:
116
+ # ---
117
+ # ---
118
+ return 'meets'
119
+ else:
120
+ # ---
121
+ # -----
122
+ return 'o'
123
+ elif r1r2 == 0:
124
+ # ------
125
+ # ----
126
+ return "fi"
127
+ else:
128
+ # ------
129
+ # --
130
+ return 'di'
131
+ elif l1l2 == 0:
132
+ if r1r2 < 0:
133
+ # ----
134
+ # ------
135
+ return 's'
136
+ elif r1r2 == 0:
137
+ # no shift, complete overlap (equal)
138
+ # ------
139
+ # ------
140
+ return 'equal'
141
+ else:
142
+ # r1r2 > 0
143
+ # ------
144
+ # ----
145
+ return 'si'
146
+ else:
147
+ # l1l2 > 0
148
+ if r1r2 < 0:
149
+ # --
150
+ # ------
151
+ return 'd'
152
+ elif r1r2 == 0:
153
+ # ----
154
+ # ------
155
+ return 'f'
156
+ else:
157
+ # r1r2 > 0
158
+ if l1r2 < 0:
159
+ # -----
160
+ # ---
161
+ return 'oi'
162
+ elif l1r2 == 0:
163
+ # ---
164
+ # ---
165
+ return 'met by'
166
+ else:
167
+ # --
168
+ # --
169
+ return 'before'
170
+
171
+
172
+ def get_extended_allen_interval(region1: pdm.PageXMLDoc, region2: pdm.PageXMLDoc,
173
+ debug: int = 0):
174
+ """Determine the (Extended) Allen Interval.
175
+
176
+ Use `max_diff` to work with thick boundaries.
177
+
178
+ :param region1: a PageXML document with spatial coordinates
179
+ :param region2: a PageXML document with spatial coordinates
180
+ :param debug: print debugging info
181
+ """
182
+ l1l2, l1r2, r1l2, r1r2 = get_normalised_region_diffs(region1, region2)
183
+ overlap1, overlap2 = determine_overlap_relation(region1, region2)
184
+ if debug > 0:
185
+ print(f"allen_interval.get_extended_allen_interval\n\tl1: left: {pdm.coords_as_span(region1.coords)}")
186
+ print(f"allen_interval.get_extended_allen_interval\n\tl2: left: {pdm.coords_as_span(region2.coords)}")
187
+ print(f"allen_interval.get_extended_allen_interval\n\tl1l2: {l1l2} r1r2: {r1r2} "
188
+ f"r1l2: {r1l2}\n\toverlap1: {overlap1} overlap2: {overlap2}")
189
+ if l1l2 < 0:
190
+ if r1r2 < 0:
191
+ if r1l2 < 0:
192
+ # --
193
+ # --
194
+ return 'after'
195
+ elif r1l2 == 0:
196
+ # ---
197
+ # ---
198
+ return 'meets'
199
+ else:
200
+ if overlap1 == 'l' and overlap2 == 'm':
201
+ # right-shift, shrink, most part 2 overlapped by least part 1 (lom)
202
+ # -----
203
+ # ---
204
+ return 'lom'
205
+ elif overlap1 == 'm' and overlap2 == 'l':
206
+ # right-shift, stretch, most part 1 overlaps least part 2 (mol)
207
+ # ---
208
+ # -----
209
+ return 'mol'
210
+ elif overlap1 == 'm' and overlap2 == 'm':
211
+ # right-shift, most part overlaps most part (mom)
212
+ # -----
213
+ # -----
214
+ return 'mom'
215
+ elif overlap1 == 'l' and overlap2 == 'l':
216
+ # right-shift, least part overlaps least part (lol)
217
+ # ----
218
+ # ---
219
+ return 'lol'
220
+ else:
221
+ raise ValueError(f"unexpected situation - l1l2: {l1l2} r1r2: {r1r2} "
222
+ f"r1l2: {r1l2} overlap1: {overlap1} overlap2: {overlap2}")
223
+ elif r1r2 == 0:
224
+ if overlap1 == 'm':
225
+ # right-shift, finished by, most part overlap (mfi)
226
+ # ------
227
+ # ----
228
+ return 'mfi'
229
+ elif overlap1 == 'l':
230
+ # right-shift, finished by, least part overlap (lfi)
231
+ # ------
232
+ # --
233
+ return "lfi"
234
+ else:
235
+ raise ValueError(f"unexpected situation - l1l2: {l1l2} r1r2: {r1r2} "
236
+ f"r1l2: {r1l2} l1r2: {l1r2} "
237
+ f"overlap1: {overlap1} overlap2: {overlap2}")
238
+ else:
239
+ diff = get_normalised_diff(abs(l1l2), abs(r1r2))
240
+ if diff < 0:
241
+ # right-shift, contained by, right-aligned
242
+ # ------
243
+ # --
244
+ return 'ldi'
245
+ elif diff == 0:
246
+ # no shift, contained by, center-aligned
247
+ # ------
248
+ # ----
249
+ return 'cdi'
250
+ else:
251
+ # left-shift, contained by, left-aligned
252
+ # ------
253
+ # --
254
+ return 'rdi'
255
+ elif l1l2 == 0:
256
+ if r1r2 < 0:
257
+ if overlap2 == 'm':
258
+ # right-shift, starts, overlaps with most part (ms)
259
+ # ----
260
+ # ------
261
+ return 'ms'
262
+ elif overlap2 == 'l':
263
+ # right-shift, starts, overlaps with least part (ls)
264
+ # --
265
+ # ------
266
+ return 'ls'
267
+ else:
268
+ raise ValueError(f"unexpected situation - l1l2: {l1l2} r1r2: {r1r2} "
269
+ f"r1l2: {r1l2} l1r2: {l1r2} "
270
+ f"overlap1: {overlap1} overlap2: {overlap2}")
271
+ elif r1r2 == 0:
272
+ # no shift, complete overlap (equal)
273
+ # ------
274
+ # ------
275
+ return 'equal'
276
+ else:
277
+ # r1r2 > 0
278
+ if overlap1 == 'm':
279
+ # left-shift, starts, most part overlaps
280
+ # ------
281
+ # ----
282
+ return 'msi'
283
+ elif overlap1 == 'l':
284
+ # left-shift, starts, least part overlaps
285
+ # ------
286
+ # --
287
+ return 'lsi'
288
+ else:
289
+ raise ValueError(f"unexpected situation - l1l2: {l1l2} r1r2: {r1r2} "
290
+ f"r1l2: {r1l2} l1r2: {l1r2} "
291
+ f"overlap1: {overlap1} overlap2: {overlap2}")
292
+ else:
293
+ # l1l2 > 0
294
+ if r1r2 < 0:
295
+ diff = get_normalised_diff(abs(l1l2), abs(r1r2))
296
+ if diff < 0:
297
+ # right-shift, contains, left-aligned
298
+ # --
299
+ # ------
300
+ return 'ld'
301
+ elif diff == 0:
302
+ # no shift, contains, center-aligned
303
+ # ----
304
+ # ------
305
+ return 'cd'
306
+ else:
307
+ # left-shift, contains, right-aligned
308
+ # --
309
+ # ------
310
+ return 'rd'
311
+ elif r1r2 == 0:
312
+ if overlap2 == 'm':
313
+ # left-shift, finishes, most part overlap (mfi)
314
+ # ----
315
+ # ------
316
+ return 'mf'
317
+ elif overlap2 == 'l':
318
+ # left-shift, finishes, least part overlap (lfi)
319
+ # --
320
+ # ------
321
+ return 'lf'
322
+ else:
323
+ raise ValueError(f"unexpected situation - l1l2: {l1l2} r1r2: {r1r2} "
324
+ f"r1l2: {r1l2} l1r2: {l1r2} "
325
+ f"overlap1: {overlap1} overlap2: {overlap2}")
326
+ else:
327
+ # r1r2 > 0
328
+ if l1r2 < 0:
329
+ if overlap1 == 'l' and overlap2 == 'm':
330
+ # left-shift, shrink, least part 2 overlapped by most part 1 (lomi)
331
+ # -----
332
+ # ---
333
+ return 'lomi'
334
+ elif overlap1 == 'm' and overlap2 == 'l':
335
+ # left-shift, stretch, least part 2 overlaps most part 1 (moli)
336
+ # ---
337
+ # -----
338
+ return 'moli'
339
+ elif overlap1 == 'm' and overlap2 == 'm':
340
+ # left-shift, most part overlaps most part (momi)
341
+ # -----
342
+ # -----
343
+ return 'momi'
344
+ elif overlap1 == 'l' and overlap2 == 'l':
345
+ # left-shift, least part overlaps least part (loli)
346
+ # ---
347
+ # ----
348
+ return 'loli'
349
+ else:
350
+ print(f"region 1: {region1.id} {region1.coords.box_string}")
351
+ print(f"region 2: {region2.id} {region2.coords.box_string}")
352
+ raise ValueError(f"unexpected situation - l1l2: {l1l2} r1r2: {r1r2} "
353
+ f"r1l2: {r1l2} l1r2: {l1r2} "
354
+ f"overlap1: {overlap1} overlap2: {overlap2}")
355
+ elif l1r2 == 0:
356
+ # ---
357
+ # ---
358
+ return 'met by'
359
+ else:
360
+ # --
361
+ # --
362
+ return 'before'
@@ -0,0 +1,254 @@
1
+ """Grid-based page-layout fingerprinting.
2
+
3
+ Each page in a `doc_set` is rasterised onto a shared `unit_size`-pixel grid (one cell is "on"
4
+ wherever a text line falls), then `GridPattern` slides a small `pattern_size` x `pattern_size`
5
+ window across that grid and counts how often each local on/off pattern occurs, both
6
+ per-document and corpus-wide. The resulting per-document TF-IDF vector (`GridPattern.tfidf`)
7
+ is a layout fingerprint that `archival_structures.analysis.page_layout_clustering` clusters
8
+ with HDBSCAN -- two pages with a similar arrangement of text lines get similar fingerprints,
9
+ regardless of the actual text content.
10
+ """
11
+
12
+ import pickle
13
+ import time
14
+ from collections import Counter, defaultdict
15
+ from typing import List, Set, Union
16
+
17
+ import numpy as np
18
+ import pagexml.model.physical_document_model as pdm
19
+ import pagexml.analysis.layout_stats as layout
20
+ from numpy.lib.stride_tricks import sliding_window_view
21
+
22
+
23
+ def get_doc_set_grid_points(doc_set: Union[List[pdm.PageXMLRegion], Set[pdm.PageXMLRegion]],
24
+ unit_size: int) -> dict[tuple[int, int], int]:
25
+ """All `(x, y)` grid coordinates (spaced `unit_size` pixels apart) covering the largest
26
+ page in `doc_set`, mapped to a dense integer index -- the shared coordinate system every
27
+ page in the set is rasterised onto."""
28
+ max_x, max_y = get_interpolated_max_x_y(doc_set, unit_size=unit_size)
29
+ grid_points = [(x, y) for x in range(0, max_x, unit_size) for y in range(0, max_y, unit_size)]
30
+ print("done setting base grid points")
31
+
32
+ return {point: pi for pi, point in enumerate(grid_points)}
33
+
34
+
35
+ def get_interpolated_max_x_y(doc_set: Union[List[pdm.PageXMLRegion], Set[pdm.PageXMLRegion]],
36
+ unit_size: int = 50) -> tuple[int, int]:
37
+ """The largest page width/height in `doc_set`, rounded to the nearest `unit_size`."""
38
+ max_x = max(page.coords.right for page in doc_set)
39
+ max_y = max(page.coords.bottom for page in doc_set)
40
+
41
+ max_x = int(np.round(max_x / unit_size, 0) * unit_size)
42
+ max_y = int(np.round(max_y / unit_size, 0) * unit_size)
43
+ return max_x, max_y
44
+
45
+
46
+ def doc_grid_points_to_vectors(doc_grid_points: np.array) -> np.array:
47
+ """Flatten a `(num_docs, range_x, range_y)` grid-point array into `(num_docs, range_x *
48
+ range_y)`, one row per document."""
49
+ dims = doc_grid_points.shape
50
+ if len(dims) != 3:
51
+ raise IndexError(f"invalid shape for doc_grid_pionts: {dims}, must be a 3D array.")
52
+ return np.resize(doc_grid_points, (dims[0], dims[1] * dims[2]))
53
+
54
+
55
+ def time_step(start, prev_step):
56
+ """Print elapsed time since `prev_step` and since `start`, returning the current time
57
+ (for chaining: `step = time_step(start, step)`)."""
58
+ curr_step = time.time()
59
+ took = curr_step - prev_step
60
+ total = curr_step - start
61
+ print(f"took {took: >.1f} seconds (total: {total: >.1f})")
62
+ return curr_step
63
+
64
+
65
+ def get_line_grid_left_right(line: pdm.PageXMLTextLine, unit_size: int = 50, indent: int = 0):
66
+ """`line`'s left/right extent (from its baseline if available, else its coordinates),
67
+ snapped to the nearest `unit_size` grid line. `indent` shifts the snapped boundary inward
68
+ on both sides before rounding."""
69
+ if line.baseline is not None and line.baseline.points is not None:
70
+ left = line.baseline.left
71
+ right = line.baseline.right
72
+ elif line.coords is not None and line.coords.points is not None:
73
+ left = line.coords.left
74
+ right = line.coords.right
75
+ else:
76
+ return None, None
77
+ # print(f"original left: {left}\toriginal right: {right}")
78
+ left += indent
79
+ dist_from_prev = left % unit_size
80
+ dist_from_next = unit_size - dist_from_prev
81
+ # print(f"left dist_from_next: {dist_from_next}")
82
+ left = left - dist_from_prev if dist_from_next > unit_size / 4 else left + dist_from_next
83
+ right -= indent
84
+ dist_from_prev = right % unit_size
85
+ dist_from_next = unit_size - dist_from_prev
86
+ # print(f"right dist_from_next: {dist_from_next}")
87
+ right = right - dist_from_prev if dist_from_prev < unit_size / 4 else right + dist_from_next
88
+ return left, right
89
+
90
+
91
+ def get_line_grid_top_bottom(line: pdm.PageXMLTextLine, unit_size: int = 50):
92
+ """`line`'s top/bottom extent (from its baseline and x-height if available, else a
93
+ margin-trimmed fraction of its bounding box), snapped to the nearest `unit_size` grid
94
+ line."""
95
+ if line.baseline is not None and line.baseline.points is not None:
96
+ if line.xheight is not None:
97
+ top = line.baseline.top - line.xheight
98
+ else:
99
+ top = line.coords.top + int(line.coords.height * 0.25)
100
+ bottom = line.baseline.bottom
101
+ elif line.coords is not None and line.coords.points is not None:
102
+ top = line.coords.top + int(line.coords.height * 0.25)
103
+ bottom = line.coords.bottom - int(line.coords.height * 0.25)
104
+ else:
105
+ return None
106
+ # print(f"original bottom: {bottom}\toriginal top: {top}")
107
+ dist_from_prev = top % unit_size
108
+ dist_from_next = unit_size - dist_from_prev
109
+ top = top - dist_from_prev if dist_from_next > unit_size / 4 else top + dist_from_next
110
+ dist_from_prev = bottom % unit_size
111
+ dist_from_next = unit_size - dist_from_prev
112
+ bottom = bottom - dist_from_prev if dist_from_prev < unit_size / 4 else bottom + dist_from_next
113
+ return top, bottom
114
+
115
+
116
+ def get_line_grid_points(doc_set: Union[List[pdm.PageXMLRegion], Set[pdm.PageXMLRegion]],
117
+ unit_size: int = 50, indent: int = 0) -> np.array:
118
+ """Rasterise every document in `doc_set` onto a shared `unit_size`-pixel grid: a
119
+ `(num_docs, range_x, range_y)` array where a cell is 1.0 wherever any text line's
120
+ (snapped) bounding box falls, 0.0 elsewhere."""
121
+ max_x, max_y = get_interpolated_max_x_y(doc_set, unit_size=unit_size)
122
+ range_x, range_y = int(max_x / unit_size), int(max_y / unit_size)
123
+
124
+ doc_grid_points = np.zeros((len(doc_set), range_x, range_y))
125
+
126
+ for di, doc in enumerate(doc_set):
127
+ for line in doc.get_lines():
128
+ left, right = get_line_grid_left_right(line, unit_size=unit_size, indent=indent)
129
+ top, bottom = get_line_grid_top_bottom(line, unit_size=unit_size)
130
+ x_start, x_end = int(left / unit_size), int(right / unit_size)
131
+ y_start, y_end = int(top / unit_size), int(bottom / unit_size)
132
+ doc_grid_points[di][x_start:x_end, y_start:y_end] = 1.0
133
+ print(f"done setting line grid_points for {len(doc_set)} docs, "
134
+ f"with {range_x} x and {range_y} y points")
135
+ return doc_grid_points
136
+
137
+
138
+ def get_region_grid_points(doc_set: Union[List[pdm.PageXMLRegion], Set[pdm.PageXMLRegion]],
139
+ unit_size: int = 50) -> np.array:
140
+ """Like `get_line_grid_points`, but rasterised from text *region* bounding boxes rather
141
+ than individual lines -- coarser, since region detection tends to merge visually distinct
142
+ elements (prefer `get_line_grid_points` for layout fingerprinting)."""
143
+ positions = ['left', 'top', 'right', 'bottom']
144
+ max_x, max_y = get_interpolated_max_x_y(doc_set, unit_size=unit_size)
145
+ range_x, range_y = int(max_x / unit_size), int(max_y / unit_size)
146
+
147
+ doc_grid_points = np.zeros((len(doc_set), range_x, range_y))
148
+
149
+ for di, doc in enumerate(doc_set):
150
+ for tr in doc.get_textual_regions():
151
+ tr_pos = {}
152
+ for pos in positions:
153
+ tr_pos[pos] = int(np.round(tr.coords.__getattribute__(pos) / unit_size, 0))
154
+ x_start, x_end = tr_pos['left'], tr_pos['right']
155
+ y_start, y_end = tr_pos['top'], tr_pos['bottom']
156
+ doc_grid_points[di][x_start:x_end, y_start:y_end] = 1.0
157
+ print(f"done setting region grid_points for {len(doc_set)} docs, "
158
+ f"with {range_x} x and {range_y} y points")
159
+ return doc_grid_points
160
+
161
+
162
+ class GridPattern:
163
+ """Per-document layout fingerprints, built by rasterising each document's text lines onto
164
+ a shared grid (`get_line_grid_points`) and counting local `pattern_size` x `pattern_size`
165
+ on/off patterns across that grid, both per-document and corpus-wide. `self.tfidf` (shape
166
+ `(num_docs, vocab_size)`) is the resulting fingerprint matrix, suitable for clustering or
167
+ nearest-neighbour search; `self.doc_pfidf(doc_id)` looks up one document's row by id."""
168
+
169
+ def __init__(self, doc_set: Union[List[pdm.PageXMLRegion], Set[pdm.PageXMLRegion]],
170
+ unit_size: int = 50, pattern_size: int = 3):
171
+ # self.doc_set = doc_set
172
+ self.doc_ids = [doc.id for doc in doc_set]
173
+ self.id2idx = {doc.id: di for di, doc in enumerate(doc_set)}
174
+ self.idx2id = {di: doc.id for di, doc in enumerate(doc_set)}
175
+ self.num_docs = len(doc_set)
176
+ self.unit_size = unit_size
177
+ self.pattern_size = pattern_size
178
+ start = time.time()
179
+ self.grid_points = get_doc_set_grid_points(doc_set, unit_size=unit_size)
180
+ step = time_step(start, start)
181
+ self.doc_grid_points = get_line_grid_points(doc_set, unit_size)
182
+ step = time_step(start, step)
183
+ self.coll_pattern_freq = Counter()
184
+ self.doc_pattern_freq = defaultdict(Counter)
185
+ self.pattern_doc_freq = Counter()
186
+ self._compute_pattern_freq()
187
+ step = time_step(start, step)
188
+ self.vocab = {pattern: pi for pi, pattern in enumerate(self.pattern_doc_freq.keys())}
189
+ self.vocab_inv = {v: k for k, v in self.vocab.items()}
190
+ self.vocab_size = len(self.vocab)
191
+ self.tf = None
192
+ self.tfidf = self._compute_pattern_tfidf()
193
+ time_step(start, step)
194
+
195
+ def doc_pf(self, doc_id):
196
+ """Raw local-pattern frequency counter (`Counter`) for the document with id `doc_id`."""
197
+ if doc_id not in self.id2idx:
198
+ raise KeyError(f"unknown doc_id {doc_id}")
199
+ return self.doc_pattern_freq[self.id2idx[doc_id]]
200
+
201
+ def doc_pfidf(self, doc_id):
202
+ """TF-IDF layout-fingerprint vector (shape `(vocab_size,)`) for the document with id
203
+ `doc_id` -- a row of `self.tfidf`."""
204
+ if doc_id not in self.id2idx:
205
+ raise KeyError(f"unknown doc_id {doc_id}")
206
+ return self.tfidf[self.id2idx[doc_id]]
207
+
208
+ def _compute_pattern_freq(self):
209
+ window_shape = (self.pattern_size, self.pattern_size)
210
+ for di in range(0, self.num_docs):
211
+ windows = sliding_window_view(self.doc_grid_points[di], window_shape)
212
+ patterns = np.reshape(windows, (windows.shape[0] * windows.shape[1], self.pattern_size**2))
213
+ patterns = [tuple(pattern) for pattern in patterns]
214
+ # patterns = np.array([(2 ** (np.where(pattern)[0]+1)).sum() for pattern in patterns])
215
+ self.doc_pattern_freq[di].update(patterns)
216
+ self.coll_pattern_freq.update(patterns)
217
+
218
+ for doc_id in self.doc_pattern_freq:
219
+ self.pattern_doc_freq.update([pattern for pattern in self.doc_pattern_freq[doc_id]])
220
+ print(f"done computing patterns, {len(self.coll_pattern_freq)} distinct patterns")
221
+
222
+ def _compute_pattern_tfidf(self):
223
+ self.tf = np.zeros((self.num_docs, self.vocab_size))
224
+ tf_idf = np.zeros((self.num_docs, self.vocab_size))
225
+ for di in self.doc_pattern_freq:
226
+ doc_length = sum(self.doc_pattern_freq[di].values())
227
+ tf_idf[di] = np.zeros(self.vocab_size)
228
+ for pattern, tf in self.doc_pattern_freq[di].most_common():
229
+ prob_tf = tf / doc_length
230
+ log_tf = 1 + np.log(tf)
231
+ cf = self.coll_pattern_freq[pattern]
232
+ df = self.pattern_doc_freq[pattern]
233
+ # idf = np.log((self.num_docs - df) / df)
234
+ idf = np.log(self.num_docs / df)
235
+ if idf < 0.0:
236
+ print(tf, cf, df, self.num_docs, self.num_docs - df + 1, idf)
237
+ pattern_idx = self.vocab[pattern]
238
+ self.tf[di][pattern_idx] = prob_tf
239
+ tf_idf[di][pattern_idx] = log_tf * idf
240
+
241
+ print("done computing tf-idf of patterns")
242
+ return tf_idf
243
+
244
+ def save(self, filename: str):
245
+ """Pickle this `GridPattern` to `filename`."""
246
+ with open(filename, 'wb') as fh:
247
+ pickle.dump(self, fh)
248
+
249
+ @staticmethod
250
+ def load(filename: str) -> 'GridPattern':
251
+ """Load a `GridPattern` previously written with `save`."""
252
+ with open(filename, 'rb') as fh:
253
+ grid_pattern = pickle.load(fh)
254
+ return grid_pattern