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/common/types.py ADDED
@@ -0,0 +1,122 @@
1
+ from dataclasses import dataclass
2
+ from multiprocessing import Value
3
+ from typing import Any, Dict, List, Optional, Tuple, Union
4
+ from numbers import Number
5
+
6
+ from torch.utils.data import DataLoader, DistributedSampler
7
+
8
+
9
+ class SharedCount:
10
+ def __init__(self, count: int = 0):
11
+ self.count = Value('i', count)
12
+
13
+ def set_value(self, epoch):
14
+ self.count.value = epoch
15
+
16
+ def get_value(self):
17
+ return self.count.value
18
+
19
+
20
+ @dataclass
21
+ class LoaderBundle:
22
+ """
23
+ Bundle a DataLoader with num_batch / num_sample limits, sampler or shared_interval counter exposed
24
+ to allow easy seed control per-interval.
25
+ """
26
+ loader: DataLoader
27
+ num_batches: int = 0
28
+ num_samples: int = 0
29
+ sampler: DistributedSampler = None
30
+ shared_interval: SharedCount = None
31
+ mean_batch_size: Optional[float] = None # set for variable batch size (NaFlex schedule) loaders
32
+
33
+ def set_interval(self, interval):
34
+ if self.shared_interval is not None:
35
+ self.shared_interval.set_value(interval)
36
+ dataset = getattr(self.loader, 'dataset', None)
37
+ if hasattr(dataset, 'set_interval_count'):
38
+ # main process side interval state of the dataset (e.g. the HF streaming dataset epoch)
39
+ dataset.set_interval_count(interval)
40
+ if self.sampler is not None and hasattr(self.sampler, 'set_epoch'):
41
+ self.sampler.set_epoch(interval)
42
+
43
+ def __iter__(self):
44
+ return self.loader.__iter__()
45
+
46
+
47
+ @dataclass
48
+ class SplitInfo:
49
+ filenames: Tuple[str]
50
+ num_samples: int
51
+ shard_lengths: Tuple[int] = ()
52
+ name: str = ''
53
+
54
+
55
+ # @dataclass
56
+ # class ShardInfo:
57
+ # url: str
58
+ # weight: float = 1.0
59
+ # num_samples: Optional[int] = None
60
+ #
61
+ #
62
+
63
+
64
+ @dataclass
65
+ class SourceSpec:
66
+ url: str
67
+ split: Optional[str] = None # dataset split
68
+ template: Optional[str] = None # template to transform url -> usage
69
+ sampling_weight: Optional[float] = None
70
+ num_samples: Optional[int] = None
71
+
72
+ # TODO resolve dataset info and track base url, shard info (sizes, etc)
73
+ # base_url: str = None
74
+ # info_url: str = None
75
+
76
+
77
+ @dataclass
78
+ class SourceInfo(SourceSpec):
79
+ split_info: Dict[str, SplitInfo] = None
80
+ shard_info: Dict[str, Dict[str, Any]] = None
81
+
82
+
83
+ @dataclass
84
+ class ShardSpec:
85
+ urls: List[str]
86
+ weights: Optional[Union[float, List[float]]] = None
87
+ sizes: Optional[List[int]] = None
88
+
89
+ def __post_init__(self):
90
+ num_shards = len(self.urls)
91
+ if self.weights is not None:
92
+ if isinstance(self.weights, Number):
93
+ self.weights = [self.weights] * num_shards
94
+ assert len(self.weights) == num_shards
95
+ if self.sizes is not None:
96
+ assert len(self.sizes) == num_shards
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class FeatureInfo:
101
+ """ Feature Information
102
+
103
+ Attributes:
104
+ output_name: output feature name, None if an intermediary feature
105
+ input_key: input dataset key(s), ';' delimited for multiple options
106
+ """
107
+ output_name: Optional[str] = 'image'
108
+ input_key: Optional[str] = 'jpg;png'
109
+ #parent: Optional[str] = None
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class ImageFeatureInfo(FeatureInfo):
114
+ """ Image Feature Information
115
+
116
+ Attributes:
117
+ image_mode: Image colour mode (e.g. 'RGB', 'RGBA', 'L')
118
+ output_name: output feature name, None if an intermediary feature
119
+ input_key: input dataset key(s), ';' delimited for multiple options
120
+ parent: parent key to search for input_key, e.g. 'json'
121
+ """
122
+ image_mode: str = 'RGB'
chugr/common/urls.py ADDED
@@ -0,0 +1,88 @@
1
+ import os
2
+ from numbers import Number
3
+ from typing import Sequence
4
+
5
+
6
+ import braceexpand
7
+ import re
8
+
9
+
10
+ def envlookup(m):
11
+ """Look up match in the environment with prefix WDS_.
12
+
13
+ Args:
14
+ m: a match object
15
+
16
+ Returns:
17
+ str: the value of the environment variable WDS_<m.group(1)>
18
+ """
19
+ key = m.group(1)
20
+ for prefix in ('WDS_', 'CHUG_'):
21
+ key = prefix + key
22
+ if key in os.environ:
23
+ return os.environ[key]
24
+ assert key in os.environ, f"missing WDS/CHUG environment variable for {key}"
25
+
26
+
27
+ def envsubst(s):
28
+ """Substitute ${var} with the value of the environment variable WDS_var.
29
+
30
+ Args:
31
+ s (str): string to be substituted
32
+
33
+ Returns:
34
+ str: the substituted string
35
+ """
36
+ return re.sub(r"\$\{(\w+)\}", envlookup, s)
37
+
38
+
39
+ def _subst_and_expand(url: str):
40
+ for i in range(10):
41
+ last = url
42
+ url = envsubst(url)
43
+ if url == last:
44
+ break
45
+ return braceexpand.braceexpand(url)
46
+
47
+
48
+ def expand_urls(urls, weights=None):
49
+ """ Expand urls (and optionally weights) if they are strings, otherwise return as lists.
50
+ """
51
+ if weights is None:
52
+ if isinstance(urls, str):
53
+ url_list = urls.split("::")
54
+ result = []
55
+ for url in url_list:
56
+ result.extend(_subst_and_expand(url))
57
+ return result, None
58
+ else:
59
+ return list(urls), None
60
+
61
+ if isinstance(urls, str):
62
+ url_list = urls.split('::')
63
+
64
+ if isinstance(weights, str):
65
+ weights = weights.split('::')
66
+ elif isinstance(weights, Number):
67
+ weights = [weights] * len(url_list)
68
+ assert len(weights) == len(url_list), \
69
+ f"Expected the number of data components ({len(url_list)}) and weights({len(weights)}) to match."
70
+ weights = [float(weight) for weight in weights]
71
+ all_urls, all_weights = [], []
72
+ for url, weight in zip(url_list, weights):
73
+ expanded_url = list(_subst_and_expand(url))
74
+ expanded_weights = [weight] * len(expanded_url)
75
+ all_urls.extend(expanded_url)
76
+ all_weights.extend(expanded_weights)
77
+ else:
78
+ all_urls = list(urls)
79
+ if isinstance(weights, Number):
80
+ # if weights is a scalar, expand to url list
81
+ all_weights = [float(weights)] * len(all_urls)
82
+ else:
83
+ assert len(weights) == len(all_urls), \
84
+ f"Expected the number of data components ({len(all_urls)}) and weights({len(weights)}) to match."
85
+ all_weights = list(weights)
86
+
87
+ return all_urls, all_weights
88
+
chugr/doc/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ from .constants import (
2
+ DEFAULT_DOC_KEY,
3
+ DEFAULT_QUESTION_KEY,
4
+ DEFAULT_QUESTION_ID_KEY,
5
+ DEFAULT_ANSWER_KEY,
6
+ DEFAULT_DOC_KEY_TUPLE,
7
+ DEFAULT_QUESTION_KEY_TUPLE,
8
+ DEFAULT_ANSWER_KEY_TUPLE,
9
+ DEFAULT_DOC_FEAT,
10
+ DEFAULT_QUESTION_FEAT,
11
+ DEFAULT_QUESTION_ID_FEAT,
12
+ DEFAULT_ANSWER_FEAT
13
+ )
14
+ from .doc_processor import DocProcessor
15
+ from .doc_read_processor import DocReadProcessor
16
+ from .doc_vqa_processor import DocVqaProcessor
17
+ from .synth_text import SynthTextCfg, SynthTextRenderer
chugr/doc/constants.py ADDED
@@ -0,0 +1,15 @@
1
+ from chugr import FeatureInfo, ImageFeatureInfo
2
+
3
+ DEFAULT_DOC_KEY = "pdf;tif;png;jpeg;jpg;webp;image"
4
+ DEFAULT_QUESTION_KEY = "question;query"
5
+ DEFAULT_QUESTION_ID_KEY = "question_id;query_id"
6
+ DEFAULT_ANSWER_KEY = "answer;answers"
7
+
8
+ DEFAULT_DOC_KEY_TUPLE = tuple(DEFAULT_DOC_KEY.split(';'))
9
+ DEFAULT_QUESTION_KEY_TUPLE = tuple(DEFAULT_QUESTION_KEY.split(';'))
10
+ DEFAULT_ANSWER_KEY_TUPLE = tuple(DEFAULT_ANSWER_KEY.split(';'))
11
+
12
+ DEFAULT_DOC_FEAT = ImageFeatureInfo('image_input', input_key=DEFAULT_DOC_KEY, image_mode='L')
13
+ DEFAULT_QUESTION_FEAT = FeatureInfo(None, input_key=DEFAULT_QUESTION_KEY)
14
+ DEFAULT_QUESTION_ID_FEAT = FeatureInfo(None, input_key=DEFAULT_QUESTION_ID_KEY)
15
+ DEFAULT_ANSWER_FEAT = FeatureInfo(None, input_key=DEFAULT_ANSWER_KEY)
@@ -0,0 +1,248 @@
1
+ import abc
2
+ import json
3
+ import logging
4
+ import random
5
+ from typing import Callable, Dict, List, Optional, Tuple
6
+
7
+ from chugr.common import FeatureInfo, ImageFeatureInfo
8
+ from chugr.wds import decode_image_pages, decode_pdf_pages
9
+
10
+
11
+ from .constants import (
12
+ DEFAULT_DOC_FEAT,
13
+ )
14
+
15
+ _logger = logging.getLogger(__name__)
16
+
17
+
18
+ def get_next_valid_page_index(
19
+ current_index: int,
20
+ num_pages: int,
21
+ page_annos: list,
22
+ retries: int = 10,
23
+ wanted: str = 'lines',
24
+ ):
25
+ """
26
+ Get the index of the next valid page which contains text. If it doesn't find any non-empty page
27
+ after 'retries' attempts, it raises a RuntimeError.
28
+
29
+ Parameters:
30
+ current_index (int): Current page index.
31
+ num_pages (int): Total number of pages.
32
+ page_annos (list): List of page annotations.
33
+ retries (int): Number of maximum retries for a given document.
34
+
35
+ Returns:
36
+ int: The index of the next non-empty page.
37
+ """
38
+ for _ in range(retries):
39
+ # Get the next index, wrap around to 0 if it exceeds num_pages (in case of random init)
40
+ current_index = (current_index + 1) % num_pages
41
+ anno_page = page_annos[current_index]
42
+ anno_page = anno_page.get(wanted, anno_page) # use 'lines' / 'words' level if exists
43
+ if anno_page["text"]:
44
+ return current_index
45
+ raise RuntimeError(f"No non-empty page found after {retries} attempts")
46
+
47
+
48
+ def _get_value(keys, sample):
49
+ if isinstance(keys, (list, tuple)):
50
+ value = None
51
+ for k in keys:
52
+ if (value := sample.get(k, None)) is not None:
53
+ break
54
+ return value
55
+ else:
56
+ return sample.get(keys, None)
57
+
58
+
59
+ class DocProcessor:
60
+ """ Process documents w/ OCR annotation for reading tasks.
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ image_process_fn: Optional[Callable] = None,
66
+ text_process_fn: Optional[Callable] = None,
67
+ image_input_feat: ImageFeatureInfo = DEFAULT_DOC_FEAT,
68
+ text_input_feat: FeatureInfo = FeatureInfo('text_input', input_key='pages'),
69
+ text_target_feat: FeatureInfo = FeatureInfo('text_target', input_key=None),
70
+ page_sampling: str = 'random',
71
+ render_dpi: int = 150,
72
+ squeeze_pages: bool = True,
73
+ expand_pages: bool = False,
74
+ flatten_json: bool = True,
75
+ seed: int = 0,
76
+ ):
77
+ """
78
+
79
+ Args:
80
+ image_process_fn:
81
+ text_process_fn:
82
+ page_sampling:
83
+ render_dpi:
84
+ seed:
85
+ """
86
+ self.image_process_fn = image_process_fn
87
+ self.text_process_fn = text_process_fn
88
+
89
+ self.image_input_feat = image_input_feat
90
+ self.image_input_name = image_input_feat.output_name
91
+ self.image_input_key = image_input_feat.input_key.split(';')
92
+ self.text_input_feat = text_input_feat
93
+ self.text_input_name = text_input_feat.output_name
94
+ self.text_input_key = text_input_feat.input_key.split(';') if text_input_feat.input_key else None
95
+ self.text_target_feat = text_target_feat
96
+ self.text_target_name = text_target_feat.output_name if text_target_feat is not None else None
97
+
98
+ self.page_sampling = page_sampling
99
+ self.render_dpi = render_dpi
100
+ self.squeeze_pages = squeeze_pages
101
+ self.expand_pages = expand_pages
102
+ self.flatten_json = flatten_json
103
+ self.seed = seed
104
+ self._generator = random.Random(seed)
105
+ self._generator_worker = None
106
+ # FIXME note, should move to torchvision v2 annotations at some point
107
+ # * they should all eventually have a generator arg for better handling random state
108
+ # * they have forms that accept bbox/points args to transform annotations in sync with image
109
+
110
+ @property
111
+ def generator(self) -> random.Random:
112
+ # (re)seed per dataloader worker so workers don't produce identical random sequences
113
+ from torch.utils.data import get_worker_info
114
+ worker_info = get_worker_info()
115
+ worker_id = worker_info.id if worker_info is not None else -1
116
+ if worker_id != self._generator_worker:
117
+ if worker_info is not None:
118
+ self._generator = random.Random(worker_info.seed + self.seed)
119
+ self._generator_worker = worker_id
120
+ return self._generator
121
+
122
+ def _preprocess_image_pages(self, decoded_pages, page_image_info=None):
123
+ if self.image_process_fn is None:
124
+ return decoded_pages
125
+
126
+ if page_image_info is not None:
127
+ # FIXME, WIP. If train objective involves masking or otherwise processing image
128
+ # with knowledge of annotations / text content, anno info should contain
129
+ # mask locations, etc. For such a task, we need to pass it to image preprocess
130
+ decoded_pages = [self.image_process_fn(dp, page_info=pi) for dp, pi in zip(decoded_pages, page_image_info)]
131
+ else:
132
+ decoded_pages = [self.image_process_fn(dp) for dp in decoded_pages]
133
+
134
+ return decoded_pages
135
+
136
+ def _decode_image_pages(
137
+ self,
138
+ sample,
139
+ ext,
140
+ page_indices,
141
+ num_anno_pages,
142
+ ):
143
+ image_mode = self.image_input_feat.image_mode
144
+
145
+ decoded_pages, num_image_pages = decode_image_pages(
146
+ sample[ext],
147
+ image_mode=image_mode,
148
+ page_indices=page_indices,
149
+ )
150
+ if num_image_pages != num_anno_pages:
151
+ _logger.warning(
152
+ f'Mismatch between num image and num annotation pages {num_image_pages} != {num_anno_pages}'
153
+ f' for sample {sample["__url__"]}, {sample["__key__"]}.')
154
+
155
+ decoded_pages = self._preprocess_image_pages(decoded_pages)
156
+
157
+ return decoded_pages, num_image_pages
158
+
159
+ def _decode_pdf_pages(
160
+ self,
161
+ sample,
162
+ ext,
163
+ page_indices,
164
+ num_anno_pages,
165
+ ):
166
+ image_mode = self.image_input_feat.image_mode
167
+ decoded_pages, num_image_pages = decode_pdf_pages(
168
+ sample[ext],
169
+ image_mode=image_mode,
170
+ page_indices=page_indices,
171
+ render_dpi=self.render_dpi,
172
+ )
173
+ if num_anno_pages is not None and num_image_pages != num_anno_pages:
174
+ _logger.warning(
175
+ f'Mismatch between num image and num annotation pages {num_image_pages} != {num_anno_pages}'
176
+ f' for sample {sample["__url__"]}, {sample["__key__"]}.')
177
+
178
+ decoded_pages = self._preprocess_image_pages(decoded_pages)
179
+
180
+ return decoded_pages, num_image_pages
181
+
182
+ @abc.abstractmethod
183
+ def _decode_anno(self, sample) -> Tuple[Dict, List[int], int]:
184
+ pass
185
+
186
+ def _expand_anno(self, anno, count: int):
187
+ expanded_annos = [
188
+ {k: v[i] if isinstance(v, (list, tuple)) else v for k, v in anno.items()}
189
+ for i in range(count)
190
+ ]
191
+ return expanded_annos
192
+
193
+ def _squeeze_anno(self, anno):
194
+ anno = {k: v[0] if isinstance(v, (list, tuple)) else v for k, v in anno.items()}
195
+ return anno
196
+
197
+ def __call__(self, sample):
198
+ if 'json' in sample and isinstance(sample['json'], bytes):
199
+ # decode json if present and in undecoded state
200
+ sample['json'] = json.loads(sample['json'])
201
+
202
+ if self.flatten_json and 'json' in sample:
203
+ # flatten json into sample
204
+ sample.update(sample.pop('json'))
205
+
206
+ # FIXME separate decode & preprocess interfaces
207
+
208
+ # decode page annotations / text
209
+ page_anno, page_indices, num_anno_pages = self._decode_anno(sample)
210
+
211
+ # decode page images
212
+ page_images = []
213
+ for ext in self.image_input_key:
214
+ if ext in sample:
215
+ if ext == 'pdf':
216
+ images, num_image_pages = self._decode_pdf_pages(
217
+ sample,
218
+ ext,
219
+ page_indices,
220
+ num_anno_pages,
221
+ )
222
+ else:
223
+ images, num_image_pages = self._decode_image_pages(
224
+ sample,
225
+ ext,
226
+ page_indices,
227
+ num_anno_pages,
228
+ )
229
+ page_images.extend(images)
230
+ # process one document type per doc, should be ordered by priority
231
+ break
232
+
233
+ assert len(page_images), 'No page images present'
234
+
235
+ if self.expand_pages and len(page_images) > 1:
236
+ # expand pages and page annotations into multiple samples (return list of sample dicts)
237
+ page_anno = self._expand_anno(page_anno, len(page_images))
238
+ decoded = [{self.image_input_name: pi, **pa} for pi, pa in zip(page_images, page_anno)]
239
+ else:
240
+ if self.squeeze_pages and len(page_images) == 1:
241
+ # squeeze page & annotation lists into singular items
242
+ page_images = page_images[0]
243
+ page_anno = self._squeeze_anno(page_anno)
244
+ decoded = {self.image_input_name: page_images, **page_anno}
245
+
246
+ return decoded
247
+
248
+