timetoalign 0.0.1__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.
- timetoalign-0.0.1.dist-info/METADATA +11 -0
- timetoalign-0.0.1.dist-info/RECORD +13 -0
- timetoalign-0.0.1.dist-info/WHEEL +5 -0
- timetoalign-0.0.1.dist-info/top_level.txt +1 -0
- tta/__init__.py +1 -0
- tta/common.py +58 -0
- tta/conversions.py +3777 -0
- tta/events.py +1527 -0
- tta/parsing.py +2236 -0
- tta/registry.py +546 -0
- tta/representations.py +826 -0
- tta/timelines.py +2192 -0
- tta/utils.py +963 -0
tta/representations.py
ADDED
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os.path as osp
|
|
5
|
+
import warnings
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from dataclasses import replace as copy_dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from pprint import pformat
|
|
10
|
+
from typing import Dict, Generic, Iterable, Optional, Self, Type, TypeVar
|
|
11
|
+
|
|
12
|
+
import pymupdf
|
|
13
|
+
from pymupdf import Document, Matrix, Page, Pixmap, Rect, csRGB
|
|
14
|
+
|
|
15
|
+
from processing.tta import utils
|
|
16
|
+
from processing.tta.common import RegisteredObject
|
|
17
|
+
from processing.tta.events import (
|
|
18
|
+
AudioSilo,
|
|
19
|
+
MidiDataFrameSilo,
|
|
20
|
+
PartituraScoreSilo,
|
|
21
|
+
Silo,
|
|
22
|
+
)
|
|
23
|
+
from processing.tta.registry import ID_str, ensure_registration, get_object_by_id
|
|
24
|
+
from processing.tta.timelines import (
|
|
25
|
+
Timeline,
|
|
26
|
+
)
|
|
27
|
+
from processing.tta.utils import NamedSources
|
|
28
|
+
from processing.utils import get_specimen_path
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class File:
|
|
35
|
+
"""A simple data class to represent a file with its identifiers, checksum, and other metadata."""
|
|
36
|
+
|
|
37
|
+
name: Optional[str] = None
|
|
38
|
+
"""Arbitrary name for the file."""
|
|
39
|
+
local_path: Optional[Path] = None
|
|
40
|
+
"""Local path to the file if present on the current filesystem."""
|
|
41
|
+
uri: Optional[str] = None
|
|
42
|
+
"""Unique Resource Identifier (URI) for the file, if applicable. Could be a URL."""
|
|
43
|
+
id: Optional[str | ID_str] = None
|
|
44
|
+
"""ID under which this file is registered and retrievable."""
|
|
45
|
+
checksum: Optional[str] = None
|
|
46
|
+
"""Checksum of the file for integrity verification."""
|
|
47
|
+
silo_type: Optional[Type[Silo]] = None
|
|
48
|
+
"""Suggested type of Silo."""
|
|
49
|
+
silo_id: Optional[str] = None
|
|
50
|
+
"""ID of the Silo when one has been instantiated for this file."""
|
|
51
|
+
timeline_id: Optional[str] = None
|
|
52
|
+
"""ID of the Timeline associated with this file, if applicable."""
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def fname(self) -> Optional[str]:
|
|
56
|
+
if self.local_path is not None:
|
|
57
|
+
return self.local_path.name
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def fext(self) -> Optional[str]:
|
|
61
|
+
"""File extension of the local path, if available."""
|
|
62
|
+
if self.local_path is not None:
|
|
63
|
+
return self.local_path.suffix.lower()
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def local_directory(self) -> Optional[Path]:
|
|
68
|
+
"""Directory of the local path, if available."""
|
|
69
|
+
if self.local_path is not None:
|
|
70
|
+
return self.local_path.parent
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def has_silo(self) -> bool:
|
|
75
|
+
"""Check if a silo has been instantiated for this file."""
|
|
76
|
+
return self.silo_id is not None
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_path(
|
|
80
|
+
cls,
|
|
81
|
+
local_path: Path | str,
|
|
82
|
+
id_prefix: Optional[str] = "file",
|
|
83
|
+
uid: Optional[str] = None,
|
|
84
|
+
**kwargs,
|
|
85
|
+
):
|
|
86
|
+
"""Create a File instance from a file path."""
|
|
87
|
+
if isinstance(local_path, str):
|
|
88
|
+
local_path = Path(local_path)
|
|
89
|
+
if not local_path.is_file():
|
|
90
|
+
if local_path.is_dir():
|
|
91
|
+
raise FileNotFoundError(
|
|
92
|
+
f"Expected a file, but got a directory: {local_path}"
|
|
93
|
+
)
|
|
94
|
+
raise FileNotFoundError(f"File not found: {local_path}")
|
|
95
|
+
self = cls(
|
|
96
|
+
name=local_path.stem,
|
|
97
|
+
local_path=local_path,
|
|
98
|
+
checksum=utils.calculate_file_checksum(local_path),
|
|
99
|
+
**kwargs,
|
|
100
|
+
)
|
|
101
|
+
if id_prefix is not None:
|
|
102
|
+
self.id = ensure_registration(self, id_prefix=id_prefix, uid=uid)
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
def get_silo(
|
|
106
|
+
self,
|
|
107
|
+
) -> Optional[Silo]:
|
|
108
|
+
"""Get the silo associated with this file, if it has been instantiated."""
|
|
109
|
+
if self.silo_id is None:
|
|
110
|
+
return None
|
|
111
|
+
return get_object_by_id(self.silo_id)
|
|
112
|
+
|
|
113
|
+
def instantiate_silo(
|
|
114
|
+
self, silo_type: Optional[Type[Silo]] = None, **kwargs
|
|
115
|
+
) -> Silo:
|
|
116
|
+
"""(Re-)instantiate an Silo for this file."""
|
|
117
|
+
if silo_type is None:
|
|
118
|
+
if self.silo_type is not None:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
"No silo type specified for instantiation. "
|
|
121
|
+
"Please provide a valid Silo subclass."
|
|
122
|
+
)
|
|
123
|
+
silo_type = self.silo_type
|
|
124
|
+
else:
|
|
125
|
+
if not issubclass(silo_type, Silo):
|
|
126
|
+
raise TypeError(f"Expected a subclass of Silo, got {silo_type}.")
|
|
127
|
+
self.silo_type = silo_type
|
|
128
|
+
silo = silo_type.from_filepath(
|
|
129
|
+
self.local_path,
|
|
130
|
+
id_prefix=f"{self.id}/silo",
|
|
131
|
+
**kwargs,
|
|
132
|
+
)
|
|
133
|
+
self.silo_id = silo.id
|
|
134
|
+
return silo
|
|
135
|
+
|
|
136
|
+
def make_timeline(
|
|
137
|
+
self,
|
|
138
|
+
) -> Timeline:
|
|
139
|
+
silo = self.get_silo()
|
|
140
|
+
if silo is None:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"No silo instantiated for {self!r}. Cannot create a timeline."
|
|
143
|
+
)
|
|
144
|
+
tl = silo.make_timeline()
|
|
145
|
+
self.timeline_id = tl.id
|
|
146
|
+
return tl
|
|
147
|
+
|
|
148
|
+
def __repr__(self):
|
|
149
|
+
info_str = "File("
|
|
150
|
+
if self.name:
|
|
151
|
+
info_str += f"name={self.name!r}, "
|
|
152
|
+
elif self.uri:
|
|
153
|
+
info_str += f"uri={self.uri!r}, "
|
|
154
|
+
elif self.fname:
|
|
155
|
+
info_str += f"fname={self.fname!r}, "
|
|
156
|
+
info_str += f"checksum={self.checksum!r})"
|
|
157
|
+
return info_str
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
ST = TypeVar("ST") # source type; currently always File
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Representation(NamedSources[ST], RegisteredObject, Generic[ST]):
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class FileCollection(Representation[File]):
|
|
168
|
+
_allowed_extensions: Optional[tuple[str, ...]] = None
|
|
169
|
+
"""If this class attribute is set, only files with the enumerated extensions will be allowed as sources.
|
|
170
|
+
Every extension needs to start with a dot, e.g. (".mid", ".midi"). If set to None, no restrictions are applied.
|
|
171
|
+
"""
|
|
172
|
+
_default_silo_types: dict[Optional[str], Type[Silo]] = {None: None}
|
|
173
|
+
"""Mapping of file extensions to Silo types. The None key is used for the default type."""
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def from_filepaths(
|
|
177
|
+
cls,
|
|
178
|
+
filepaths: Iterable[Path | str] | Path | str,
|
|
179
|
+
create_silos: Optional[bool | Type[Silo] | Iterable[Type[Silo]]] = None,
|
|
180
|
+
) -> Self:
|
|
181
|
+
self = cls()
|
|
182
|
+
for path in utils.make_argument_iterable(filepaths):
|
|
183
|
+
self.add_sources(File.from_path(path))
|
|
184
|
+
return self
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def from_directory(
|
|
188
|
+
cls,
|
|
189
|
+
directory: Path | str,
|
|
190
|
+
create_silos: Optional[bool | Type[Silo] | Iterable[Type[Silo]]] = None,
|
|
191
|
+
) -> Self:
|
|
192
|
+
self = cls()
|
|
193
|
+
if isinstance(directory, str):
|
|
194
|
+
directory = Path(directory)
|
|
195
|
+
if not self.allowed_extensions:
|
|
196
|
+
filepaths = directory.glob("*")
|
|
197
|
+
else:
|
|
198
|
+
extensions = self.allowed_extensions
|
|
199
|
+
filepaths = [
|
|
200
|
+
p
|
|
201
|
+
for p in directory.iterdir()
|
|
202
|
+
if p.is_file() and p.suffix.lower() in extensions
|
|
203
|
+
]
|
|
204
|
+
self.add_sources(sources=filepaths, create_silos=create_silos)
|
|
205
|
+
return self
|
|
206
|
+
|
|
207
|
+
def __init__(
|
|
208
|
+
self,
|
|
209
|
+
*,
|
|
210
|
+
silo_types: Optional[dict[Optional[str], Type[Silo]]] = None,
|
|
211
|
+
id_prefix: str,
|
|
212
|
+
uid: Optional[str] = None,
|
|
213
|
+
):
|
|
214
|
+
super().__init__(id_prefix=id_prefix, uid=uid)
|
|
215
|
+
self._silo_types: dict[Optional[str], Type[Silo]] = {}
|
|
216
|
+
self.silo_types = silo_types
|
|
217
|
+
self._timelines = {}
|
|
218
|
+
self._silo_default_timelines: dict[str | int, str] = {}
|
|
219
|
+
"""Mapping a key to the ID of the default timeline for the respective silo, once it has been added."""
|
|
220
|
+
self._spine_id = None
|
|
221
|
+
self._sources: dict[int | str, File] = {}
|
|
222
|
+
"""Currently only local files are supported as sources. They can be added with or without a
|
|
223
|
+
name. In the latter case, a key will be assigned based on the order of addition."""
|
|
224
|
+
self._checksums = {}
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def allowed_extensions(self) -> tuple[str, ...]:
|
|
228
|
+
"""Allowed file extensions for this collection."""
|
|
229
|
+
if self._allowed_extensions is None:
|
|
230
|
+
return tuple()
|
|
231
|
+
return tuple(
|
|
232
|
+
"." + ext.lower().strip(".")
|
|
233
|
+
for ext in utils.make_argument_iterable(self._allowed_extensions)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def silo_types(self) -> dict[Optional[str], Type[Silo]]:
|
|
238
|
+
"""Mapping of file extensions to Silo types."""
|
|
239
|
+
return self._silo_types
|
|
240
|
+
|
|
241
|
+
@silo_types.setter
|
|
242
|
+
def silo_types(self, value: Optional[dict[Optional[str], Type[Silo]]]):
|
|
243
|
+
if value is None:
|
|
244
|
+
self._silo_types = self._default_silo_types.copy()
|
|
245
|
+
elif isinstance(value, dict):
|
|
246
|
+
for key, val in value.items():
|
|
247
|
+
if key is not None and not isinstance(key, str):
|
|
248
|
+
raise TypeError(f"Expected a string key for silo type, got {key}.")
|
|
249
|
+
if not isinstance(val, type) or not issubclass(val, Silo):
|
|
250
|
+
raise TypeError(f"Expected a subclass of Silo, got {val}.")
|
|
251
|
+
self._silo_types[key] = val
|
|
252
|
+
else:
|
|
253
|
+
raise TypeError(f"Expected a dict of silo types, got {type(value)}.")
|
|
254
|
+
|
|
255
|
+
def __repr__(self):
|
|
256
|
+
info_str = (
|
|
257
|
+
f"{self.class_name}("
|
|
258
|
+
f"id={self.id!r}, "
|
|
259
|
+
f"n_timelines={self.n_timelines})"
|
|
260
|
+
f"spine={self._spine_id!r}, "
|
|
261
|
+
f"n_sources={self.n_sources}"
|
|
262
|
+
)
|
|
263
|
+
if self.n_sources > 0:
|
|
264
|
+
info_str += pformat(self._sources, indent=4, width=80)
|
|
265
|
+
info_str += ")"
|
|
266
|
+
return info_str
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def n_timelines(self):
|
|
270
|
+
return len(self._timelines)
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def n_silos(self):
|
|
274
|
+
return sum(f.has_silo for f in self.iter_sources())
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def n_sources(self):
|
|
278
|
+
return len(self._sources)
|
|
279
|
+
|
|
280
|
+
def add_timeline(self, timeline: Timeline):
|
|
281
|
+
"""The first timeline added is treated as the spine."""
|
|
282
|
+
self._timelines[timeline.id] = timeline
|
|
283
|
+
if self.n_timelines == 1:
|
|
284
|
+
self._spine_id = timeline.id
|
|
285
|
+
|
|
286
|
+
def update_default_timelines(
|
|
287
|
+
self,
|
|
288
|
+
keys: Iterable[str | int | Path] | str | int | Path = None,
|
|
289
|
+
):
|
|
290
|
+
"""Retrieve default timelines from the silos associated with the given keys and add them
|
|
291
|
+
to the representation. This does not instantiate new silos nor re-generate once generated
|
|
292
|
+
default timelines.
|
|
293
|
+
"""
|
|
294
|
+
for key, source in self.iter_sources(keys, as_tuples=True):
|
|
295
|
+
if key in self._silo_default_timelines:
|
|
296
|
+
# Timeline already added for this silo
|
|
297
|
+
continue
|
|
298
|
+
if not source.has_silo:
|
|
299
|
+
warnings.warn(
|
|
300
|
+
f"No silo instantiated for {source!r}. Skipping default timeline retrieval."
|
|
301
|
+
)
|
|
302
|
+
continue
|
|
303
|
+
timeline = source.make_timeline()
|
|
304
|
+
self.add_timeline(timeline)
|
|
305
|
+
self._silo_default_timelines[key] = timeline.id
|
|
306
|
+
if len(self._silo_default_timelines) == 0:
|
|
307
|
+
warnings.warn(
|
|
308
|
+
f"No default timelines were created. Probably, no silo types were "
|
|
309
|
+
f"associated with the sources in {self.class_name} {self.id!r}."
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def get_default_timelines(
|
|
313
|
+
self, keys: Iterable[str | int] | str | int = None
|
|
314
|
+
) -> dict[str | int, Timeline]:
|
|
315
|
+
"""Retrieve default timelines from the silos associated with the given keys."""
|
|
316
|
+
keys = self.resolve_keys(keys)
|
|
317
|
+
self.update_default_timelines(keys=keys)
|
|
318
|
+
if self.n_timelines == 0:
|
|
319
|
+
raise ValueError(
|
|
320
|
+
f"No default timelines available for {self.class_name} {self.id!r}. "
|
|
321
|
+
"Probably none of the sources is associated with a silo type."
|
|
322
|
+
)
|
|
323
|
+
return {key: self._timelines[self._silo_default_timelines[key]] for key in keys}
|
|
324
|
+
|
|
325
|
+
def get_key(self, key_or_path: str | int | Path) -> int | str:
|
|
326
|
+
"""Get the key for a given source path or key. If the key is not found, it raises KeyError."""
|
|
327
|
+
try:
|
|
328
|
+
return super().get_key(key_or_path)
|
|
329
|
+
except KeyError:
|
|
330
|
+
for key, file in self._sources.items():
|
|
331
|
+
if file.local_path == key_or_path:
|
|
332
|
+
return key
|
|
333
|
+
raise KeyError(f"Source {key_or_path!r} not found in sources.")
|
|
334
|
+
|
|
335
|
+
def _add_silo(self, key: str | int | Path, silo: Silo):
|
|
336
|
+
"""Add a silo to the representation. If the silo already exists, it will be replaced."""
|
|
337
|
+
source = self.get_source(key)
|
|
338
|
+
if source.has_silo:
|
|
339
|
+
warnings.warn(
|
|
340
|
+
f"{source!r} already was associated with {source.silo_id}. The reference "
|
|
341
|
+
f"has been replaced with {silo.id}.",
|
|
342
|
+
)
|
|
343
|
+
source.silo_id = silo.id
|
|
344
|
+
|
|
345
|
+
def _make_silo(self, key: str | int | Path, silo_type: Optional[Type[Silo]] = None):
|
|
346
|
+
source = self.get_source(key)
|
|
347
|
+
source.instantiate_silo(silo_type=silo_type, id_prefix=f"{self.id}/silo")
|
|
348
|
+
|
|
349
|
+
def get_silos(self, keys: Iterable[str] | str | int) -> list[Silo]:
|
|
350
|
+
"""Get the silos for the given keys or for all sources."""
|
|
351
|
+
silos = []
|
|
352
|
+
for source in self.iter_sources(keys):
|
|
353
|
+
if source.has_silo:
|
|
354
|
+
silos.append(source.get_silo())
|
|
355
|
+
return silos
|
|
356
|
+
|
|
357
|
+
def get_source(self, key: str | int | Path):
|
|
358
|
+
if isinstance(key, Path):
|
|
359
|
+
return self.get_source_by_path(key)
|
|
360
|
+
return super().get_source(key)
|
|
361
|
+
|
|
362
|
+
def get_source_by_path(self, path: Path | str) -> File:
|
|
363
|
+
"""Get a source by its local path. If the path is not found, it raises KeyError."""
|
|
364
|
+
if isinstance(path, str):
|
|
365
|
+
path = Path(path)
|
|
366
|
+
for k, file in self._sources.items():
|
|
367
|
+
if file.local_path == path:
|
|
368
|
+
return file
|
|
369
|
+
raise KeyError(f"None of the source files corresponds to the path {path!r}.")
|
|
370
|
+
|
|
371
|
+
def get_spine(self) -> Timeline:
|
|
372
|
+
if self.n_timelines == 0:
|
|
373
|
+
self.update_default_timelines()
|
|
374
|
+
if self._spine_id is None:
|
|
375
|
+
raise ValueError(
|
|
376
|
+
f"No spine timeline set for {self.class_name} {self.id!r}. "
|
|
377
|
+
"Please set a spine using set_spine() or add a timeline."
|
|
378
|
+
)
|
|
379
|
+
return self._timelines[self._spine_id]
|
|
380
|
+
|
|
381
|
+
def iter_sources(
|
|
382
|
+
self,
|
|
383
|
+
keys: Optional[Iterable[str | int] | str | int] = None,
|
|
384
|
+
as_tuples: bool = False,
|
|
385
|
+
) -> Iterable[File] | Iterable[tuple[str | int, File]]:
|
|
386
|
+
"""Iterate over all sources in the collection."""
|
|
387
|
+
if keys is None:
|
|
388
|
+
if as_tuples:
|
|
389
|
+
yield from self._sources.items()
|
|
390
|
+
else:
|
|
391
|
+
yield from self._sources.values()
|
|
392
|
+
else:
|
|
393
|
+
for key in self.resolve_keys(keys):
|
|
394
|
+
yield key, self.get_source(key) if as_tuples else self.get_source(key)
|
|
395
|
+
|
|
396
|
+
def set_spine(self, timeline: str | Timeline):
|
|
397
|
+
if isinstance(timeline, str):
|
|
398
|
+
timeline_id = timeline
|
|
399
|
+
if timeline_id not in self._timelines:
|
|
400
|
+
timeline = get_object_by_id(timeline_id)
|
|
401
|
+
self.add_timeline(timeline)
|
|
402
|
+
else:
|
|
403
|
+
timeline_id = timeline.id
|
|
404
|
+
if timeline_id not in self._timelines:
|
|
405
|
+
self.add_timeline(timeline)
|
|
406
|
+
self._spine_id = timeline_id
|
|
407
|
+
|
|
408
|
+
def add_sources(
|
|
409
|
+
self,
|
|
410
|
+
sources: Iterable[File | Path | str] | File | Path | str,
|
|
411
|
+
create_silos: bool | Type[Silo] | Iterable[Type[Silo]] = None,
|
|
412
|
+
):
|
|
413
|
+
"""For now assuming that sources are local files. All sources can be retrieved by their
|
|
414
|
+
index (order of addition) or, if assigned, by their name using the get_source() and
|
|
415
|
+
get_sources() methods.
|
|
416
|
+
"""
|
|
417
|
+
for source in utils.make_argument_iterable(sources):
|
|
418
|
+
self._add_source(source, create_silo=create_silos)
|
|
419
|
+
|
|
420
|
+
def _add_source(
|
|
421
|
+
self,
|
|
422
|
+
source: File,
|
|
423
|
+
name: Optional[str] = None,
|
|
424
|
+
create_silo: Optional[bool | Type[Silo]] = None,
|
|
425
|
+
**kwargs,
|
|
426
|
+
):
|
|
427
|
+
new_key = super()._add_source(source=source, name=name)
|
|
428
|
+
self.logger.debug(
|
|
429
|
+
f"Added source {name!r} = {source!r} to {self.class_name} {self.id!r}"
|
|
430
|
+
)
|
|
431
|
+
if create_silo is False:
|
|
432
|
+
return
|
|
433
|
+
source = self.get_source(new_key)
|
|
434
|
+
if create_silo is None or create_silo is True:
|
|
435
|
+
if source.silo_type is not None:
|
|
436
|
+
create_with_type = source.silo_type
|
|
437
|
+
elif create_silo is True:
|
|
438
|
+
raise ValueError(
|
|
439
|
+
f"No silo type specified for source {source.local_path!r} "
|
|
440
|
+
f"and no default type available in {self.class_name} {self.id!r}."
|
|
441
|
+
)
|
|
442
|
+
else:
|
|
443
|
+
return # if None and no default silo class (neither Repr. nor File): do nothing
|
|
444
|
+
elif isinstance(create_silo, type) and issubclass(create_silo, Silo):
|
|
445
|
+
create_with_type = create_silo
|
|
446
|
+
else:
|
|
447
|
+
raise TypeError(
|
|
448
|
+
f"Expected boolean or a subclass of Silo for create_silo, got {create_silo}."
|
|
449
|
+
)
|
|
450
|
+
silo = source.instantiate_silo(create_with_type, **kwargs)
|
|
451
|
+
self.logger.info(f"Created silo {silo.id!r} for source {source!r}")
|
|
452
|
+
|
|
453
|
+
def get_silo_type(self, key: Optional[str | int] = None) -> Type[Silo]:
|
|
454
|
+
"""Get the silo type for a given source key or path. If key is None, returns the default silo type."""
|
|
455
|
+
file = self.get_source(key)
|
|
456
|
+
return file.silo_type
|
|
457
|
+
|
|
458
|
+
def _adapt_source(self, source: File | Path | str) -> File:
|
|
459
|
+
if isinstance(source, (Path, str)):
|
|
460
|
+
source = File.from_path(source, id_prefix=f"{self.id}/file")
|
|
461
|
+
if isinstance(source, File):
|
|
462
|
+
if source.silo_type is None:
|
|
463
|
+
if source.fext is not None and source.fext in self._silo_types:
|
|
464
|
+
source.silo_type = self._silo_types[source.fext]
|
|
465
|
+
elif None in self._silo_types:
|
|
466
|
+
source.silo_type = self._silo_types[None]
|
|
467
|
+
return source
|
|
468
|
+
raise TypeError(f"Expected a File, Path, or str, got {type(source)}.")
|
|
469
|
+
|
|
470
|
+
def validate_source(self, source: File):
|
|
471
|
+
"""Should raise when a source is not valid."""
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class GraphicalRepresentation(FileCollection):
|
|
476
|
+
|
|
477
|
+
def __init__(
|
|
478
|
+
self,
|
|
479
|
+
silo_types: Optional[dict[Optional[str], Type[Silo]]] = None,
|
|
480
|
+
id_prefix: str = "gr",
|
|
481
|
+
uid: Optional[str] = None,
|
|
482
|
+
):
|
|
483
|
+
super().__init__(silo_types=silo_types, id_prefix=id_prefix, uid=uid)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
class LogicalRepresentation(FileCollection):
|
|
487
|
+
|
|
488
|
+
def __init__(
|
|
489
|
+
self,
|
|
490
|
+
silo_types: Optional[dict[Optional[str], Type[Silo]]] = None,
|
|
491
|
+
id_prefix: str = "mu",
|
|
492
|
+
uid: Optional[str] = None,
|
|
493
|
+
):
|
|
494
|
+
super().__init__(silo_types=silo_types, id_prefix=id_prefix, uid=uid)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class PhysicalRepresentation(FileCollection):
|
|
498
|
+
|
|
499
|
+
def __init__(
|
|
500
|
+
self,
|
|
501
|
+
silo_types: Optional[dict[Optional[str], Type[Silo]]] = None,
|
|
502
|
+
id_prefix: str = "ph",
|
|
503
|
+
uid: Optional[str] = None,
|
|
504
|
+
):
|
|
505
|
+
super().__init__(silo_types=silo_types, id_prefix=id_prefix, uid=uid)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class AlignmentRepresentation(FileCollection):
|
|
509
|
+
|
|
510
|
+
def __init__(
|
|
511
|
+
self,
|
|
512
|
+
silo_types: Optional[dict[Optional[str], Type[Silo]]] = None,
|
|
513
|
+
id_prefix: str = "al",
|
|
514
|
+
uid: Optional[str] = None,
|
|
515
|
+
):
|
|
516
|
+
super().__init__(silo_types=silo_types, id_prefix=id_prefix, uid=uid)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
class MatchfileRepresentation(AlignmentRepresentation):
|
|
520
|
+
_allowed_extensions = (".match",)
|
|
521
|
+
_default_silo_types = {None: PartituraScoreSilo}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
class MidiRepresentation(LogicalRepresentation):
|
|
525
|
+
_allowed_extensions = (".mid", ".midi")
|
|
526
|
+
_default_silo_types = {None: MidiDataFrameSilo}
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
class AudioRepresentation(PhysicalRepresentation):
|
|
530
|
+
_default_silo_types = {None: AudioSilo}
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# region to be refactored
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@dataclass
|
|
537
|
+
class EmbeddedImage:
|
|
538
|
+
"""
|
|
539
|
+
Represents information about an embedded image instance on a PDF page,
|
|
540
|
+
as provided by an item in pymupdf's page.get_image_info() list.
|
|
541
|
+
"""
|
|
542
|
+
|
|
543
|
+
number: int
|
|
544
|
+
bbox: Rect | tuple[float, float, float, float] # (x0, y0, x1, y1), in pt
|
|
545
|
+
transform: (
|
|
546
|
+
Matrix | tuple[float, float, float, float, float, float]
|
|
547
|
+
) # (x-scale, y-shear, x-shear, y-scale, x-shift, y-shift)
|
|
548
|
+
width: int # original image width
|
|
549
|
+
height: int # original image height
|
|
550
|
+
colorspace: int # colorspace.n
|
|
551
|
+
cs_name: str # colorspace name
|
|
552
|
+
xres: int # X resolution, in px
|
|
553
|
+
yres: int # Y resolution, in px
|
|
554
|
+
bpc: int # Bits per component
|
|
555
|
+
size: int # Size in bytes
|
|
556
|
+
digest: bytes # MD5 hashcode
|
|
557
|
+
has_mask: bool # whether the image is transparent and has a mask
|
|
558
|
+
xref: int # The cross-reference number of the image object
|
|
559
|
+
page_index: int
|
|
560
|
+
page_width: float # measured in points
|
|
561
|
+
page_height: float # measured in points
|
|
562
|
+
filename: Optional[str] = None
|
|
563
|
+
filepath: Optional[str] = None
|
|
564
|
+
|
|
565
|
+
def __post_init__(self):
|
|
566
|
+
self.bbox = Rect(self.bbox)
|
|
567
|
+
self.transform = Matrix(self.transform)
|
|
568
|
+
|
|
569
|
+
@property
|
|
570
|
+
def inverse_transform(self) -> Optional[Matrix]:
|
|
571
|
+
result = Matrix(self.transform)
|
|
572
|
+
degenerate_matrix = result.invert() # mutates
|
|
573
|
+
if degenerate_matrix:
|
|
574
|
+
return None
|
|
575
|
+
return result
|
|
576
|
+
|
|
577
|
+
def get_image_rect(self):
|
|
578
|
+
return Rect(0, 0, self.width, self.height)
|
|
579
|
+
|
|
580
|
+
def image_pixel_to_page_point(
|
|
581
|
+
self,
|
|
582
|
+
bbox_pixels: Rect,
|
|
583
|
+
):
|
|
584
|
+
"""Transform a bounding box (px) within the image to a bounding box (pt) on the PDF page."""
|
|
585
|
+
shrink = Matrix(1 / self.width, 0, 0, 1 / self.height, 0, 0)
|
|
586
|
+
return bbox_pixels * shrink * self.transform
|
|
587
|
+
|
|
588
|
+
def page_point_to_image_pixel(
|
|
589
|
+
self,
|
|
590
|
+
bbox_page_points: Rect,
|
|
591
|
+
) -> Rect:
|
|
592
|
+
"""Transform a bounding box (pt) on the PDF page back to a bounding box (px) within the image."""
|
|
593
|
+
expand = Matrix(self.width, 0, 0, self.height, 0, 0)
|
|
594
|
+
return bbox_page_points * self.inverse_transform * expand
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
# class Orientation(FancyStrEnum):
|
|
598
|
+
# horizontal = auto()
|
|
599
|
+
# h = horizontal
|
|
600
|
+
# vertical = auto()
|
|
601
|
+
# v = vertical
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# @dataclass
|
|
605
|
+
# class ImgSegment(Segment):
|
|
606
|
+
# """
|
|
607
|
+
# A segment comprises a time interval the dimensions of which are defined by two boundary
|
|
608
|
+
# :class:`timestamps <Timestamp>`.
|
|
609
|
+
# Its backbone is the spine which is an ordered sequence of :class:`timestamps <Timestamp>` which can correspond
|
|
610
|
+
# to :class:`events <Event>`
|
|
611
|
+
# """
|
|
612
|
+
# name: str
|
|
613
|
+
# unit: TimeUnit | str
|
|
614
|
+
# img: EmbeddedImage
|
|
615
|
+
# orientation: Orientation
|
|
616
|
+
# start: Optional[Timestamp | VirtualCoordinate] = None
|
|
617
|
+
# end: Optional[Timestamp | VirtualCoordinate] = None
|
|
618
|
+
# origin: VirtualCoordinate = field(init=False)
|
|
619
|
+
# # spine: Spine
|
|
620
|
+
# # events: dict[ID_str, Segment]
|
|
621
|
+
# ID: ID_str = field(init=False)
|
|
622
|
+
#
|
|
623
|
+
# def __post_init__(self):
|
|
624
|
+
# self.unit = TimeUnit(self.unit)
|
|
625
|
+
# self.origin = VirtualCoordinate(0, self.unit)
|
|
626
|
+
# self.ID = register_object(self.name, self)
|
|
627
|
+
# self.orientation = Orientation(self.orientation)
|
|
628
|
+
#
|
|
629
|
+
# def as_interval(self):
|
|
630
|
+
# if self.start is None or self.end is None:
|
|
631
|
+
# return
|
|
632
|
+
# start, end = self.start.value, self.end.value
|
|
633
|
+
# return pd.Interval(start, end, closed="left")
|
|
634
|
+
#
|
|
635
|
+
# @property
|
|
636
|
+
# def length(self):
|
|
637
|
+
# rect = self.img.get_image_rect()
|
|
638
|
+
# if self.orientation == Orientation.horizontal:
|
|
639
|
+
# return Length(rect.width, "px")
|
|
640
|
+
# return Length(rect.height, "px")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
class PdfFile(GraphicalRepresentation):
|
|
644
|
+
|
|
645
|
+
def __init__(
|
|
646
|
+
self, doc: pymupdf.Document, id_prefix: str = "gr", uid: Optional[str] = None
|
|
647
|
+
):
|
|
648
|
+
assert isinstance(
|
|
649
|
+
doc, pymupdf.Document
|
|
650
|
+
), f"Expected PDF document, got {type(doc)}"
|
|
651
|
+
super().__init__(id_prefix=id_prefix, uid=uid)
|
|
652
|
+
self.doc = doc
|
|
653
|
+
self.images = get_images_embedded_in_document(doc)
|
|
654
|
+
|
|
655
|
+
#
|
|
656
|
+
# def append_segment(
|
|
657
|
+
# self,
|
|
658
|
+
# segment: ImgSegment
|
|
659
|
+
# ):
|
|
660
|
+
# self.segments[segment.ID] = segment
|
|
661
|
+
# self.spine.append_segment(segment)
|
|
662
|
+
#
|
|
663
|
+
# def define_and_append_new_segment(
|
|
664
|
+
# self,
|
|
665
|
+
# page_index: int,
|
|
666
|
+
# image_index: int,
|
|
667
|
+
# crop_px: Optional[Rect] = None,
|
|
668
|
+
# orientation: Orientation | str = Orientation.horizontal,
|
|
669
|
+
# name: Optional[str] = None,
|
|
670
|
+
# store_in: Optional[str] = None
|
|
671
|
+
# ) -> ImgSegment:
|
|
672
|
+
# """Combination of .define_new_segment() and .append_segment()"""
|
|
673
|
+
# segment = self.define_new_segment(page_index, image_index, crop_px, orientation, name, store_in)
|
|
674
|
+
# self.append_segment(segment)
|
|
675
|
+
#
|
|
676
|
+
# def define_new_segment(
|
|
677
|
+
# self,
|
|
678
|
+
# page_index: int,
|
|
679
|
+
# image_index: int,
|
|
680
|
+
# crop_px: Optional[Rect] = None,
|
|
681
|
+
# orientation: Orientation | str = Orientation.horizontal,
|
|
682
|
+
# name: Optional[str] = None,
|
|
683
|
+
# store_in: Optional[str] = None
|
|
684
|
+
# ) -> ImgSegment:
|
|
685
|
+
# if not name:
|
|
686
|
+
# name = f"{self.name}_ImgSegment"
|
|
687
|
+
# img = self.images[page_index][image_index]
|
|
688
|
+
# img_rect = img.get_image_rect()
|
|
689
|
+
# if crop_px:
|
|
690
|
+
# crop_px = Rect(crop_px)
|
|
691
|
+
# assert img_rect.contains(
|
|
692
|
+
# crop_px
|
|
693
|
+
# ), (f"The crop box {crop_px} is not fully contained by "
|
|
694
|
+
# f"image[{page_index}][{image_index}] ({img.width}px × {img.height}px)")
|
|
695
|
+
# pix, new_img = get_cropped_pix(self.doc, img, crop_px)
|
|
696
|
+
# else:
|
|
697
|
+
# pix = get_pixmap(self.doc, img.xref)
|
|
698
|
+
# new_img = copy_dataclass(img)
|
|
699
|
+
# segment = ImgSegment(name=name, unit="px", img=new_img, orientation=orientation)
|
|
700
|
+
# if store_in:
|
|
701
|
+
# segment.img.filename = segment.ID + ".png"
|
|
702
|
+
# segment.img.filepath = osp.join(store_in, segment.img.filename)
|
|
703
|
+
# pix.save(segment.img.filepath)
|
|
704
|
+
# print(f"Stored {segment.img.filename}")
|
|
705
|
+
# del (pix)
|
|
706
|
+
# return segment
|
|
707
|
+
#
|
|
708
|
+
# def get_text(self, option) -> Dict[int, Any]:
|
|
709
|
+
# result = {}
|
|
710
|
+
# for page_index, page in self.iter_pages():
|
|
711
|
+
# result[page_index] = page.get_text(option)
|
|
712
|
+
# return result
|
|
713
|
+
#
|
|
714
|
+
# def get_words(self):
|
|
715
|
+
# dataframes = {}
|
|
716
|
+
# for page_index, page in self.iter_pages():
|
|
717
|
+
# words = pd.DataFrame(
|
|
718
|
+
# page.get_text("words"), columns=["x0", "y0", "x1", "y1", "word", "block_no", "line_no", "word_no"]
|
|
719
|
+
# )
|
|
720
|
+
# dataframes[page_index] = words
|
|
721
|
+
# return pd.concat(dataframes, names=["page", "ix"]).reset_index().drop(columns="ix")
|
|
722
|
+
#
|
|
723
|
+
# def iter_pages(self) -> Iterator[tuple[int, pymupdf.Page]]:
|
|
724
|
+
# yield from enumerate(self.doc)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def get_images_embedded_in_page(page: Page) -> list[EmbeddedImage]:
|
|
728
|
+
width = page.rect.width
|
|
729
|
+
height = page.rect.height
|
|
730
|
+
image_info_list = page.get_image_info(xrefs=True)
|
|
731
|
+
result = []
|
|
732
|
+
for image_index, image_info in enumerate(
|
|
733
|
+
image_info_list, start=1
|
|
734
|
+
): # enumerate the image list
|
|
735
|
+
image_info["cs_name"] = image_info.pop("cs-name") #
|
|
736
|
+
image_info["has_mask"] = image_info.pop("has-mask")
|
|
737
|
+
img = EmbeddedImage(
|
|
738
|
+
**image_info, page_index=page.number, page_width=width, page_height=height
|
|
739
|
+
)
|
|
740
|
+
result.append(img)
|
|
741
|
+
return result
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def get_images_embedded_in_document(
|
|
745
|
+
doc: Document | str,
|
|
746
|
+
) -> Dict[int, list[EmbeddedImage]]:
|
|
747
|
+
result = {}
|
|
748
|
+
for idx, page in enumerate(doc):
|
|
749
|
+
result[idx] = get_images_embedded_in_page(page)
|
|
750
|
+
return result
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def get_pixmap(doc: Document, xref: int) -> Pixmap:
|
|
754
|
+
pix = Pixmap(doc, xref) # create a Pixmap
|
|
755
|
+
if pix.n - pix.alpha > 3: # CMYK: convert to RGB first
|
|
756
|
+
pix = Pixmap(csRGB, pix)
|
|
757
|
+
return pix
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def store_embedded_image(
|
|
761
|
+
doc: Document | str,
|
|
762
|
+
xref: int,
|
|
763
|
+
filepath: str,
|
|
764
|
+
):
|
|
765
|
+
pix = get_pixmap(doc, xref)
|
|
766
|
+
pix.save(filepath)
|
|
767
|
+
logger.info(f"Stored {filepath}")
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def update_img_from_cropped_pix(img, pix, crop_px, **kwargs):
|
|
771
|
+
new_bbox = img.image_pixel_to_page_point(crop_px)
|
|
772
|
+
return copy_dataclass(
|
|
773
|
+
img,
|
|
774
|
+
bbox=new_bbox,
|
|
775
|
+
width=crop_px.width,
|
|
776
|
+
height=crop_px.height,
|
|
777
|
+
size=pix.size,
|
|
778
|
+
digest=pix.digest,
|
|
779
|
+
**kwargs,
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def crop_pixmap(
|
|
784
|
+
pix: Pixmap, crop_px: Rect, img: EmbeddedImage, **kwargs
|
|
785
|
+
) -> tuple[Pixmap, EmbeddedImage]:
|
|
786
|
+
crop_px = Rect(crop_px)
|
|
787
|
+
cropped_pix = Pixmap(pix, img.width, img.height, crop_px)
|
|
788
|
+
new_img = update_img_from_cropped_pix(img, cropped_pix, crop_px, **kwargs)
|
|
789
|
+
return cropped_pix, new_img
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def store_image(
|
|
793
|
+
doc: Document | str,
|
|
794
|
+
img: EmbeddedImage,
|
|
795
|
+
filepath: str,
|
|
796
|
+
crop_px: Optional[Rect] = None,
|
|
797
|
+
) -> EmbeddedImage:
|
|
798
|
+
filename = osp.basename(filepath)
|
|
799
|
+
pix = get_pixmap(doc, xref=img.xref)
|
|
800
|
+
if crop_px is not None:
|
|
801
|
+
pix, new_img = crop_pixmap(pix, crop_px, img, filename, filepath)
|
|
802
|
+
else:
|
|
803
|
+
new_img = copy_dataclass(
|
|
804
|
+
img,
|
|
805
|
+
filename=filename,
|
|
806
|
+
filepath=filepath,
|
|
807
|
+
)
|
|
808
|
+
pix.save(filepath)
|
|
809
|
+
del pix
|
|
810
|
+
logger.info(f"Stored {filepath}")
|
|
811
|
+
return new_img
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def get_cropped_pix(
|
|
815
|
+
doc: Document | str, img: EmbeddedImage, crop_px: Optional[Rect] = None, **kwargs
|
|
816
|
+
) -> tuple[Pixmap, EmbeddedImage]:
|
|
817
|
+
pix = get_pixmap(doc, xref=img.xref)
|
|
818
|
+
return crop_pixmap(pix, crop_px, img, **kwargs)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
# endregion to be refactored
|
|
822
|
+
|
|
823
|
+
if __name__ == "__main__":
|
|
824
|
+
filepath = get_specimen_path("supra_rolls", "midi", "fd660zf8362_exp.mid")
|
|
825
|
+
midi_file = MidiRepresentation.from_filepaths(filepath)
|
|
826
|
+
print(midi_file)
|