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.
- archival_structures/__init__.py +5 -0
- archival_structures/analysis/allen_interval.py +362 -0
- archival_structures/analysis/grid_analysis.py +254 -0
- archival_structures/analysis/line_clustering.py +104 -0
- archival_structures/analysis/neighbourhood_analysis.py +121 -0
- archival_structures/analysis/opening_detection.py +396 -0
- archival_structures/analysis/page.py +106 -0
- archival_structures/analysis/page_layout_clustering.py +52 -0
- archival_structures/analysis/region_calculus.py +81 -0
- archival_structures/analysis/sequence_patterns.py +230 -0
- archival_structures/analysis/stream_analysis.py +126 -0
- archival_structures/analysis/text_analysis.py +226 -0
- archival_structures/analysis/token_analysis.py +81 -0
- archival_structures/clustering/colour_clustering.py +1199 -0
- archival_structures/clustering/colour_quantisation.py +74 -0
- archival_structures/clustering/image_clustering.py +295 -0
- archival_structures/clustering/peaks.py +281 -0
- archival_structures/clustering/thumbnail_color_clustering.py +0 -0
- archival_structures/datasets/__init__.py +4 -0
- archival_structures/datasets/annotations.py +166 -0
- archival_structures/datasets/images_transcriptions.py +109 -0
- archival_structures/image/__init__.py +4 -0
- archival_structures/image/crop_borders.py +428 -0
- archival_structures/image/image_annotation.py +207 -0
- archival_structures/image/image_base.py +134 -0
- archival_structures/image/image_drawing.py +448 -0
- archival_structures/image/image_processing.py +475 -0
- archival_structures/image/pagexml_bridge.py +83 -0
- archival_structures/model/image.py +230 -0
- archival_structures/model/inventory_number.py +91 -0
- archival_structures/parsers/__init__.py +1 -0
- archival_structures/parsers/ead_parser.py +699 -0
- archival_structures/parsers/ead_start_end_year.py +91 -0
- archival_structures/parsers/mets_parser.py +86 -0
- archival_structures/parsers/read.py +188 -0
- archival_structures/stats/token_stats.py +4 -0
- archival_structures/utils/file.py +15 -0
- archival_structures/utils/image_utils.py +103 -0
- archival_structures/utils/wordlist.py +31 -0
- archival_structures-0.1.0.dist-info/METADATA +154 -0
- archival_structures-0.1.0.dist-info/RECORD +43 -0
- archival_structures-0.1.0.dist-info/WHEEL +4 -0
- 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
|