chugr 0.2.0.dev0__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.
- chugr/__init__.py +46 -0
- chugr/app/test.py +105 -0
- chugr/common/__init__.py +9 -0
- chugr/common/cli.py +234 -0
- chugr/common/collate.py +104 -0
- chugr/common/config.py +335 -0
- chugr/common/random.py +76 -0
- chugr/common/serialize.py +150 -0
- chugr/common/task_config.py +24 -0
- chugr/common/types.py +122 -0
- chugr/common/urls.py +88 -0
- chugr/doc/__init__.py +17 -0
- chugr/doc/constants.py +15 -0
- chugr/doc/doc_processor.py +248 -0
- chugr/doc/doc_read_processor.py +249 -0
- chugr/doc/doc_vqa_processor.py +113 -0
- chugr/doc/synth_text.py +212 -0
- chugr/hfds/__init__.py +2 -0
- chugr/hfds/collate.py +83 -0
- chugr/hfds/loader.py +385 -0
- chugr/hfds/wrappers.py +108 -0
- chugr/image/__init__.py +4 -0
- chugr/image/build_transforms_doc.py +427 -0
- chugr/image/build_transforms_image.py +203 -0
- chugr/image/transforms_alb.py +112 -0
- chugr/image/transforms_factory.py +58 -0
- chugr/image/transforms_torch.py +137 -0
- chugr/loader.py +249 -0
- chugr/naflex.py +357 -0
- chugr/task_pipeline/__init__.py +7 -0
- chugr/task_pipeline/pipeline_doc_read.py +66 -0
- chugr/task_pipeline/pipeline_doc_vqa.py +124 -0
- chugr/task_pipeline/pipeline_factory.py +32 -0
- chugr/task_pipeline/pipeline_gtparse.py +93 -0
- chugr/task_pipeline/pipeline_image_text.py +90 -0
- chugr/task_pipeline/pipeline_manual.py +27 -0
- chugr/text/__init__.py +1 -0
- chugr/text/tokenization.py +222 -0
- chugr/version.py +1 -0
- chugr/wds/__init__.py +7 -0
- chugr/wds/dataset_info.py +65 -0
- chugr/wds/decode.py +319 -0
- chugr/wds/filters.py +142 -0
- chugr/wds/helpers.py +68 -0
- chugr/wds/loader.py +187 -0
- chugr/wds/pipeline.py +182 -0
- chugr/wds/shardlists.py +134 -0
- chugr/wds/tariterators.py +70 -0
- chugr-0.2.0.dev0.dist-info/METADATA +436 -0
- chugr-0.2.0.dev0.dist-info/RECORD +53 -0
- chugr-0.2.0.dev0.dist-info/WHEEL +4 -0
- chugr-0.2.0.dev0.dist-info/entry_points.txt +4 -0
- chugr-0.2.0.dev0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Optional, Callable
|
|
3
|
+
|
|
4
|
+
from chugr import ImageFeatureInfo, FeatureInfo
|
|
5
|
+
from chugr.doc import DocProcessor, DEFAULT_DOC_FEAT
|
|
6
|
+
from chugr.doc.doc_processor import get_next_valid_page_index, _get_value, _logger
|
|
7
|
+
from chugr.doc.synth_text import SynthTextCfg, SynthTextRenderer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DocReadProcessor(DocProcessor):
|
|
11
|
+
""" Process documents w/ OCR annotation for reading tasks.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
image_process_fn: Optional[Callable] = None,
|
|
17
|
+
text_process_fn: Optional[Callable] = None,
|
|
18
|
+
image_input_feat: ImageFeatureInfo = DEFAULT_DOC_FEAT,
|
|
19
|
+
text_input_feat: FeatureInfo = FeatureInfo('text_input', input_key='pages'),
|
|
20
|
+
text_target_feat: FeatureInfo = FeatureInfo('text_target', input_key=None),
|
|
21
|
+
line_break: str = '\n',
|
|
22
|
+
page_sampling: str = 'random',
|
|
23
|
+
render_dpi: int = 150,
|
|
24
|
+
squeeze_pages: bool = True,
|
|
25
|
+
expand_pages: bool = False,
|
|
26
|
+
flatten_json: bool = True,
|
|
27
|
+
seed: int = 0,
|
|
28
|
+
synth_prob: float = 0.,
|
|
29
|
+
synth_cfg: Optional[SynthTextCfg] = None,
|
|
30
|
+
):
|
|
31
|
+
"""
|
|
32
|
+
Args:
|
|
33
|
+
synth_prob: probability of replacing a sample's page image w/ a synthetic rendering of (part of) its
|
|
34
|
+
annotation text (target == rendered text, no layout annotations used, page image not decoded)
|
|
35
|
+
synth_cfg: synthetic text rendering config
|
|
36
|
+
"""
|
|
37
|
+
super().__init__(
|
|
38
|
+
image_process_fn=image_process_fn,
|
|
39
|
+
text_process_fn=text_process_fn,
|
|
40
|
+
image_input_feat=image_input_feat,
|
|
41
|
+
text_input_feat=text_input_feat,
|
|
42
|
+
text_target_feat=text_target_feat,
|
|
43
|
+
render_dpi=render_dpi,
|
|
44
|
+
page_sampling=page_sampling,
|
|
45
|
+
squeeze_pages=squeeze_pages,
|
|
46
|
+
expand_pages=expand_pages,
|
|
47
|
+
flatten_json=flatten_json,
|
|
48
|
+
seed=seed,
|
|
49
|
+
)
|
|
50
|
+
self.line_break = line_break
|
|
51
|
+
assert page_sampling in ('random', 'first', 'all_valid', 'all')
|
|
52
|
+
self.synth_prob = synth_prob
|
|
53
|
+
self.synth = SynthTextRenderer(synth_cfg) if synth_prob > 0 else None
|
|
54
|
+
|
|
55
|
+
def __call__(self, sample):
|
|
56
|
+
if self.synth is not None and self.generator.random() < self.synth_prob:
|
|
57
|
+
return self._synth_sample(sample)
|
|
58
|
+
return super().__call__(sample)
|
|
59
|
+
|
|
60
|
+
def _process_text(self, text):
|
|
61
|
+
""" Tokenize text (if text_process_fn set), returns (text input, text target or None). """
|
|
62
|
+
if self.text_process_fn is None:
|
|
63
|
+
return text, None
|
|
64
|
+
processed = self.text_process_fn(text)
|
|
65
|
+
assert self.text_input_name in processed, \
|
|
66
|
+
f"Text input name '{self.text_input_name}' not found in processed sample."
|
|
67
|
+
target = processed.get(self.text_target_name) if self.text_target_name else None
|
|
68
|
+
if self.text_target_feat is not None and target is None:
|
|
69
|
+
assert False, f"Expected a text target named '{self.text_target_name}' in processed sample."
|
|
70
|
+
return processed[self.text_input_name], target
|
|
71
|
+
|
|
72
|
+
def _synth_sample(self, sample):
|
|
73
|
+
if 'json' in sample:
|
|
74
|
+
if isinstance(sample['json'], bytes):
|
|
75
|
+
sample['json'] = json.loads(sample['json'])
|
|
76
|
+
if self.flatten_json:
|
|
77
|
+
sample.update(sample.pop('json'))
|
|
78
|
+
anno = _get_value(self.text_input_key, sample)
|
|
79
|
+
assert anno, f"No annotation found with keys ({self.text_input_key})."
|
|
80
|
+
pages = [p['lines'] if 'lines' in p else p for p in anno]
|
|
81
|
+
valid = [i for i, p in enumerate(pages) if p.get('text')]
|
|
82
|
+
if not valid:
|
|
83
|
+
raise RuntimeError("No text on any page, skipping sample...")
|
|
84
|
+
page_index = self.generator.choice(valid)
|
|
85
|
+
image, lines = self.synth(pages[page_index]['text'], rng=self.generator)
|
|
86
|
+
if self.image_input_feat.image_mode and image.mode != self.image_input_feat.image_mode:
|
|
87
|
+
image = image.convert(self.image_input_feat.image_mode)
|
|
88
|
+
image = self._preprocess_image_pages([image])[0]
|
|
89
|
+
text_input, text_target = self._process_text(self.line_break.join(lines))
|
|
90
|
+
|
|
91
|
+
output = {
|
|
92
|
+
self.image_input_name: image,
|
|
93
|
+
self.text_input_name: text_input,
|
|
94
|
+
'_parse': {
|
|
95
|
+
'num_pages': len(anno),
|
|
96
|
+
'page_indices': [page_index],
|
|
97
|
+
'page_text': [lines],
|
|
98
|
+
'synthetic': True,
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
if text_target is not None:
|
|
102
|
+
output[self.text_target_name] = text_target
|
|
103
|
+
if not self.squeeze_pages:
|
|
104
|
+
for k in (self.image_input_name, self.text_input_name, self.text_target_name):
|
|
105
|
+
if k in output:
|
|
106
|
+
output[k] = [output[k]]
|
|
107
|
+
return output
|
|
108
|
+
|
|
109
|
+
def _process_anno_pages(self, anno):
|
|
110
|
+
assert isinstance(anno, (list, tuple)), f"Annotation should be a list of pages"
|
|
111
|
+
num_pages = len(anno)
|
|
112
|
+
if not num_pages:
|
|
113
|
+
raise RuntimeError("Empty annotation. Skipping...")
|
|
114
|
+
|
|
115
|
+
# FIXME for initial behaviour we will randomly sample one of N pages
|
|
116
|
+
# TODO determine if we want to train in multi-page mode, use another sampling strategy?
|
|
117
|
+
page_indices = []
|
|
118
|
+
try:
|
|
119
|
+
if self.page_sampling == 'random':
|
|
120
|
+
n_wanted_pages = min(1, num_pages) # TODO increase for multi-page processing, rand start+end?
|
|
121
|
+
current_index = self.generator.randrange(-1, num_pages - 1)
|
|
122
|
+
for _ in range(n_wanted_pages):
|
|
123
|
+
current_index = get_next_valid_page_index(current_index, num_pages, anno)
|
|
124
|
+
page_indices.append(current_index)
|
|
125
|
+
elif self.page_sampling == 'first':
|
|
126
|
+
current_index = get_next_valid_page_index(-1, num_pages, anno)
|
|
127
|
+
page_indices.append(current_index)
|
|
128
|
+
elif self.page_sampling == 'all_valid':
|
|
129
|
+
current_index = -1
|
|
130
|
+
for _ in range(num_pages):
|
|
131
|
+
current_index = get_next_valid_page_index(current_index, num_pages, anno)
|
|
132
|
+
page_indices.append(current_index)
|
|
133
|
+
elif self.page_sampling == 'all':
|
|
134
|
+
page_indices = list(range(num_pages))
|
|
135
|
+
except RuntimeError:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
if not page_indices:
|
|
139
|
+
raise RuntimeError("No valid annotated pages. Skipping...")
|
|
140
|
+
|
|
141
|
+
text_pages = []
|
|
142
|
+
tokenized_text_pages = []
|
|
143
|
+
target_pages = []
|
|
144
|
+
for current_index in page_indices:
|
|
145
|
+
# FIXME currently encoding each page separately with own start/end tokens.
|
|
146
|
+
# For multi-age should consider encoding in one sequence w/ page-break tokens.
|
|
147
|
+
anno_page = anno[current_index]
|
|
148
|
+
if 'lines' in anno_page:
|
|
149
|
+
# Two supported formats right now
|
|
150
|
+
# {
|
|
151
|
+
# 'pages': [
|
|
152
|
+
# {
|
|
153
|
+
# 'text': [], # these are lines
|
|
154
|
+
# 'bbox': [],
|
|
155
|
+
# }
|
|
156
|
+
# ]
|
|
157
|
+
# }
|
|
158
|
+
#
|
|
159
|
+
# OR
|
|
160
|
+
#
|
|
161
|
+
# {
|
|
162
|
+
# 'pages': [
|
|
163
|
+
# {
|
|
164
|
+
# 'lines': {
|
|
165
|
+
# 'text': [],
|
|
166
|
+
# 'bbox': [],
|
|
167
|
+
# },
|
|
168
|
+
# 'words': {
|
|
169
|
+
# 'text': [],
|
|
170
|
+
# 'bbox': [],
|
|
171
|
+
# }
|
|
172
|
+
# }
|
|
173
|
+
# ]
|
|
174
|
+
# }
|
|
175
|
+
#
|
|
176
|
+
#
|
|
177
|
+
anno_page = anno_page['lines']
|
|
178
|
+
|
|
179
|
+
# Currently page text is created by concatenating lines of text with a CR line break
|
|
180
|
+
# Additions could involve:
|
|
181
|
+
# * using different line-break tokens between lines
|
|
182
|
+
# * using word-level bbox anno information to mask works and construct partial lines
|
|
183
|
+
# * group lines into blocks (or use block annos) and treat blocks / paragraphs of text and
|
|
184
|
+
if not anno_page["text"]:
|
|
185
|
+
raise RuntimeError("No text on page, skipping sample...")
|
|
186
|
+
|
|
187
|
+
text = self.line_break.join(anno_page["text"])
|
|
188
|
+
|
|
189
|
+
# tokenize w/ and generate training target if enabled
|
|
190
|
+
text_input, text_target = self._process_text(text)
|
|
191
|
+
tokenized_text_pages.append(text_input)
|
|
192
|
+
if text_target is not None:
|
|
193
|
+
target_pages.append(text_target)
|
|
194
|
+
|
|
195
|
+
text_pages.append(anno_page["text"]) # unencoded text added as lines
|
|
196
|
+
|
|
197
|
+
gt_parse = {
|
|
198
|
+
'num_pages': num_pages, # total # of pages in doc
|
|
199
|
+
'page_indices': page_indices, # page indices sampled
|
|
200
|
+
'page_text': text_pages, # text of sampled page indices pages[].lines[]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
output = {
|
|
204
|
+
self.text_input_name: tokenized_text_pages,
|
|
205
|
+
'_parse': gt_parse,
|
|
206
|
+
}
|
|
207
|
+
if target_pages:
|
|
208
|
+
output[self.text_target_name] = target_pages
|
|
209
|
+
|
|
210
|
+
return output
|
|
211
|
+
|
|
212
|
+
def _decode_anno(self, sample):
|
|
213
|
+
anno = _get_value(self.text_input_key, sample)
|
|
214
|
+
assert anno is not None, f"No annotation found with keys ({self.text_input_key})."
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
page_anno = self._process_anno_pages(anno)
|
|
218
|
+
except Exception as exn:
|
|
219
|
+
_logger.error(f'Issue processing annotation for {sample["__url__"]}, {sample["__key__"]}.')
|
|
220
|
+
#_logger.error(json.dumps(anno, indent=4))
|
|
221
|
+
raise exn
|
|
222
|
+
|
|
223
|
+
# extract info from the _parse
|
|
224
|
+
info = page_anno.get('_parse', {})
|
|
225
|
+
page_indices = info.get('page_indices', [0]) # the samples page indices
|
|
226
|
+
num_anno_pages = info.get('num_pages', 1)
|
|
227
|
+
|
|
228
|
+
# TODO support 'image info' to relay details such as text bbox, layout
|
|
229
|
+
# page_image_info = info.get('image_info', None)
|
|
230
|
+
# if page_image_info is not None:
|
|
231
|
+
# assert len(page_image_info) == len(page_indices)
|
|
232
|
+
|
|
233
|
+
return page_anno, page_indices, num_anno_pages
|
|
234
|
+
|
|
235
|
+
def _expand_anno(self, anno, count: int):
|
|
236
|
+
expanded_annos = []
|
|
237
|
+
for i in range(count):
|
|
238
|
+
sample = {}
|
|
239
|
+
for k, v in anno.items():
|
|
240
|
+
if k == '_parse':
|
|
241
|
+
gt_parse = {}
|
|
242
|
+
gt_parse['num_pages'] = v['num_pages']
|
|
243
|
+
gt_parse['page_indices'] = [v['page_indices'][i]]
|
|
244
|
+
gt_parse['page_text'] = [v['page_text'][i]]
|
|
245
|
+
sample[k] = gt_parse
|
|
246
|
+
else:
|
|
247
|
+
sample[k] = v[i] if isinstance(v, (list, tuple)) else v
|
|
248
|
+
expanded_annos.append(sample)
|
|
249
|
+
return expanded_annos
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from typing import Callable, Dict, List, Optional, Tuple
|
|
3
|
+
|
|
4
|
+
from chugr import FeatureInfo, ImageFeatureInfo
|
|
5
|
+
from chugr.doc import DocProcessor, DEFAULT_QUESTION_FEAT, DEFAULT_QUESTION_ID_FEAT, DEFAULT_ANSWER_FEAT, \
|
|
6
|
+
DEFAULT_DOC_FEAT
|
|
7
|
+
from chugr.doc.doc_processor import _get_value
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DocVqaProcessor(DocProcessor):
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
image_process_fn: Optional[Callable] = None,
|
|
14
|
+
text_process_fn: Optional[Callable] = None,
|
|
15
|
+
question_feat: FeatureInfo = DEFAULT_QUESTION_FEAT,
|
|
16
|
+
question_id_feat: FeatureInfo = DEFAULT_QUESTION_ID_FEAT,
|
|
17
|
+
answer_feat: FeatureInfo = DEFAULT_ANSWER_FEAT,
|
|
18
|
+
multi_qa_feat: Optional[FeatureInfo] = None,
|
|
19
|
+
image_input_feat: ImageFeatureInfo = DEFAULT_DOC_FEAT,
|
|
20
|
+
text_input_feat: FeatureInfo = FeatureInfo('text_input', input_key=None),
|
|
21
|
+
text_target_feat: Optional[FeatureInfo] = FeatureInfo('text_target', input_key=None),
|
|
22
|
+
question_prefix: Optional[str] = '<s_question>',
|
|
23
|
+
question_suffix: Optional[str] = '</s_question>',
|
|
24
|
+
answer_prefix: Optional[str] = '<s_answer>',
|
|
25
|
+
answer_suffix: Optional[str] = '</s_answer>',
|
|
26
|
+
render_dpi: int = 150,
|
|
27
|
+
squeeze_pages: bool = True,
|
|
28
|
+
expand_pages: bool = False,
|
|
29
|
+
flatten_json: bool = True,
|
|
30
|
+
seed: int = 0,
|
|
31
|
+
):
|
|
32
|
+
super().__init__(
|
|
33
|
+
image_process_fn=image_process_fn,
|
|
34
|
+
text_process_fn=text_process_fn,
|
|
35
|
+
image_input_feat=image_input_feat,
|
|
36
|
+
text_input_feat=text_input_feat,
|
|
37
|
+
text_target_feat=text_target_feat,
|
|
38
|
+
render_dpi=render_dpi,
|
|
39
|
+
squeeze_pages=squeeze_pages,
|
|
40
|
+
expand_pages=expand_pages,
|
|
41
|
+
flatten_json=flatten_json,
|
|
42
|
+
seed=seed,
|
|
43
|
+
)
|
|
44
|
+
self.question_feat = question_feat
|
|
45
|
+
self.question_key = question_feat.input_key.split(';')
|
|
46
|
+
self.question_id_feat = question_id_feat
|
|
47
|
+
self.question_id_key = question_id_feat.input_key.split(';')
|
|
48
|
+
self.answer_feat = answer_feat
|
|
49
|
+
self.answer_key = answer_feat.input_key.split(';')
|
|
50
|
+
if multi_qa_feat is not None:
|
|
51
|
+
self.expand_pages = True # override
|
|
52
|
+
self.multi_qa_key = multi_qa_feat.input_key.split(';')
|
|
53
|
+
else:
|
|
54
|
+
# expand pages only used / supported for multi-qa expansion right now
|
|
55
|
+
self.expand_pages = False
|
|
56
|
+
self.multi_qa_key = None
|
|
57
|
+
|
|
58
|
+
# FIXME support flexible q/a prompting formats, do with prefix/suffix or template strings?
|
|
59
|
+
# Donut style: '<s_docvqa><s_question>{question}</s_question><s_answer>{answer}</answer><eos>'
|
|
60
|
+
# Common: 'Question: {question} Answer: {answer}<eos>'
|
|
61
|
+
self.question_prefix = question_prefix or ''
|
|
62
|
+
self.question_suffix = question_suffix or ''
|
|
63
|
+
self.answer_prefix = answer_prefix or ''
|
|
64
|
+
self.answer_suffix = answer_suffix or ''
|
|
65
|
+
#self.prompt_template = '<s_question>{question}</s_question><s_answer>'
|
|
66
|
+
#self.prompt_template_full = '<s_question>{question}</s_question><s_answer>{answer}</s_answer>'
|
|
67
|
+
|
|
68
|
+
def _decode_anno(self, sample) -> Tuple[Dict, List[int], int]:
|
|
69
|
+
if self.multi_qa_key:
|
|
70
|
+
# FIXME multi qa expansion is a WIP
|
|
71
|
+
qa_list = sample[self.multi_qa_key]
|
|
72
|
+
assert isinstance(qa_list, (list, tuple)), f'Expected a list or tuple, got {type(qa_list)}.'
|
|
73
|
+
assert False, 'WIP'
|
|
74
|
+
else:
|
|
75
|
+
question = _get_value(self.question_key, sample)
|
|
76
|
+
question_id = _get_value(self.question_id_key, sample)
|
|
77
|
+
answers = _get_value(self.answer_key, sample)
|
|
78
|
+
|
|
79
|
+
if isinstance(answers, str):
|
|
80
|
+
answers = [answers]
|
|
81
|
+
if answers and self.text_target_feat is not None:
|
|
82
|
+
answer = self.generator.choice(answers)
|
|
83
|
+
else:
|
|
84
|
+
answer = None
|
|
85
|
+
|
|
86
|
+
input_text = self.question_prefix + question + self.question_suffix + self.answer_prefix
|
|
87
|
+
if answer is not None:
|
|
88
|
+
input_text += answer + self.answer_suffix
|
|
89
|
+
|
|
90
|
+
output = {
|
|
91
|
+
self.text_input_name: input_text,
|
|
92
|
+
'_parse': {
|
|
93
|
+
'question_id': question_id,
|
|
94
|
+
'question': question,
|
|
95
|
+
'answers': answers, # list, all answers included in parse
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if self.text_process_fn is not None:
|
|
100
|
+
processed = self.text_process_fn(input_text)
|
|
101
|
+
assert self.text_input_name in processed, \
|
|
102
|
+
f"Text input name '{self.text_input_name}' not found in processed sample."
|
|
103
|
+
if self.text_target_feat is not None:
|
|
104
|
+
assert self.text_target_name in processed, f"Expected a text target named '{self.text_target_name}' in processed sample."
|
|
105
|
+
output.update(processed)
|
|
106
|
+
else:
|
|
107
|
+
output[self.text_input_name] = input_text
|
|
108
|
+
|
|
109
|
+
return output, [0], 1
|
|
110
|
+
|
|
111
|
+
def _expand_anno(self, anno, count: int):
|
|
112
|
+
# FIXME implement expansion for multi-qa (and eventually multi-page option)
|
|
113
|
+
pass
|
chugr/doc/synth_text.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
""" Synthetic text rendering for reading warmup.
|
|
2
|
+
|
|
3
|
+
Renders (real) document text lines w/ varied fonts, sizes, and layouts. The training target is exactly the rendered
|
|
4
|
+
text, so rendered pages need no layout annotations (bboxes), only the text.
|
|
5
|
+
"""
|
|
6
|
+
import glob
|
|
7
|
+
import io
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import random
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
from typing import List, Optional, Sequence, Tuple
|
|
15
|
+
|
|
16
|
+
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
17
|
+
|
|
18
|
+
_logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_FONT_DIRS = ('/usr/share/fonts', '/usr/local/share/fonts', '~/.fonts', '~/.local/share/fonts')
|
|
21
|
+
|
|
22
|
+
# Latin text fonts (excludes symbol, dingbat, script, math, CJK fallback fonts)
|
|
23
|
+
_DEFAULT_FONT_RE = (
|
|
24
|
+
r'^(DejaVu(Sans|Serif)(Condensed|Mono)?(-(Bold|Oblique|Italic|BoldOblique|BoldItalic))?'
|
|
25
|
+
r'|Liberation(Sans|Serif|Mono|SansNarrow)-.*'
|
|
26
|
+
r'|Free(Sans|Serif|Mono).*'
|
|
27
|
+
r'|(C059|P052|NimbusSans|NimbusSansNarrow|NimbusRoman|NimbusMonoPS|URWBookman|URWGothic)-.*'
|
|
28
|
+
r')\.(ttf|otf)$'
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# printable latin-1 (IDL OCR text is ~all latin-1), other chars are dropped from rendered text + target
|
|
32
|
+
_CHARSET = set(chr(c) for c in range(0x20, 0x7f)) | set(chr(c) for c in range(0xa1, 0x100))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class SynthTextCfg:
|
|
37
|
+
font_paths: Optional[List[str]] = None # explicit font files, default: discover system fonts (font_pattern)
|
|
38
|
+
font_pattern: str = _DEFAULT_FONT_RE # regex on font file basename for discovery
|
|
39
|
+
lines_range: Tuple[int, int] = (1, 48) # rendered lines per sample (after wrapping), log-uniform
|
|
40
|
+
max_chars: int = 3000 # cap on rendered characters (keep the target within the text length budget)
|
|
41
|
+
font_size_range: Tuple[int, int] = (16, 44) # px
|
|
42
|
+
width_range: Tuple[int, int] = (500, 1600) # px, text block width
|
|
43
|
+
line_spacing_range: Tuple[float, float] = (1.0, 1.6) # x font size
|
|
44
|
+
margin_range: Tuple[float, float] = (0.02, 0.12) # fraction of text block width
|
|
45
|
+
page_prob: float = 0.3 # place the text block on a letter aspect page (vs a tight crop around the text)
|
|
46
|
+
paragraph_gap_prob: float = 0.1 # per line chance of an extra blank gap before it
|
|
47
|
+
center_prob: float = 0.08 # per line chance of centered (vs left aligned) line
|
|
48
|
+
blur_prob: float = 0.2
|
|
49
|
+
noise_prob: float = 0.2
|
|
50
|
+
jpeg_prob: float = 0.2
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def discover_fonts(pattern: str = _DEFAULT_FONT_RE, dirs: Sequence[str] = _FONT_DIRS) -> List[str]:
|
|
54
|
+
regex = re.compile(pattern)
|
|
55
|
+
found = {}
|
|
56
|
+
for d in dirs:
|
|
57
|
+
for path in glob.glob(os.path.join(os.path.expanduser(d), '**', '*.*'), recursive=True):
|
|
58
|
+
name = os.path.basename(path)
|
|
59
|
+
if regex.match(name) and name not in found:
|
|
60
|
+
found[name] = path
|
|
61
|
+
return sorted(found.values())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _check_font(path: str) -> bool:
|
|
65
|
+
""" Font loads and (if fontTools is available) covers the charset. """
|
|
66
|
+
try:
|
|
67
|
+
ImageFont.truetype(path, 20)
|
|
68
|
+
except Exception:
|
|
69
|
+
return False
|
|
70
|
+
try:
|
|
71
|
+
from fontTools.ttLib import TTFont
|
|
72
|
+
except ImportError:
|
|
73
|
+
return True
|
|
74
|
+
try:
|
|
75
|
+
cmap = TTFont(path, lazy=True).getBestCmap() or {}
|
|
76
|
+
except Exception:
|
|
77
|
+
return False
|
|
78
|
+
return all(ord(c) in cmap for c in _CHARSET if c != ' ')
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@lru_cache(maxsize=512)
|
|
82
|
+
def _load_font(path: str, size: int):
|
|
83
|
+
return ImageFont.truetype(path, size)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _wrap_line(text: str, font, width: int) -> List[str]:
|
|
87
|
+
""" Greedy word wrap to `width` px, words longer than the width are split by character. """
|
|
88
|
+
if font.getlength(text) <= width:
|
|
89
|
+
return [text]
|
|
90
|
+
out = []
|
|
91
|
+
current = ''
|
|
92
|
+
for word in text.split(' '):
|
|
93
|
+
candidate = word if not current else current + ' ' + word
|
|
94
|
+
if font.getlength(candidate) <= width:
|
|
95
|
+
current = candidate
|
|
96
|
+
continue
|
|
97
|
+
if current:
|
|
98
|
+
out.append(current)
|
|
99
|
+
while font.getlength(word) > width and len(word) > 1:
|
|
100
|
+
n = len(word)
|
|
101
|
+
while n > 1 and font.getlength(word[:n]) > width:
|
|
102
|
+
n -= 1
|
|
103
|
+
out.append(word[:n])
|
|
104
|
+
word = word[n:]
|
|
105
|
+
current = word
|
|
106
|
+
if current:
|
|
107
|
+
out.append(current)
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def clean_text_lines(lines: Sequence[str]) -> List[str]:
|
|
112
|
+
out = []
|
|
113
|
+
for line in lines:
|
|
114
|
+
line = ''.join(c for c in line.replace('\t', ' ') if c in _CHARSET)
|
|
115
|
+
line = ' '.join(line.split())
|
|
116
|
+
if line:
|
|
117
|
+
out.append(line)
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class SynthTextRenderer:
|
|
122
|
+
""" Render text lines to a grayscale document image, returns (image, rendered lines). """
|
|
123
|
+
|
|
124
|
+
def __init__(self, cfg: Optional[SynthTextCfg] = None):
|
|
125
|
+
self.cfg = cfg = cfg or SynthTextCfg()
|
|
126
|
+
paths = cfg.font_paths or discover_fonts(cfg.font_pattern)
|
|
127
|
+
self.fonts = [p for p in paths if _check_font(p)]
|
|
128
|
+
assert self.fonts, f'No usable fonts found (pattern {cfg.font_pattern}).'
|
|
129
|
+
_logger.info(f'SynthTextRenderer using {len(self.fonts)} fonts.')
|
|
130
|
+
|
|
131
|
+
def __call__(self, lines: Sequence[str], rng: Optional[random.Random] = None) -> Tuple[Image.Image, List[str]]:
|
|
132
|
+
cfg = self.cfg
|
|
133
|
+
rng = rng or random
|
|
134
|
+
lines = clean_text_lines(lines)
|
|
135
|
+
if not lines:
|
|
136
|
+
raise RuntimeError('No renderable text.')
|
|
137
|
+
|
|
138
|
+
font_path = rng.choice(self.fonts)
|
|
139
|
+
font_size = rng.randint(*cfg.font_size_range)
|
|
140
|
+
font = _load_font(font_path, font_size)
|
|
141
|
+
block_w = rng.randint(*cfg.width_range)
|
|
142
|
+
lo, hi = cfg.lines_range
|
|
143
|
+
num_lines = int(round(lo * (hi / lo) ** rng.random())) if hi > lo else lo
|
|
144
|
+
|
|
145
|
+
# contiguous span of source lines, wrapped to the block width
|
|
146
|
+
start = rng.randrange(len(lines))
|
|
147
|
+
rendered = []
|
|
148
|
+
line_center = []
|
|
149
|
+
line_gap = []
|
|
150
|
+
num_chars = 0
|
|
151
|
+
for src in lines[start:]:
|
|
152
|
+
center = rng.random() < cfg.center_prob
|
|
153
|
+
gap = rng.random() < cfg.paragraph_gap_prob
|
|
154
|
+
for i, wrapped in enumerate(_wrap_line(src, font, block_w)):
|
|
155
|
+
if len(rendered) >= num_lines or (rendered and num_chars + len(wrapped) > cfg.max_chars):
|
|
156
|
+
break
|
|
157
|
+
rendered.append(wrapped)
|
|
158
|
+
line_center.append(center)
|
|
159
|
+
line_gap.append(gap and i == 0 and len(rendered) > 1)
|
|
160
|
+
num_chars += len(wrapped) + 1
|
|
161
|
+
else:
|
|
162
|
+
continue
|
|
163
|
+
break
|
|
164
|
+
|
|
165
|
+
line_h = int(font_size * rng.uniform(*cfg.line_spacing_range))
|
|
166
|
+
text_w = int(max(font.getlength(l) for l in rendered))
|
|
167
|
+
text_h = line_h * (len(rendered) + sum(line_gap))
|
|
168
|
+
if not any(line_center):
|
|
169
|
+
block_w = max(text_w, min(block_w, text_w + font_size * 4)) # tight-ish block for left aligned text
|
|
170
|
+
margin_x = int(block_w * rng.uniform(*cfg.margin_range)) + 2
|
|
171
|
+
margin_y = int(block_w * rng.uniform(*cfg.margin_range)) + 2
|
|
172
|
+
canvas_w = block_w + 2 * margin_x
|
|
173
|
+
canvas_h = text_h + 2 * margin_y
|
|
174
|
+
offset_x, offset_y = margin_x, margin_y
|
|
175
|
+
if rng.random() < cfg.page_prob:
|
|
176
|
+
# letter-ish page, text block placed at a random position
|
|
177
|
+
page_w = max(canvas_w, int(canvas_w * rng.uniform(1.0, 1.4)))
|
|
178
|
+
page_h = max(canvas_h, int(page_w * rng.uniform(1.25, 1.45)))
|
|
179
|
+
offset_x += rng.randint(0, page_w - canvas_w)
|
|
180
|
+
offset_y += rng.randint(0, page_h - canvas_h)
|
|
181
|
+
canvas_w, canvas_h = page_w, page_h
|
|
182
|
+
|
|
183
|
+
bg = rng.randint(215, 255)
|
|
184
|
+
ink = rng.randint(0, min(80, bg - 120))
|
|
185
|
+
img = Image.new('L', (canvas_w, canvas_h), color=bg)
|
|
186
|
+
draw = ImageDraw.Draw(img)
|
|
187
|
+
y = offset_y
|
|
188
|
+
for text, center, gap in zip(rendered, line_center, line_gap):
|
|
189
|
+
if gap:
|
|
190
|
+
y += line_h
|
|
191
|
+
x = offset_x
|
|
192
|
+
if center:
|
|
193
|
+
x += max(0, (block_w - int(font.getlength(text))) // 2)
|
|
194
|
+
draw.text((x, y), text, font=font, fill=ink)
|
|
195
|
+
y += line_h
|
|
196
|
+
|
|
197
|
+
if rng.random() < cfg.blur_prob:
|
|
198
|
+
img = img.filter(ImageFilter.GaussianBlur(radius=rng.uniform(0.3, 1.0)))
|
|
199
|
+
if rng.random() < cfg.noise_prob:
|
|
200
|
+
img = _add_noise(img, rng.uniform(3, 12), rng)
|
|
201
|
+
if rng.random() < cfg.jpeg_prob:
|
|
202
|
+
buf = io.BytesIO()
|
|
203
|
+
img.save(buf, format='JPEG', quality=rng.randint(30, 90))
|
|
204
|
+
img = Image.open(io.BytesIO(buf.getvalue())).convert('L')
|
|
205
|
+
return img, rendered
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _add_noise(img: Image.Image, sigma: float, rng: random.Random) -> Image.Image:
|
|
209
|
+
import numpy as np
|
|
210
|
+
arr = np.asarray(img, dtype=np.float32)
|
|
211
|
+
arr = arr + np.random.default_rng(rng.randrange(2 ** 31)).normal(0, sigma, arr.shape).astype(np.float32)
|
|
212
|
+
return Image.fromarray(arr.clip(0, 255).astype(np.uint8), mode='L')
|
chugr/hfds/__init__.py
ADDED
chugr/hfds/collate.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from typing import List, Callable, Optional
|
|
2
|
+
|
|
3
|
+
import torch.utils
|
|
4
|
+
from torch.utils.data import IterableDataset, DataLoader
|
|
5
|
+
|
|
6
|
+
from chugr.common import collate
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def invoke(f, *args, **kwargs):
|
|
10
|
+
if isinstance(f, (IterableDataset, DataLoader)) and len(args) == 0:
|
|
11
|
+
return iter(f)
|
|
12
|
+
if isinstance(f, list):
|
|
13
|
+
return iter(f)
|
|
14
|
+
if callable(f):
|
|
15
|
+
result = f(*args, **kwargs)
|
|
16
|
+
return result
|
|
17
|
+
raise ValueError(f"{f}: not a valid pipeline stage")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
"""
|
|
21
|
+
pipe = [wds.rename(image='jpg'), wds.map_dict(image=tf), wds.to_tuple('image', 'cls')]
|
|
22
|
+
hfc = HfCollate(pipe)
|
|
23
|
+
dl = DataLoader(ds, batch_size=32, num_workers=4, persistent_workers=True, collate_fn=hfc)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def flatten_bytes(data):
|
|
27
|
+
for sample in data:
|
|
28
|
+
to_replace = {k for k, v in sample.items() if isinstance(v, dict) and 'bytes' in v}
|
|
29
|
+
if to_replace:
|
|
30
|
+
result = {k: v for k, v in sample.items() if k not in to_replace}
|
|
31
|
+
result.update({k: sample[k]['bytes'] for k in to_replace})
|
|
32
|
+
yield result
|
|
33
|
+
else:
|
|
34
|
+
yield sample
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class HfCollate:
|
|
38
|
+
""" Collation wrapper that applies processing pipeline for HF datasets use
|
|
39
|
+
"""
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
pipeline: List[Callable],
|
|
43
|
+
collate_fn: Optional[Callable] = None,
|
|
44
|
+
apply_collate: bool = True,
|
|
45
|
+
):
|
|
46
|
+
"""
|
|
47
|
+
Args:
|
|
48
|
+
pipeline: list of pipeline functions
|
|
49
|
+
collate_fn: use a custom collation function, otherwise defaults to torch default_collate
|
|
50
|
+
"""
|
|
51
|
+
self.pipeline = pipeline
|
|
52
|
+
self.collate_fn = collate_fn or collate
|
|
53
|
+
self.apply_collate = apply_collate
|
|
54
|
+
self._debug = False
|
|
55
|
+
|
|
56
|
+
def __call__(self, batch):
|
|
57
|
+
item = False
|
|
58
|
+
if not self.apply_collate and isinstance(batch, dict):
|
|
59
|
+
batch = [batch]
|
|
60
|
+
item = True
|
|
61
|
+
|
|
62
|
+
if self._debug:
|
|
63
|
+
for b in batch:
|
|
64
|
+
for k, v in b.items():
|
|
65
|
+
print(k, type(v))
|
|
66
|
+
if isinstance(v, torch.Tensor):
|
|
67
|
+
print(v.shape)
|
|
68
|
+
|
|
69
|
+
if self.pipeline:
|
|
70
|
+
for pipe_fn in self.pipeline:
|
|
71
|
+
batch = invoke(pipe_fn, batch)
|
|
72
|
+
|
|
73
|
+
batch = list(batch)
|
|
74
|
+
|
|
75
|
+
if self._debug:
|
|
76
|
+
for b in batch:
|
|
77
|
+
for k, v in b.items():
|
|
78
|
+
print(k, v)
|
|
79
|
+
|
|
80
|
+
if self.apply_collate:
|
|
81
|
+
return self.collate_fn(batch)
|
|
82
|
+
else:
|
|
83
|
+
return batch[0] if item else batch
|