iterframes 0.5.0__tar.gz → 0.6.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.
- iterframes-0.6.0/.github/scripts/check_site.py +50 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/.github/workflows/ci.yaml +9 -0
- iterframes-0.6.0/.github/workflows/pages.yaml +61 -0
- iterframes-0.6.0/.release-please-manifest.json +3 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/AGENTS.md +36 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/CHANGELOG.md +7 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/Cargo.lock +1 -1
- {iterframes-0.5.0 → iterframes-0.6.0}/Cargo.toml +1 -1
- {iterframes-0.5.0 → iterframes-0.6.0}/PKG-INFO +15 -2
- {iterframes-0.5.0 → iterframes-0.6.0}/README.md +14 -1
- {iterframes-0.5.0 → iterframes-0.6.0}/docs/index.md +7 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/docs/reference.md +30 -3
- {iterframes-0.5.0 → iterframes-0.6.0}/iterframes/__init__.py +15 -1
- {iterframes-0.5.0 → iterframes-0.6.0}/src/decoder.rs +38 -4
- {iterframes-0.5.0 → iterframes-0.6.0}/src/lib.rs +34 -5
- {iterframes-0.5.0 → iterframes-0.6.0}/tests/test_benchmark.py +26 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/tests/test_select.py +101 -0
- iterframes-0.6.0/web/README.md +123 -0
- iterframes-0.6.0/web/assets/favicon.svg +5 -0
- iterframes-0.6.0/web/assets/fonts/jakarta-latin.woff2 +0 -0
- iterframes-0.6.0/web/assets/fonts/jetbrains-mono-latin.woff2 +0 -0
- iterframes-0.6.0/web/assets/frames.webp +0 -0
- iterframes-0.6.0/web/assets/og.png +0 -0
- iterframes-0.6.0/web/assets/scenes.webp +0 -0
- iterframes-0.6.0/web/index.html +532 -0
- iterframes-0.6.0/web/main.js +467 -0
- iterframes-0.6.0/web/og-card.html +51 -0
- iterframes-0.6.0/web/styles.css +730 -0
- iterframes-0.5.0/.release-please-manifest.json +0 -3
- {iterframes-0.5.0 → iterframes-0.6.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/.gitignore +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/.pre-commit-config.yaml +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/.readthedocs.yaml +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/CLAUDE.md +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/LICENSE +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/build.rs +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/docs/development.md +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/mkdocs.yml +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/pyproject.toml +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/release-please-config.json +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/scripts/build-ffmpeg.sh +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/src/dlpack.rs +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/src/ffmpeg.rs +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/tests/conftest.py +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/tests/data/video_480x270.mp4 +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/tests/data/video_av1_480x270.mp4 +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/tests/test_read.py +0 -0
- {iterframes-0.5.0 → iterframes-0.6.0}/uv.lock +0 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Check that every local reference in the landing page resolves to a file.
|
|
2
|
+
|
|
3
|
+
The page has no build step, so nothing else would catch a renamed asset or a
|
|
4
|
+
typo in a path until the deployed site showed a broken image.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import unquote, urlparse
|
|
11
|
+
|
|
12
|
+
# href="...", src="..." and url(...) in CSS.
|
|
13
|
+
REFERENCES = re.compile(
|
|
14
|
+
r"""(?:href|src)\s*=\s*["']([^"']+)["']|url\(\s*["']?([^"')]+)["']?\s*\)"""
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def local(reference: str) -> bool:
|
|
19
|
+
parsed = urlparse(reference)
|
|
20
|
+
return not parsed.scheme and not parsed.netloc and bool(parsed.path)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(root: Path) -> int:
|
|
24
|
+
missing = []
|
|
25
|
+
for source in sorted(root.rglob("*")):
|
|
26
|
+
if source.suffix not in {".html", ".css", ".js"}:
|
|
27
|
+
continue
|
|
28
|
+
text = source.read_text(encoding="utf-8")
|
|
29
|
+
for match in REFERENCES.finditer(text):
|
|
30
|
+
reference = match.group(1) or match.group(2)
|
|
31
|
+
if not local(reference):
|
|
32
|
+
continue
|
|
33
|
+
path = unquote(urlparse(reference).path)
|
|
34
|
+
if path in {"./", "."}:
|
|
35
|
+
continue
|
|
36
|
+
target = (source.parent / path).resolve()
|
|
37
|
+
if not target.exists():
|
|
38
|
+
line = text.count("\n", 0, match.start()) + 1
|
|
39
|
+
missing.append(f"{source}:{line}: {reference}")
|
|
40
|
+
|
|
41
|
+
for problem in missing:
|
|
42
|
+
print(f"missing: {problem}", file=sys.stderr)
|
|
43
|
+
if missing:
|
|
44
|
+
return 1
|
|
45
|
+
print(f"every local reference under {root} resolves")
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
raise SystemExit(main(Path(sys.argv[1] if len(sys.argv) > 1 else "web")))
|
|
@@ -8,11 +8,20 @@ name: CI
|
|
|
8
8
|
# updates its release pull request, and merging that pull request creates
|
|
9
9
|
# the tag and a draft GitHub release, then the jobs below attach the
|
|
10
10
|
# wheels, publish them to PyPI, and publish the release.
|
|
11
|
+
# The landing page has its own workflow, and nothing here builds it, so a
|
|
12
|
+
# commit that only touches web/ skips the wheel matrix. release-please skips
|
|
13
|
+
# with it, and picks those commits up on the next push that does build
|
|
14
|
+
# something.
|
|
11
15
|
on:
|
|
12
16
|
push:
|
|
13
17
|
branches:
|
|
14
18
|
- main
|
|
19
|
+
paths-ignore: &page
|
|
20
|
+
- 'web/**'
|
|
21
|
+
- '.github/workflows/pages.yaml'
|
|
22
|
+
- '.github/scripts/check_site.py'
|
|
15
23
|
pull_request:
|
|
24
|
+
paths-ignore: *page
|
|
16
25
|
workflow_dispatch:
|
|
17
26
|
|
|
18
27
|
permissions:
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Publishes web/ to https://alesanfra.github.io/iterframes/.
|
|
2
|
+
#
|
|
3
|
+
# The page is plain HTML, CSS and JavaScript, so there is nothing to build:
|
|
4
|
+
# the job uploads the directory and hands it to Pages. It runs only when
|
|
5
|
+
# web/ changes, and ci.yaml ignores web/ for the same reason, so a change
|
|
6
|
+
# to one never spends minutes on the other.
|
|
7
|
+
#
|
|
8
|
+
# Pages must be set to "GitHub Actions" under Settings > Pages for the
|
|
9
|
+
# deployment to be accepted.
|
|
10
|
+
name: Pages
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
push:
|
|
14
|
+
branches:
|
|
15
|
+
- main
|
|
16
|
+
paths: &page
|
|
17
|
+
- 'web/**'
|
|
18
|
+
- '.github/workflows/pages.yaml'
|
|
19
|
+
- '.github/scripts/check_site.py'
|
|
20
|
+
pull_request:
|
|
21
|
+
paths: *page
|
|
22
|
+
workflow_dispatch:
|
|
23
|
+
|
|
24
|
+
permissions:
|
|
25
|
+
contents: read
|
|
26
|
+
|
|
27
|
+
# One deployment at a time, and never cancel one halfway: a cancelled
|
|
28
|
+
# deployment leaves the live site on the previous version.
|
|
29
|
+
concurrency:
|
|
30
|
+
group: pages
|
|
31
|
+
cancel-in-progress: false
|
|
32
|
+
|
|
33
|
+
jobs:
|
|
34
|
+
check:
|
|
35
|
+
name: Check the page
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
steps:
|
|
38
|
+
- uses: actions/checkout@v7
|
|
39
|
+
- name: Check every local link and asset resolves
|
|
40
|
+
run: python3 .github/scripts/check_site.py web
|
|
41
|
+
|
|
42
|
+
deploy:
|
|
43
|
+
name: Deploy to GitHub Pages
|
|
44
|
+
# Pull requests get the check above; only main is published.
|
|
45
|
+
if: ${{ github.event_name != 'pull_request' }}
|
|
46
|
+
needs: check
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
permissions:
|
|
49
|
+
pages: write
|
|
50
|
+
id-token: write
|
|
51
|
+
environment:
|
|
52
|
+
name: github-pages
|
|
53
|
+
url: ${{ steps.deployment.outputs.page_url }}
|
|
54
|
+
steps:
|
|
55
|
+
- uses: actions/checkout@v7
|
|
56
|
+
- uses: actions/configure-pages@v6
|
|
57
|
+
- uses: actions/upload-pages-artifact@v5
|
|
58
|
+
with:
|
|
59
|
+
path: web
|
|
60
|
+
- id: deployment
|
|
61
|
+
uses: actions/deploy-pages@v5
|
|
@@ -54,6 +54,13 @@ as `iterframes.iterframes`, and `iterframes/__init__.py` wraps its
|
|
|
54
54
|
frame, which decoding cannot return either, so frame numbers are those of
|
|
55
55
|
a plain `read`. A stream that cannot seek, such as raw H.264, is opened
|
|
56
56
|
again and read from the start (`Source::open`).
|
|
57
|
+
- `approximate` goes with `frames` only, and carries a tolerance in frames
|
|
58
|
+
(`True` is `usize::MAX`), in `Selection::Frames`. `selected` then maps
|
|
59
|
+
each index through `Seeker::nearest_key`, which snaps it to the key frame
|
|
60
|
+
closest to it when that is within the tolerance, and leaves it alone
|
|
61
|
+
otherwise. Nothing else changes: the index pass still runs, and two
|
|
62
|
+
indices that snap to the same key frame decode it once each, so the caller
|
|
63
|
+
gets one frame per number asked for.
|
|
57
64
|
- Errors travel through the channel and become Python exceptions in
|
|
58
65
|
`impl From<Error> for PyErr`. A closed channel means the end of the video.
|
|
59
66
|
- Dropping the reader closes the channel; the thread notices on its next
|
|
@@ -103,6 +110,7 @@ as `iterframes.iterframes`, and `iterframes/__init__.py` wraps its
|
|
|
103
110
|
| `scripts/build-ffmpeg.sh` | Static FFmpeg and dav1d, run by `build.rs` |
|
|
104
111
|
| `tests/` | pytest suite; frames are compared with PyAV |
|
|
105
112
|
| `docs/` | MkDocs site published on Read the Docs |
|
|
113
|
+
| `web/` | Landing page published on GitHub Pages |
|
|
106
114
|
|
|
107
115
|
## Environment
|
|
108
116
|
|
|
@@ -145,6 +153,34 @@ built module; check them instead of writing them from memory. Keep the
|
|
|
145
153
|
text short, in American English, with no performance claims that have not
|
|
146
154
|
been measured.
|
|
147
155
|
|
|
156
|
+
The landing page in `web/` is part of the docs: whenever an argument, a
|
|
157
|
+
default, or the behavior of a feature changes, update it in the same
|
|
158
|
+
change as the code, and say so in the pull request. Its diagrams are
|
|
159
|
+
drawn from the numbers declared in `web/main.js`, so change the number
|
|
160
|
+
and let the drawing and the captions follow; never write a figure into
|
|
161
|
+
the HTML that the script also computes. `web/README.md` lists those
|
|
162
|
+
numbers and the files. Check the result in a browser before proposing the
|
|
163
|
+
change: open `web/index.html`, or serve the directory with
|
|
164
|
+
`python3 -m http.server --directory web`. The page has no build step and no
|
|
165
|
+
third-party request: its two typefaces are self-hosted woff2 files in
|
|
166
|
+
`web/assets/fonts/`, and everything else is plain HTML, CSS, and JavaScript.
|
|
167
|
+
`web/README.md` describes the look and the colour rules; keep them rather
|
|
168
|
+
than restyling one component on its own.
|
|
169
|
+
|
|
170
|
+
## Landing page
|
|
171
|
+
|
|
172
|
+
`web/` is the page at
|
|
173
|
+
[alesanfra.github.io/iterframes](https://alesanfra.github.io/iterframes/):
|
|
174
|
+
plain HTML, CSS and JavaScript, no build step, no framework, no web fonts and
|
|
175
|
+
no third-party script. `.github/workflows/pages.yaml` uploads the directory
|
|
176
|
+
and runs only when it changes; `ci.yaml` ignores it, so neither spends minutes
|
|
177
|
+
on the other. Its two animated diagrams are built from constants at the top of
|
|
178
|
+
`main.js`, and their captions are computed from the same constants, so a
|
|
179
|
+
number cannot appear in the drawing and the prose with two different values.
|
|
180
|
+
`web/README.md` has the `ffmpeg` commands that made the images.
|
|
181
|
+
|
|
182
|
+
Keep its claims to what the benchmarks measured, as in the docs.
|
|
183
|
+
|
|
148
184
|
## Conventions
|
|
149
185
|
|
|
150
186
|
- Commits follow [Conventional Commits](https://www.conventionalcommits.org/),
|
|
@@ -6,6 +6,13 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
6
6
|
From 0.4.0 on, [release-please](https://github.com/googleapis/release-please)
|
|
7
7
|
writes each entry from the Conventional Commits since the previous release.
|
|
8
8
|
|
|
9
|
+
## [0.6.0](https://github.com/alesanfra/iterframes/compare/v0.5.0...v0.6.0) (2026-09-20)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
* read approximate frames ([#11](https://github.com/alesanfra/iterframes/issues/11)) ([2158899](https://github.com/alesanfra/iterframes/commit/2158899f72f73d69c0020236181e340bb8648fc1))
|
|
15
|
+
|
|
9
16
|
## [0.5.0](https://github.com/alesanfra/iterframes/compare/v0.4.0...v0.5.0) (2026-09-20)
|
|
10
17
|
|
|
11
18
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: iterframes
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Classifier: Intended Audience :: Science/Research
|
|
5
5
|
Classifier: Programming Language :: Python :: 3
|
|
6
6
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
@@ -43,7 +43,9 @@ adding to it, even when your code is pure Python. `pip install iterframes`
|
|
|
43
43
|
brings FFmpeg with it: nothing else to install, no system packages, no
|
|
44
44
|
`ffmpeg` binary to call.
|
|
45
45
|
|
|
46
|
-
The
|
|
46
|
+
The landing page at
|
|
47
|
+
[alesanfra.github.io/iterframes](https://alesanfra.github.io/iterframes/)
|
|
48
|
+
shows what the decoder does while your loop runs. The documentation is at
|
|
47
49
|
[iterframes.readthedocs.io](https://iterframes.readthedocs.io): the
|
|
48
50
|
[reference](https://iterframes.readthedocs.io/en/latest/reference/)
|
|
49
51
|
documents every argument and error, and the
|
|
@@ -113,6 +115,17 @@ before it; frames asked for in order cost no more than reading the video
|
|
|
113
115
|
straight through. See
|
|
114
116
|
[Reading frames by number](https://iterframes.readthedocs.io/en/latest/reference/#reading-frames-by-number).
|
|
115
117
|
|
|
118
|
+
When a frame nearby will do, `approximate` reads the key frame closest to
|
|
119
|
+
each of `frames`, which costs one decoded frame instead of the frames from
|
|
120
|
+
the key frame on:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
# The nearest key frame within 5 frames, else the frame itself.
|
|
124
|
+
frames = list(iterframes.read("video.mp4", frames=[100, 200, 300], approximate=5))
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
See [Approximate frames](https://iterframes.readthedocs.io/en/latest/reference/#approximate-frames).
|
|
128
|
+
|
|
116
129
|
## Hardware decoding
|
|
117
130
|
|
|
118
131
|
Pass `device` to decode on a GPU instead of the CPU, which then stays free
|
|
@@ -20,7 +20,9 @@ adding to it, even when your code is pure Python. `pip install iterframes`
|
|
|
20
20
|
brings FFmpeg with it: nothing else to install, no system packages, no
|
|
21
21
|
`ffmpeg` binary to call.
|
|
22
22
|
|
|
23
|
-
The
|
|
23
|
+
The landing page at
|
|
24
|
+
[alesanfra.github.io/iterframes](https://alesanfra.github.io/iterframes/)
|
|
25
|
+
shows what the decoder does while your loop runs. The documentation is at
|
|
24
26
|
[iterframes.readthedocs.io](https://iterframes.readthedocs.io): the
|
|
25
27
|
[reference](https://iterframes.readthedocs.io/en/latest/reference/)
|
|
26
28
|
documents every argument and error, and the
|
|
@@ -90,6 +92,17 @@ before it; frames asked for in order cost no more than reading the video
|
|
|
90
92
|
straight through. See
|
|
91
93
|
[Reading frames by number](https://iterframes.readthedocs.io/en/latest/reference/#reading-frames-by-number).
|
|
92
94
|
|
|
95
|
+
When a frame nearby will do, `approximate` reads the key frame closest to
|
|
96
|
+
each of `frames`, which costs one decoded frame instead of the frames from
|
|
97
|
+
the key frame on:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
# The nearest key frame within 5 frames, else the frame itself.
|
|
101
|
+
frames = list(iterframes.read("video.mp4", frames=[100, 200, 300], approximate=5))
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
See [Approximate frames](https://iterframes.readthedocs.io/en/latest/reference/#approximate-frames).
|
|
105
|
+
|
|
93
106
|
## Hardware decoding
|
|
94
107
|
|
|
95
108
|
Pass `device` to decode on a GPU instead of the CPU, which then stays free
|
|
@@ -64,6 +64,13 @@ clip = list(iterframes.read("video.mp4", frames=[0, 30, 60]))
|
|
|
64
64
|
every_fifth = iterframes.read("video.mp4", start=100, stop=200, step=5)
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
+
Or, when a frame nearby will do, read the nearest key frame to each of
|
|
68
|
+
them and skip the decoding in between:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
clip = list(iterframes.read("video.mp4", frames=[0, 30, 60], approximate=5))
|
|
72
|
+
```
|
|
73
|
+
|
|
67
74
|
Stop whenever you like; the decoder stops with the loop:
|
|
68
75
|
|
|
69
76
|
```python
|
|
@@ -5,7 +5,7 @@ Everything lives in the top-level `iterframes` module.
|
|
|
5
5
|
## read
|
|
6
6
|
|
|
7
7
|
```python
|
|
8
|
-
read(path, height=None, width=None, prefetch_frames=1, device="cpu", on_device=False, frames=None, start=0, stop=None, step=1) -> Iterator[numpy.ndarray]
|
|
8
|
+
read(path, height=None, width=None, prefetch_frames=1, device="cpu", on_device=False, frames=None, start=0, stop=None, step=1, approximate=None) -> Iterator[numpy.ndarray]
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
Yields the frames of the video at `path`, in order, or with `frames`, or
|
|
@@ -23,6 +23,7 @@ Each frame is a C-contiguous, writable `numpy.ndarray` of shape
|
|
|
23
23
|
| `on_device` | With `device="cuda"`, yield `CudaFrame` objects left on the GPU instead of arrays. See [Frames on the GPU](#frames-on-the-gpu) |
|
|
24
24
|
| `frames` | The numbers of the frames to read, in the order to read them. See [Reading frames by number](#reading-frames-by-number) |
|
|
25
25
|
| `start`, `stop`, `step` | The frames to read, as a slice of the video |
|
|
26
|
+
| `approximate` | With `frames`, read the key frame nearest to each of them instead, when it is within this many frames. `True` is any distance. See [Approximate frames](#approximate-frames) |
|
|
26
27
|
|
|
27
28
|
Frames are resized with bilinear interpolation. When only one of `height`
|
|
28
29
|
and `width` is given, the other keeps the size of the video, so the aspect
|
|
@@ -48,7 +49,7 @@ for frame in iterframes.read("video.mp4", height=270, width=480):
|
|
|
48
49
|
## read_batches
|
|
49
50
|
|
|
50
51
|
```python
|
|
51
|
-
read_batches(path, batch_size, height=None, width=None, prefetch_frames=1, device="cpu", drop_last=False, frames=None, start=0, stop=None, step=1) -> Iterator[numpy.ndarray]
|
|
52
|
+
read_batches(path, batch_size, height=None, width=None, prefetch_frames=1, device="cpu", drop_last=False, frames=None, start=0, stop=None, step=1, approximate=None) -> Iterator[numpy.ndarray]
|
|
52
53
|
```
|
|
53
54
|
|
|
54
55
|
Yields the frames of the video in batches, in order, for models that take
|
|
@@ -108,6 +109,32 @@ Frame numbers are those of [`read`](#read): frame `n` is the one `read`
|
|
|
108
109
|
yields `n`-th. A number the video does not have raises `IndexError`,
|
|
109
110
|
while a slice past the end stops at the last frame, as a list does.
|
|
110
111
|
|
|
112
|
+
## Approximate frames
|
|
113
|
+
|
|
114
|
+
Decoding a frame in the middle of a group of pictures costs every frame
|
|
115
|
+
from the key frame on. `approximate` spends one decoded frame instead, by
|
|
116
|
+
reading the key frame nearest to each frame asked for. It goes with
|
|
117
|
+
`frames` only; with `start`, `stop`, and `step` it raises `ValueError`.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
# The nearest key frame to each of these, however far away it is.
|
|
121
|
+
frames = list(iterframes.read("video.mp4", frames=[100, 200, 300], approximate=True))
|
|
122
|
+
|
|
123
|
+
# The nearest key frame within 5 frames, else the frame itself.
|
|
124
|
+
frames = list(iterframes.read("video.mp4", frames=[100, 200, 300], approximate=5))
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A number bounds the error: a frame moves by at most that many frames, and
|
|
128
|
+
one with no key frame that close is decoded exactly. `True` accepts any
|
|
129
|
+
distance, which in a video with a key frame every 10 seconds means a frame
|
|
130
|
+
up to 5 seconds away from the one asked for. `False`, `0`, and `None`
|
|
131
|
+
read every frame exactly.
|
|
132
|
+
|
|
133
|
+
The video is still indexed, so its packets are read either way, and the
|
|
134
|
+
saving is in decoding alone. Two frames near the same key frame become
|
|
135
|
+
the same frame, which is decoded once for each of them: the iterator
|
|
136
|
+
yields as many frames as `frames` asks for, in the same order.
|
|
137
|
+
|
|
111
138
|
## Errors
|
|
112
139
|
|
|
113
140
|
Errors are raised by the first `next()` on the iterator, not by the call
|
|
@@ -147,7 +174,7 @@ FFmpeg also spreads the decoding of each video over several threads.
|
|
|
147
174
|
## FrameReader
|
|
148
175
|
|
|
149
176
|
```python
|
|
150
|
-
FrameReader(path, height=None, width=None, prefetch_frames=1, device="cpu", on_device=False, batch_size=None, drop_last=False, frames=None, start=0, stop=None, step=1)
|
|
177
|
+
FrameReader(path, height=None, width=None, prefetch_frames=1, device="cpu", on_device=False, batch_size=None, drop_last=False, frames=None, start=0, stop=None, step=1, approximate=None)
|
|
151
178
|
```
|
|
152
179
|
|
|
153
180
|
The iterator behind `read` and `read_batches`. It yields `Frame` objects,
|
|
@@ -47,6 +47,7 @@ def read(
|
|
|
47
47
|
start: int = 0,
|
|
48
48
|
stop: Optional[int] = None,
|
|
49
49
|
step: int = 1,
|
|
50
|
+
approximate: Union[bool, int, None] = None,
|
|
50
51
|
) -> Iterator[Union[np.ndarray, CudaFrame]]:
|
|
51
52
|
"""Yield the frames of the video at ``path``, in order.
|
|
52
53
|
|
|
@@ -81,6 +82,15 @@ def read(
|
|
|
81
82
|
the file first, by reading its packets without decoding them, and then
|
|
82
83
|
decodes each frame from the key frame before it, so frames asked for
|
|
83
84
|
in order cost no more than reading the video straight through.
|
|
85
|
+
|
|
86
|
+
``approximate`` trades accuracy for speed, and goes with ``frames``
|
|
87
|
+
only: each frame asked for is read as the key frame nearest to it, so
|
|
88
|
+
it costs one decoded frame instead of the frames from the key frame
|
|
89
|
+
on. ``approximate=n`` moves a frame by at most ``n`` frames and reads
|
|
90
|
+
the rest exactly, which bounds the error; ``approximate=True`` moves
|
|
91
|
+
it by any distance. The video still has to be indexed, so the packets
|
|
92
|
+
are read either way. Two frames near the same key frame are then the
|
|
93
|
+
same frame, and are decoded once each.
|
|
84
94
|
"""
|
|
85
95
|
reader = FrameReader(
|
|
86
96
|
path,
|
|
@@ -93,6 +103,7 @@ def read(
|
|
|
93
103
|
start=start,
|
|
94
104
|
stop=stop,
|
|
95
105
|
step=step,
|
|
106
|
+
approximate=approximate,
|
|
96
107
|
)
|
|
97
108
|
if on_device:
|
|
98
109
|
yield from reader
|
|
@@ -114,6 +125,7 @@ def read_batches(
|
|
|
114
125
|
start: int = 0,
|
|
115
126
|
stop: Optional[int] = None,
|
|
116
127
|
step: int = 1,
|
|
128
|
+
approximate: Union[bool, int, None] = None,
|
|
117
129
|
) -> Iterator[np.ndarray]:
|
|
118
130
|
"""Yield the frames of the video at ``path`` in batches, in order.
|
|
119
131
|
|
|
@@ -125,7 +137,8 @@ def read_batches(
|
|
|
125
137
|
|
|
126
138
|
Takes the same arguments as :func:`read`, except ``on_device``, so
|
|
127
139
|
``frames``, or ``start``, ``stop``, and ``step``, batch the frames
|
|
128
|
-
with those numbers instead of the whole video
|
|
140
|
+
with those numbers instead of the whole video, and ``approximate``
|
|
141
|
+
batches the key frames nearest to ``frames``. The background thread
|
|
129
142
|
decodes up to ``prefetch_frames`` frames ahead, rounded up to whole
|
|
130
143
|
batches.
|
|
131
144
|
"""
|
|
@@ -143,6 +156,7 @@ def read_batches(
|
|
|
143
156
|
start=start,
|
|
144
157
|
stop=stop,
|
|
145
158
|
step=step,
|
|
159
|
+
approximate=approximate,
|
|
146
160
|
)
|
|
147
161
|
# The arrays share memory with the batches, which they keep alive.
|
|
148
162
|
for batch in reader:
|
|
@@ -38,8 +38,13 @@ pub enum Selection {
|
|
|
38
38
|
/// straight through as well, and stops early.
|
|
39
39
|
First(usize),
|
|
40
40
|
/// These frames, in this order; a negative number counts from the end
|
|
41
|
-
/// of the video.
|
|
42
|
-
|
|
41
|
+
/// of the video. With `approximate`, each of them is read as the
|
|
42
|
+
/// nearest key frame within that many frames of it, which costs one
|
|
43
|
+
/// decoded frame instead of the ones from the key frame on.
|
|
44
|
+
Frames {
|
|
45
|
+
wanted: Vec<isize>,
|
|
46
|
+
approximate: Option<usize>,
|
|
47
|
+
},
|
|
43
48
|
/// The frames from `start` to `stop`, every `step` of them, as a
|
|
44
49
|
/// Python slice: negative bounds count from the end, and `stop` of
|
|
45
50
|
/// `None` is the end of the video.
|
|
@@ -67,7 +72,7 @@ impl Selection {
|
|
|
67
72
|
match self {
|
|
68
73
|
Selection::All => Ok((0..frames).collect()),
|
|
69
74
|
Selection::First(count) => Ok((0..frames.min(*count)).collect()),
|
|
70
|
-
Selection::Frames
|
|
75
|
+
Selection::Frames { wanted, .. } => wanted.iter().copied().map(resolve).collect(),
|
|
71
76
|
// Out of range bounds clamp, as a slice of a list does.
|
|
72
77
|
Selection::Range { start, stop, step } => {
|
|
73
78
|
let clamp = |index: isize| {
|
|
@@ -250,8 +255,18 @@ fn selected(
|
|
|
250
255
|
tx: &Sender<Message>,
|
|
251
256
|
) -> Result<bool, Error> {
|
|
252
257
|
let mut seeker = Seeker::new(source, input, decoder)?;
|
|
258
|
+
let mut indices = selection.indices(seeker.len())?;
|
|
259
|
+
if let Selection::Frames {
|
|
260
|
+
approximate: Some(tolerance),
|
|
261
|
+
..
|
|
262
|
+
} = selection
|
|
263
|
+
{
|
|
264
|
+
for index in &mut indices {
|
|
265
|
+
*index = seeker.nearest_key(*index, tolerance);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
253
268
|
let mut frame = Frame::new();
|
|
254
|
-
for index in
|
|
269
|
+
for index in indices {
|
|
255
270
|
seeker.frame(index, &mut frame)?;
|
|
256
271
|
if !converter.send(&mut frame, tx)? {
|
|
257
272
|
return Ok(false);
|
|
@@ -518,6 +533,25 @@ impl Seeker<'_> {
|
|
|
518
533
|
}
|
|
519
534
|
}
|
|
520
535
|
|
|
536
|
+
/// The key frame nearest to `index`, when no more than `tolerance`
|
|
537
|
+
/// frames away from it, else `index` itself. A tie goes to the key
|
|
538
|
+
/// frame before, which decoding is more likely to reach by going on.
|
|
539
|
+
fn nearest_key(&self, index: usize, tolerance: usize) -> usize {
|
|
540
|
+
let before = self.key_frame_before(index);
|
|
541
|
+
let nearest = match self
|
|
542
|
+
.keys
|
|
543
|
+
.get(self.keys.partition_point(|key| *key <= index))
|
|
544
|
+
{
|
|
545
|
+
Some(after) if after - index < index - before => *after,
|
|
546
|
+
_ => before,
|
|
547
|
+
};
|
|
548
|
+
if nearest.abs_diff(index) <= tolerance {
|
|
549
|
+
nearest
|
|
550
|
+
} else {
|
|
551
|
+
index
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
521
555
|
/// The last key frame at or before `index`. The numbers rise, so a
|
|
522
556
|
/// long video costs a binary search rather than a scan.
|
|
523
557
|
fn key_frame_before(&self, index: usize) -> usize {
|
|
@@ -332,7 +332,8 @@ impl Plane {
|
|
|
332
332
|
/// `batch_size`, a `Batch` of that many frames (fewer in the last one,
|
|
333
333
|
/// unless `drop_last`), whose `prefetch_frames` rounds up to whole batches.
|
|
334
334
|
/// `frames`, or `start`, `stop`, and `step`, pick the frames to decode by
|
|
335
|
-
/// number instead of reading the whole video
|
|
335
|
+
/// number instead of reading the whole video, and `approximate` reads each
|
|
336
|
+
/// of `frames` as the key frame nearest to it. Use `iterframes.read` and
|
|
336
337
|
/// `iterframes.read_batches`, which wrap them in NumPy arrays.
|
|
337
338
|
#[pyclass(module = "iterframes")]
|
|
338
339
|
struct FrameReader {
|
|
@@ -344,7 +345,8 @@ impl FrameReader {
|
|
|
344
345
|
#[new]
|
|
345
346
|
#[pyo3(signature = (
|
|
346
347
|
path, height=None, width=None, prefetch_frames=1, device="cpu", on_device=false,
|
|
347
|
-
batch_size=None, drop_last=false, frames=None, start=0, stop=None, step=1
|
|
348
|
+
batch_size=None, drop_last=false, frames=None, start=0, stop=None, step=1,
|
|
349
|
+
approximate=None
|
|
348
350
|
))]
|
|
349
351
|
#[allow(clippy::too_many_arguments)]
|
|
350
352
|
fn new(
|
|
@@ -360,8 +362,9 @@ impl FrameReader {
|
|
|
360
362
|
start: isize,
|
|
361
363
|
stop: Option<isize>,
|
|
362
364
|
step: isize,
|
|
365
|
+
approximate: Option<&Bound<'_, PyAny>>,
|
|
363
366
|
) -> PyResult<Self> {
|
|
364
|
-
let selection = selection(frames, start, stop, step)?;
|
|
367
|
+
let selection = selection(frames, start, stop, step, approximate)?;
|
|
365
368
|
let batching = match batch_size {
|
|
366
369
|
None => None,
|
|
367
370
|
Some(0) => return Err(PyValueError::new_err("batch_size must be at least 1")),
|
|
@@ -432,19 +435,28 @@ impl FrameReader {
|
|
|
432
435
|
|
|
433
436
|
/// Which frames to decode, from the arguments Python passes: `frames`
|
|
434
437
|
/// lists them, while `start`, `stop`, and `step` slice them as a list is
|
|
435
|
-
/// sliced. The two ways cannot be mixed
|
|
438
|
+
/// sliced. The two ways cannot be mixed, and only `frames` can be
|
|
439
|
+
/// approximate.
|
|
436
440
|
fn selection(
|
|
437
441
|
frames: Option<Vec<isize>>,
|
|
438
442
|
start: isize,
|
|
439
443
|
stop: Option<isize>,
|
|
440
444
|
step: isize,
|
|
445
|
+
approximate: Option<&Bound<'_, PyAny>>,
|
|
441
446
|
) -> PyResult<Selection> {
|
|
442
447
|
let sliced = start != 0 || stop.is_some() || step != 1;
|
|
448
|
+
let approximate = tolerance(approximate)?;
|
|
443
449
|
match frames {
|
|
444
450
|
Some(_) if sliced => Err(PyValueError::new_err(
|
|
445
451
|
"frames does not go with start, stop, or step",
|
|
446
452
|
)),
|
|
447
|
-
Some(
|
|
453
|
+
Some(wanted) => Ok(Selection::Frames {
|
|
454
|
+
wanted,
|
|
455
|
+
approximate,
|
|
456
|
+
}),
|
|
457
|
+
None if approximate.is_some() => Err(PyValueError::new_err(
|
|
458
|
+
"approximate needs frames: a slice cannot be approximate",
|
|
459
|
+
)),
|
|
448
460
|
None if step < 1 => Err(PyValueError::new_err("step must be at least 1")),
|
|
449
461
|
// Frames counted from the first are read straight through, with
|
|
450
462
|
// no index and no seeking.
|
|
@@ -461,6 +473,23 @@ fn selection(
|
|
|
461
473
|
}
|
|
462
474
|
}
|
|
463
475
|
|
|
476
|
+
/// How far a frame may move to the nearest key frame, in frames, from
|
|
477
|
+
/// what Python passes as `approximate`: `True` is any distance, a number
|
|
478
|
+
/// is at most that many frames, and `False` or `None` is no move at all.
|
|
479
|
+
fn tolerance(approximate: Option<&Bound<'_, PyAny>>) -> PyResult<Option<usize>> {
|
|
480
|
+
let Some(approximate) = approximate else {
|
|
481
|
+
return Ok(None);
|
|
482
|
+
};
|
|
483
|
+
// A bool is an int in Python, so it has to be read first.
|
|
484
|
+
if let Ok(any_distance) = approximate.extract::<bool>() {
|
|
485
|
+
return Ok(any_distance.then_some(usize::MAX));
|
|
486
|
+
}
|
|
487
|
+
let frames = approximate.extract::<usize>().map_err(|_| {
|
|
488
|
+
PyValueError::new_err("approximate must be True, False, or a number of frames")
|
|
489
|
+
})?;
|
|
490
|
+
Ok(Some(frames))
|
|
491
|
+
}
|
|
492
|
+
|
|
464
493
|
/// The hardware devices of this build, by the names PyTorch gives them,
|
|
465
494
|
/// which Python users know better than FFmpeg's.
|
|
466
495
|
fn hardware_devices() -> impl Iterator<Item = (&'static str, ffmpeg::DeviceType)> {
|
|
@@ -108,3 +108,29 @@ def test_slice_from_the_middle_seeks_once(video_path):
|
|
|
108
108
|
whole, second_half = read(), read(start=450)
|
|
109
109
|
print(f"\nwhole {whole:.3f}s, start=450 {second_half:.3f}s")
|
|
110
110
|
assert second_half < 0.75 * whole
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_approximate_frames_decode_less(video_path):
|
|
114
|
+
"""Approximate frames must cost one decoded frame each.
|
|
115
|
+
|
|
116
|
+
Reading frames in the middle of their group of pictures decodes every
|
|
117
|
+
frame from the key frame on, while the nearest key frames decode one
|
|
118
|
+
frame each. Both pay for the pass that indexes the file.
|
|
119
|
+
"""
|
|
120
|
+
with av.open(str(video_path)) as container:
|
|
121
|
+
frames = list(container.decode(video=0))
|
|
122
|
+
keys = [index for index, frame in enumerate(frames) if frame.key_frame]
|
|
123
|
+
# Frames far enough into their group of pictures to cost several
|
|
124
|
+
# decoded frames each, which approximate reading skips.
|
|
125
|
+
wanted = [key + 20 for key in keys if key + 20 < len(frames)][:20]
|
|
126
|
+
|
|
127
|
+
def read(**arguments):
|
|
128
|
+
def decode():
|
|
129
|
+
for _ in iterframes.read(video_path, frames=wanted, **arguments):
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
return best_of(decode)
|
|
133
|
+
|
|
134
|
+
exact, approximate = read(), read(approximate=True)
|
|
135
|
+
print(f"\nexact {exact:.3f}s, approximate {approximate:.3f}s")
|
|
136
|
+
assert approximate < exact
|