image-video-utils 1.0.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 MAIGNAN Nicolas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: image-video-utils
3
+ Version: 1.0.0
4
+ Summary: Image and video utils...
5
+ Keywords: frames,image,metadata,video
6
+ Author: Nicolas MAIGNAN
7
+ Author-email: Nicolas MAIGNAN <nicolas.maignan@univ-lorraine.fr>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Dist: av>=14.4.0,<18.0.0
11
+ Requires-Dist: beartype>=0.22.9,<1.0.0
12
+ Requires-Dist: imageio>=2.37.2,<3.0.0
13
+ Requires-Dist: jaxtyping>=0.3.9,<1.0.0
14
+ Requires-Dist: rich>=14.2.0,<16.0.0
15
+ Requires-Dist: scikit-image>=0.25.2,<1.0.0
16
+ Requires-Dist: typer>=0.20.0,<1.0.0
17
+ Requires-Python: >=3.11
18
+ Project-URL: documentation, https://image-video-utils-cf9507.gitlabpages.inria.fr
19
+ Project-URL: repository, https://gitlab.inria.fr/anr-arce/utils/image-video-utils
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Image & Video Utils
23
+
24
+ [![Python badge](https://img.shields.io/badge/Python-3.11+-0066cc?style=for-the-badge&logo=python&logoColor=yellow)](https://www.python.org/downloads/)
25
+ [![MIT License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge&)](https://spdx.org/licenses/MIT.html)
26
+
27
+ [![uv badge](https://img.shields.io/badge/Packager-uv-dd5fe8?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNDEiIGhlaWdodD0iNDEiIHZpZXdCb3g9IjAgMCA0MSA0MSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTS01LjI4NjE5ZS0wNiAwLjE2ODYyOUwwLjA4NDMwOTggMjAuMTY4NUwwLjE1MTc2MiAzNi4xNjgzQzAuMTYxMDc1IDM4LjM3NzQgMS45NTk0NyA0MC4xNjA3IDQuMTY4NTkgNDAuMTUxNEwyMC4xNjg0IDQwLjA4NEwzMC4xNjg0IDQwLjA0MThMMzEuMTg1MiA0MC4wMzc1QzMzLjM4NzcgNDAuMDI4MiAzNS4xNjgzIDM4LjIwMjYgMzUuMTY4MyAzNlYzNkwzNy4wMDAzIDM2TDM3LjAwMDMgMzkuOTk5Mkw0MC4xNjgzIDM5Ljk5OTZMMzkuOTk5NiAtOS45NDY1M2UtMDdMMjEuNTk5OCAwLjA3NzU2ODlMMjEuNjc3NCAxNi4wMTg1TDIxLjY3NzQgMjUuOTk5OEwyMC4wNzc0IDI1Ljk5OThMMTguMzk5OCAyNS45OTk4TDE4LjQ3NzQgMTYuMDMyTDE4LjM5OTggMC4wOTEwNTkzTC01LjI4NjE5ZS0wNiAwLjE2ODYyOVoiIGZpbGw9IiNERTVGRTkiLz4KPC9zdmc+Cg==)](https://docs.astral.sh/uv/)
28
+ [![Ruff badge](https://img.shields.io/badge/Linter_&_Formatter-Ruff-d6ff64?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjx0aXRsZT5SdWZmPC90aXRsZT48cGF0aCBkPSJNMzUwIDE2OS45NzFjMC0xMS4wMy04Ljk0MS0xOS45NzEtMTkuOTcxLTE5Ljk3MUgxNTB2MjAwaDkydi02MGgxNnY2MGg5MnYtNzZoLTQwdi0xNmgyMC4wMjljMTEuMDMgMCAxOS45NzEtOC45NDEgMTkuOTcxLTE5Ljk3MXYtNjguMDU4Wk0yNzQgMjI2djE2aC00OHYtMTZoNDhaIiBzdHlsZT0iZmlsbDojZDdmZjY0IiB0cmFuc2Zvcm09Im1hdHJpeCguNSAwIDAgLjUgLTc1IC03NSkiLz48L3N2Zz4=)](https://docs.astral.sh/ruff/)
29
+ [![ty badge](https://img.shields.io/badge/Type_checker-ty-48eae0?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNDgiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OCA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQ4IDcuNjhIMjcuODRWMEgzLjg0VjcuNjhIMFYyNS45MkgzLjg0VjQwLjIxMzZDMy44NCA0NC41MTM5IDcuMzI2MDcgNDggMTEuNjI2NCA0OEg0OFYyOS43NkgyNy44NFYyNS45Mkg0MC4yMTM2QzQ0LjUxMzkgMjUuOTIgNDggMjIuNDMzOSA0OCAxOC4xMzM2VjcuNjhaIiBmaWxsPSIjNDZFQkUxIi8+Cjwvc3ZnPgo=)](https://docs.astral.sh/ty/)
30
+ [![bear-ified](https://raw.githubusercontent.com/beartype/beartype-assets/main/badge/bear-ified.svg)](https://beartype.readthedocs.io)
31
+
32
+ This repository provides a Python module for interacting with image and video files using sensible defaults and clear, consistent interfaces.
33
+
34
+ ## ✨ Features
35
+
36
+ - An `ImageResolution` class to represent the resolution of images or video frames.
37
+ - A `reshape` function to resize images easily.
38
+ - A `VideoMetadata` class to read video information such as the number of frames, frame rate, or codec.
39
+ - Multiple functions to read and write videos as NumPy arrays, image files, or video files.
40
+ - A simple CLI to extract frames or encode videos.
41
+
42
+ ## 📦 Installation
43
+
44
+ ### The module
45
+
46
+ You can install `image_video_utils` directly from source using `pip`:
47
+
48
+ ```bash
49
+ pip install git+https://gitlab.inria.fr/nmaignan/image-video-utils.git
50
+ ```
51
+
52
+ You can also use another Python package manager such as `uv`:
53
+
54
+ ```bash
55
+ uv add git+https://gitlab.inria.fr/nmaignan/image-video-utils.git
56
+ ```
57
+
58
+ ### The CLI
59
+
60
+ You can install the CLI tool with [**`pipx`**](https://pipx.pypa.io/stable/) or [**`uv`**](https://docs.astral.sh/uv/):
61
+
62
+ ```bash
63
+ uv tool install git+https://gitlab.inria.fr/nmaignan/image-video-utils
64
+ ```
65
+
66
+ ## 🧪 Usage
67
+
68
+ ### The module
69
+
70
+ Import the required utilities in your Python code:
71
+
72
+ ```python
73
+ from image_video_utils.video import write_video_from_iterable
74
+ ```
75
+
76
+ ### The CLI
77
+
78
+ The CLI is available via:
79
+
80
+ ```bash
81
+ ivutils --help
82
+ ```
83
+
84
+ ## 📚 Documentation
85
+
86
+ The documentation is generated using [**`pdoc`**](https://pdoc.dev/) and is available as a [GitLab Page](https://image-video-utils-cf9507.gitlabpages.inria.fr/).
@@ -0,0 +1,65 @@
1
+ # Image & Video Utils
2
+
3
+ [![Python badge](https://img.shields.io/badge/Python-3.11+-0066cc?style=for-the-badge&logo=python&logoColor=yellow)](https://www.python.org/downloads/)
4
+ [![MIT License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge&)](https://spdx.org/licenses/MIT.html)
5
+
6
+ [![uv badge](https://img.shields.io/badge/Packager-uv-dd5fe8?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNDEiIGhlaWdodD0iNDEiIHZpZXdCb3g9IjAgMCA0MSA0MSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTS01LjI4NjE5ZS0wNiAwLjE2ODYyOUwwLjA4NDMwOTggMjAuMTY4NUwwLjE1MTc2MiAzNi4xNjgzQzAuMTYxMDc1IDM4LjM3NzQgMS45NTk0NyA0MC4xNjA3IDQuMTY4NTkgNDAuMTUxNEwyMC4xNjg0IDQwLjA4NEwzMC4xNjg0IDQwLjA0MThMMzEuMTg1MiA0MC4wMzc1QzMzLjM4NzcgNDAuMDI4MiAzNS4xNjgzIDM4LjIwMjYgMzUuMTY4MyAzNlYzNkwzNy4wMDAzIDM2TDM3LjAwMDMgMzkuOTk5Mkw0MC4xNjgzIDM5Ljk5OTZMMzkuOTk5NiAtOS45NDY1M2UtMDdMMjEuNTk5OCAwLjA3NzU2ODlMMjEuNjc3NCAxNi4wMTg1TDIxLjY3NzQgMjUuOTk5OEwyMC4wNzc0IDI1Ljk5OThMMTguMzk5OCAyNS45OTk4TDE4LjQ3NzQgMTYuMDMyTDE4LjM5OTggMC4wOTEwNTkzTC01LjI4NjE5ZS0wNiAwLjE2ODYyOVoiIGZpbGw9IiNERTVGRTkiLz4KPC9zdmc+Cg==)](https://docs.astral.sh/uv/)
7
+ [![Ruff badge](https://img.shields.io/badge/Linter_&_Formatter-Ruff-d6ff64?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjx0aXRsZT5SdWZmPC90aXRsZT48cGF0aCBkPSJNMzUwIDE2OS45NzFjMC0xMS4wMy04Ljk0MS0xOS45NzEtMTkuOTcxLTE5Ljk3MUgxNTB2MjAwaDkydi02MGgxNnY2MGg5MnYtNzZoLTQwdi0xNmgyMC4wMjljMTEuMDMgMCAxOS45NzEtOC45NDEgMTkuOTcxLTE5Ljk3MXYtNjguMDU4Wk0yNzQgMjI2djE2aC00OHYtMTZoNDhaIiBzdHlsZT0iZmlsbDojZDdmZjY0IiB0cmFuc2Zvcm09Im1hdHJpeCguNSAwIDAgLjUgLTc1IC03NSkiLz48L3N2Zz4=)](https://docs.astral.sh/ruff/)
8
+ [![ty badge](https://img.shields.io/badge/Type_checker-ty-48eae0?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNDgiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OCA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQ4IDcuNjhIMjcuODRWMEgzLjg0VjcuNjhIMFYyNS45MkgzLjg0VjQwLjIxMzZDMy44NCA0NC41MTM5IDcuMzI2MDcgNDggMTEuNjI2NCA0OEg0OFYyOS43NkgyNy44NFYyNS45Mkg0MC4yMTM2QzQ0LjUxMzkgMjUuOTIgNDggMjIuNDMzOSA0OCAxOC4xMzM2VjcuNjhaIiBmaWxsPSIjNDZFQkUxIi8+Cjwvc3ZnPgo=)](https://docs.astral.sh/ty/)
9
+ [![bear-ified](https://raw.githubusercontent.com/beartype/beartype-assets/main/badge/bear-ified.svg)](https://beartype.readthedocs.io)
10
+
11
+ This repository provides a Python module for interacting with image and video files using sensible defaults and clear, consistent interfaces.
12
+
13
+ ## ✨ Features
14
+
15
+ - An `ImageResolution` class to represent the resolution of images or video frames.
16
+ - A `reshape` function to resize images easily.
17
+ - A `VideoMetadata` class to read video information such as the number of frames, frame rate, or codec.
18
+ - Multiple functions to read and write videos as NumPy arrays, image files, or video files.
19
+ - A simple CLI to extract frames or encode videos.
20
+
21
+ ## 📦 Installation
22
+
23
+ ### The module
24
+
25
+ You can install `image_video_utils` directly from source using `pip`:
26
+
27
+ ```bash
28
+ pip install git+https://gitlab.inria.fr/nmaignan/image-video-utils.git
29
+ ```
30
+
31
+ You can also use another Python package manager such as `uv`:
32
+
33
+ ```bash
34
+ uv add git+https://gitlab.inria.fr/nmaignan/image-video-utils.git
35
+ ```
36
+
37
+ ### The CLI
38
+
39
+ You can install the CLI tool with [**`pipx`**](https://pipx.pypa.io/stable/) or [**`uv`**](https://docs.astral.sh/uv/):
40
+
41
+ ```bash
42
+ uv tool install git+https://gitlab.inria.fr/nmaignan/image-video-utils
43
+ ```
44
+
45
+ ## 🧪 Usage
46
+
47
+ ### The module
48
+
49
+ Import the required utilities in your Python code:
50
+
51
+ ```python
52
+ from image_video_utils.video import write_video_from_iterable
53
+ ```
54
+
55
+ ### The CLI
56
+
57
+ The CLI is available via:
58
+
59
+ ```bash
60
+ ivutils --help
61
+ ```
62
+
63
+ ## 📚 Documentation
64
+
65
+ The documentation is generated using [**`pdoc`**](https://pdoc.dev/) and is available as a [GitLab Page](https://image-video-utils-cf9507.gitlabpages.inria.fr/).
@@ -0,0 +1,65 @@
1
+ [project]
2
+ name = "image-video-utils"
3
+ version = "1.0.0"
4
+ description = "Image and video utils..."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [
10
+ { name = "Nicolas MAIGNAN", email = "nicolas.maignan@univ-lorraine.fr" },
11
+ ]
12
+ keywords = ["frames", "image", "metadata", "video"]
13
+ dependencies = [
14
+ "av>=14.4.0,<18.0.0",
15
+ "beartype>=0.22.9,<1.0.0",
16
+ "imageio>=2.37.2,<3.0.0",
17
+ "jaxtyping>=0.3.9,<1.0.0",
18
+ "rich>=14.2.0,<16.0.0",
19
+ "scikit-image>=0.25.2,<1.0.0",
20
+ "typer>=0.20.0,<1.0.0",
21
+ ]
22
+
23
+ [project.urls]
24
+ documentation = "https://image-video-utils-cf9507.gitlabpages.inria.fr"
25
+ repository = "https://gitlab.inria.fr/anr-arce/utils/image-video-utils"
26
+
27
+ [project.scripts]
28
+ ivutils = "image_video_utils.cli:app"
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "ruff>=0.12.4,<1.0.0",
33
+ "ty>=0.0.2,<1.0.0",
34
+ ]
35
+
36
+ [build-system]
37
+ requires = ["uv_build>=0.11.6,<0.12"]
38
+ build-backend = "uv_build"
39
+
40
+ [tool.ruff]
41
+ line-length = 88
42
+ indent-width = 4
43
+
44
+ [tool.ruff.format]
45
+ docstring-code-format = true
46
+ line-ending = "lf"
47
+ skip-magic-trailing-comma = true
48
+
49
+ [tool.ruff.lint]
50
+ select = ["ALL"]
51
+ ignore = [
52
+ "FBT", # Boolean trap is fine in this codebase
53
+ "FIX", # Editors should highlight those, but dev notes mustn't block commit
54
+ "TD", # TODO/FIXME tracking overkill for solo project
55
+ "COM812", # Ruff formats trailing commas, so redundant
56
+ "F722", # False positive with jaxtyping string annotations
57
+ "PLC0415", # Imports inside functions needed until Python supports lazy imports natively
58
+ "PYI041", # float | int needed for beartype runtime type checking
59
+ ]
60
+
61
+ [tool.ruff.lint.isort]
62
+ split-on-trailing-comma = false
63
+
64
+ [tool.ruff.lint.pydocstyle]
65
+ convention = "google"
@@ -0,0 +1,18 @@
1
+ """Utility module for handling images and video files.
2
+
3
+ Submodules:
4
+ - `image_video_utils.formats`: Provides classes to ensure image and video files are in supported formats.
5
+ - `image_video_utils.image`: Provides a class to manage and manipulate image resolutions.
6
+ - `image_video_utils.video`: Provides tools to read, write, and iterate over video files or numbered frame sequences.
7
+
8
+ Documentation:
9
+ All elements include inline docstring for IDE assistance. Complete documentation is available at:
10
+ https://image-video-utils-cf9507.gitlabpages.inria.fr
11
+
12
+ License:
13
+ MIT License
14
+ """ # noqa: E501
15
+
16
+ from importlib.metadata import version
17
+
18
+ __version__ = version(__name__)
@@ -0,0 +1,94 @@
1
+ """CLI module."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from rich import print as rich_print
8
+
9
+ # ------------------------------------------------------------
10
+ # APPLICATION
11
+ # ------------------------------------------------------------
12
+
13
+ app = typer.Typer()
14
+
15
+ # ------------------------------------------------------------
16
+ # COMMANDS
17
+ # ------------------------------------------------------------
18
+
19
+
20
+ @app.command()
21
+ def metadata(
22
+ video: Annotated[Path, typer.Argument(help="Path to the input video file.")],
23
+ ) -> None:
24
+ """Print metadata of a video file."""
25
+ from image_video_utils.video import get_video_metadata
26
+
27
+ metadata = get_video_metadata(video)
28
+ lines = [
29
+ f"Codec: [cyan bold]{metadata.codec}[/cyan bold]",
30
+ f"Resolution: [cyan bold]{metadata.resolution}[/cyan bold]",
31
+ f"Number of frames: [cyan bold]{metadata.num_frames}[/cyan bold]",
32
+ f"Frame rate: [cyan bold]{metadata.frame_rate}[/cyan bold]",
33
+ ]
34
+ rich_print("\n".join(lines))
35
+
36
+
37
+ @app.command()
38
+ def extract(
39
+ video: Annotated[Path, typer.Argument(help="Path to the input video file.")],
40
+ directory: Annotated[
41
+ Path, typer.Argument(help="Directory where extracted frames will be saved.")
42
+ ] = Path("frames"),
43
+ filename_template: Annotated[
44
+ str,
45
+ typer.Option(
46
+ help="Template for frame filenames, must include one integer formatting placeholder." # noqa: E501
47
+ ),
48
+ ] = "frame_{:05d}.png",
49
+ bar: Annotated[bool, typer.Option(help="Show a progress bar.")] = True,
50
+ ) -> None:
51
+ """Write frame files from a video file."""
52
+ from image_video_utils.video import write_frames_from_video
53
+
54
+ write_frames_from_video(
55
+ output_dir=directory,
56
+ video_path=video,
57
+ filename_template=filename_template,
58
+ progress=bar,
59
+ )
60
+
61
+
62
+ @app.command()
63
+ def write(
64
+ frames: Annotated[
65
+ Path,
66
+ typer.Argument(help="Directory containing image files (sorted by filename)."),
67
+ ],
68
+ video: Annotated[
69
+ Path, typer.Argument(help="Path to the output video file.")
70
+ ] = Path("video.mkv"),
71
+ fps: Annotated[
72
+ float, typer.Option(help="Frames per second of the resulting video.")
73
+ ] = 24,
74
+ codec: Annotated[str, typer.Option(help="Codec for video creation.")] = "ffv1",
75
+ progress_bar: Annotated[bool, typer.Option(help="Show a progress bar.")] = True,
76
+ ) -> None:
77
+ """Create a video from image files."""
78
+ from image_video_utils.video import write_video_from_images
79
+
80
+ write_video_from_images(
81
+ output_path=video,
82
+ images_dir=frames,
83
+ fps=fps,
84
+ codec=codec,
85
+ progress=progress_bar,
86
+ )
87
+
88
+
89
+ # ------------------------------------------------------------
90
+ # SCRIPT
91
+ # ------------------------------------------------------------
92
+
93
+ if __name__ == "__main__":
94
+ app()
@@ -0,0 +1,87 @@
1
+ """Classes that combine formats and extensions and can be used with other functions."""
2
+
3
+ from enum import Enum
4
+ from pathlib import Path
5
+ from typing import Self
6
+
7
+ from beartype import beartype
8
+
9
+ # ------------------------------------------------------------
10
+ # BASE CLASS
11
+ # ------------------------------------------------------------
12
+
13
+
14
+ @beartype
15
+ class FormatEnum(Enum):
16
+ """Base class for file format/extension enums."""
17
+
18
+ @property
19
+ def default_extension(self) -> str:
20
+ """Default extension for this format."""
21
+ return self.value[0] if isinstance(self.value, tuple) else self.value
22
+
23
+ @classmethod
24
+ def to_list(cls) -> list[str]:
25
+ """Return a list of all extensions (in order)."""
26
+ exts = []
27
+ for e in cls:
28
+ if isinstance(e.value, tuple):
29
+ exts.extend(e.value)
30
+ else:
31
+ exts.append(e.value)
32
+ return exts
33
+
34
+ @classmethod
35
+ def to_set(cls) -> set[str]:
36
+ """Return a set of all extensions (for fast lookup)."""
37
+ return set(cls.to_list())
38
+
39
+ @classmethod
40
+ def is_valid_ext(cls, ext: str) -> bool:
41
+ """Check if the extension is supported."""
42
+ ext = ext.lower().lstrip(".")
43
+ return ext in cls.to_set()
44
+
45
+ @classmethod
46
+ def is_valid_file(cls, path: str | Path) -> bool:
47
+ """Check if a file has a supported extension."""
48
+ path = Path(path)
49
+ return path.is_file() and cls.is_valid_ext(path.suffix)
50
+
51
+ @classmethod
52
+ def from_ext(cls, ext: str) -> Self:
53
+ """Return the enum member matching a given extension (case-insensitive)."""
54
+ ext = ext.lower().lstrip(".")
55
+ for member in cls:
56
+ values = (
57
+ member.value if isinstance(member.value, tuple) else (member.value,)
58
+ )
59
+ if ext in values:
60
+ return member
61
+ msg = f"Unsupported extension: {ext}"
62
+ raise ValueError(msg)
63
+
64
+
65
+ # ------------------------------------------------------------
66
+ # CLASSES
67
+ # ------------------------------------------------------------
68
+
69
+
70
+ @beartype
71
+ class ImageFormat(FormatEnum):
72
+ """Enumeration of supported image file extensions."""
73
+
74
+ BMP = "bmp"
75
+ PNG = "png"
76
+ JPEG = ("jpg", "jpeg")
77
+ WEBP = "webp"
78
+
79
+
80
+ @beartype
81
+ class VideoFormat(FormatEnum):
82
+ """Enumeration of supported video file extensions."""
83
+
84
+ AVI = "avi"
85
+ MKV = "mkv"
86
+ MP4 = "mp4"
87
+ WEBM = "webm"
@@ -0,0 +1,194 @@
1
+ """Functions to interact with image spatial resolution."""
2
+
3
+ from collections.abc import Iterator, Sequence
4
+ from dataclasses import dataclass
5
+ from typing import Final, Self
6
+
7
+ import numpy as np
8
+ from beartype import beartype
9
+ from jaxtyping import Real
10
+ from skimage.transform import resize
11
+
12
+ # ------------------------------------------------------------
13
+ # CONSTANTS
14
+ # ------------------------------------------------------------
15
+
16
+ _NUM_RESOLUTION_DIMENSIONS: Final[int] = 2
17
+ """Number of spatial dimensions for images."""
18
+
19
+ # ------------------------------------------------------------
20
+ # CLASSES
21
+ # ------------------------------------------------------------
22
+
23
+
24
+ @beartype
25
+ @dataclass(frozen=True)
26
+ class ImageResolution:
27
+ """Represents the spatial resolution (height x width) of an image.
28
+
29
+ This class provides basic comparison operators based on total pixel count
30
+ (i.e., height x width), and a factory method to construct an instance
31
+ from any 2-element sequence (list, tuple, torch.Size, etc.).
32
+
33
+ Attributes:
34
+ height (int): The height of the image in pixels.
35
+ width (int): The width of the image in pixels.
36
+ """
37
+
38
+ height: int
39
+ width: int
40
+
41
+ def __str__(self) -> str:
42
+ """Return a string representation of the resolution."""
43
+ return f"{self.height}x{self.width}"
44
+
45
+ def __iter__(self) -> Iterator[int]:
46
+ """Allow unpacking of ImageResolution as (height, width).
47
+
48
+ Returns:
49
+ An iterator over (height, width).
50
+ """
51
+ yield self.height
52
+ yield self.width
53
+
54
+ def __lt__(self, other: Self) -> bool:
55
+ """Override the 'less than' operator.
56
+
57
+ Returns:
58
+ True if the current resolution is smaller than the other.
59
+ """
60
+ return self.height * self.width < other.height * other.width
61
+
62
+ def __le__(self, other: Self) -> bool:
63
+ """Override the 'less than or equal to' operator.
64
+
65
+ Returns:
66
+ True if the current resolution is smaller than or equal to the other.
67
+ """
68
+ return self.height * self.width <= other.height * other.width
69
+
70
+ def __gt__(self, other: Self) -> bool:
71
+ """Override the 'greater than' operator.
72
+
73
+ Returns:
74
+ True if the current resolution is greater than the other.
75
+ """
76
+ return self.height * self.width > other.height * other.width
77
+
78
+ def __ge__(self, other: Self) -> bool:
79
+ """Override the 'greater than or equal to' operator.
80
+
81
+ Returns:
82
+ True if the current resolution is greater than or equal to the other.
83
+ """
84
+ return self.height * self.width >= other.height * other.width
85
+
86
+ def __mul__(self, multiplier: int) -> Self:
87
+ """Multiply height and width by a positive integer.
88
+
89
+ Args:
90
+ multiplier: The positive integer multiplier.
91
+
92
+ Returns:
93
+ New resolution scaled by multiplier.
94
+
95
+ Raises:
96
+ ValueError: If `multiplier` is not a positive integer.
97
+ """
98
+ if not isinstance(multiplier, int) or multiplier <= 0:
99
+ msg = f"Multiplier must be a positive integer, got {multiplier} instead."
100
+ raise ValueError(msg)
101
+ return type(self)(self.height * multiplier, self.width * multiplier)
102
+
103
+ def __floordiv__(self, divisor: int) -> Self:
104
+ """Perform integer division of height and width by a positive integer.
105
+
106
+ Args:
107
+ divisor: The positive integer divisor.
108
+
109
+ Returns:
110
+ New resolution divided by divisor.
111
+
112
+ Raises:
113
+ ValueError: If `divisor` is not a positive integer.
114
+ """
115
+ if not isinstance(divisor, int) or divisor <= 0:
116
+ msg = f"Divisor must be a positive integer, got {divisor} instead."
117
+ raise ValueError(msg)
118
+ return type(self)(self.height // divisor, self.width // divisor)
119
+
120
+ @classmethod
121
+ def from_iterable(cls, shape: Sequence[int]) -> Self:
122
+ """Construct an `ImageResolution` from a sequence of two integers.
123
+
124
+ Args:
125
+ shape: A sequence (list, tuple, torch.Size, etc.) containing
126
+ the height and width.
127
+
128
+ Returns:
129
+ The corresponding `ImageResolution` object.
130
+
131
+ Raises:
132
+ `TypeError`: If the input is not a sequence of integers.
133
+ `ValueError`: If the input does not contain exactly two elements.
134
+ """
135
+ if not isinstance(shape, Sequence):
136
+ msg = "Shape must be a sequence (list, tuple, etc.)."
137
+ raise TypeError(msg)
138
+ if len(shape) != _NUM_RESOLUTION_DIMENSIONS:
139
+ msg = "Shape must contain exactly two elements."
140
+ raise ValueError(msg)
141
+
142
+ height, width = shape
143
+ if not isinstance(height, int) or not isinstance(width, int):
144
+ msg = "Elements of shape must be integers."
145
+ raise TypeError(msg)
146
+
147
+ return cls(height=height, width=width)
148
+
149
+
150
+ # ------------------------------------------------------------
151
+ # PUBLIC FUNCTIONS
152
+ # ------------------------------------------------------------
153
+
154
+
155
+ @beartype
156
+ def reshape(
157
+ image: Real[np.ndarray, "h w c"], resolution: ImageResolution
158
+ ) -> Real[np.ndarray, "{resolution.height} {resolution.width} c"]:
159
+ """Resize an image to a target resolution using linear interpolation.
160
+
161
+ The function resizes the input image to match the specified resolution.
162
+ - If the target resolution is larger than the original, it upsamples.
163
+ - If the target resolution is smaller, it downsamples with anti-aliasing.
164
+ - If the resolution matches, the original image is returned unchanged.
165
+
166
+ Notes:
167
+ The interpolation is performed using a first-order spline (linear)
168
+ from scikit-image. While similar to bilinear interpolation, it applies
169
+ spline-based interpolation rather than a convolutional bilinear kernel.
170
+ As a result, the output may be slightly smoother.
171
+
172
+ Args:
173
+ image: Input image array of shape (H, W, C).
174
+ resolution: Target resolution as an ImageResolution object.
175
+
176
+ Returns:
177
+ Resized image with the same dtype as the input.
178
+ """
179
+ org_resolution = ImageResolution.from_iterable(image.shape[0:2])
180
+ if resolution < org_resolution:
181
+ out = resize(
182
+ image,
183
+ (resolution.height, resolution.width),
184
+ order=1,
185
+ preserve_range=True,
186
+ anti_aliasing=True,
187
+ )
188
+ elif resolution > org_resolution:
189
+ out = resize(
190
+ image, (resolution.height, resolution.width), order=1, preserve_range=True
191
+ )
192
+ else:
193
+ return image
194
+ return out.astype(image.dtype, copy=False)
File without changes
@@ -0,0 +1,505 @@
1
+ """Functions to interact with videos.
2
+
3
+ In this module, an RGB frame is a NumPy uint8 array of shape (H, W, C).
4
+ This means a frame is encoded on three RGB channels in [0, 255].
5
+
6
+ For arrays regrouping multiple frames, the temporal dimension
7
+ is added as the first dimension (i.e. of shape (N, H, W, C)).
8
+ """
9
+
10
+ import warnings
11
+ from collections.abc import Iterable, Iterator
12
+ from dataclasses import dataclass
13
+ from itertools import islice
14
+ from pathlib import Path
15
+ from typing import Any, Final
16
+
17
+ import imageio.v3 as iio
18
+ import numpy as np
19
+ from beartype import beartype
20
+ from jaxtyping import UInt8
21
+ from rich.progress import track
22
+
23
+ from image_video_utils.formats import ImageFormat, VideoFormat
24
+ from image_video_utils.image import ImageResolution
25
+
26
+ # ------------------------------------------------------------
27
+ # CONSTANTS
28
+ # ------------------------------------------------------------
29
+
30
+ _MAX_FRAMES: Final[int] = 1000
31
+ """Maximal number of frames before dropping the counting."""
32
+
33
+ # ------------------------------------------------------------
34
+ # CLASSES
35
+ # ------------------------------------------------------------
36
+
37
+
38
+ @beartype
39
+ @dataclass(frozen=True)
40
+ class VideoMetadata:
41
+ """Video metadata including file path, codec, and frame properties."""
42
+
43
+ filepath: Path
44
+ """Path of the file."""
45
+ codec: str
46
+ """Codec used to encode the file."""
47
+ num_frames: int
48
+ """Number of frames in the video."""
49
+ frame_rate: float
50
+ """Frame rate of the video."""
51
+ resolution: ImageResolution
52
+ """Resolution of the frames."""
53
+
54
+ @property
55
+ def format(self) -> VideoFormat:
56
+ """Format of the file."""
57
+ return VideoFormat.from_ext(self.filepath.suffix)
58
+
59
+ @property
60
+ def stem(self) -> str:
61
+ """Base filename without extension."""
62
+ return self.filepath.stem
63
+
64
+ @property
65
+ def suffix(self) -> str:
66
+ """File extension with leading dot."""
67
+ return self.filepath.suffix
68
+
69
+ @property
70
+ def extension(self) -> str:
71
+ """File extension without leading dot."""
72
+ return self.filepath.suffix.lstrip(".")
73
+
74
+
75
+ @beartype
76
+ @dataclass(frozen=True)
77
+ class ProgressConfig:
78
+ """Configuration for the progress bar displayed during video writing."""
79
+
80
+ enabled: bool = False
81
+ """Show a progress bar while writing frames or video."""
82
+ title: str = "Writing frames"
83
+ """Label displayed on the progress bar."""
84
+ transient: bool = False
85
+ """Clear the progress bar once writing is complete."""
86
+
87
+
88
+ # ------------------------------------------------------------
89
+ # METADATA
90
+ # ------------------------------------------------------------
91
+
92
+
93
+ @beartype
94
+ def get_video_metadata(video_path: str | Path) -> VideoMetadata:
95
+ """Extract video metadata from a file.
96
+
97
+ Args:
98
+ video_path: Path to the video file.
99
+
100
+ Returns:
101
+ A `VideoMetadata` object.
102
+ """
103
+ video_path = Path(video_path)
104
+ if not VideoFormat.is_valid_file(video_path):
105
+ msg = f"Unsupported video format: {video_path.suffix}"
106
+ raise ValueError(msg)
107
+
108
+ props = iio.improps(video_path)
109
+ meta = iio.immeta(video_path)
110
+
111
+ num_frames = props.n_images
112
+ if num_frames == 0 or num_frames is None:
113
+ meta_ci = _normalize_metadata(meta)
114
+ number_of_frames = meta_ci.get("number_of_frames")
115
+ fps = meta_ci.get("fps")
116
+ duration = meta_ci.get("duration")
117
+
118
+ if number_of_frames:
119
+ num_frames = int(number_of_frames)
120
+
121
+ elif fps and duration:
122
+ if isinstance(duration, str):
123
+ duration = _parse_duration_seconds(duration)
124
+ num_frames = round(fps * duration)
125
+
126
+ else:
127
+ # Fallback if number of frames is not available in props
128
+ num_frames = sum(1 for _ in islice(frame_iter(video_path), _MAX_FRAMES + 1))
129
+ if num_frames > _MAX_FRAMES:
130
+ warnings.warn(
131
+ f"Too much frames in {video_path.name} to count them.",
132
+ RuntimeWarning,
133
+ stacklevel=2,
134
+ )
135
+
136
+ codec = meta.get("codec", "unknown")
137
+
138
+ return VideoMetadata(
139
+ filepath=video_path,
140
+ codec=codec,
141
+ num_frames=num_frames,
142
+ frame_rate=meta["fps"],
143
+ resolution=ImageResolution.from_iterable(props.shape[1:3]),
144
+ )
145
+
146
+
147
+ # ------------------------------------------------------------
148
+ # READ FILES
149
+ # ------------------------------------------------------------
150
+
151
+
152
+ @beartype
153
+ def frame_iter(
154
+ video_path: str | Path,
155
+ ) -> Iterator[UInt8[np.ndarray, "height width channels=3"]]:
156
+ """Yield RGB frames from a video file.
157
+
158
+ Args:
159
+ video_path: Path to the input video file.
160
+
161
+ Returns:
162
+ Iterator over RGB frames.
163
+ """
164
+ video_path = Path(video_path)
165
+
166
+ if not VideoFormat.is_valid_file(video_path):
167
+ msg = f"{video_path} is not a video file."
168
+ raise ValueError(msg)
169
+
170
+ return iio.imiter(video_path, plugin="pyav", format="rgb24")
171
+
172
+
173
+ @beartype
174
+ def list_image_paths(images_dir: str | Path) -> list[Path]:
175
+ """Return a sorted list of valid image file paths from a directory.
176
+
177
+ Args:
178
+ images_dir: Directory containing image files.
179
+
180
+ Returns:
181
+ Sorted list of Path objects pointing to valid image files. Each Path
182
+ is the full path to the file, not just the file name.
183
+ """
184
+ images_dir = Path(images_dir)
185
+
186
+ image_paths = sorted(
187
+ f for f in images_dir.iterdir() if ImageFormat.is_valid_file(f)
188
+ )
189
+ if not image_paths:
190
+ msg = f"No image files found in {images_dir}."
191
+ raise ValueError(msg)
192
+
193
+ return image_paths
194
+
195
+
196
+ @beartype
197
+ def frame_dir_iter(
198
+ images_dir: str | Path,
199
+ ) -> Iterator[UInt8[np.ndarray, "height width channels=3"]]:
200
+ """Yield RGB frames from a directory.
201
+
202
+ Args:
203
+ images_dir: Directory containing frame files.
204
+
205
+ Returns:
206
+ Iterator over RGB frames.
207
+ """
208
+ for path in list_image_paths(Path(images_dir)):
209
+ yield iio.imread(path, plugin="pillow", mode="RGB")
210
+
211
+
212
+ # ------------------------------------------------------------
213
+ # WRITE FRAMES
214
+ # ------------------------------------------------------------
215
+
216
+
217
+ @beartype
218
+ def write_frames_from_ndarray(
219
+ output_dir: str | Path,
220
+ frames: UInt8[np.ndarray, "num_frames height width channels=3"],
221
+ *,
222
+ filename_template: str = "{:05d}.png",
223
+ progress: bool | ProgressConfig = False,
224
+ ) -> None:
225
+ """Write frame files from a NumPy array of frames.
226
+
227
+ Args:
228
+ output_dir: Directory where extracted frames will be saved.
229
+ frames: RGB video frames in one array.
230
+ filename_template: Template for frame filenames, must include one integer
231
+ formatting placeholder.
232
+ progress: Progress bar configuration.
233
+ """
234
+ output_dir = Path(output_dir)
235
+
236
+ output_dir.mkdir(parents=True, exist_ok=True)
237
+
238
+ _write_frames_from_iterable(
239
+ output_dir,
240
+ frames,
241
+ filename_template=filename_template,
242
+ num_frames=frames.shape[0],
243
+ progress=progress,
244
+ )
245
+
246
+
247
+ @beartype
248
+ def write_frames_from_video(
249
+ output_dir: str | Path,
250
+ video_path: str | Path,
251
+ *,
252
+ filename_template: str = "{:05d}.png",
253
+ progress: bool | ProgressConfig = False,
254
+ ) -> None:
255
+ """Write frame files from a video file.
256
+
257
+ Args:
258
+ output_dir: Directory where extracted frames will be saved.
259
+ video_path: Path to the input video file.
260
+ filename_template: Template for frame filenames, must include one integer
261
+ formatting placeholder.
262
+ progress: Progress bar configuration.
263
+ """
264
+ video_path = Path(video_path)
265
+ output_dir = Path(output_dir)
266
+
267
+ output_dir.mkdir(parents=True, exist_ok=True)
268
+
269
+ _write_frames_from_iterable(
270
+ output_dir,
271
+ frame_iter(video_path),
272
+ filename_template=filename_template,
273
+ num_frames=get_video_metadata(video_path).num_frames,
274
+ progress=progress,
275
+ )
276
+
277
+
278
+ # ------------------------------------------------------------
279
+ # VIDEO CREATION
280
+ # ------------------------------------------------------------
281
+
282
+
283
+ @beartype
284
+ def write_video_from_ndarray(
285
+ output_file: str | Path,
286
+ frames: UInt8[np.ndarray, "num_frames height width channels=3"],
287
+ *,
288
+ fps: int | float = 24,
289
+ codec: str = "ffv1",
290
+ progress: bool | ProgressConfig = False,
291
+ ) -> None:
292
+ """Create a video from a NumPy array of frames.
293
+
294
+ Args:
295
+ output_file: Path to the output video file.
296
+ frames: RGB video frames in one array.
297
+ fps: Frames per second of the resulting video.
298
+ codec: Codec for video creation.
299
+ progress: Progress bar configuration.
300
+ """
301
+ output_file = Path(output_file)
302
+
303
+ write_video_from_iterable(
304
+ output_file,
305
+ frames,
306
+ fps=fps,
307
+ codec=codec,
308
+ num_frames=frames.shape[0],
309
+ progress=progress,
310
+ )
311
+
312
+
313
+ @beartype
314
+ def write_video_from_images(
315
+ output_path: str | Path,
316
+ images_dir: str | Path,
317
+ *,
318
+ fps: int | float = 24,
319
+ codec: str = "ffv1",
320
+ progress: bool | ProgressConfig = False,
321
+ ) -> None:
322
+ """Create a video from image files.
323
+
324
+ Args:
325
+ output_path: Path to the output video file.
326
+ images_dir: Directory containing image files (sorted by filename).
327
+ fps: Frames per second of the resulting video.
328
+ codec: Codec for video creation.
329
+ progress: Progress bar configuration.
330
+ """
331
+ images_dir = Path(images_dir)
332
+ output_path = Path(output_path)
333
+
334
+ write_video_from_iterable(
335
+ output_path,
336
+ frame_dir_iter(images_dir),
337
+ fps=fps,
338
+ codec=codec,
339
+ num_frames=len(list_image_paths(images_dir)),
340
+ progress=progress,
341
+ )
342
+
343
+
344
+ @beartype
345
+ def write_video_from_iterable( # noqa: PLR0913
346
+ output_file: str | Path,
347
+ frames: Iterable[UInt8[np.ndarray, "height width channels=3"]],
348
+ *,
349
+ fps: int | float = 24,
350
+ codec: str = "ffv1",
351
+ num_frames: int | None = None,
352
+ progress: bool | ProgressConfig = False,
353
+ ) -> None:
354
+ """Write a sequence of frames to a video file.
355
+
356
+ Args:
357
+ output_file: Path to the output video file.
358
+ frames: Iterable of RGB frames.
359
+ fps: Frames per second of the resulting video.
360
+ codec: Codec for video creation.
361
+ num_frames: Number of frames, for video metadata and progress bar.
362
+ progress: Progress bar configuration.
363
+ """
364
+ output_file = Path(output_file)
365
+ progress = (
366
+ ProgressConfig(enabled=progress, title="Writing video")
367
+ if isinstance(progress, bool)
368
+ else progress
369
+ )
370
+
371
+ with iio.imopen(output_file, "w", plugin="pyav") as out_file:
372
+ out_file.init_video_stream(codec, fps=fps)
373
+ if num_frames:
374
+ out_file.container_metadata["NUMBER_OF_FRAMES"] = f"{num_frames}"
375
+ for frame in track(
376
+ frames,
377
+ total=num_frames,
378
+ description=progress.title,
379
+ disable=not progress.enabled,
380
+ transient=progress.transient,
381
+ ):
382
+ out_file.write_frame(frame, pixel_format="rgb24")
383
+
384
+
385
+ # ------------------------------------------------------------
386
+ # RE-ENCODE
387
+ # ------------------------------------------------------------
388
+
389
+
390
+ @beartype
391
+ def reencode_video(
392
+ input_file: str | Path,
393
+ output_file: str | Path,
394
+ *,
395
+ codec: str = "ffv1",
396
+ progress: bool | ProgressConfig = False,
397
+ ) -> None:
398
+ """Re-encode a video with another codec.
399
+
400
+ Args:
401
+ input_file: Path to the input video file.
402
+ output_file: Path to the output video file.
403
+ codec: Codec for video creation.
404
+ progress: Progress bar configuration.
405
+ """
406
+ input_file = Path(input_file)
407
+ output_file = Path(output_file)
408
+ progress = (
409
+ ProgressConfig(enabled=progress, title="Re-encode video")
410
+ if isinstance(progress, bool)
411
+ else progress
412
+ )
413
+ video_metadata = get_video_metadata(input_file)
414
+
415
+ with iio.imopen(output_file, "w", plugin="pyav") as out_file:
416
+ out_file.init_video_stream(codec, fps=video_metadata.frame_rate)
417
+ out_file.container_metadata["NUMBER_OF_FRAMES"] = f"{video_metadata.num_frames}"
418
+ for frame in track(
419
+ frame_iter(input_file),
420
+ total=video_metadata.num_frames,
421
+ description=progress.title,
422
+ disable=not progress.enabled,
423
+ transient=progress.transient,
424
+ ):
425
+ out_file.write_frame(frame, pixel_format="rgb24")
426
+
427
+
428
+ # ------------------------------------------------------------
429
+ # PRIVATE FUNCTIONS
430
+ # ------------------------------------------------------------
431
+
432
+
433
+ @beartype
434
+ def _write_frames_from_iterable(
435
+ output_dir: Path,
436
+ frames: Iterable[UInt8[np.ndarray, "height width channels=3"]],
437
+ *,
438
+ filename_template: str = "{:05d}.png",
439
+ num_frames: int | None = None,
440
+ progress: bool | ProgressConfig = False,
441
+ ) -> None:
442
+ """Write a sequence of frames to an image directory.
443
+
444
+ Args:
445
+ output_dir: Directory where extracted frames will be saved.
446
+ frames: Iterable of RGB frames.
447
+ filename_template: Template for frame filenames, must include one integer
448
+ formatting placeholder.
449
+ num_frames: Number of frames, used for the progress bar.
450
+ progress: Progress bar configuration.
451
+ """
452
+ progress = (
453
+ ProgressConfig(enabled=progress, title="Writing frames")
454
+ if isinstance(progress, bool)
455
+ else progress
456
+ )
457
+
458
+ for i, frame in enumerate(
459
+ track(
460
+ frames,
461
+ description=progress.title,
462
+ total=num_frames,
463
+ disable=not progress.enabled,
464
+ transient=progress.transient,
465
+ )
466
+ ):
467
+ iio.imwrite(output_dir / filename_template.format(i), frame)
468
+
469
+
470
+ @beartype
471
+ def _parse_duration_seconds(duration: str) -> float:
472
+ """Convert a HH:MM:SS(.ffffff) duration string to seconds.
473
+
474
+ Args:
475
+ duration: Duration formatted as HH:MM:SS or HH:MM:SS.microseconds.
476
+
477
+ Returns:
478
+ Duration in seconds.
479
+
480
+ Raises:
481
+ ValueError: If the duration format is invalid.
482
+ """
483
+ hours, minutes, seconds = duration.split(":")
484
+ return 3600.0 * float(hours) + 60.0 * float(minutes) + float(seconds)
485
+
486
+
487
+ @beartype
488
+ def _normalize_metadata(meta: dict[str, Any]) -> dict[str, Any]:
489
+ """Normalize metadata keys to lowercase and strip language suffixes.
490
+
491
+ Args:
492
+ meta: Raw metadata dictionary.
493
+
494
+ Returns:
495
+ Normalized metadata dictionary.
496
+ """
497
+ normalized: dict[str, object] = {}
498
+
499
+ for key, value in meta.items():
500
+ low_key = key.lower()
501
+ if "-" in low_key:
502
+ low_key = low_key.split("-", 1)[0]
503
+ normalized[low_key] = value
504
+
505
+ return normalized