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.
- image_video_utils-1.0.0/LICENSE +21 -0
- image_video_utils-1.0.0/PKG-INFO +86 -0
- image_video_utils-1.0.0/README.md +65 -0
- image_video_utils-1.0.0/pyproject.toml +65 -0
- image_video_utils-1.0.0/src/image_video_utils/__init__.py +18 -0
- image_video_utils-1.0.0/src/image_video_utils/cli.py +94 -0
- image_video_utils-1.0.0/src/image_video_utils/formats.py +87 -0
- image_video_utils-1.0.0/src/image_video_utils/image.py +194 -0
- image_video_utils-1.0.0/src/image_video_utils/py.typed +0 -0
- image_video_utils-1.0.0/src/image_video_utils/video.py +505 -0
|
@@ -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
|
+
[](https://www.python.org/downloads/)
|
|
25
|
+
[](https://spdx.org/licenses/MIT.html)
|
|
26
|
+
|
|
27
|
+
[](https://docs.astral.sh/uv/)
|
|
28
|
+
[](https://docs.astral.sh/ruff/)
|
|
29
|
+
[](https://docs.astral.sh/ty/)
|
|
30
|
+
[](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
|
+
[](https://www.python.org/downloads/)
|
|
4
|
+
[](https://spdx.org/licenses/MIT.html)
|
|
5
|
+
|
|
6
|
+
[](https://docs.astral.sh/uv/)
|
|
7
|
+
[](https://docs.astral.sh/ruff/)
|
|
8
|
+
[](https://docs.astral.sh/ty/)
|
|
9
|
+
[](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
|