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.
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)