"""Module providing classes for handling image metadata and sequences."""
from __future__ import annotations
import re
import logging
from pathlib import Path
from dataclasses import dataclass, field
from typing import Dict, List, Optional
import opentimelineio.opentime as opentime
from . import utils
IMAGE_INFO_DEFAULTS = {
"width": 1920,
"height": 1080,
"channels": 3,
"fps": 24.0,
"par": 1.0,
"timecode": "00:00:00:00",
"origin_x": 0,
"origin_y": 0,
"display_width": 1920,
"display_height": 1080,
}
log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG)
[docs]
@dataclass
class ImageInfo:
"""A dataclass for reading image metadata.
Note:
All attributes are optional and will be set from calling ``iinfo`` and ``ffprobe`` in :obj:`ImageInfo.update()` if found.
Defaults are set if not found and set by user. See the next example for defaults.
.. admonition:: Example with Defaults
.. code-block:: python
image = ImageInfo(
path=Path("path/to/image.exr"),
width = 1920
height = 1080
channels = 3
fps = 24.0
par = 1.0
timecode = "00:00:00:00"
origin_x = 0
origin_y = 0
display_width = 1920
display_height = 1080
)
Attributes:
path(Path): Path to the image file.
width(Optional[int]): Image width.
height(Optional[int]): Image height.
origin_x(Optional[int]): Origin x position.
origin_y(Optional[int]): Origin y position.
display_width(Optional[int]): Display width.
display_height(Optional[int]): Display height.
par(Optional[float]): Pixel aspect ratio.
channels(Optional[int]): Number of channels.
fps(Optional[float]): Frames per second.
timecode(Optional[str]): Timecode.
"""
path: Path = field(default_factory=Path)
# fmt: off
width: int = field(
default=IMAGE_INFO_DEFAULTS["width"], init=False, repr=True)
height: int = field(
default=IMAGE_INFO_DEFAULTS["height"], init=False, repr=True)
origin_x: int = field(
default=IMAGE_INFO_DEFAULTS["origin_x"], init=False, repr=False)
origin_y: int = field(
default=IMAGE_INFO_DEFAULTS["origin_y"], init=False, repr=False)
display_width: int = field(
default=IMAGE_INFO_DEFAULTS["display_width"], init=False, repr=False)
display_height: int = field(
default=IMAGE_INFO_DEFAULTS["display_height"], init=False, repr=False)
par: float = field(
default=IMAGE_INFO_DEFAULTS["par"], init=False, repr=False)
channels: int = field(
default=IMAGE_INFO_DEFAULTS["channels"], init=False, repr=True)
fps: float = field(
default=IMAGE_INFO_DEFAULTS["fps"], init=False, repr=False)
timecode: str = field(
default=IMAGE_INFO_DEFAULTS["timecode"], init=False, repr=False)
# fmt: on
def __post_init__(self):
"""Post init function for ImageInfo class.
Checks if path is set and calls update.
Raises:
ValueError if path is not set.
"""
if not self.path:
raise ValueError("ImageInfo needs to be initialized with a path")
self.update()
def __gt__(self, other: ImageInfo) -> bool:
return self.frame_number > other.frame_number
def __lt__(self, other: ImageInfo) -> bool:
return self.frame_number < other.frame_number
[docs]
def update(self, force_ffprobe: Optional[bool] = False) -> None:
"""Updates metadata by calling iinfo and ffprobe.
Attention:
During testing it was found that ``ffprobe`` reported different
framerates on different systems. Therefore we added the
``force_ffprobe=False`` flag to silently disable ``ffprobe``.
Arguments:
force_ffprobe (Optional[bool]): whether to override attributes with
``ffprobe`` output if found.
"""
iinfo_res = utils.call_iinfo(self.path)
ffprobe_res = utils.call_ffprobe(self.path)
for k, v in iinfo_res.items():
if not v:
continue
if ffprobe_res.get(k) and force_ffprobe:
v = ffprobe_res[k]
setattr(self, k, v)
@property
def filename(self) -> str:
""":obj:`str`: The image file name including extension."""
return self.path.name
@property
def rational_time(self) -> opentime.RationalTime:
""":obj:`opentime.RationalTime`: Retrieved from :obj:`ImageInfo.timecode` using otio library."""
if not all([self.timecode, self.fps]):
raise Exception("no timecode and fps found")
return opentime.from_timecode(self.timecode, self.fps)
@property
def frame_number(self) -> int:
""":obj:`int`: Retrieved from the filename by using regex lookup.
Note:
The regex could use a little love to be more robust.
Filename should be something like ``filename.0001.exr``.
"""
if not self.filename:
raise Exception("needs filename for querying frame number")
matches = re.findall(r"\.(\d+)\.", self.filename)
if len(matches) > 1:
raise ValueError("can't handle multiple found frame numbers")
result = int(matches[0])
return result
@property
def extension(self) -> str:
""":obj:`str`: The file extension."""
return self.path.suffix
@property
def name(self) -> str:
""":obj:`str`: The file name with extension.
Note:
Used in SequenceInfo but could become obsolete in favor
for `filename`.
"""
return f"{self.path.stem}{self.path.suffix}"
@property
def filepath(self) -> str:
""":obj:`str`: The file path as posix string."""
return self.path.resolve().as_posix()
[docs]
@dataclass
class SequenceInfo:
"""Class for handling image sequences by using instances of `ImageInfo`.
Hint:
If you want to scan a directory for image sequences, you can use the
``scan`` classmethod.
Attributes:
path Any[Path, str]: Path to the image sequence directory.
imageinfos List[ImageInfo]: List of all files as `ImageInfo` to be
used.
"""
path: Path = field(default_factory=Path)
imageinfos: List[ImageInfo] = field(default_factory=list)
def __post_init__(self):
if not all([self.path, self.imageinfos]):
raise ValueError(
"SequenceInfo needs to be initialized with path and imageinfos"
)
[docs]
@classmethod
def scan(cls, directory: str | Path) -> List[SequenceInfo]:
"""Scan a directory for a list of images.
Attention:
Currently only supports EXR files. Needs to be extended and tested
for other formats.
Arguments:
directory (Any[str, Path]): Path to the directory
to be scanned.
Returns:
List[SequenceInfo]: List of all found sequences.
"""
log.info(f"Scanning {directory}")
if not isinstance(directory, Path):
directory = Path(directory)
if not directory.is_dir():
raise NotImplementedError(f"{directory} is no directory")
files_map: Dict[Path, ImageInfo] = {}
for item in directory.iterdir():
if not item.is_file():
continue
if item.suffix not in (".exr"):
log.warning(f"{item.suffix} not in (.exr)")
continue
_parts = item.stem.split(".")
if len(_parts) > 2:
log.warning(f"{_parts = }")
continue
seq_key = Path(item.parent, _parts[0])
if seq_key not in files_map.keys():
files_map[seq_key] = []
files_map[seq_key].append(ImageInfo(item))
return [
cls(path=seq_key.parent, imageinfos=seq_files)
for seq_key, seq_files in files_map.items()
]
@property
def frames(self) -> List[int]:
""":obj:`List[int]`: List of all available frame numbers in the sequence.""" # noqa
return self.imageinfos
@property
def start_frame(self) -> int:
""":obj:`int`: the lowest frame number in the sequence."""
return min(self.frames).frame_number
@property
def end_frame(self) -> int:
""":obj:`int`: the highest frame number in the sequence."""
return max(self.frames).frame_number
@property
def format_string(self) -> str:
""":obj:`str`: A sequence representation used for ``ffmpeg`` arguments formatting.""" # noqa
frame: ImageInfo = min(self.frames)
ext: str = frame.extension
basename = frame.name.split(".")[0]
result = f"{basename}.%0{self.padding}d{ext}"
return result
@property
def hash_string(self) -> str:
""":obj:`str`: A sequence representation used for ``oiiotool`` arguments formatting.""" # noqa
frame: ImageInfo = min(self.frames)
ext: str = frame.extension
basename = frame.name.split(".")[0]
result = f"{basename}.{self.start_frame}-{self.end_frame}#{ext}"
return result
@property
def format_string(self) -> str:
""":obj:`str`: A sequence representation used for ``ffmpeg`` arguments formatting. # noqa
Error:
That's a duplicate so let's run tests and remove it.
"""
frame: ImageInfo = min(self.frames)
ext: str = frame.extension
basename = frame.name.split(".")[0]
result = f"{basename}.%0{self.padding}d{ext}"
return result
@property
def padding(self) -> int:
""":obj:`int`: The sequence's frame padding."""
frame = min(self.frames)
result = len(str(frame.frame_number))
return result
@property
def frames_missing(self) -> bool:
""":obj:`bool`: Property for checking if any frames are missing in the sequence. # noqa
Note:
Could be extended to also return which frames are missing.
"""
start = min(self.frames).frame_number
end = max(self.frames).frame_number
expected: int = len(range(start, end)) + 1
return not expected == len(self.frames)
@property
def width(self) -> int:
""":obj:`int`: the sequence's width based on the first frame found."""
return self.imageinfos[0].width
@property
def display_width(self) -> int:
""":obj:`int`: the sequence's display_width based on the first frame found.""" # noqa
return self.imageinfos[0].display_width
@property
def height(self) -> int:
""":obj:`int`: the sequence's height based on the first frame found."""
return self.imageinfos[0].height
@property
def display_height(self) -> int:
""":obj:`int`: the sequence's display_height based on the first frame found.""" # noqa
return self.imageinfos[0].display_height