adaptive-motion-preprocessing 1.0.0rc1__py3-none-any.whl
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.
- adaptive_motion_preprocessing-1.0.0rc1.dist-info/METADATA +124 -0
- adaptive_motion_preprocessing-1.0.0rc1.dist-info/RECORD +14 -0
- adaptive_motion_preprocessing-1.0.0rc1.dist-info/WHEEL +4 -0
- adaptive_motion_preprocessing-1.0.0rc1.dist-info/licenses/LICENSE +21 -0
- amprep/__init__.py +14 -0
- amprep/adaptive_sampler.py +198 -0
- amprep/background_subtractor.py +202 -0
- amprep/frame_window.py +130 -0
- amprep/motion_history_encoder.py +120 -0
- amprep/motion_trigger.py +64 -0
- amprep/noise_reducer.py +83 -0
- amprep/preprocessor.py +163 -0
- amprep/py.typed +0 -0
- amprep/types.py +94 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: adaptive-motion-preprocessing
|
|
3
|
+
Version: 1.0.0rc1
|
|
4
|
+
Summary: Adaptive motion preprocessing: video frames in, encoded motion images out.
|
|
5
|
+
Project-URL: Homepage, https://github.com/sdrfsh/adaptive-motion-preprocessing
|
|
6
|
+
Project-URL: Repository, https://github.com/sdrfsh/adaptive-motion-preprocessing
|
|
7
|
+
Project-URL: Issues, https://github.com/sdrfsh/adaptive-motion-preprocessing/issues
|
|
8
|
+
Author: Sahar Darafsh
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: computer-vision,motion-detection,opencv,preprocessing
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: numpy
|
|
25
|
+
Requires-Dist: opencv-python
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# 🎞️ Adaptive Motion Preprocessing
|
|
32
|
+
|
|
33
|
+
Turn video into motion images your neural network can read.
|
|
34
|
+
|
|
35
|
+
While something moves, each window of frames becomes one grayscale picture:
|
|
36
|
+
older frames dim, newer ones bright, so a single image shows where the
|
|
37
|
+
subject went and how fast. Every image has the same shape, so your model
|
|
38
|
+
never gets a surprise.
|
|
39
|
+
|
|
40
|
+
## 📦 Install
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
pip install adaptive-motion-preprocessing
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then `import amprep`.
|
|
47
|
+
|
|
48
|
+
## 🚀 Quick start
|
|
49
|
+
|
|
50
|
+
You bring the frames (any iterable of `uint8` BGR arrays) and the package
|
|
51
|
+
does the rest:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import cv2
|
|
55
|
+
|
|
56
|
+
from amprep import AdaptiveMotionPreprocessor
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def frames_from(path):
|
|
60
|
+
capture = cv2.VideoCapture(path)
|
|
61
|
+
try:
|
|
62
|
+
if not capture.isOpened():
|
|
63
|
+
raise OSError(f"cannot open video: {path}")
|
|
64
|
+
while True:
|
|
65
|
+
ok, frame = capture.read()
|
|
66
|
+
if not ok:
|
|
67
|
+
return
|
|
68
|
+
yield frame
|
|
69
|
+
finally:
|
|
70
|
+
capture.release()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
for image in AdaptiveMotionPreprocessor().process(frames_from("clip.mp4")):
|
|
74
|
+
print(image.data.shape)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 🎥 Try it on your webcam
|
|
78
|
+
|
|
79
|
+
See your camera and the motion images side by side, live:
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
git clone https://github.com/sdrfsh/adaptive-motion-preprocessing
|
|
83
|
+
cd adaptive-motion-preprocessing
|
|
84
|
+
pip install -e .
|
|
85
|
+
python examples/live_camera.py
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Stay out of shot for a second while it learns the background, then move.
|
|
89
|
+
Press `q` or `Esc` to quit. Add `--camera 1` for an external webcam, or
|
|
90
|
+
`--threshold 0.03` if it triggers when nothing is moving.
|
|
91
|
+
|
|
92
|
+
## ⚙️ Settings
|
|
93
|
+
|
|
94
|
+
All optional keyword arguments of `AdaptiveMotionPreprocessor(...)`:
|
|
95
|
+
|
|
96
|
+
| Setting | Default | What it does |
|
|
97
|
+
| --- | --- | --- |
|
|
98
|
+
| `motion_threshold` | `0.01` | Share of the frame that must be moving before frames are collected |
|
|
99
|
+
| `window_frames` | `10` | Frames per window, and one image per full window |
|
|
100
|
+
| `sample_frames` | `4` | Frames painted into each image (at most `window_frames`) |
|
|
101
|
+
| `width`, `height` | `None` | Output size; leave unset to keep the frame size, or set both |
|
|
102
|
+
| `noise_reducer` | median filter | Your own `NoiseReducer` subclass |
|
|
103
|
+
| `background_subtractor` | KNN | Your own `BackgroundSubtractor` subclass |
|
|
104
|
+
|
|
105
|
+
## 💡 Good to know
|
|
106
|
+
|
|
107
|
+
- ⏱️ **Frames, not seconds.** 10 frames is about 0.33 s at 30 fps and 1 s at
|
|
108
|
+
10 fps. The package never reads the frame rate, so that math is yours.
|
|
109
|
+
- 🔁 **A steady stream.** While motion lasts you get one image every
|
|
110
|
+
`window_frames` frames. A half-full window is dropped when motion stops.
|
|
111
|
+
- 🌱 **Warm-up.** The default subtractor spends its first 4 frames learning
|
|
112
|
+
the background, so they never produce images. Change it with
|
|
113
|
+
`KNNBackgroundSubtractor(warmup_frames=...)`.
|
|
114
|
+
- 🎬 **New scene?** Call `reset()`. It forgets the background, any half-built
|
|
115
|
+
window and the frame size. Otherwise state carries over between
|
|
116
|
+
`process()` calls.
|
|
117
|
+
- 📐 **One frame size per scene.** Frames that change size mid-stream raise a
|
|
118
|
+
`ValueError`. Call `reset()` first if the change is on purpose.
|
|
119
|
+
|
|
120
|
+
## 📚 Examples
|
|
121
|
+
|
|
122
|
+
- [examples/live_camera.py](examples/live_camera.py): watch it live on your webcam, camera and motion image side by side
|
|
123
|
+
- [examples/from_video_file.py](examples/from_video_file.py): run it on a video file
|
|
124
|
+
- [examples/custom_background_subtractor.py](examples/custom_background_subtractor.py): plug in your own stage
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
amprep/__init__.py,sha256=Lh-D9z9J9WdWF6Aotn28ZdGb9rIFswtcyAWT7wA0hlY,442
|
|
2
|
+
amprep/adaptive_sampler.py,sha256=t7HRXr_tMeq08GQujbizxhVtlotfx_YgBPWGXkVdREM,8599
|
|
3
|
+
amprep/background_subtractor.py,sha256=Q7KSirUW05xnBAIOYqiAP6yR8vVfhiRmIu-n29EieMY,8273
|
|
4
|
+
amprep/frame_window.py,sha256=k1V5cdMrvH45kEdZXQfw3XkCkYhnNrna3nYv_FZRwyA,5008
|
|
5
|
+
amprep/motion_history_encoder.py,sha256=N8t7bf7jIHKvCkbbpvoTmCqVmGYTk7kscrSStH1rEP4,5471
|
|
6
|
+
amprep/motion_trigger.py,sha256=K1p_FBLSMQb_gW7U4vL5L-ye10BH7KUHkBbtfN2Fefo,2222
|
|
7
|
+
amprep/noise_reducer.py,sha256=KGq-N70B2uooDk7W-vKjFylbcUTdwiWWzcvITLsPoYU,2935
|
|
8
|
+
amprep/preprocessor.py,sha256=BV8oMLTwVbSsdtoYgidZPZGrRcrUCWskM0PB3CbQ9cQ,7258
|
|
9
|
+
amprep/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
amprep/types.py,sha256=9y4CgymT5YCs5q-3MWE_-hfrpxCMItIAOVrwxp_Aruc,3579
|
|
11
|
+
adaptive_motion_preprocessing-1.0.0rc1.dist-info/METADATA,sha256=R4t1lXdR4gI21Uh_qOZ8idpYLpXZCbHdlWaXoHcK5Hw,4601
|
|
12
|
+
adaptive_motion_preprocessing-1.0.0rc1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
13
|
+
adaptive_motion_preprocessing-1.0.0rc1.dist-info/licenses/LICENSE,sha256=xq9lXmM9Cd-IZcyzZ7_QGeHpNSTeDARC2MaH-Rpiwrc,1070
|
|
14
|
+
adaptive_motion_preprocessing-1.0.0rc1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sahar Darafsh
|
|
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.
|
amprep/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from amprep.background_subtractor import BackgroundSubtractor, KNNBackgroundSubtractor
|
|
2
|
+
from amprep.noise_reducer import MedianNoiseReducer, NoiseReducer
|
|
3
|
+
from amprep.preprocessor import AdaptiveMotionPreprocessor
|
|
4
|
+
from amprep.types import Frame, MotionImage
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AdaptiveMotionPreprocessor",
|
|
8
|
+
"BackgroundSubtractor",
|
|
9
|
+
"Frame",
|
|
10
|
+
"KNNBackgroundSubtractor",
|
|
11
|
+
"MedianNoiseReducer",
|
|
12
|
+
"MotionImage",
|
|
13
|
+
"NoiseReducer",
|
|
14
|
+
]
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from amprep.types import Window
|
|
6
|
+
|
|
7
|
+
_log = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
DEFAULT_SAMPLE_FRAMES = 4
|
|
10
|
+
"""Frames kept from every window, whatever the motion did.
|
|
11
|
+
|
|
12
|
+
Fixed on purpose. The encoder behind this stage has to emit one shape
|
|
13
|
+
every time, and a constant number of frames going in is the cheapest way
|
|
14
|
+
to get there: it has nothing to pad or drop. Keep it at or below
|
|
15
|
+
``window_frames`` or short windows will yield fewer.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
DEFAULT_FULL_SPEED = 0.35
|
|
19
|
+
"""Change rate at which the samples sit on consecutive frames.
|
|
20
|
+
|
|
21
|
+
A share of the subject changing per frame. Normal walking measures about
|
|
22
|
+
0.17 and running about 0.55, so 0.35 puts walking mid-range and running
|
|
23
|
+
at the tightest spacing. Measured on synthetic masks.
|
|
24
|
+
|
|
25
|
+
Not a setting for users: a share of the subject changing per frame is
|
|
26
|
+
not a quantity anyone can choose sensibly by hand, so the preprocessor
|
|
27
|
+
does not ask for it.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AdaptiveFrameSampler:
|
|
32
|
+
"""Picks which frames of a window to keep, closer together when motion is fast.
|
|
33
|
+
|
|
34
|
+
Always the same number of frames; what velocity changes is how far
|
|
35
|
+
apart they sit. The aim is to hold the *amount of change* between
|
|
36
|
+
consecutive samples roughly steady rather than the time between
|
|
37
|
+
them::
|
|
38
|
+
|
|
39
|
+
window of 10: 0 1 2 3 4 5 6 7 8 9
|
|
40
|
+
|
|
41
|
+
slow motion X . . X . . X . . X spread across the window
|
|
42
|
+
fast motion . . . . . . X X X X packed at the newest frames
|
|
43
|
+
|
|
44
|
+
Both extremes are there for the same reason. Sampling a slow scene
|
|
45
|
+
tightly gives four near-identical frames, which describe no motion at
|
|
46
|
+
all; sampling a fast one across the whole window gives four frames
|
|
47
|
+
the subject has jumped between, which describe motion that looks
|
|
48
|
+
discontinuous. Moving the spacing keeps the step between samples in a
|
|
49
|
+
useful middle band either way.
|
|
50
|
+
|
|
51
|
+
The cost is that fast motion is sampled from the end of the window
|
|
52
|
+
and the opening frames are not represented. That is the trade the
|
|
53
|
+
fixed count buys: with a constant number of frames, coverage and
|
|
54
|
+
density cannot both be held. Density is what carries the shape of the
|
|
55
|
+
movement, and when it has to be bought, it is bought from the oldest
|
|
56
|
+
frames rather than the freshest: the samples always reach the most
|
|
57
|
+
recent frame in the window.
|
|
58
|
+
|
|
59
|
+
Velocity is read from the masks rather than the frames: a mask is
|
|
60
|
+
already "what is moving", so the share of it that changes between one
|
|
61
|
+
frame and the next is a rate of change needing no optical flow.
|
|
62
|
+
|
|
63
|
+
``full_speed`` is the change rate at which the spacing bottoms out,
|
|
64
|
+
and it is fixed for the life of the sampler. That makes the sampler
|
|
65
|
+
stateless: each window is judged on its own, nothing carries from one
|
|
66
|
+
window to the next, and there is nothing to reset between scenes.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
sample_frames: Frames kept from every window. At least 2, so
|
|
70
|
+
there is always a before and an after. A window holding
|
|
71
|
+
fewer frames than this yields only what it has, so keep it
|
|
72
|
+
at or below the collector's ``window_frames``.
|
|
73
|
+
full_speed: Change rate at which the samples are packed onto
|
|
74
|
+
consecutive frames, as a share of the subject changing per
|
|
75
|
+
frame. Defaults to ``DEFAULT_FULL_SPEED``.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
sample_frames: int = DEFAULT_SAMPLE_FRAMES,
|
|
81
|
+
full_speed: float = DEFAULT_FULL_SPEED,
|
|
82
|
+
) -> None:
|
|
83
|
+
# bool is an int subclass, so it is refused by name before the
|
|
84
|
+
# range check, where True would otherwise read as 1.
|
|
85
|
+
if isinstance(sample_frames, bool) or not isinstance(sample_frames, int):
|
|
86
|
+
raise ValueError(f"sample_frames must be an integer, got {sample_frames!r}")
|
|
87
|
+
if sample_frames < 2:
|
|
88
|
+
raise ValueError(f"sample_frames must be at least 2, got {sample_frames}")
|
|
89
|
+
if isinstance(full_speed, bool) or not isinstance(full_speed, (int, float)):
|
|
90
|
+
raise ValueError(f"full_speed must be a number, got {full_speed!r}")
|
|
91
|
+
if not full_speed > 0:
|
|
92
|
+
raise ValueError(f"full_speed must be positive, got {full_speed!r}")
|
|
93
|
+
|
|
94
|
+
self._sample_frames = sample_frames
|
|
95
|
+
self._full_speed = float(full_speed)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def sample_frames(self) -> int:
|
|
99
|
+
"""Frames kept from every window."""
|
|
100
|
+
return self._sample_frames
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def full_speed(self) -> float:
|
|
104
|
+
"""Change rate at which the samples sit on consecutive frames."""
|
|
105
|
+
return self._full_speed
|
|
106
|
+
|
|
107
|
+
def velocity(self, window: Window) -> float:
|
|
108
|
+
"""Return how fast the foreground changes, over the second half of ``window``.
|
|
109
|
+
|
|
110
|
+
For each pair of consecutive masks, the share of the subject that
|
|
111
|
+
flipped between them: pixels that changed, over pixels foreground
|
|
112
|
+
in either. The result is the mean of those shares, so it stays in
|
|
113
|
+
``[0, 1]`` whatever the window length or the resolution: an
|
|
114
|
+
average over pairs rather than a total divided by the number of
|
|
115
|
+
steps, which would shrink as the window grew.
|
|
116
|
+
|
|
117
|
+
The second half is measured because that is the half the samples
|
|
118
|
+
are taken from when motion is fast.
|
|
119
|
+
|
|
120
|
+
Unlike tracking the centre of the foreground, this sees a subject
|
|
121
|
+
approaching the camera, which grows without moving sideways, and
|
|
122
|
+
two subjects moving apart, whose combined centre stays put.
|
|
123
|
+
|
|
124
|
+
It is relative to the subject's size: a small, distant subject
|
|
125
|
+
reads faster than a large, close one moving at the same speed,
|
|
126
|
+
because the same displacement flips a larger share of it.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
window: The window to measure. See ``Window``.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Mean share of the foreground changing between consecutive
|
|
133
|
+
frames, in ``[0, 1]``. Zero when no measured pair holds any
|
|
134
|
+
foreground at all.
|
|
135
|
+
"""
|
|
136
|
+
start = (len(window) - 1) // 2
|
|
137
|
+
shares = []
|
|
138
|
+
for earlier, later in zip(
|
|
139
|
+
window.masks[start:-1], window.masks[start + 1 :], strict=True
|
|
140
|
+
):
|
|
141
|
+
was, now = earlier > 0, later > 0
|
|
142
|
+
# Pixels foreground in either frame. Dividing by this rather
|
|
143
|
+
# than by the frame area is what makes the reading relative
|
|
144
|
+
# to the subject instead of to the picture.
|
|
145
|
+
union = np.count_nonzero(was | now)
|
|
146
|
+
if union:
|
|
147
|
+
shares.append(np.count_nonzero(was ^ now) / union)
|
|
148
|
+
return float(np.mean(shares)) if shares else 0.0
|
|
149
|
+
|
|
150
|
+
def select(self, window: Window) -> tuple[int, ...]:
|
|
151
|
+
"""Return the indices of the frames worth keeping from ``window``.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
window: The window to sample. See ``Window``.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Exactly ``sample_frames`` indices, or every index the window
|
|
158
|
+
has if it holds fewer. Strictly increasing, within the
|
|
159
|
+
window, and always reaching its last frame.
|
|
160
|
+
"""
|
|
161
|
+
count = min(self._sample_frames, len(window))
|
|
162
|
+
if count < 2:
|
|
163
|
+
return (0,)
|
|
164
|
+
|
|
165
|
+
speed = self.velocity(window)
|
|
166
|
+
# Saturating rather than proportional: past full_speed the
|
|
167
|
+
# samples are already on consecutive frames and cannot tighten
|
|
168
|
+
# further, and an outlier reading must not reach past it.
|
|
169
|
+
fraction = min(1.0, speed / self._full_speed)
|
|
170
|
+
|
|
171
|
+
# The span is how much of the window the samples cover. At its
|
|
172
|
+
# widest they reach back to the first frame; at its narrowest
|
|
173
|
+
# they sit on consecutive frames, which needs exactly count - 1
|
|
174
|
+
# steps. Using a span rather than an integer stride is what keeps
|
|
175
|
+
# the choice fine-grained: a stride of 1 or 2 is all a ten-frame
|
|
176
|
+
# window can offer, while the span moves through every value
|
|
177
|
+
# between.
|
|
178
|
+
widest = len(window) - 1
|
|
179
|
+
narrowest = count - 1
|
|
180
|
+
span = round(widest - fraction * (widest - narrowest))
|
|
181
|
+
|
|
182
|
+
# Anchored at the newest frame and reaching backwards, so
|
|
183
|
+
# tightening the spacing drops the oldest frames rather than the
|
|
184
|
+
# freshest. Spacing stays at least one frame because span is
|
|
185
|
+
# never below count - 1, so rounding cannot collapse two samples
|
|
186
|
+
# onto the same index.
|
|
187
|
+
last = len(window) - 1
|
|
188
|
+
indices = np.linspace(last - span, last, count).round().astype(int)
|
|
189
|
+
|
|
190
|
+
_log.debug(
|
|
191
|
+
"change rate %.4f (full speed %.4f) -> %d frames spanning %d of %d",
|
|
192
|
+
speed,
|
|
193
|
+
self._full_speed,
|
|
194
|
+
count,
|
|
195
|
+
span,
|
|
196
|
+
len(window),
|
|
197
|
+
)
|
|
198
|
+
return tuple(int(index) for index in indices)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from amprep.types import Frame
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _validate(array: Frame, label: str, ndim: int) -> None:
|
|
10
|
+
"""Raise when ``array`` is not a ``uint8`` array of ``ndim`` dimensions.
|
|
11
|
+
|
|
12
|
+
A three-dimensional array is additionally required to be BGR, i.e. to
|
|
13
|
+
have exactly three channels.
|
|
14
|
+
"""
|
|
15
|
+
if not isinstance(array, np.ndarray):
|
|
16
|
+
raise TypeError(f"Expected {label} to be a NumPy array, got {type(array)}")
|
|
17
|
+
if array.dtype != np.uint8:
|
|
18
|
+
raise TypeError(f"Expected {label} to be uint8, got {array.dtype}")
|
|
19
|
+
if array.ndim != ndim:
|
|
20
|
+
raise ValueError(
|
|
21
|
+
f"Expected {label} to be {ndim}-dimensional, got {array.shape}"
|
|
22
|
+
)
|
|
23
|
+
if ndim == 3 and array.shape[2] != 3:
|
|
24
|
+
raise ValueError(f"Expected {label} to be (H, W, 3), got {array.shape}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BackgroundSubtractor(ABC):
|
|
28
|
+
"""Separates moving foreground from a learned background model.
|
|
29
|
+
|
|
30
|
+
Subclass this and implement ``_apply`` and ``reset`` to plug in your
|
|
31
|
+
own subtractor. Do not override ``apply``: it validates the input
|
|
32
|
+
frame and the returned mask against the contract below. Pass an
|
|
33
|
+
instance to ``AdaptiveMotionPreprocessor(background_subtractor=...)``;
|
|
34
|
+
leave it unset and the package's default implementation is used
|
|
35
|
+
instead.
|
|
36
|
+
|
|
37
|
+
Contract:
|
|
38
|
+
``_apply`` takes a ``uint8`` ``(H, W, 3)`` BGR frame and must
|
|
39
|
+
return a **single-channel** ``uint8`` mask of shape ``(H, W)``,
|
|
40
|
+
the same height and width as its input. Implementations are
|
|
41
|
+
stateful: consecutive calls are expected to come from the same
|
|
42
|
+
video, in order, so the background model can adapt.
|
|
43
|
+
|
|
44
|
+
``reset`` discards that accumulated state. It is the caller's way
|
|
45
|
+
of saying the next frame belongs to a different scene, so a model
|
|
46
|
+
learned from the preceding frames describes a background that is
|
|
47
|
+
no longer there.
|
|
48
|
+
|
|
49
|
+
``warmup_frames`` says how many frames a subtractor needs before
|
|
50
|
+
its masks mean anything. It is concrete and defaults to 0, so a
|
|
51
|
+
subclass that is useful from its first frame writes nothing;
|
|
52
|
+
override it only if yours is not.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def _apply(self, frame: Frame) -> Frame:
|
|
57
|
+
"""Return the foreground mask for ``frame``.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
frame: The input frame to process. See ``Frame`` for its
|
|
61
|
+
contract.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
A single-channel ``uint8`` mask of shape ``(H, W)`` matching
|
|
65
|
+
the input frame's height and width.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def reset(self) -> None:
|
|
70
|
+
"""Discard the learned background model.
|
|
71
|
+
|
|
72
|
+
Called when the scene changes (a cut, a camera move, or the
|
|
73
|
+
start of a different video), so that state accumulated from the
|
|
74
|
+
preceding frames does not leak into the next one. Implementations
|
|
75
|
+
that hold no state between frames should implement this as a
|
|
76
|
+
no-op.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def warmup_frames(self) -> int:
|
|
81
|
+
"""Frames before this subtractor's masks are meaningful. Default 0."""
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
def apply(self, frame: Frame) -> Frame:
|
|
85
|
+
"""Return the foreground mask for ``frame``."""
|
|
86
|
+
_validate(frame, "the input frame", ndim=3)
|
|
87
|
+
|
|
88
|
+
mask = self._apply(frame)
|
|
89
|
+
|
|
90
|
+
_validate(mask, "the mask returned by _apply", ndim=2)
|
|
91
|
+
if mask.shape != frame.shape[:2]:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"_apply must preserve frame H×W: {frame.shape[:2]} -> {mask.shape}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return mask
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class KNNBackgroundSubtractor(BackgroundSubtractor):
|
|
100
|
+
"""Separates foreground with OpenCV's KNN background subtractor.
|
|
101
|
+
|
|
102
|
+
The package default. KNN models each pixel's recent history as a
|
|
103
|
+
cloud of samples and calls a new value foreground when too few of its
|
|
104
|
+
neighbours sit close to it. That copes with the swaying leaves and
|
|
105
|
+
rippling water a single-Gaussian model keeps flagging as motion,
|
|
106
|
+
which matters here because every false foreground pixel becomes
|
|
107
|
+
spurious motion in the encoded output.
|
|
108
|
+
|
|
109
|
+
The mask is strictly binary: 0 for background, 255 for foreground.
|
|
110
|
+
OpenCV marks shadows with an intermediate grey, and a downstream
|
|
111
|
+
stage that thresholds a mask at anything other than 0 would silently
|
|
112
|
+
treat those pixels as half-present, so they are folded into one of
|
|
113
|
+
the two labels here instead.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
history: Number of recent frames the model is built from. Longer
|
|
117
|
+
tolerates slower background change but takes longer to
|
|
118
|
+
forget an object that stops moving and becomes scenery.
|
|
119
|
+
dist2_threshold: Squared distance between a pixel and a sample
|
|
120
|
+
for that sample to count as its neighbour. Larger admits
|
|
121
|
+
more variation as background, so the mask keeps less noise
|
|
122
|
+
and fewer faint edges.
|
|
123
|
+
detect_shadows: Whether to recognise shadows and exclude them
|
|
124
|
+
from the foreground. Shadows move with their subject, so
|
|
125
|
+
leaving this on keeps them out of the silhouette at a modest
|
|
126
|
+
cost in speed. Turn it off and they are foreground like any
|
|
127
|
+
other change.
|
|
128
|
+
warmup_frames: Frames to ignore at the start, while the model
|
|
129
|
+
has too few samples to judge against and marks most of the
|
|
130
|
+
image as motion. 4 settles a clean source at the default
|
|
131
|
+
``dist2_threshold``. A strict threshold needs more, and how
|
|
132
|
+
much more is set by how far the source itself flickers rather
|
|
133
|
+
than by the threshold alone: at 100 a lightly noisy scene
|
|
134
|
+
takes 5, while a threshold strict enough for its own noise
|
|
135
|
+
never settles and reads every frame as motion.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
history: int = 500,
|
|
141
|
+
dist2_threshold: float = 400.0,
|
|
142
|
+
detect_shadows: bool = True,
|
|
143
|
+
warmup_frames: int = 4,
|
|
144
|
+
) -> None:
|
|
145
|
+
if not isinstance(history, int) or isinstance(history, bool) or history <= 0:
|
|
146
|
+
raise ValueError(f"history must be a positive integer, got {history!r}")
|
|
147
|
+
if (
|
|
148
|
+
isinstance(dist2_threshold, bool)
|
|
149
|
+
or not isinstance(dist2_threshold, (int, float))
|
|
150
|
+
or dist2_threshold <= 0
|
|
151
|
+
):
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"dist2_threshold must be a positive number, got {dist2_threshold!r}"
|
|
154
|
+
)
|
|
155
|
+
if not isinstance(detect_shadows, bool):
|
|
156
|
+
raise ValueError(
|
|
157
|
+
f"detect_shadows must be a boolean, got {detect_shadows!r}"
|
|
158
|
+
)
|
|
159
|
+
# Zero is allowed, and means the very first mask is trusted; only
|
|
160
|
+
# a negative count is meaningless.
|
|
161
|
+
if (
|
|
162
|
+
not isinstance(warmup_frames, int)
|
|
163
|
+
or isinstance(warmup_frames, bool)
|
|
164
|
+
or warmup_frames < 0
|
|
165
|
+
):
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"warmup_frames must be a non-negative integer, got {warmup_frames!r}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
self._history = history
|
|
171
|
+
self._dist2_threshold = float(dist2_threshold)
|
|
172
|
+
self._detect_shadows = detect_shadows
|
|
173
|
+
self._warmup_frames = warmup_frames
|
|
174
|
+
self._subtractor = self._build()
|
|
175
|
+
|
|
176
|
+
def _build(self) -> cv2.BackgroundSubtractorKNN:
|
|
177
|
+
"""Return a freshly constructed subtractor with no learned model."""
|
|
178
|
+
return cv2.createBackgroundSubtractorKNN(
|
|
179
|
+
history=self._history,
|
|
180
|
+
dist2Threshold=self._dist2_threshold,
|
|
181
|
+
detectShadows=self._detect_shadows,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def warmup_frames(self) -> int:
|
|
186
|
+
"""Frames to ignore at the start, as given to the constructor."""
|
|
187
|
+
return self._warmup_frames
|
|
188
|
+
|
|
189
|
+
def _apply(self, frame: Frame) -> Frame:
|
|
190
|
+
mask = self._subtractor.apply(frame)
|
|
191
|
+
# OpenCV labels foreground 255 and everything else (background,
|
|
192
|
+
# and shadows when detect_shadows is on) below it.
|
|
193
|
+
return np.where(mask == 255, np.uint8(255), np.uint8(0))
|
|
194
|
+
|
|
195
|
+
def reset(self) -> None:
|
|
196
|
+
"""Discard the learned background model.
|
|
197
|
+
|
|
198
|
+
OpenCV exposes no way to clear a subtractor in place, so the
|
|
199
|
+
model is thrown away and rebuilt. The next frame is treated as
|
|
200
|
+
the opening frame of a new scene.
|
|
201
|
+
"""
|
|
202
|
+
self._subtractor = self._build()
|
amprep/frame_window.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from amprep.types import Frame, Window
|
|
4
|
+
|
|
5
|
+
_log = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
DEFAULT_WINDOW_FRAMES = 10
|
|
8
|
+
"""Frames gathered into one window.
|
|
9
|
+
|
|
10
|
+
Counts frames, not seconds: ten is about 0.33 s at 30 fps and 1 s at
|
|
11
|
+
10 fps. The package never reads the frame rate, so converting between
|
|
12
|
+
the two is the caller's arithmetic.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
MIN_WINDOW_FRAMES = 2
|
|
16
|
+
"""Fewest frames a window can be asked to hold.
|
|
17
|
+
|
|
18
|
+
The adaptive sampler compares the first frame of a window against the
|
|
19
|
+
middle one, which needs two distinct frames to be a comparison at all.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FrameWindowCollector:
|
|
24
|
+
"""Gathers frames into fixed-size windows while motion lasts.
|
|
25
|
+
|
|
26
|
+
Fed one frame at a time along with the trigger's verdict on it. While
|
|
27
|
+
the trigger says moving, frames are collected; on the frame that
|
|
28
|
+
fills the window, that window is handed back and the next one begins
|
|
29
|
+
immediately. There is no cooldown, so a long burst of motion produces
|
|
30
|
+
back-to-back windows with no frame skipped between them::
|
|
31
|
+
|
|
32
|
+
trigger: idle ACTIVE x 10 ACTIVE x 10 idle
|
|
33
|
+
window: [1..10] -> out [1..10] -> out (partial, dropped)
|
|
34
|
+
|
|
35
|
+
Frames and their masks are kept together, because the two stages
|
|
36
|
+
downstream need different halves of the same moment: the sampler
|
|
37
|
+
reads frames, the encoder reads masks.
|
|
38
|
+
|
|
39
|
+
A partial window is discarded when motion stops. Velocity is measured
|
|
40
|
+
across a window, so a window cut short describes a slower movement
|
|
41
|
+
than actually happened: a stage cannot tell "the object slowed" from
|
|
42
|
+
"the recording stopped". Dropping the remainder loses a fragment of
|
|
43
|
+
real motion; keeping it would report a false one, which is worse.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
window_frames: Frames per window, at least ``MIN_WINDOW_FRAMES``.
|
|
47
|
+
Defaults to ``DEFAULT_WINDOW_FRAMES`` (10).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, window_frames: int = DEFAULT_WINDOW_FRAMES) -> None:
|
|
51
|
+
# bool is an int subclass, so it is turned away by name before the
|
|
52
|
+
# range check, where True would otherwise read as 1.
|
|
53
|
+
if (
|
|
54
|
+
isinstance(window_frames, bool)
|
|
55
|
+
or not isinstance(window_frames, int)
|
|
56
|
+
or window_frames < MIN_WINDOW_FRAMES
|
|
57
|
+
):
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"window_frames must be an integer of at least "
|
|
60
|
+
f"{MIN_WINDOW_FRAMES}, got {window_frames!r}"
|
|
61
|
+
)
|
|
62
|
+
self._window_frames = window_frames
|
|
63
|
+
self._clear()
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def window_frames(self) -> int:
|
|
67
|
+
"""Frames per window, as given to the constructor."""
|
|
68
|
+
return self._window_frames
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def pending(self) -> int:
|
|
72
|
+
"""Frames collected so far towards the window being built.
|
|
73
|
+
|
|
74
|
+
Zero between windows as well as while idle: a window that is
|
|
75
|
+
handed over leaves nothing behind it.
|
|
76
|
+
"""
|
|
77
|
+
return len(self._frames)
|
|
78
|
+
|
|
79
|
+
def reset(self) -> None:
|
|
80
|
+
"""Drop whatever was being collected and start again empty.
|
|
81
|
+
|
|
82
|
+
The caller's way of saying the next frame belongs to a different
|
|
83
|
+
scene, so frames gathered before a cut cannot end up in a window
|
|
84
|
+
beside frames from after it.
|
|
85
|
+
"""
|
|
86
|
+
self._clear()
|
|
87
|
+
|
|
88
|
+
def _clear(self) -> None:
|
|
89
|
+
"""Empty the buffers, saying nothing about why they were emptied.
|
|
90
|
+
|
|
91
|
+
Kept separate from ``reset`` because the two internal callers are
|
|
92
|
+
not scene changes: one abandons a partial window, the other has
|
|
93
|
+
just handed a full one over. Should ``reset`` ever grow behaviour
|
|
94
|
+
that belongs to a new scene, it must not fire on those.
|
|
95
|
+
"""
|
|
96
|
+
self._frames: list[Frame] = []
|
|
97
|
+
self._masks: list[Frame] = []
|
|
98
|
+
|
|
99
|
+
def update(self, frame: Frame, mask: Frame, active: bool) -> Window | None:
|
|
100
|
+
"""Take one frame, and return a window if this frame completed one.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
frame: The frame captured at this moment. See ``Frame``.
|
|
104
|
+
mask: Its foreground mask, from the background subtractor.
|
|
105
|
+
active: The trigger's verdict on this frame. ``False`` drops
|
|
106
|
+
this frame and discards anything part-collected with it.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The completed ``Window`` if this frame filled one, otherwise
|
|
110
|
+
``None``. Collecting resumes from empty on the next frame,
|
|
111
|
+
so consecutive windows share no frames.
|
|
112
|
+
"""
|
|
113
|
+
if not active:
|
|
114
|
+
if self._frames:
|
|
115
|
+
_log.debug("motion ended, dropping %d frames", len(self._frames))
|
|
116
|
+
self._clear()
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
self._frames.append(frame)
|
|
120
|
+
self._masks.append(mask)
|
|
121
|
+
|
|
122
|
+
if len(self._frames) < self._window_frames:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
window = Window(frames=tuple(self._frames), masks=tuple(self._masks))
|
|
126
|
+
# Emptied rather than kept, so the next frame opens the next
|
|
127
|
+
# window: that is what "no cooldown" means here.
|
|
128
|
+
self._clear()
|
|
129
|
+
_log.debug("window complete at %d frames", len(window))
|
|
130
|
+
return window
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
from amprep.types import MotionImage, Window
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MotionHistoryEncoder:
|
|
8
|
+
"""Paints sampled masks into one gray image: old = dim, new = bright.
|
|
9
|
+
|
|
10
|
+
The last stage. The sampled masks go in and one picture comes out,
|
|
11
|
+
each mask painted at its own brightness so the result reads as a
|
|
12
|
+
trail.
|
|
13
|
+
|
|
14
|
+
A frame's brightness is how late in the window it was captured: the
|
|
15
|
+
newest frame is 255, the first frame of a ten-frame window is 26. Fast
|
|
16
|
+
motion is sampled from the end of the window, so its whole trail is
|
|
17
|
+
bright; slow motion spans the window, so its trail fades. The picture
|
|
18
|
+
shows both how far the subject moved and how long that took.
|
|
19
|
+
|
|
20
|
+
Where the masks overlap the brighter value wins, so the subject's
|
|
21
|
+
current position is always on top and the dimmer trail shows where it
|
|
22
|
+
came from.
|
|
23
|
+
|
|
24
|
+
Size is the caller's business. Given no size the masks are painted at
|
|
25
|
+
the size they arrive in, untouched: a 100x200 source yields a
|
|
26
|
+
100x200 image, and a model built for that source needs nothing done
|
|
27
|
+
to it. The shape stays fixed across windows because the source
|
|
28
|
+
resolution does, not because anything here enforces it.
|
|
29
|
+
|
|
30
|
+
Give a size and every mask is scaled to it instead, for a model whose
|
|
31
|
+
input differs from the camera. Scaling uses ``INTER_AREA``, which
|
|
32
|
+
averages the pixels it merges rather than picking one of them, so a
|
|
33
|
+
subject two pixels wide still leaves a small nonzero value after a
|
|
34
|
+
large reduction instead of disappearing at most positions. Any
|
|
35
|
+
nonzero mask pixel counts as foreground, so that value is painted at
|
|
36
|
+
its frame's full brightness, not dimmed by the averaging.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
width: Output width in pixels, or ``None`` to keep the source
|
|
40
|
+
width. Must be given together with ``height``.
|
|
41
|
+
height: Output height in pixels, or ``None`` to keep the source
|
|
42
|
+
height. Must be given together with ``width``.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, *, width: int | None = None, height: int | None = None) -> None:
|
|
46
|
+
if (width is None) != (height is None):
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"width and height must be given together, or neither: "
|
|
49
|
+
f"got width={width!r}, height={height!r}"
|
|
50
|
+
)
|
|
51
|
+
for name, value in (("width", width), ("height", height)):
|
|
52
|
+
if value is None:
|
|
53
|
+
continue
|
|
54
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
55
|
+
raise ValueError(f"{name} must be a positive integer, got {value!r}")
|
|
56
|
+
self._width = width
|
|
57
|
+
self._height = height
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def width(self) -> int | None:
|
|
61
|
+
"""Output width in pixels, or ``None`` to keep the source width."""
|
|
62
|
+
return self._width
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def height(self) -> int | None:
|
|
66
|
+
"""Output height in pixels, or ``None`` to keep the source height."""
|
|
67
|
+
return self._height
|
|
68
|
+
|
|
69
|
+
def encode(self, window: Window, indices: tuple[int, ...]) -> MotionImage:
|
|
70
|
+
"""Paint the masks at ``indices`` into one motion image.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
window: The window the masks come from. See ``Window``.
|
|
74
|
+
indices: Which of its frames to paint, strictly increasing
|
|
75
|
+
and within the window, oldest first. The sampler
|
|
76
|
+
produces them in exactly that form.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
A ``MotionImage`` whose ``data`` is ``uint8`` and is either
|
|
80
|
+
the configured size or the size of the first painted mask.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ValueError: If ``indices`` is empty, not strictly increasing,
|
|
84
|
+
or reaches outside the window. Brightness encodes time,
|
|
85
|
+
so an out-of-order index would silently paint the past
|
|
86
|
+
on top of the present.
|
|
87
|
+
"""
|
|
88
|
+
if not indices:
|
|
89
|
+
raise ValueError("indices must not be empty")
|
|
90
|
+
if list(indices) != sorted(set(indices)):
|
|
91
|
+
raise ValueError(f"indices must be strictly increasing, got {indices}")
|
|
92
|
+
if indices[0] < 0 or indices[-1] >= len(window):
|
|
93
|
+
raise ValueError(f"indices out of range for a window of {len(window)}")
|
|
94
|
+
|
|
95
|
+
if self._height is None or self._width is None:
|
|
96
|
+
# No size asked for, so the first mask sets it and nothing is
|
|
97
|
+
# scaled. Later masks are only touched if the source somehow
|
|
98
|
+
# changed resolution mid-window.
|
|
99
|
+
shape = window.masks[indices[0]].shape[:2]
|
|
100
|
+
else:
|
|
101
|
+
shape = (self._height, self._width)
|
|
102
|
+
|
|
103
|
+
image = np.zeros(shape, dtype=np.uint8)
|
|
104
|
+
for index in indices:
|
|
105
|
+
# By when the frame was captured, not by its place in the
|
|
106
|
+
# sample: the same frame paints the same grey however the
|
|
107
|
+
# sampler spaced the frames around it.
|
|
108
|
+
brightness = np.uint8(round(255 * (index + 1) / len(window)))
|
|
109
|
+
mask = window.masks[index]
|
|
110
|
+
if mask.shape[:2] != shape:
|
|
111
|
+
# cv2 wants (width, height). numpy shapes are (height, width).
|
|
112
|
+
mask = cv2.resize(
|
|
113
|
+
mask, (shape[1], shape[0]), interpolation=cv2.INTER_AREA
|
|
114
|
+
)
|
|
115
|
+
painted = np.where(mask > 0, brightness, np.uint8(0))
|
|
116
|
+
# Brighter wins, and brighter means newer, so the newest mask
|
|
117
|
+
# ends up on top wherever the trail crosses itself.
|
|
118
|
+
np.maximum(image, painted, out=image)
|
|
119
|
+
|
|
120
|
+
return MotionImage(data=image, frame_indices=tuple(indices))
|
amprep/motion_trigger.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from amprep.types import Frame
|
|
6
|
+
|
|
7
|
+
_log = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
DEFAULT_THRESHOLD = 0.01
|
|
10
|
+
"""Fraction of the frame that must be foreground to count as motion.
|
|
11
|
+
|
|
12
|
+
Sits between a speck (about 0.25%) and a small object (about 2%) on
|
|
13
|
+
synthetic scenes. Tune it on real footage using ``last_fraction``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MotionTrigger:
|
|
18
|
+
"""Says whether something is moving, one mask at a time.
|
|
19
|
+
|
|
20
|
+
Active when the foreground fraction is at or above ``threshold``,
|
|
21
|
+
idle below it. Reacts from the very first mask; warm-up is decided
|
|
22
|
+
by the background subtractor, not here.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
threshold: Fraction of the frame, in (0, 1]. Defaults to
|
|
26
|
+
``DEFAULT_THRESHOLD`` (1%).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, threshold: float = DEFAULT_THRESHOLD) -> None:
|
|
30
|
+
if isinstance(threshold, bool) or not isinstance(threshold, (int, float)):
|
|
31
|
+
raise ValueError(f"threshold must be a number, got {threshold!r}")
|
|
32
|
+
if not 0 < threshold <= 1:
|
|
33
|
+
raise ValueError(f"threshold must be in (0, 1], got {threshold!r}")
|
|
34
|
+
self._threshold = float(threshold)
|
|
35
|
+
self.reset()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def threshold(self) -> float:
|
|
39
|
+
"""The threshold in use."""
|
|
40
|
+
return self._threshold
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_active(self) -> bool:
|
|
44
|
+
return self._active
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def last_fraction(self) -> float:
|
|
48
|
+
"""Last measured fraction. Look at this to tune ``threshold``."""
|
|
49
|
+
return self._last_fraction
|
|
50
|
+
|
|
51
|
+
def reset(self) -> None:
|
|
52
|
+
self._active = False
|
|
53
|
+
self._last_fraction = 0.0
|
|
54
|
+
|
|
55
|
+
def update(self, mask: Frame) -> bool:
|
|
56
|
+
# ``count_nonzero`` hands back a NumPy integer, which would make
|
|
57
|
+
# the fraction a ``float64`` and the comparison below a
|
|
58
|
+
# ``np.bool_``, and ``np.bool_`` is not a ``bool``, so a caller
|
|
59
|
+
# writing ``if trigger.update(mask) is True`` would silently
|
|
60
|
+
# never match. Coercing here keeps both annotations honest.
|
|
61
|
+
self._last_fraction = float(np.count_nonzero(mask) / mask.size)
|
|
62
|
+
self._active = self._last_fraction >= self._threshold
|
|
63
|
+
_log.debug("foreground %.4f -> %s", self._last_fraction, self._active)
|
|
64
|
+
return self._active
|
amprep/noise_reducer.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from amprep.types import Frame
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _validate(frame: Frame, label: str) -> None:
|
|
10
|
+
"""Raise ``TypeError`` when ``frame`` is not a valid video frame."""
|
|
11
|
+
if not isinstance(frame, np.ndarray):
|
|
12
|
+
raise TypeError(f"Expected {label} to be a NumPy array, got {type(frame)}")
|
|
13
|
+
if frame.dtype != np.uint8:
|
|
14
|
+
raise TypeError(f"Expected {label} to be uint8, got {frame.dtype}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NoiseReducer(ABC):
|
|
18
|
+
"""Removes sensor noise from a frame before background subtraction.
|
|
19
|
+
|
|
20
|
+
Subclass this and implement ``_apply`` to plug in your own denoising.
|
|
21
|
+
Do not override ``apply``: it validates the input and the returned
|
|
22
|
+
frame against the contract below. Pass an instance to
|
|
23
|
+
``AdaptiveMotionPreprocessor(noise_reducer=...)``; leave it unset and
|
|
24
|
+
the package's default implementation is used instead.
|
|
25
|
+
|
|
26
|
+
Contract:
|
|
27
|
+
``_apply`` must return a frame with the **same shape and dtype**
|
|
28
|
+
as its input: ``uint8``, ``(H, W, 3)``, BGR. Downstream stages
|
|
29
|
+
size their buffers from the first frame they see, so a stage that
|
|
30
|
+
changes shape mid-stream breaks them silently.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def _apply(self, frame: Frame) -> Frame:
|
|
35
|
+
"""Return a denoised copy of ``frame``.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
frame: The frame to denoise. See ``Frame`` for its contract.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A frame of the same shape and dtype as the input.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def apply(self, frame: Frame) -> Frame:
|
|
45
|
+
"""Return a denoised copy of ``frame``."""
|
|
46
|
+
_validate(frame, "the input frame")
|
|
47
|
+
|
|
48
|
+
result = self._apply(frame)
|
|
49
|
+
|
|
50
|
+
_validate(result, "the frame returned by _apply")
|
|
51
|
+
if result.shape != frame.shape:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"_apply must preserve shape: {frame.shape} -> {result.shape}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MedianNoiseReducer(NoiseReducer):
|
|
60
|
+
"""Removes sensor noise with a median filter.
|
|
61
|
+
|
|
62
|
+
The package default. A median discards extreme pixel values instead
|
|
63
|
+
of averaging them in, which suits the salt-and-pepper noise that
|
|
64
|
+
would otherwise become phantom foreground in the mask, and it blurs
|
|
65
|
+
edges far less than a Gaussian, so the silhouette the sampler and
|
|
66
|
+
encoder depend on stays sharp.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
ksize: Aperture size, an odd integer greater than 1. Larger
|
|
70
|
+
removes more noise and costs more time. ``3`` is roughly
|
|
71
|
+
seven times faster with sharper edges, at about twice the
|
|
72
|
+
residual noise.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, ksize: int = 5) -> None:
|
|
76
|
+
if not isinstance(ksize, int) or ksize <= 1 or ksize % 2 == 0:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"ksize must be an odd integer greater than 1, got {ksize!r}"
|
|
79
|
+
)
|
|
80
|
+
self._ksize = ksize
|
|
81
|
+
|
|
82
|
+
def _apply(self, frame: Frame) -> Frame:
|
|
83
|
+
return cv2.medianBlur(frame, self._ksize)
|
amprep/preprocessor.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from collections.abc import Iterable, Iterator
|
|
2
|
+
|
|
3
|
+
from amprep.adaptive_sampler import DEFAULT_SAMPLE_FRAMES, AdaptiveFrameSampler
|
|
4
|
+
from amprep.background_subtractor import BackgroundSubtractor, KNNBackgroundSubtractor
|
|
5
|
+
from amprep.frame_window import DEFAULT_WINDOW_FRAMES, FrameWindowCollector
|
|
6
|
+
from amprep.motion_history_encoder import MotionHistoryEncoder
|
|
7
|
+
from amprep.motion_trigger import DEFAULT_THRESHOLD, MotionTrigger
|
|
8
|
+
from amprep.noise_reducer import MedianNoiseReducer, NoiseReducer
|
|
9
|
+
from amprep.types import Frame, MotionImage
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AdaptiveMotionPreprocessor:
|
|
13
|
+
"""Turns video frames into encoded motion images.
|
|
14
|
+
|
|
15
|
+
The public entry point: one class holding the whole pipeline, with
|
|
16
|
+
each stage swappable for your own implementation.
|
|
17
|
+
|
|
18
|
+
Every stage argument defaults to ``None``, which is replaced here
|
|
19
|
+
with the packaged implementation of that stage. Resolving the
|
|
20
|
+
default inside ``__init__`` (rather than behind a factory or at the
|
|
21
|
+
first frame) means an assembled preprocessor always holds real
|
|
22
|
+
stage objects, so there is no second code path in which a stage is
|
|
23
|
+
still missing.
|
|
24
|
+
|
|
25
|
+
While motion lasts, one ``MotionImage`` comes out every
|
|
26
|
+
``window_frames`` frames. A subject that lingers produces a
|
|
27
|
+
continuous stream, so a caller wiring this to a network should expect
|
|
28
|
+
that.
|
|
29
|
+
|
|
30
|
+
State carries across ``process()`` calls: splitting one stream into
|
|
31
|
+
chunks gives the same result as one call. Call ``reset()`` when the
|
|
32
|
+
scene changes. Frames must keep one size until then.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
noise_reducer: Removes sensor noise before subtraction. Defaults
|
|
36
|
+
to ``MedianNoiseReducer``.
|
|
37
|
+
background_subtractor: Separates moving foreground from the
|
|
38
|
+
learned background. Defaults to ``KNNBackgroundSubtractor``.
|
|
39
|
+
motion_threshold: Share of the frame that must move. Default 1%.
|
|
40
|
+
window_frames: Frames per window. Default 10.
|
|
41
|
+
sample_frames: Frames kept per window. Default 4.
|
|
42
|
+
width: Output width, or ``None`` to keep the frame width.
|
|
43
|
+
height: Output height, or ``None`` to keep the frame height.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
noise_reducer: NoiseReducer | None = None,
|
|
49
|
+
background_subtractor: BackgroundSubtractor | None = None,
|
|
50
|
+
*,
|
|
51
|
+
motion_threshold: float = DEFAULT_THRESHOLD,
|
|
52
|
+
window_frames: int = DEFAULT_WINDOW_FRAMES,
|
|
53
|
+
sample_frames: int = DEFAULT_SAMPLE_FRAMES,
|
|
54
|
+
width: int | None = None,
|
|
55
|
+
height: int | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
if noise_reducer is None:
|
|
58
|
+
noise_reducer = MedianNoiseReducer()
|
|
59
|
+
elif not isinstance(noise_reducer, NoiseReducer):
|
|
60
|
+
raise TypeError(
|
|
61
|
+
f"noise_reducer must be a NoiseReducer, got {type(noise_reducer)}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if background_subtractor is None:
|
|
65
|
+
background_subtractor = KNNBackgroundSubtractor()
|
|
66
|
+
elif not isinstance(background_subtractor, BackgroundSubtractor):
|
|
67
|
+
raise TypeError(
|
|
68
|
+
"background_subtractor must be a BackgroundSubtractor, "
|
|
69
|
+
f"got {type(background_subtractor)}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
self._noise_reducer = noise_reducer
|
|
73
|
+
self._background_subtractor = background_subtractor
|
|
74
|
+
|
|
75
|
+
# Each stage checks its own setting.
|
|
76
|
+
self._trigger = MotionTrigger(threshold=motion_threshold)
|
|
77
|
+
self._collector = FrameWindowCollector(window_frames=window_frames)
|
|
78
|
+
self._sampler = AdaptiveFrameSampler(sample_frames=sample_frames)
|
|
79
|
+
self._encoder = MotionHistoryEncoder(width=width, height=height)
|
|
80
|
+
|
|
81
|
+
# The one check no single stage can do.
|
|
82
|
+
if self._sampler.sample_frames > self._collector.window_frames:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"sample_frames ({sample_frames}) cannot exceed "
|
|
85
|
+
f"window_frames ({window_frames})"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
self._frames_seen = 0
|
|
89
|
+
self._frame_shape: tuple[int, ...] | None = None
|
|
90
|
+
|
|
91
|
+
def process(self, frames: Iterable[Frame]) -> Iterator[MotionImage]:
|
|
92
|
+
"""Turn a stream of frames into encoded motion images.
|
|
93
|
+
|
|
94
|
+
The input side of the pipeline, and the whole of it: the package
|
|
95
|
+
does no video I/O. Any iterable of frames is accepted (a list, a
|
|
96
|
+
generator, a custom iterator, a loop around a camera the caller
|
|
97
|
+
opened) because by the time a frame arrives here it is a
|
|
98
|
+
``uint8`` BGR array, and where it came from is neither recoverable
|
|
99
|
+
nor needed. Capture stays outside the package on purpose: nothing
|
|
100
|
+
in here owns a device handle, so nothing in here can leak one.
|
|
101
|
+
The README carries the few lines that read frames from a file.
|
|
102
|
+
|
|
103
|
+
The stream is consumed lazily, one frame at a time, and is never
|
|
104
|
+
materialised. An unbounded source is therefore fine: a live
|
|
105
|
+
camera can be handed over and abandoned whenever the caller
|
|
106
|
+
likes. Because this is a generator, it does not touch the
|
|
107
|
+
input at all until it is iterated.
|
|
108
|
+
|
|
109
|
+
Frames must arrive in capture order. The stages behind this one
|
|
110
|
+
accumulate state across consecutive frames, so a shuffled stream
|
|
111
|
+
describes motion that never happened.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
frames: The frames to process, in capture order. ``Frame``
|
|
115
|
+
gives the contract each one must satisfy; it is enforced
|
|
116
|
+
by ``NoiseReducer.apply`` on every frame, so it is not
|
|
117
|
+
re-checked here.
|
|
118
|
+
|
|
119
|
+
Yields:
|
|
120
|
+
One ``MotionImage`` per completed window.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
ValueError: If the frame size changes mid-stream. Call
|
|
124
|
+
``reset()`` first when it is meant to.
|
|
125
|
+
"""
|
|
126
|
+
for frame in frames:
|
|
127
|
+
denoised = self._noise_reducer.apply(frame) # also validates the frame
|
|
128
|
+
self._check_frame_shape(denoised)
|
|
129
|
+
mask = self._background_subtractor.apply(denoised)
|
|
130
|
+
|
|
131
|
+
# The model still learns from these frames; only its verdict
|
|
132
|
+
# is ignored.
|
|
133
|
+
self._frames_seen += 1
|
|
134
|
+
if self._frames_seen <= self._background_subtractor.warmup_frames:
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
active = self._trigger.update(mask)
|
|
138
|
+
window = self._collector.update(frame, mask, active)
|
|
139
|
+
if window is not None:
|
|
140
|
+
yield self._encoder.encode(window, self._sampler.select(window))
|
|
141
|
+
|
|
142
|
+
def reset(self) -> None:
|
|
143
|
+
"""Start over for a new scene.
|
|
144
|
+
|
|
145
|
+
Forgets the background, motion, any half-full window, the warm-up
|
|
146
|
+
count and the frame size. Never called automatically: a fixed
|
|
147
|
+
camera fed in chunks should keep what it has learned.
|
|
148
|
+
"""
|
|
149
|
+
self._background_subtractor.reset()
|
|
150
|
+
self._trigger.reset()
|
|
151
|
+
self._collector.reset()
|
|
152
|
+
self._frames_seen = 0
|
|
153
|
+
self._frame_shape = None
|
|
154
|
+
|
|
155
|
+
def _check_frame_shape(self, frame: Frame) -> None:
|
|
156
|
+
"""Raise if the frame size changed since the stream began."""
|
|
157
|
+
if self._frame_shape is None:
|
|
158
|
+
self._frame_shape = frame.shape
|
|
159
|
+
elif frame.shape != self._frame_shape:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"frame size changed from {self._frame_shape} to {frame.shape}; "
|
|
162
|
+
"call reset() before processing frames of a different size"
|
|
163
|
+
)
|
amprep/py.typed
ADDED
|
File without changes
|
amprep/types.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from numpy.typing import NDArray
|
|
5
|
+
|
|
6
|
+
Frame = NDArray[np.uint8]
|
|
7
|
+
"""A single video frame.
|
|
8
|
+
|
|
9
|
+
Contract:
|
|
10
|
+
dtype: ``numpy.uint8`` (values 0-255)
|
|
11
|
+
shape: ``(H, W, 3)``
|
|
12
|
+
channel order: **BGR**, the order OpenCV decodes a frame into
|
|
13
|
+
|
|
14
|
+
BGR is not a bug to be fixed. Frames are consumed by OpenCV operations
|
|
15
|
+
throughout the pipeline; converting to RGB here would silently change
|
|
16
|
+
what every downstream stage sees.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class MotionImage:
|
|
22
|
+
"""The encoded output handed to a downstream neural network.
|
|
23
|
+
|
|
24
|
+
Contract:
|
|
25
|
+
``data`` dtype: ``numpy.uint8``
|
|
26
|
+
``data`` shape: ``(H, W)``, one channel. The ``width`` and
|
|
27
|
+
``height`` the preprocessor was given, or the
|
|
28
|
+
size of the incoming frames when it was given
|
|
29
|
+
none. Nothing is padded. It does not depend on
|
|
30
|
+
how many frames were sampled or how fast the
|
|
31
|
+
motion was, and the frame size cannot change
|
|
32
|
+
mid-stream without ``reset()``, so it holds for
|
|
33
|
+
every ``MotionImage`` of a stream.
|
|
34
|
+
|
|
35
|
+
A fixed shape is the point of this type: a downstream model declares
|
|
36
|
+
one input size, so a pipeline that sometimes emits a different shape
|
|
37
|
+
must fail loudly rather than at inference time.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
data: The encoded motion image.
|
|
41
|
+
frame_indices: Indices, within the source window, of the frames
|
|
42
|
+
the adaptive sampler selected. Kept so the sampler's
|
|
43
|
+
behaviour is inspectable from its output alone.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
data: Frame
|
|
47
|
+
frame_indices: tuple[int, ...]
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
"""Ensure the image data uses 8-bit unsigned integer values."""
|
|
51
|
+
if self.data.dtype != np.uint8:
|
|
52
|
+
raise TypeError(f"MotionImage.data must be uint8, got {self.data.dtype}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Window:
|
|
57
|
+
"""A full run of consecutive frames, collected while motion lasted.
|
|
58
|
+
|
|
59
|
+
The handover between the collector and the two stages that read it:
|
|
60
|
+
the adaptive sampler measures how fast things moved from ``masks``,
|
|
61
|
+
where the foreground centroid is trivially locatable, and the encoder
|
|
62
|
+
stacks the ``frames`` the sampler picked. Both are carried because
|
|
63
|
+
neither can be recovered from the other, and they are carried
|
|
64
|
+
together because a mask is only meaningful beside the frame it came
|
|
65
|
+
from.
|
|
66
|
+
|
|
67
|
+
Contract:
|
|
68
|
+
``frames`` and ``masks`` are the same length, and the entries at
|
|
69
|
+
any index were captured at the same moment. A window is never
|
|
70
|
+
empty and never partial: the collector emits one only once it is
|
|
71
|
+
full, so a stage reading this never has to ask whether it got a
|
|
72
|
+
whole one.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
frames: The frames, in capture order. See ``Frame``.
|
|
76
|
+
masks: The foreground mask for each frame, in the same order.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
frames: tuple[Frame, ...]
|
|
80
|
+
masks: tuple[Frame, ...]
|
|
81
|
+
|
|
82
|
+
def __post_init__(self) -> None:
|
|
83
|
+
"""Ensure every frame kept its mask, and that there is anything here."""
|
|
84
|
+
if len(self.frames) != len(self.masks):
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"Window needs one mask per frame, got {len(self.frames)} "
|
|
87
|
+
f"frames and {len(self.masks)} masks"
|
|
88
|
+
)
|
|
89
|
+
if not self.frames:
|
|
90
|
+
raise ValueError("Window must not be empty")
|
|
91
|
+
|
|
92
|
+
def __len__(self) -> int:
|
|
93
|
+
"""The number of frames in the window."""
|
|
94
|
+
return len(self.frames)
|