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
chugr/naflex.py ADDED
@@ -0,0 +1,357 @@
1
+ """ NaFlex (variable resolution patch sequence) batching for document pipelines.
2
+
3
+ Adapted from the open_clip NaFlex streaming data pipeline:
4
+
5
+ * NaFlexBatchScheduler: a deterministic (seq_len, batch_size) schedule, identical across ranks (seeded), sampled
6
+ per batch from a set of patch sequence length buckets, w/ batch size from a token budget (patches + an optional
7
+ per-row text token cost). Shuffled per interval, padded and stride-split across dataloader workers.
8
+ * NaFlexBatcher: webdataset stage that pulls samples, runs the task processor for the batch's seq_len bucket
9
+ (decode / render + image transform + text processing), skips & replaces samples that fail to process so every
10
+ batch is full (equal step shapes across ranks), and collates patches padded to the bucket seq_len.
11
+ * NaFlexMapDataset: the same schedule driven batching over a map-style dataset (e.g. non-streaming HF datasets),
12
+ adapted from the open_clip NaFlexMapDatasetWrapper, w/ rank sharded shuffled indices in place of a sample stream.
13
+ * LengthBucketer: reorders samples so similar (text) lengths batch together, reduces decoder padding.
14
+
15
+ NOTE schedule driven batching of a stream needs an effectively infinite sample stream so every rank / worker can
16
+ always fill its schedule, the wds loader uses resampled shards or (w/o replacement) a repeating, per-pass reshuffled
17
+ shard list.
18
+ """
19
+ import json
20
+ import logging
21
+ import math
22
+ import os
23
+ import random
24
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
25
+
26
+ import torch
27
+
28
+ from chugr.common import collate, collate_naflex, shuffled_interval_indices
29
+
30
+ _logger = logging.getLogger(__name__)
31
+
32
+ _MAX_CONSECUTIVE_FAILURES = int(os.environ.get('CHUGR_MAX_CONSECUTIVE_FAILURES', '1000'))
33
+
34
+
35
+ def calculate_batch_size(tokens_per_batch: int, row_tokens: int, divisor: int = 1, max_size: Optional[int] = None):
36
+ batch_size = tokens_per_batch // max(row_tokens, 1)
37
+ if divisor > 1:
38
+ batch_size = (batch_size // divisor) * divisor
39
+ batch_size = max(batch_size, 1)
40
+ if max_size is not None:
41
+ batch_size = min(batch_size, max_size)
42
+ return batch_size
43
+
44
+
45
+ def collate_naflex_batch(samples: List[Dict[str, Any]], image_key: str, seq_len: int) -> Dict[str, Any]:
46
+ """ Collate processed samples, NaFlex image patches padded to the batch's bucket `seq_len`. """
47
+ images = [s.pop(image_key) for s in samples]
48
+ out = collate(samples)
49
+ out[image_key] = collate_naflex(images, pad_to=seq_len)
50
+ return out
51
+
52
+
53
+ def _interval_value(interval) -> int:
54
+ if hasattr(interval, 'get_value'):
55
+ return int(interval.get_value())
56
+ return int(interval or 0)
57
+
58
+
59
+ class NaFlexBatchScheduler:
60
+ """ Deterministic, rank identical NaFlex (seq_len, batch_size) schedule for one interval (epoch). """
61
+
62
+ def __init__(
63
+ self,
64
+ seq_lens: Sequence[int],
65
+ max_tokens_per_batch: int,
66
+ num_samples: Optional[int] = None,
67
+ num_tokens: Optional[int] = None,
68
+ seq_len_probs: Optional[Sequence[float]] = None,
69
+ per_row_text_tokens: int = 0,
70
+ batch_divisor: int = 8,
71
+ seed: int = 42,
72
+ shuffle: bool = True,
73
+ world_size: int = 1,
74
+ ):
75
+ if (num_samples is None) == (num_tokens is None):
76
+ raise ValueError('Specify exactly one of `num_samples` or `num_tokens` for NaFlex batching.')
77
+ if seq_len_probs is not None:
78
+ if len(seq_len_probs) != len(seq_lens):
79
+ raise ValueError('`seq_len_probs` must match `seq_lens` length.')
80
+ pairs = sorted({int(s): float(p) for s, p in zip(seq_lens, seq_len_probs)}.items())
81
+ self.seq_lens = [s for s, _ in pairs]
82
+ total = sum(p for _, p in pairs)
83
+ assert total > 0
84
+ self.seq_len_probs = [p / total for _, p in pairs]
85
+ else:
86
+ self.seq_lens = sorted(set(int(s) for s in seq_lens))
87
+ self.seq_len_probs = None
88
+ assert self.seq_lens and all(s > 0 for s in self.seq_lens)
89
+ self.max_tokens_per_batch = int(max_tokens_per_batch)
90
+ self.per_row_text_tokens = int(per_row_text_tokens or 0)
91
+ self.batch_divisor = int(batch_divisor)
92
+ self.seed = int(seed)
93
+ self.shuffle = shuffle
94
+ self.world_size = max(1, int(world_size))
95
+
96
+ generator = torch.Generator()
97
+ generator.manual_seed(self.seed)
98
+ schedule = []
99
+ if num_samples is not None:
100
+ remaining = math.ceil(int(num_samples) / self.world_size)
101
+ while remaining > 0:
102
+ seq_len = self._next_seq_len(generator)
103
+ bs = calculate_batch_size(
104
+ self.max_tokens_per_batch, seq_len + self.per_row_text_tokens, self.batch_divisor, remaining)
105
+ schedule.append((seq_len, bs))
106
+ remaining -= bs
107
+ else:
108
+ remaining = math.ceil(int(num_tokens) / self.world_size)
109
+ while remaining > 0:
110
+ seq_len = self._next_seq_len(generator)
111
+ bs = calculate_batch_size(
112
+ min(self.max_tokens_per_batch, remaining), seq_len + self.per_row_text_tokens, self.batch_divisor)
113
+ schedule.append((seq_len, bs))
114
+ remaining -= bs * seq_len
115
+ self._schedule = schedule
116
+
117
+ def _next_seq_len(self, generator: torch.Generator) -> int:
118
+ if self.seq_len_probs is None:
119
+ idx = int(torch.randint(0, len(self.seq_lens), (1,), generator=generator).item())
120
+ else:
121
+ idx = int(torch.multinomial(torch.tensor(self.seq_len_probs), 1, generator=generator).item())
122
+ return self.seq_lens[idx]
123
+
124
+ @staticmethod
125
+ def pad_schedule_for_workers(schedule: List[Tuple[int, int]], num_workers: int) -> List[Tuple[int, int]]:
126
+ if num_workers <= 1 or not schedule:
127
+ return schedule
128
+ target = math.ceil(len(schedule) / num_workers) * num_workers
129
+ pad = target - len(schedule)
130
+ if pad > 0:
131
+ schedule = schedule + (schedule * math.ceil(pad / len(schedule)))[:pad]
132
+ return schedule
133
+
134
+ def interval_schedule(self, interval: int, num_workers: int = 1) -> List[Tuple[int, int]]:
135
+ schedule = self.pad_schedule_for_workers(list(self._schedule), max(1, num_workers))
136
+ if self.shuffle:
137
+ random.Random(self.seed + interval).shuffle(schedule)
138
+ return schedule
139
+
140
+ def worker_schedule(self, interval: int) -> List[Tuple[int, int]]:
141
+ info = torch.utils.data.get_worker_info()
142
+ num_workers = info.num_workers if info is not None else 1
143
+ worker_id = info.id if info is not None else 0
144
+ return self.interval_schedule(interval, num_workers)[worker_id::num_workers]
145
+
146
+ def num_batches_for_workers(self, num_workers: int) -> int:
147
+ """ Batches per rank per interval. """
148
+ return len(self.pad_schedule_for_workers(list(self._schedule), max(1, num_workers)))
149
+
150
+ def num_samples_for_workers(self, num_workers: int) -> int:
151
+ """ Samples per interval (all ranks). """
152
+ sched = self.pad_schedule_for_workers(list(self._schedule), max(1, num_workers))
153
+ return sum(bs for _, bs in sched) * self.world_size
154
+
155
+ def mean_batch_size_for_workers(self, num_workers: int) -> float:
156
+ sched = self.pad_schedule_for_workers(list(self._schedule), max(1, num_workers))
157
+ return sum(bs for _, bs in sched) / len(sched)
158
+
159
+
160
+ class NaFlexBatcher:
161
+ """ webdataset stage, schedule driven NaFlex batching w/ per seq_len bucket task processors. """
162
+
163
+ def __init__(
164
+ self,
165
+ scheduler: NaFlexBatchScheduler,
166
+ processors: Dict[int, Callable[[Dict[str, Any]], Any]],
167
+ image_key: str = 'image_input',
168
+ interval=0,
169
+ handler: Optional[Callable[[Exception], bool]] = None,
170
+ drop_last: bool = True,
171
+ ):
172
+ missing = set(scheduler.seq_lens) - set(processors.keys())
173
+ assert not missing, f'Missing processors for seq_lens {missing}'
174
+ self.scheduler = scheduler
175
+ self.processors = processors
176
+ self.image_key = image_key
177
+ self.interval = interval
178
+ self.handler = handler
179
+ self.drop_last = drop_last
180
+
181
+ def _collate(self, samples: List[Dict[str, Any]], seq_len: int) -> Dict[str, Any]:
182
+ return collate_naflex_batch(samples, self.image_key, seq_len)
183
+
184
+ def __call__(self, src: Iterable[Dict[str, Any]]):
185
+ interval = _interval_value(self.interval)
186
+ samples_iter = iter(src)
187
+ for seq_len, batch_size in self.scheduler.worker_schedule(interval):
188
+ processor = self.processors[seq_len]
189
+ batch = []
190
+ failures = 0
191
+ while len(batch) < batch_size:
192
+ try:
193
+ sample = next(samples_iter)
194
+ except StopIteration:
195
+ break
196
+ try:
197
+ processed = processor(sample)
198
+ except Exception as ex:
199
+ # skip & replace, a falsy handler return (e.g. reraise) stops
200
+ if self.handler is not None and not self.handler(ex):
201
+ raise
202
+ failures += 1
203
+ if failures > _MAX_CONSECUTIVE_FAILURES:
204
+ raise
205
+ continue
206
+ failures = 0
207
+ batch.append(processed)
208
+ if len(batch) == batch_size or (batch and not self.drop_last):
209
+ yield self._collate(batch, seq_len)
210
+
211
+
212
+ class NaFlexMapDataset(torch.utils.data.IterableDataset):
213
+ """ Schedule driven NaFlex batching over a map-style dataset (e.g. a non-streaming HF dataset), yields batches.
214
+
215
+ Adapted from the open_clip NaFlexMapDatasetWrapper. Every rank / worker derives the same shuffled index list
216
+ and (seq_len, batch_size) schedule for the interval, a rank takes indices [rank::world_size], a worker every
217
+ num_workers-th batch of the schedule. Use w/ a DataLoader w/ batch_size=None.
218
+
219
+ Differences from the open_clip wrapper:
220
+ * Indices continue through the dataset permutation across intervals (see `shuffled_interval_indices`), so
221
+ intervals shorter than the dataset visit every sample before repeating (vs the head of a new permutation).
222
+ * Samples that fail to load / process are replaced w/ a random other sample (subject to `handler`, as
223
+ NaFlexBatcher), so batches are always full and step shapes equal across ranks.
224
+ """
225
+
226
+ def __init__(
227
+ self,
228
+ dataset,
229
+ scheduler: NaFlexBatchScheduler,
230
+ processors: Dict[int, Callable[[Dict[str, Any]], Any]],
231
+ image_key: str = 'image_input',
232
+ interval=0,
233
+ rank: int = 0,
234
+ world_size: int = 1,
235
+ seed: int = 42,
236
+ handler: Optional[Callable[[Exception], bool]] = None,
237
+ ):
238
+ if not hasattr(dataset, '__len__') or not hasattr(dataset, '__getitem__'):
239
+ raise TypeError('NaFlex map batching requires a map-style dataset.')
240
+ missing = set(scheduler.seq_lens) - set(processors.keys())
241
+ assert not missing, f'Missing processors for seq_lens {missing}'
242
+ assert 0 <= rank < world_size
243
+ assert world_size == scheduler.world_size, 'scheduler world_size must match (per rank schedule)'
244
+ self.dataset = dataset
245
+ self.scheduler = scheduler
246
+ self.processors = processors
247
+ self.image_key = image_key
248
+ self.interval = interval
249
+ self.rank = int(rank)
250
+ self.world_size = int(world_size)
251
+ self.seed = int(seed)
252
+ self.handler = handler
253
+
254
+ def __iter__(self):
255
+ interval = _interval_value(self.interval)
256
+ info = torch.utils.data.get_worker_info()
257
+ num_workers = info.num_workers if info is not None else 1
258
+ worker_id = info.id if info is not None else 0
259
+ dataset_len = len(self.dataset)
260
+
261
+ # same (padded, shuffled) schedule and index list on every rank / worker
262
+ schedule = self.scheduler.interval_schedule(interval, num_workers)
263
+ samples_per_rank = sum(batch_size for _, batch_size in schedule)
264
+ indices = shuffled_interval_indices(
265
+ dataset_len, samples_per_rank * self.world_size, interval, self.seed)[self.rank::self.world_size]
266
+ # replacement indices for failed samples, distinct per interval / rank / worker
267
+ rng = random.Random(self.seed + 7919 * interval + 104729 * self.rank + 1299709 * worker_id)
268
+
269
+ offset = 0
270
+ for batch_idx, (seq_len, batch_size) in enumerate(schedule):
271
+ batch_indices = indices[offset:offset + batch_size]
272
+ offset += batch_size
273
+ if batch_idx % num_workers != worker_id:
274
+ continue
275
+ processor = self.processors[seq_len]
276
+ batch = []
277
+ failures = 0
278
+ pos = 0
279
+ while len(batch) < batch_size:
280
+ if pos < len(batch_indices):
281
+ idx = batch_indices[pos]
282
+ pos += 1
283
+ else:
284
+ idx = rng.randrange(dataset_len)
285
+ try:
286
+ processed = processor(self.dataset[idx])
287
+ except Exception as ex:
288
+ # skip & replace, a falsy handler return (e.g. reraise) stops
289
+ if self.handler is not None and not self.handler(ex):
290
+ raise
291
+ failures += 1
292
+ if failures > _MAX_CONSECUTIVE_FAILURES:
293
+ raise
294
+ continue
295
+ failures = 0
296
+ batch.append(processed)
297
+ yield collate_naflex_batch(batch, self.image_key, seq_len)
298
+
299
+
300
+ def annotation_text_length(sample: Dict[str, Any], keys: Sequence[str] = ('json',)) -> int:
301
+ """ Approximate text length (characters) of a raw document sample's annotation (for length bucketing). """
302
+ for k in keys:
303
+ if k not in sample:
304
+ continue
305
+ anno = sample[k]
306
+ if isinstance(anno, bytes):
307
+ anno = json.loads(anno)
308
+ sample[k] = anno # keep decoded
309
+ pages = anno.get('pages', anno) if isinstance(anno, dict) else anno
310
+ total = 0
311
+ try:
312
+ for page in pages:
313
+ page = page.get('lines', page)
314
+ total += sum(len(t) for t in page.get('text', []))
315
+ except (AttributeError, TypeError):
316
+ return 0
317
+ n = max(len(pages), 1)
318
+ return total // n # mean per page (pages are sampled)
319
+ return 0
320
+
321
+
322
+ class LengthBucketer:
323
+ """ webdataset stage, reorder samples in pools sorted by length (chunks shuffled), reorder only. """
324
+
325
+ def __init__(
326
+ self,
327
+ length_fn: Callable[[Dict[str, Any]], int] = annotation_text_length,
328
+ pool: int = 1024,
329
+ chunk: int = 64,
330
+ seed: int = 42,
331
+ interval=0,
332
+ ):
333
+ self.length_fn = length_fn
334
+ self.pool = max(1, int(pool))
335
+ self.chunk = max(1, int(chunk))
336
+ self.seed = int(seed)
337
+ self.interval = interval
338
+
339
+ def _flush(self, buffer, rng):
340
+ buffer.sort(key=self.length_fn)
341
+ chunks = [buffer[i:i + self.chunk] for i in range(0, len(buffer), self.chunk)]
342
+ rng.shuffle(chunks)
343
+ for c in chunks:
344
+ yield from c
345
+
346
+ def __call__(self, src):
347
+ info = torch.utils.data.get_worker_info()
348
+ worker_id = info.id if info is not None else 0
349
+ rng = random.Random(self.seed + _interval_value(self.interval) * 131 + worker_id)
350
+ buffer = []
351
+ for sample in src:
352
+ buffer.append(sample)
353
+ if len(buffer) >= self.pool:
354
+ yield from self._flush(buffer, rng)
355
+ buffer = []
356
+ if buffer:
357
+ yield from self._flush(buffer, rng)
@@ -0,0 +1,7 @@
1
+ from .pipeline_doc_read import build_task_pipeline_doc_read, DataTaskDocReadCfg
2
+ from .pipeline_doc_vqa import build_task_pipeline_doc_vqa, DataTaskDocVqaCfg
3
+ from .pipeline_gtparse import build_task_pipeline_gtparse
4
+ from .pipeline_image_text import build_task_pipeline_image_text, DataTaskImageTextCfg
5
+ from .pipeline_manual import build_task_pipeline_manual, DataTaskManualCfg
6
+
7
+ from .pipeline_factory import create_task_pipeline, create_task_processor
@@ -0,0 +1,66 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Callable, Optional
3
+
4
+ import webdataset as wds
5
+
6
+ from chugr.common import DataTaskCfg, FeatureInfo, ImageFeatureInfo
7
+ from chugr.doc import DocReadProcessor, DEFAULT_DOC_FEAT
8
+ from chugr.doc.synth_text import SynthTextCfg
9
+ from chugr.wds import get_error_handler
10
+
11
+
12
+ @dataclass
13
+ class DataTaskDocReadCfg(DataTaskCfg):
14
+ image_input_feat: ImageFeatureInfo = DEFAULT_DOC_FEAT
15
+ text_input_feat: FeatureInfo = FeatureInfo('text_input', input_key='pages')
16
+ text_target_feat: Optional[FeatureInfo] = FeatureInfo('text_target', input_key=None)
17
+ page_sampling: str = 'random'
18
+ render_dpi: int = 150
19
+ synth_prob: float = 0. # probability of a synthetic text rendering (of the sample's text) replacing the page
20
+ synth_cfg: Optional[SynthTextCfg] = None
21
+
22
+
23
+ def create_processor_doc_read(cfg: DataTaskDocReadCfg, image_process_fn: Optional[Callable] = None):
24
+ return DocReadProcessor(
25
+ image_process_fn=image_process_fn if image_process_fn is not None else cfg.image_process_fn,
26
+ text_process_fn=cfg.text_process_fn,
27
+ image_input_feat=cfg.image_input_feat,
28
+ text_input_feat=cfg.text_input_feat,
29
+ text_target_feat=cfg.text_target_feat,
30
+ page_sampling=cfg.page_sampling,
31
+ render_dpi=cfg.render_dpi,
32
+ flatten_json=cfg.flatten_json,
33
+ synth_prob=cfg.synth_prob,
34
+ synth_cfg=cfg.synth_cfg,
35
+ )
36
+
37
+
38
+ def build_task_pipeline_doc_read(
39
+ cfg: DataTaskDocReadCfg,
40
+ ):
41
+ handler = get_error_handler(cfg.error_handler)
42
+ pipe = []
43
+
44
+ # document decoding & pre-processing done together, there is coupling in random page
45
+ # selection and in the future, masking of image and/or text
46
+ pipe += [wds.map(create_processor_doc_read(cfg), handler=handler)]
47
+
48
+ if cfg.output_tuple:
49
+ # NOTE in this mode we lose '_parse' key and would need to derive from target
50
+ # Unless we add support for parse as the last tuple element?
51
+ if cfg.text_target_feat is not None:
52
+ pipe += [
53
+ wds.to_tuple(
54
+ cfg.image_input_feat.output_name,
55
+ cfg.text_input_feat.output_name,
56
+ cfg.text_target_feat.output_name,
57
+ )
58
+ ]
59
+ else:
60
+ pipe += [
61
+ wds.to_tuple(
62
+ cfg.image_input_feat.output_name,
63
+ cfg.text_input_feat.output_name,
64
+ )
65
+ ]
66
+ return pipe
@@ -0,0 +1,124 @@
1
+ from dataclasses import dataclass, field
2
+ from functools import partial
3
+ from typing import Callable, Dict, Optional, Union
4
+
5
+ import webdataset as wds
6
+
7
+ from chugr.common import DataTaskCfg, FeatureInfo, ImageFeatureInfo
8
+ from chugr.wds import get_error_handler
9
+ from chugr.doc import (
10
+ DocVqaProcessor,
11
+ DEFAULT_DOC_KEY,
12
+ DEFAULT_QUESTION_KEY,
13
+ DEFAULT_QUESTION_ID_KEY,
14
+ DEFAULT_ANSWER_KEY,
15
+ DEFAULT_DOC_KEY_TUPLE,
16
+ DEFAULT_QUESTION_KEY_TUPLE,
17
+ DEFAULT_ANSWER_KEY_TUPLE,
18
+ DEFAULT_DOC_FEAT,
19
+ DEFAULT_QUESTION_FEAT,
20
+ DEFAULT_QUESTION_ID_FEAT,
21
+ DEFAULT_ANSWER_FEAT
22
+ )
23
+
24
+
25
+ def filter_missing(
26
+ sample,
27
+ image_key=DEFAULT_DOC_KEY_TUPLE,
28
+ question_key=DEFAULT_QUESTION_KEY_TUPLE,
29
+ answer_key=DEFAULT_ANSWER_KEY_TUPLE,
30
+ ):
31
+ has_question = any(k in sample for k in question_key)
32
+ has_answer = any(k in sample for k in answer_key)
33
+ has_image = any(k in sample for k in image_key)
34
+ return has_question and has_answer and has_image
35
+
36
+
37
+ # Currently assuming this schema as default, one set of question/answers per image, images possibly duplicated
38
+ # sample = {
39
+ # 'png': bytes,
40
+ # 'question_id': 33,
41
+ # 'doc_id': 55, # optional
42
+ # 'question': 'what is a trumpet?',
43
+ # 'answers': ['an instrument', 'a brass instrument']
44
+ # }
45
+ #
46
+
47
+
48
+ @dataclass
49
+ class DataTaskDocVqaCfg(DataTaskCfg):
50
+ """
51
+ Attributes:
52
+ answer_feat:
53
+ question_feat:
54
+ question_id_feat:
55
+ image_input_feat:
56
+ text_input_feat:
57
+ text_target_feat:
58
+ question_prefix:
59
+ question_suffix:
60
+ answer_prefix:
61
+ answer_suffix:
62
+ render_dpi:
63
+ """
64
+ answer_feat: FeatureInfo = DEFAULT_ANSWER_FEAT
65
+ question_feat: FeatureInfo = DEFAULT_QUESTION_FEAT
66
+ question_id_feat: FeatureInfo = DEFAULT_QUESTION_ID_FEAT
67
+ image_input_feat: ImageFeatureInfo = DEFAULT_DOC_FEAT
68
+ text_input_feat: FeatureInfo = FeatureInfo('text_input', input_key=None)
69
+ text_target_feat: Optional[FeatureInfo] = FeatureInfo('text_target', input_key=None)
70
+ question_prefix: Optional[str] = '<s_question>'
71
+ question_suffix: Optional[str] = '</s_question>'
72
+ answer_prefix: Optional[str] = '<s_answer>'
73
+ answer_suffix: Optional[str] = '</s_answer>'
74
+ # FIXME prompt templates instead of prefix+suffix above?
75
+ render_dpi: int = 144
76
+
77
+
78
+ def create_processor_doc_vqa(cfg: DataTaskDocVqaCfg, image_process_fn: Optional[Callable] = None):
79
+ return DocVqaProcessor(
80
+ image_process_fn=image_process_fn if image_process_fn is not None else cfg.image_process_fn,
81
+ text_process_fn=cfg.text_process_fn,
82
+ image_input_feat=cfg.image_input_feat,
83
+ text_input_feat=cfg.text_input_feat,
84
+ text_target_feat=cfg.text_target_feat,
85
+ question_feat=cfg.question_feat,
86
+ answer_feat=cfg.answer_feat,
87
+ question_id_feat=cfg.question_id_feat,
88
+ render_dpi=cfg.render_dpi,
89
+ question_prefix=cfg.question_prefix,
90
+ question_suffix=cfg.question_suffix,
91
+ answer_prefix=cfg.answer_prefix,
92
+ answer_suffix=cfg.answer_suffix,
93
+ )
94
+
95
+
96
+ def build_task_pipeline_doc_vqa(
97
+ cfg: DataTaskDocVqaCfg,
98
+ ):
99
+ # document decoding & pre-processing done together, there is coupling in random page
100
+ # selection and in the future, masking of image and/or text
101
+ handler = get_error_handler(cfg.error_handler)
102
+
103
+ pipe = [wds.map(create_processor_doc_vqa(cfg), handler=handler)]
104
+
105
+ if cfg.output_tuple:
106
+ # NOTE in this mode we lose '_parse' key and would need to derive from target
107
+ # Unless we add support for parse as the last tuple element?
108
+ if cfg.text_target_feat is not None:
109
+ pipe += [
110
+ wds.to_tuple(
111
+ cfg.image_input_feat.output_name,
112
+ cfg.text_input_feat.output_name,
113
+ cfg.text_target_feat.output_name,
114
+ )
115
+ ]
116
+ else:
117
+ pipe += [
118
+ wds.to_tuple(
119
+ cfg.image_input_feat.output_name,
120
+ cfg.text_input_feat.output_name,
121
+ )
122
+ ]
123
+ return pipe
124
+
@@ -0,0 +1,32 @@
1
+ from chugr.common import DataTaskCfg
2
+
3
+ from .pipeline_doc_read import build_task_pipeline_doc_read, create_processor_doc_read, DataTaskDocReadCfg
4
+ from .pipeline_doc_vqa import build_task_pipeline_doc_vqa, create_processor_doc_vqa, DataTaskDocVqaCfg
5
+ from .pipeline_image_text import build_task_pipeline_image_text, DataTaskImageTextCfg
6
+ # from .donut_gtparse_pipe import
7
+ from .pipeline_manual import build_task_pipeline_manual, DataTaskManualCfg
8
+
9
+ _cfg_to_create = {
10
+ DataTaskDocReadCfg: build_task_pipeline_doc_read,
11
+ DataTaskDocVqaCfg: build_task_pipeline_doc_vqa,
12
+ DataTaskImageTextCfg: build_task_pipeline_image_text,
13
+ DataTaskManualCfg: build_task_pipeline_manual,
14
+ }
15
+
16
+
17
+ def create_task_pipeline(cfg: DataTaskCfg):
18
+ create_fn = _cfg_to_create[type(cfg)]
19
+ return create_fn(cfg)
20
+
21
+
22
+ _cfg_to_processor = {
23
+ DataTaskDocReadCfg: create_processor_doc_read,
24
+ DataTaskDocVqaCfg: create_processor_doc_vqa,
25
+ }
26
+
27
+
28
+ def create_task_processor(cfg: DataTaskCfg, image_process_fn=None):
29
+ """ Per-sample processor (decode + image / text processing) for tasks that support NaFlex bucketed batching. """
30
+ if type(cfg) not in _cfg_to_processor:
31
+ raise ValueError(f'NaFlex batching is not supported for task cfg {type(cfg).__name__}.')
32
+ return _cfg_to_processor[type(cfg)](cfg, image_process_fn=image_process_fn)
@@ -0,0 +1,93 @@
1
+ from dataclasses import dataclass, field
2
+ from functools import partial
3
+ from typing import Callable, Optional, Union
4
+
5
+ import webdataset as wds
6
+
7
+ from chugr.common import DataTaskCfg, ImageFeatureInfo, FeatureInfo
8
+ from chugr.wds import get_error_handler, create_image_decoder
9
+
10
+ _DEFAULT_IMG_KEY = "jpg;png;jpeg;webp;tif"
11
+ _DEFAULT_TXT_KEY = "ground_truth"
12
+ _DEFAULT_IMG_KEY_TUPLE = tuple(_DEFAULT_IMG_KEY.split(';'))
13
+ _DEFAULT_TXT_KEY_TUPLE = tuple(_DEFAULT_TXT_KEY.split(';'))
14
+
15
+ _DEFAULT_IMAGE_FEAT = ImageFeatureInfo('image_input', input_key=_DEFAULT_IMG_KEY, image_mode='L')
16
+ _DEFAULT_TXT_FEAT = FeatureInfo('ground_truth', input_key=_DEFAULT_TXT_KEY)
17
+
18
+
19
+ @dataclass
20
+ class DataTaskImageTextCfg(DataTaskCfg):
21
+ image_input_feat: ImageFeatureInfo = _DEFAULT_IMAGE_FEAT
22
+ text_input_feat: FeatureInfo = _DEFAULT_TXT_FEAT
23
+
24
+
25
+ def filter_no_caption_or_no_image(
26
+ sample,
27
+ image_key=_DEFAULT_IMG_KEY_TUPLE,
28
+ text_key=_DEFAULT_TXT_KEY_TUPLE
29
+ ):
30
+ has_caption = any(k in sample for k in text_key)
31
+ has_image = any(k in sample for k in image_key)
32
+ return has_caption and has_image
33
+
34
+
35
+ def build_task_pipeline_gtparse(
36
+ cfg: DataTaskImageTextCfg,
37
+ ):
38
+ """ Create pipeline for dual image & text input pipelines.
39
+ FIXME add support for caption target for caption tasks or separate pipe?
40
+ """
41
+ handler = get_error_handler(cfg.error_handler)
42
+ pipe = []
43
+
44
+ if cfg.filter_valid:
45
+ filter_fn = partial(
46
+ filter_no_caption_or_no_image,
47
+ image_key=tuple(cfg.image_input_feat.input_key.split(';')),
48
+ text_key=tuple(cfg.text_input_feat.input_key.split(';')),
49
+ )
50
+ pipe += [
51
+ wds.select(filter_fn)
52
+ ]
53
+
54
+ if cfg.decode_and_process_fn:
55
+ pipe += [
56
+ wds.map(cfg.decode_and_process_fn)
57
+ ]
58
+ else:
59
+ decode_fn = create_image_decoder(
60
+ cfg.decode_fn,
61
+ image_mode=cfg.image_input_feat.image_mode,
62
+ handler=handler,
63
+ )
64
+
65
+ rename_dict = {
66
+ cfg.image_input_feat.output_name: cfg.text_input_feat.input_key,
67
+ cfg.text_input_feat.output_name: cfg.text_input_feat.input_key,
68
+ }
69
+ pipe += [
70
+ decode_fn,
71
+ wds.rename(**rename_dict),
72
+ ]
73
+
74
+ map_dict = {}
75
+ if cfg.image_process_fn is not None:
76
+ map_dict[cfg.image_input_feat.output_name] = cfg.image_process_fn
77
+ if cfg.text_process_fn is not None:
78
+ map_dict[cfg.text_input_feat.output_name] = cfg.text_process_fn
79
+
80
+ if map_dict:
81
+ pipe += [
82
+ wds.map_dict(**map_dict, handler=handler)
83
+ ]
84
+
85
+ if cfg.output_tuple:
86
+ pipe += [
87
+ wds.to_tuple(
88
+ cfg.image_input_feat.output_name,
89
+ cfg.text_input_feat.output_name,
90
+ )
91
+ ]
92
+
93
+ return pipe