shotdrift 0.2.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.
- shotdrift-0.2.0/LICENSE +21 -0
- shotdrift-0.2.0/PKG-INFO +283 -0
- shotdrift-0.2.0/README.md +256 -0
- shotdrift-0.2.0/pyproject.toml +44 -0
- shotdrift-0.2.0/setup.cfg +4 -0
- shotdrift-0.2.0/src/shotdrift/__init__.py +25 -0
- shotdrift-0.2.0/src/shotdrift/cli.py +95 -0
- shotdrift-0.2.0/src/shotdrift/core.py +236 -0
- shotdrift-0.2.0/src/shotdrift/expect.py +132 -0
- shotdrift-0.2.0/src/shotdrift/frames.py +135 -0
- shotdrift-0.2.0/src/shotdrift/ingest.py +109 -0
- shotdrift-0.2.0/src/shotdrift/motion.py +220 -0
- shotdrift-0.2.0/src/shotdrift/path.py +312 -0
- shotdrift-0.2.0/src/shotdrift/verdict.py +218 -0
- shotdrift-0.2.0/src/shotdrift.egg-info/PKG-INFO +283 -0
- shotdrift-0.2.0/src/shotdrift.egg-info/SOURCES.txt +24 -0
- shotdrift-0.2.0/src/shotdrift.egg-info/dependency_links.txt +1 -0
- shotdrift-0.2.0/src/shotdrift.egg-info/entry_points.txt +2 -0
- shotdrift-0.2.0/src/shotdrift.egg-info/requires.txt +5 -0
- shotdrift-0.2.0/src/shotdrift.egg-info/top_level.txt +1 -0
- shotdrift-0.2.0/tests/test_core.py +194 -0
- shotdrift-0.2.0/tests/test_expect.py +115 -0
- shotdrift-0.2.0/tests/test_ingest.py +146 -0
- shotdrift-0.2.0/tests/test_motion.py +115 -0
- shotdrift-0.2.0/tests/test_path.py +90 -0
- shotdrift-0.2.0/tests/test_verdict.py +176 -0
shotdrift-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Syamjith NK
|
|
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.
|
shotdrift-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shotdrift
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Measure whether a video holds the camera move it was given.
|
|
5
|
+
Author-email: Syamjith NK <hello@syamjithnk.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Syamjith-NK/shotdrift
|
|
8
|
+
Project-URL: Source, https://github.com/Syamjith-NK/shotdrift
|
|
9
|
+
Project-URL: Issues, https://github.com/Syamjith-NK/shotdrift/issues
|
|
10
|
+
Keywords: video,camera,cinematography,ai-video,quality-assurance,motion-estimation,generative-video,vfx,measurement
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Multimedia :: Video
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: numpy>=1.22
|
|
23
|
+
Requires-Dist: pillow>=9
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# shotdrift
|
|
29
|
+
|
|
30
|
+
**You asked for a slow push in. Measure whether you got one.**
|
|
31
|
+
|
|
32
|
+
Generative video is given a camera move and is under no obligation to deliver it.
|
|
33
|
+
The usual check is a person watching sixty clips and forming an impression.
|
|
34
|
+
`shotdrift` measures the camera path out of the pixels — pan, zoom, roll, frame by
|
|
35
|
+
frame — and reports whether one physical camera could have produced it.
|
|
36
|
+
|
|
37
|
+
```console
|
|
38
|
+
$ pip install git+https://github.com/Syamjith-NK/shotdrift
|
|
39
|
+
$ shotdrift pan_demo.mp4 --expect push-in
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
pan_demo.mp4
|
|
44
|
+
1280x720 -> measured at 512x288, 97 frames @ 24 fps
|
|
45
|
+
|
|
46
|
+
camera path
|
|
47
|
+
dominant move pan
|
|
48
|
+
pan 0.7159 of frame width (x -0.7159, y -0.0000)
|
|
49
|
+
zoom 1.000x
|
|
50
|
+
roll -0.00 deg
|
|
51
|
+
reversals 0 (measured, not judged)
|
|
52
|
+
jerk 0.35
|
|
53
|
+
incoherence 0.0000
|
|
54
|
+
morph 0.041 (used to find cuts, not judged)
|
|
55
|
+
closure 0.0000
|
|
56
|
+
confidence 0.98
|
|
57
|
+
|
|
58
|
+
asked for: push-in
|
|
59
|
+
NOT HELD - asked for push in / dolly in; zoom moved +0.0001, under the 0.02
|
|
60
|
+
floor - that move did not happen
|
|
61
|
+
|
|
62
|
+
no findings: the motion is consistent with one physical camera.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
That clip is a perfectly good shot — smooth, coherent, one physical camera. It is
|
|
66
|
+
also not the shot that was ordered: it pans, and the push-in never happened. Both facts matter and they are reported
|
|
67
|
+
separately, because "is this a real camera move?" and "is it the move I asked
|
|
68
|
+
for?" are different questions.
|
|
69
|
+
|
|
70
|
+
Exit codes make it usable in a loop: `0` clean, `1` findings at or above
|
|
71
|
+
`--min-severity`, `2` could not run, `3` the clip could not be measured.
|
|
72
|
+
|
|
73
|
+
## Why pixels and not a solve
|
|
74
|
+
|
|
75
|
+
Nothing else is available. You are handed an mp4 by a model that will not tell you
|
|
76
|
+
what it did. No camera metadata, no depth, no solve — so the measurement has to
|
|
77
|
+
come from the frames themselves, which means it also works on footage from a
|
|
78
|
+
camera, a render, or a competitor's demo reel.
|
|
79
|
+
|
|
80
|
+
Everything is reported in **fractions of the frame width**, never pixels, so a
|
|
81
|
+
bound means the same thing on a 720p proxy and a 4K master.
|
|
82
|
+
|
|
83
|
+
## What it measures
|
|
84
|
+
|
|
85
|
+
A grid of tiles is tracked by phase correlation, then one similarity transform is
|
|
86
|
+
fitted to all of them:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
dx = tx + s*x - r*y
|
|
90
|
+
dy = ty + s*y + r*x
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
That yields pan, scale and roll — and the part the fit **cannot** explain is the
|
|
94
|
+
other half of the product. If tiles refuse to agree with any single camera,
|
|
95
|
+
something in the frame is moving independently of one.
|
|
96
|
+
|
|
97
|
+
| | what it means |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `incoherence` | tile disagreement the camera model cannot explain |
|
|
100
|
+
| `jerk` | third derivative of the path: a carried camera changes speed smoothly |
|
|
101
|
+
| `wander` | travelled a long way, arrived nowhere |
|
|
102
|
+
| `breathing` | scale oscillates without going anywhere |
|
|
103
|
+
| `closure` | adding up every step vs. measuring first-to-last directly |
|
|
104
|
+
|
|
105
|
+
`closure` is worth a sentence. The per-frame chain says where frame *k* ended up
|
|
106
|
+
by summing every step; measuring frame 0 against frame *k* directly asks the same
|
|
107
|
+
question using none of those steps. Where both are trustworthy they must agree.
|
|
108
|
+
It is an independent route to the same number rather than a second opinion from
|
|
109
|
+
the same code.
|
|
110
|
+
|
|
111
|
+
Severities are `clean`, `soft`, `broken`. `soft` exists so that a real camera with
|
|
112
|
+
a person walking through it can be *reported* without being *blocked* — the first
|
|
113
|
+
thing anyone does with a noisy alarm is stop reading it. `broken` is the default
|
|
114
|
+
exit gate; `--min-severity soft` makes it strict.
|
|
115
|
+
|
|
116
|
+
## Declared moves
|
|
117
|
+
|
|
118
|
+
```console
|
|
119
|
+
$ shotdrift *.mp4 --expect push-in --min-severity broken
|
|
120
|
+
$ shotdrift --list-moves
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`static` `locked` `push-in` `dolly-in` `zoom-in` `pull-out` `dolly-out`
|
|
124
|
+
`zoom-out` `pan-left` `pan-right` `tilt-up` `tilt-down` `roll-cw` `roll-ccw`
|
|
125
|
+
|
|
126
|
+
Each is checked three ways, because the three failures need different fixes:
|
|
127
|
+
**did it happen** at all, **was it the right way round**, and **was it held** or
|
|
128
|
+
did a quarter of the travel run backwards.
|
|
129
|
+
|
|
130
|
+
The signs are **camera-relative**. A camera panning right makes the picture move
|
|
131
|
+
left, so `pan-right` expects a negative picture-x. Writing those the intuitive way
|
|
132
|
+
round made a correctly measured clean pan report *"x went the other way"* — the
|
|
133
|
+
measurement was right and the vocabulary was wrong.
|
|
134
|
+
|
|
135
|
+
## Edited sequences
|
|
136
|
+
|
|
137
|
+
A clip containing cuts is segmented and each shot measured on its own. Run as one
|
|
138
|
+
take, an edited reel reported 13 reversals and a broken closure: all true, and
|
|
139
|
+
useless, because there were six shots and no single camera move was ever there to
|
|
140
|
+
hold.
|
|
141
|
+
|
|
142
|
+
Cuts come free from the morph residual already being computed. There are two
|
|
143
|
+
signatures, and the second nearly got missed: where structure survives the join —
|
|
144
|
+
a whip transition, a dissolve — morph spikes to 1.7–8.6 against the 0.36 real
|
|
145
|
+
within-shot footage reaches. Across a **clean** cut between unrelated shots,
|
|
146
|
+
nothing correlates at all, so no camera can be fitted and morph is `NaN`. The
|
|
147
|
+
first version only tested for the spike, so it caught smeared transitions and
|
|
148
|
+
walked straight past an ordinary hard cut.
|
|
149
|
+
|
|
150
|
+
Use `--no-segment` to force one measurement over everything.
|
|
151
|
+
|
|
152
|
+
## What it cannot do
|
|
153
|
+
|
|
154
|
+
This section is the useful one.
|
|
155
|
+
|
|
156
|
+
**It does not detect invented geometry.** A tool like this ought to catch the
|
|
157
|
+
melting-background tell, and I could not make it work. Measured over a fixed warp
|
|
158
|
+
budget, real footage reached a structural residual of **1.06** while a literal
|
|
159
|
+
cross-dissolve between two entirely different worlds read **0.358**. Real scenes
|
|
160
|
+
contain people, LED walls and vision mixes, so they change structure *more* than
|
|
161
|
+
melting geometry does. No threshold separates them in either direction, so that
|
|
162
|
+
check is not shipped. A dissolve under a locked-off camera comes back **clean**
|
|
163
|
+
here, and that is pinned as a test so it cannot quietly change.
|
|
164
|
+
|
|
165
|
+
**Direction changes are not a fault signal.** Counting reversals seemed obvious.
|
|
166
|
+
Real operated footage showed **27** direction changes on a slow zoom — an operator
|
|
167
|
+
riding a rocker — against **3** in a deliberately broken control. The real
|
|
168
|
+
material scores worse than the fault, so the count carries no signal alone. The
|
|
169
|
+
question it was trying to answer is answered properly by `--expect`, which
|
|
170
|
+
measures backtracked travel against the move actually requested.
|
|
171
|
+
|
|
172
|
+
**Subject motion is a confound, by construction.** A person crossing a locked-off
|
|
173
|
+
frame raises `incoherence`. That is reported as `soft`, with advice saying so,
|
|
174
|
+
rather than pretended away. Trimmed fitting rejects the worst-fitting quarter of
|
|
175
|
+
tiles, which handles a subject occupying part of the frame and will not save you
|
|
176
|
+
from one that fills it.
|
|
177
|
+
|
|
178
|
+
**Cut detection can miss a cut between two visually similar shots**, and
|
|
179
|
+
`closure` degrades to `NaN` once the first and last frames no longer overlap. Both
|
|
180
|
+
report "not measurable" rather than a number — an anchor that cannot be trusted is
|
|
181
|
+
skipped, because *cannot measure* must never be reported as *measured bad*.
|
|
182
|
+
|
|
183
|
+
## Calibration
|
|
184
|
+
|
|
185
|
+
Thresholds are not taste. Every bound was set by measuring real single-camera
|
|
186
|
+
footage and synthetic controls with known faults; `calibration.md` records the
|
|
187
|
+
distributions and `validate_real.py` re-derives them.
|
|
188
|
+
|
|
189
|
+
The requirement that shaped the tool is **silence on real footage**: across 24
|
|
190
|
+
shots from two events, nothing `broken` fires, and the three `soft` findings are
|
|
191
|
+
all genuine operator behaviour — two stage cameras that reframed and came back,
|
|
192
|
+
one of which also zoomed in and back out.
|
|
193
|
+
|
|
194
|
+
| measured over 24 real shots | real max | gate | soft at |
|
|
195
|
+
|---|---|---|---|
|
|
196
|
+
| `incoherence` | 0.00068 | — | 0.004 |
|
|
197
|
+
| `closure` | 0.0092 | — | 0.02 |
|
|
198
|
+
| `jerk` | 2.35 | `speed` ≥ 0.005 | 1.9 |
|
|
199
|
+
| `breathing` | 110 | `scale_amp` ≥ 0.04 | 4.0 |
|
|
200
|
+
| `wander` | 52 | travel ≥ 0.05 | 4.0 |
|
|
201
|
+
|
|
202
|
+
**Three of those five are gated rather than thresholded, and that is the most
|
|
203
|
+
useful thing in this README.** They are ratios, and on a locked-off camera each
|
|
204
|
+
denominator is legitimately near zero, so noise divided by nothing produces an
|
|
205
|
+
enormous number on the most ordinary footage there is. Real maxima of 2.3, 110 and
|
|
206
|
+
52 against bounds of 1.9 and 4.0 are not near-misses — the ratios have come apart,
|
|
207
|
+
and no bound can be raised to cover that without covering every real fault too.
|
|
208
|
+
What works is refusing to judge the quality of a move until there demonstrably
|
|
209
|
+
*was* one. Every gate above has a measured gap behind it: real footage reaches a
|
|
210
|
+
speed of 0.003 where the slowest control with a genuine move sits at 0.014.
|
|
211
|
+
|
|
212
|
+
### The harness passed while the tool was wrong
|
|
213
|
+
|
|
214
|
+
Worth the paragraph because it is the failure mode of every calibrated tool.
|
|
215
|
+
|
|
216
|
+
`shotdrift <clip>` reported **`BROKEN` on four of seven real clips** while
|
|
217
|
+
`validate_real.py` printed `VALIDATION PASSED` — same code, same files, same
|
|
218
|
+
afternoon. The harness measured the first **8 seconds**; the tool measures **600
|
|
219
|
+
frames**. And `breathing` was gated on *accumulated* scale change against a fixed
|
|
220
|
+
bound, which on a locked-off camera walks 0.0025 at 100 frames to 0.0263 at 1200
|
|
221
|
+
while the per-frame rate stays flat at 2e-5. The gate was crossed somewhere past
|
|
222
|
+
500 frames by nothing but clip length, and 8 seconds at 50p is 400 frames — just
|
|
223
|
+
underneath it.
|
|
224
|
+
|
|
225
|
+
So: **a fixed bound on a cumulative quantity is a time bomb**, and **a control
|
|
226
|
+
that does not run the shipped configuration validates a configuration nobody
|
|
227
|
+
uses**. The gate is now peak-to-peak amplitude, which does not grow with length
|
|
228
|
+
and is what a viewer can actually see, and the harness defaults to the whole clip.
|
|
229
|
+
|
|
230
|
+
`breathing` also lost its `broken` bound in the same round, for the third
|
|
231
|
+
instance of the pattern this tool keeps running into: real operated footage
|
|
232
|
+
reached a breathing ratio of 169 against the deliberate fault's 46. An operator
|
|
233
|
+
zooming in and back out *is* scale oscillating without going anywhere.
|
|
234
|
+
|
|
235
|
+
## Python
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
from shotdrift import measure
|
|
239
|
+
|
|
240
|
+
r = measure("take_07.mp4", expect="push-in")
|
|
241
|
+
print(r.verdict, r.ok)
|
|
242
|
+
for s in r.shots: # cuts are detected; one entry per shot
|
|
243
|
+
print(s.start, s.end, s.verdict, [f.code for f in s.findings])
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Frames you never wrote to disk
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
from shotdrift import measure_frames, report
|
|
250
|
+
|
|
251
|
+
r = measure_frames(batch, expect="push-in") # (n, h, w, c), numpy or torch
|
|
252
|
+
print(report(r))
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
No ffmpeg, no temp file. Takes uint8 0..255 or float 0..1, greyscale or RGB(A),
|
|
256
|
+
and a channels-first batch is refused by name rather than measured sideways.
|
|
257
|
+
`report()` renders exactly what the command line prints — the CLI calls it, so
|
|
258
|
+
the two surfaces cannot drift apart.
|
|
259
|
+
|
|
260
|
+
## In ComfyUI
|
|
261
|
+
|
|
262
|
+
[**shotdrift-comfyui**](https://github.com/Syamjith-NK/shotdrift-comfyui) measures
|
|
263
|
+
the batch inside the graph that produced it, and can stop the queue when a take
|
|
264
|
+
does not hold the move it was given. That is the point of measuring here rather
|
|
265
|
+
than afterwards: an unattended run of sixty takes is only worth doing if the bad
|
|
266
|
+
ones announce themselves.
|
|
267
|
+
|
|
268
|
+
## Requirements
|
|
269
|
+
|
|
270
|
+
Python ≥ 3.9, `numpy`, `pillow`, and **ffmpeg on PATH**. No OpenCV, no torch, no
|
|
271
|
+
network, no GPU. A tool that needs a 2 GB wheel to measure a camera move does not
|
|
272
|
+
get run.
|
|
273
|
+
|
|
274
|
+
## Development
|
|
275
|
+
|
|
276
|
+
```console
|
|
277
|
+
pip install -e ".[test]"
|
|
278
|
+
pytest -q # 83 tests
|
|
279
|
+
PYTHONPATH=src python validate_real.py # real footage + controls, as shipped
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
MIT. Built by [Syamjith NK](https://syamjithnk.com) — cinematographer and AI
|
|
283
|
+
creative technologist, Abu Dhabi.
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# shotdrift
|
|
2
|
+
|
|
3
|
+
**You asked for a slow push in. Measure whether you got one.**
|
|
4
|
+
|
|
5
|
+
Generative video is given a camera move and is under no obligation to deliver it.
|
|
6
|
+
The usual check is a person watching sixty clips and forming an impression.
|
|
7
|
+
`shotdrift` measures the camera path out of the pixels — pan, zoom, roll, frame by
|
|
8
|
+
frame — and reports whether one physical camera could have produced it.
|
|
9
|
+
|
|
10
|
+
```console
|
|
11
|
+
$ pip install git+https://github.com/Syamjith-NK/shotdrift
|
|
12
|
+
$ shotdrift pan_demo.mp4 --expect push-in
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
pan_demo.mp4
|
|
17
|
+
1280x720 -> measured at 512x288, 97 frames @ 24 fps
|
|
18
|
+
|
|
19
|
+
camera path
|
|
20
|
+
dominant move pan
|
|
21
|
+
pan 0.7159 of frame width (x -0.7159, y -0.0000)
|
|
22
|
+
zoom 1.000x
|
|
23
|
+
roll -0.00 deg
|
|
24
|
+
reversals 0 (measured, not judged)
|
|
25
|
+
jerk 0.35
|
|
26
|
+
incoherence 0.0000
|
|
27
|
+
morph 0.041 (used to find cuts, not judged)
|
|
28
|
+
closure 0.0000
|
|
29
|
+
confidence 0.98
|
|
30
|
+
|
|
31
|
+
asked for: push-in
|
|
32
|
+
NOT HELD - asked for push in / dolly in; zoom moved +0.0001, under the 0.02
|
|
33
|
+
floor - that move did not happen
|
|
34
|
+
|
|
35
|
+
no findings: the motion is consistent with one physical camera.
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
That clip is a perfectly good shot — smooth, coherent, one physical camera. It is
|
|
39
|
+
also not the shot that was ordered: it pans, and the push-in never happened. Both facts matter and they are reported
|
|
40
|
+
separately, because "is this a real camera move?" and "is it the move I asked
|
|
41
|
+
for?" are different questions.
|
|
42
|
+
|
|
43
|
+
Exit codes make it usable in a loop: `0` clean, `1` findings at or above
|
|
44
|
+
`--min-severity`, `2` could not run, `3` the clip could not be measured.
|
|
45
|
+
|
|
46
|
+
## Why pixels and not a solve
|
|
47
|
+
|
|
48
|
+
Nothing else is available. You are handed an mp4 by a model that will not tell you
|
|
49
|
+
what it did. No camera metadata, no depth, no solve — so the measurement has to
|
|
50
|
+
come from the frames themselves, which means it also works on footage from a
|
|
51
|
+
camera, a render, or a competitor's demo reel.
|
|
52
|
+
|
|
53
|
+
Everything is reported in **fractions of the frame width**, never pixels, so a
|
|
54
|
+
bound means the same thing on a 720p proxy and a 4K master.
|
|
55
|
+
|
|
56
|
+
## What it measures
|
|
57
|
+
|
|
58
|
+
A grid of tiles is tracked by phase correlation, then one similarity transform is
|
|
59
|
+
fitted to all of them:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
dx = tx + s*x - r*y
|
|
63
|
+
dy = ty + s*y + r*x
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
That yields pan, scale and roll — and the part the fit **cannot** explain is the
|
|
67
|
+
other half of the product. If tiles refuse to agree with any single camera,
|
|
68
|
+
something in the frame is moving independently of one.
|
|
69
|
+
|
|
70
|
+
| | what it means |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `incoherence` | tile disagreement the camera model cannot explain |
|
|
73
|
+
| `jerk` | third derivative of the path: a carried camera changes speed smoothly |
|
|
74
|
+
| `wander` | travelled a long way, arrived nowhere |
|
|
75
|
+
| `breathing` | scale oscillates without going anywhere |
|
|
76
|
+
| `closure` | adding up every step vs. measuring first-to-last directly |
|
|
77
|
+
|
|
78
|
+
`closure` is worth a sentence. The per-frame chain says where frame *k* ended up
|
|
79
|
+
by summing every step; measuring frame 0 against frame *k* directly asks the same
|
|
80
|
+
question using none of those steps. Where both are trustworthy they must agree.
|
|
81
|
+
It is an independent route to the same number rather than a second opinion from
|
|
82
|
+
the same code.
|
|
83
|
+
|
|
84
|
+
Severities are `clean`, `soft`, `broken`. `soft` exists so that a real camera with
|
|
85
|
+
a person walking through it can be *reported* without being *blocked* — the first
|
|
86
|
+
thing anyone does with a noisy alarm is stop reading it. `broken` is the default
|
|
87
|
+
exit gate; `--min-severity soft` makes it strict.
|
|
88
|
+
|
|
89
|
+
## Declared moves
|
|
90
|
+
|
|
91
|
+
```console
|
|
92
|
+
$ shotdrift *.mp4 --expect push-in --min-severity broken
|
|
93
|
+
$ shotdrift --list-moves
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`static` `locked` `push-in` `dolly-in` `zoom-in` `pull-out` `dolly-out`
|
|
97
|
+
`zoom-out` `pan-left` `pan-right` `tilt-up` `tilt-down` `roll-cw` `roll-ccw`
|
|
98
|
+
|
|
99
|
+
Each is checked three ways, because the three failures need different fixes:
|
|
100
|
+
**did it happen** at all, **was it the right way round**, and **was it held** or
|
|
101
|
+
did a quarter of the travel run backwards.
|
|
102
|
+
|
|
103
|
+
The signs are **camera-relative**. A camera panning right makes the picture move
|
|
104
|
+
left, so `pan-right` expects a negative picture-x. Writing those the intuitive way
|
|
105
|
+
round made a correctly measured clean pan report *"x went the other way"* — the
|
|
106
|
+
measurement was right and the vocabulary was wrong.
|
|
107
|
+
|
|
108
|
+
## Edited sequences
|
|
109
|
+
|
|
110
|
+
A clip containing cuts is segmented and each shot measured on its own. Run as one
|
|
111
|
+
take, an edited reel reported 13 reversals and a broken closure: all true, and
|
|
112
|
+
useless, because there were six shots and no single camera move was ever there to
|
|
113
|
+
hold.
|
|
114
|
+
|
|
115
|
+
Cuts come free from the morph residual already being computed. There are two
|
|
116
|
+
signatures, and the second nearly got missed: where structure survives the join —
|
|
117
|
+
a whip transition, a dissolve — morph spikes to 1.7–8.6 against the 0.36 real
|
|
118
|
+
within-shot footage reaches. Across a **clean** cut between unrelated shots,
|
|
119
|
+
nothing correlates at all, so no camera can be fitted and morph is `NaN`. The
|
|
120
|
+
first version only tested for the spike, so it caught smeared transitions and
|
|
121
|
+
walked straight past an ordinary hard cut.
|
|
122
|
+
|
|
123
|
+
Use `--no-segment` to force one measurement over everything.
|
|
124
|
+
|
|
125
|
+
## What it cannot do
|
|
126
|
+
|
|
127
|
+
This section is the useful one.
|
|
128
|
+
|
|
129
|
+
**It does not detect invented geometry.** A tool like this ought to catch the
|
|
130
|
+
melting-background tell, and I could not make it work. Measured over a fixed warp
|
|
131
|
+
budget, real footage reached a structural residual of **1.06** while a literal
|
|
132
|
+
cross-dissolve between two entirely different worlds read **0.358**. Real scenes
|
|
133
|
+
contain people, LED walls and vision mixes, so they change structure *more* than
|
|
134
|
+
melting geometry does. No threshold separates them in either direction, so that
|
|
135
|
+
check is not shipped. A dissolve under a locked-off camera comes back **clean**
|
|
136
|
+
here, and that is pinned as a test so it cannot quietly change.
|
|
137
|
+
|
|
138
|
+
**Direction changes are not a fault signal.** Counting reversals seemed obvious.
|
|
139
|
+
Real operated footage showed **27** direction changes on a slow zoom — an operator
|
|
140
|
+
riding a rocker — against **3** in a deliberately broken control. The real
|
|
141
|
+
material scores worse than the fault, so the count carries no signal alone. The
|
|
142
|
+
question it was trying to answer is answered properly by `--expect`, which
|
|
143
|
+
measures backtracked travel against the move actually requested.
|
|
144
|
+
|
|
145
|
+
**Subject motion is a confound, by construction.** A person crossing a locked-off
|
|
146
|
+
frame raises `incoherence`. That is reported as `soft`, with advice saying so,
|
|
147
|
+
rather than pretended away. Trimmed fitting rejects the worst-fitting quarter of
|
|
148
|
+
tiles, which handles a subject occupying part of the frame and will not save you
|
|
149
|
+
from one that fills it.
|
|
150
|
+
|
|
151
|
+
**Cut detection can miss a cut between two visually similar shots**, and
|
|
152
|
+
`closure` degrades to `NaN` once the first and last frames no longer overlap. Both
|
|
153
|
+
report "not measurable" rather than a number — an anchor that cannot be trusted is
|
|
154
|
+
skipped, because *cannot measure* must never be reported as *measured bad*.
|
|
155
|
+
|
|
156
|
+
## Calibration
|
|
157
|
+
|
|
158
|
+
Thresholds are not taste. Every bound was set by measuring real single-camera
|
|
159
|
+
footage and synthetic controls with known faults; `calibration.md` records the
|
|
160
|
+
distributions and `validate_real.py` re-derives them.
|
|
161
|
+
|
|
162
|
+
The requirement that shaped the tool is **silence on real footage**: across 24
|
|
163
|
+
shots from two events, nothing `broken` fires, and the three `soft` findings are
|
|
164
|
+
all genuine operator behaviour — two stage cameras that reframed and came back,
|
|
165
|
+
one of which also zoomed in and back out.
|
|
166
|
+
|
|
167
|
+
| measured over 24 real shots | real max | gate | soft at |
|
|
168
|
+
|---|---|---|---|
|
|
169
|
+
| `incoherence` | 0.00068 | — | 0.004 |
|
|
170
|
+
| `closure` | 0.0092 | — | 0.02 |
|
|
171
|
+
| `jerk` | 2.35 | `speed` ≥ 0.005 | 1.9 |
|
|
172
|
+
| `breathing` | 110 | `scale_amp` ≥ 0.04 | 4.0 |
|
|
173
|
+
| `wander` | 52 | travel ≥ 0.05 | 4.0 |
|
|
174
|
+
|
|
175
|
+
**Three of those five are gated rather than thresholded, and that is the most
|
|
176
|
+
useful thing in this README.** They are ratios, and on a locked-off camera each
|
|
177
|
+
denominator is legitimately near zero, so noise divided by nothing produces an
|
|
178
|
+
enormous number on the most ordinary footage there is. Real maxima of 2.3, 110 and
|
|
179
|
+
52 against bounds of 1.9 and 4.0 are not near-misses — the ratios have come apart,
|
|
180
|
+
and no bound can be raised to cover that without covering every real fault too.
|
|
181
|
+
What works is refusing to judge the quality of a move until there demonstrably
|
|
182
|
+
*was* one. Every gate above has a measured gap behind it: real footage reaches a
|
|
183
|
+
speed of 0.003 where the slowest control with a genuine move sits at 0.014.
|
|
184
|
+
|
|
185
|
+
### The harness passed while the tool was wrong
|
|
186
|
+
|
|
187
|
+
Worth the paragraph because it is the failure mode of every calibrated tool.
|
|
188
|
+
|
|
189
|
+
`shotdrift <clip>` reported **`BROKEN` on four of seven real clips** while
|
|
190
|
+
`validate_real.py` printed `VALIDATION PASSED` — same code, same files, same
|
|
191
|
+
afternoon. The harness measured the first **8 seconds**; the tool measures **600
|
|
192
|
+
frames**. And `breathing` was gated on *accumulated* scale change against a fixed
|
|
193
|
+
bound, which on a locked-off camera walks 0.0025 at 100 frames to 0.0263 at 1200
|
|
194
|
+
while the per-frame rate stays flat at 2e-5. The gate was crossed somewhere past
|
|
195
|
+
500 frames by nothing but clip length, and 8 seconds at 50p is 400 frames — just
|
|
196
|
+
underneath it.
|
|
197
|
+
|
|
198
|
+
So: **a fixed bound on a cumulative quantity is a time bomb**, and **a control
|
|
199
|
+
that does not run the shipped configuration validates a configuration nobody
|
|
200
|
+
uses**. The gate is now peak-to-peak amplitude, which does not grow with length
|
|
201
|
+
and is what a viewer can actually see, and the harness defaults to the whole clip.
|
|
202
|
+
|
|
203
|
+
`breathing` also lost its `broken` bound in the same round, for the third
|
|
204
|
+
instance of the pattern this tool keeps running into: real operated footage
|
|
205
|
+
reached a breathing ratio of 169 against the deliberate fault's 46. An operator
|
|
206
|
+
zooming in and back out *is* scale oscillating without going anywhere.
|
|
207
|
+
|
|
208
|
+
## Python
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from shotdrift import measure
|
|
212
|
+
|
|
213
|
+
r = measure("take_07.mp4", expect="push-in")
|
|
214
|
+
print(r.verdict, r.ok)
|
|
215
|
+
for s in r.shots: # cuts are detected; one entry per shot
|
|
216
|
+
print(s.start, s.end, s.verdict, [f.code for f in s.findings])
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Frames you never wrote to disk
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
from shotdrift import measure_frames, report
|
|
223
|
+
|
|
224
|
+
r = measure_frames(batch, expect="push-in") # (n, h, w, c), numpy or torch
|
|
225
|
+
print(report(r))
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
No ffmpeg, no temp file. Takes uint8 0..255 or float 0..1, greyscale or RGB(A),
|
|
229
|
+
and a channels-first batch is refused by name rather than measured sideways.
|
|
230
|
+
`report()` renders exactly what the command line prints — the CLI calls it, so
|
|
231
|
+
the two surfaces cannot drift apart.
|
|
232
|
+
|
|
233
|
+
## In ComfyUI
|
|
234
|
+
|
|
235
|
+
[**shotdrift-comfyui**](https://github.com/Syamjith-NK/shotdrift-comfyui) measures
|
|
236
|
+
the batch inside the graph that produced it, and can stop the queue when a take
|
|
237
|
+
does not hold the move it was given. That is the point of measuring here rather
|
|
238
|
+
than afterwards: an unattended run of sixty takes is only worth doing if the bad
|
|
239
|
+
ones announce themselves.
|
|
240
|
+
|
|
241
|
+
## Requirements
|
|
242
|
+
|
|
243
|
+
Python ≥ 3.9, `numpy`, `pillow`, and **ffmpeg on PATH**. No OpenCV, no torch, no
|
|
244
|
+
network, no GPU. A tool that needs a 2 GB wheel to measure a camera move does not
|
|
245
|
+
get run.
|
|
246
|
+
|
|
247
|
+
## Development
|
|
248
|
+
|
|
249
|
+
```console
|
|
250
|
+
pip install -e ".[test]"
|
|
251
|
+
pytest -q # 83 tests
|
|
252
|
+
PYTHONPATH=src python validate_real.py # real footage + controls, as shipped
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
MIT. Built by [Syamjith NK](https://syamjithnk.com) — cinematographer and AI
|
|
256
|
+
creative technologist, Abu Dhabi.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "shotdrift"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Measure whether a video holds the camera move it was given."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Syamjith NK", email = "hello@syamjithnk.com" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"video", "camera", "cinematography", "ai-video", "quality-assurance",
|
|
15
|
+
"motion-estimation", "generative-video", "vfx", "measurement",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Intended Audience :: End Users/Desktop",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Topic :: Multimedia :: Video",
|
|
25
|
+
"Topic :: Scientific/Engineering :: Image Processing",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["numpy>=1.22", "pillow>=9"]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
test = ["pytest>=7"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/Syamjith-NK/shotdrift"
|
|
34
|
+
Source = "https://github.com/Syamjith-NK/shotdrift"
|
|
35
|
+
Issues = "https://github.com/Syamjith-NK/shotdrift/issues"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
shotdrift = "shotdrift.cli:main"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["src"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""shotdrift - measure whether a video holds the camera move it was given.
|
|
2
|
+
|
|
3
|
+
AI video is asked for a camera move and is not obliged to deliver one. The usual
|
|
4
|
+
check is a person watching sixty clips. This measures the camera path out of the
|
|
5
|
+
pixels - pan, zoom, roll, frame by frame - and reports whether one physical camera
|
|
6
|
+
could have produced it.
|
|
7
|
+
|
|
8
|
+
from shotdrift import measure
|
|
9
|
+
r = measure("take_07.mp4", expect="push-in")
|
|
10
|
+
print(r.verdict, [f.code for f in r.findings])
|
|
11
|
+
|
|
12
|
+
Frames already in memory - inside a generation graph, say, where the clip was
|
|
13
|
+
never written to disk - skip ffmpeg entirely:
|
|
14
|
+
|
|
15
|
+
from shotdrift import measure_frames, report
|
|
16
|
+
r = measure_frames(image_batch, expect="push-in") # (n, h, w, c), 0..1
|
|
17
|
+
print(report(r))
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from .core import Result, Shot, measure, measure_frames, report
|
|
23
|
+
|
|
24
|
+
__version__ = "0.2.0"
|
|
25
|
+
__all__ = ["measure", "measure_frames", "report", "Result", "Shot", "__version__"]
|