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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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))
@@ -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
@@ -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)