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.
Files changed (53) hide show
  1. chugr/__init__.py +46 -0
  2. chugr/app/test.py +105 -0
  3. chugr/common/__init__.py +9 -0
  4. chugr/common/cli.py +234 -0
  5. chugr/common/collate.py +104 -0
  6. chugr/common/config.py +335 -0
  7. chugr/common/random.py +76 -0
  8. chugr/common/serialize.py +150 -0
  9. chugr/common/task_config.py +24 -0
  10. chugr/common/types.py +122 -0
  11. chugr/common/urls.py +88 -0
  12. chugr/doc/__init__.py +17 -0
  13. chugr/doc/constants.py +15 -0
  14. chugr/doc/doc_processor.py +248 -0
  15. chugr/doc/doc_read_processor.py +249 -0
  16. chugr/doc/doc_vqa_processor.py +113 -0
  17. chugr/doc/synth_text.py +212 -0
  18. chugr/hfds/__init__.py +2 -0
  19. chugr/hfds/collate.py +83 -0
  20. chugr/hfds/loader.py +385 -0
  21. chugr/hfds/wrappers.py +108 -0
  22. chugr/image/__init__.py +4 -0
  23. chugr/image/build_transforms_doc.py +427 -0
  24. chugr/image/build_transforms_image.py +203 -0
  25. chugr/image/transforms_alb.py +112 -0
  26. chugr/image/transforms_factory.py +58 -0
  27. chugr/image/transforms_torch.py +137 -0
  28. chugr/loader.py +249 -0
  29. chugr/naflex.py +357 -0
  30. chugr/task_pipeline/__init__.py +7 -0
  31. chugr/task_pipeline/pipeline_doc_read.py +66 -0
  32. chugr/task_pipeline/pipeline_doc_vqa.py +124 -0
  33. chugr/task_pipeline/pipeline_factory.py +32 -0
  34. chugr/task_pipeline/pipeline_gtparse.py +93 -0
  35. chugr/task_pipeline/pipeline_image_text.py +90 -0
  36. chugr/task_pipeline/pipeline_manual.py +27 -0
  37. chugr/text/__init__.py +1 -0
  38. chugr/text/tokenization.py +222 -0
  39. chugr/version.py +1 -0
  40. chugr/wds/__init__.py +7 -0
  41. chugr/wds/dataset_info.py +65 -0
  42. chugr/wds/decode.py +319 -0
  43. chugr/wds/filters.py +142 -0
  44. chugr/wds/helpers.py +68 -0
  45. chugr/wds/loader.py +187 -0
  46. chugr/wds/pipeline.py +182 -0
  47. chugr/wds/shardlists.py +134 -0
  48. chugr/wds/tariterators.py +70 -0
  49. chugr-0.2.0.dev0.dist-info/METADATA +436 -0
  50. chugr-0.2.0.dev0.dist-info/RECORD +53 -0
  51. chugr-0.2.0.dev0.dist-info/WHEEL +4 -0
  52. chugr-0.2.0.dev0.dist-info/entry_points.txt +4 -0
  53. 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
@@ -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
@@ -0,0 +1,2 @@
1
+ from .loader import create_loader_hf
2
+ from .wrappers import SafeDataset
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