adaptive-motion-preprocessing 1.0.0rc1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- adaptive_motion_preprocessing-1.0.0rc1/.github/dependabot.yml +11 -0
- adaptive_motion_preprocessing-1.0.0rc1/.github/workflows/ci.yml +92 -0
- adaptive_motion_preprocessing-1.0.0rc1/.github/workflows/publish.yml +49 -0
- adaptive_motion_preprocessing-1.0.0rc1/.gitignore +30 -0
- adaptive_motion_preprocessing-1.0.0rc1/LICENSE +21 -0
- adaptive_motion_preprocessing-1.0.0rc1/PKG-INFO +124 -0
- adaptive_motion_preprocessing-1.0.0rc1/README.md +94 -0
- adaptive_motion_preprocessing-1.0.0rc1/examples/custom_background_subtractor.py +78 -0
- adaptive_motion_preprocessing-1.0.0rc1/examples/from_video_file.py +50 -0
- adaptive_motion_preprocessing-1.0.0rc1/examples/live_camera.py +120 -0
- adaptive_motion_preprocessing-1.0.0rc1/pyproject.toml +56 -0
- adaptive_motion_preprocessing-1.0.0rc1/src/amprep/__init__.py +14 -0
- adaptive_motion_preprocessing-1.0.0rc1/src/amprep/adaptive_sampler.py +198 -0
- adaptive_motion_preprocessing-1.0.0rc1/src/amprep/background_subtractor.py +202 -0
- adaptive_motion_preprocessing-1.0.0rc1/src/amprep/frame_window.py +130 -0
- adaptive_motion_preprocessing-1.0.0rc1/src/amprep/motion_history_encoder.py +120 -0
- adaptive_motion_preprocessing-1.0.0rc1/src/amprep/motion_trigger.py +64 -0
- adaptive_motion_preprocessing-1.0.0rc1/src/amprep/noise_reducer.py +83 -0
- adaptive_motion_preprocessing-1.0.0rc1/src/amprep/preprocessor.py +163 -0
- adaptive_motion_preprocessing-1.0.0rc1/src/amprep/py.typed +0 -0
- adaptive_motion_preprocessing-1.0.0rc1/src/amprep/types.py +94 -0
- adaptive_motion_preprocessing-1.0.0rc1/tests/test_adaptive_sampler.py +461 -0
- adaptive_motion_preprocessing-1.0.0rc1/tests/test_background_subtractor.py +557 -0
- adaptive_motion_preprocessing-1.0.0rc1/tests/test_end_to_end.py +293 -0
- adaptive_motion_preprocessing-1.0.0rc1/tests/test_examples.py +115 -0
- adaptive_motion_preprocessing-1.0.0rc1/tests/test_frame_window.py +249 -0
- adaptive_motion_preprocessing-1.0.0rc1/tests/test_import.py +4 -0
- adaptive_motion_preprocessing-1.0.0rc1/tests/test_motion_history_encoder.py +187 -0
- adaptive_motion_preprocessing-1.0.0rc1/tests/test_motion_trigger.py +197 -0
- adaptive_motion_preprocessing-1.0.0rc1/tests/test_noise_reducer.py +197 -0
- adaptive_motion_preprocessing-1.0.0rc1/tests/test_pipeline.py +175 -0
- adaptive_motion_preprocessing-1.0.0rc1/tests/test_plugin_contract.py +271 -0
- adaptive_motion_preprocessing-1.0.0rc1/tests/test_process_input.py +242 -0
- adaptive_motion_preprocessing-1.0.0rc1/tests/test_types.py +28 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
concurrency:
|
|
10
|
+
group: ci-${{ github.ref }}
|
|
11
|
+
cancel-in-progress: true
|
|
12
|
+
|
|
13
|
+
env:
|
|
14
|
+
FORCE_COLOR: "1"
|
|
15
|
+
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
lint:
|
|
19
|
+
name: Lint
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
|
|
24
|
+
- uses: actions/setup-python@v5
|
|
25
|
+
with:
|
|
26
|
+
python-version: "3.12"
|
|
27
|
+
cache: pip
|
|
28
|
+
cache-dependency-path: pyproject.toml
|
|
29
|
+
|
|
30
|
+
- name: Install dependencies
|
|
31
|
+
run: python -m pip install ruff
|
|
32
|
+
|
|
33
|
+
- name: Ruff check
|
|
34
|
+
run: ruff check --output-format=github .
|
|
35
|
+
|
|
36
|
+
- name: Ruff format
|
|
37
|
+
run: ruff format --check --diff .
|
|
38
|
+
|
|
39
|
+
test:
|
|
40
|
+
name: Test (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
|
41
|
+
runs-on: ${{ matrix.os }}
|
|
42
|
+
strategy:
|
|
43
|
+
fail-fast: false
|
|
44
|
+
matrix:
|
|
45
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
46
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
47
|
+
steps:
|
|
48
|
+
- uses: actions/checkout@v4
|
|
49
|
+
|
|
50
|
+
- uses: actions/setup-python@v5
|
|
51
|
+
with:
|
|
52
|
+
python-version: ${{ matrix.python-version }}
|
|
53
|
+
cache: pip
|
|
54
|
+
cache-dependency-path: pyproject.toml
|
|
55
|
+
|
|
56
|
+
- name: Install OpenCV system libraries
|
|
57
|
+
if: runner.os == 'Linux'
|
|
58
|
+
run: |
|
|
59
|
+
sudo apt-get update
|
|
60
|
+
sudo apt-get install -y --no-install-recommends libgl1 libglib2.0-0
|
|
61
|
+
|
|
62
|
+
- name: Install package
|
|
63
|
+
run: python -m pip install -e ".[dev]"
|
|
64
|
+
|
|
65
|
+
- name: Run tests
|
|
66
|
+
run: pytest -v
|
|
67
|
+
|
|
68
|
+
build:
|
|
69
|
+
name: Build distributions
|
|
70
|
+
runs-on: ubuntu-latest
|
|
71
|
+
steps:
|
|
72
|
+
- uses: actions/checkout@v4
|
|
73
|
+
|
|
74
|
+
- uses: actions/setup-python@v5
|
|
75
|
+
with:
|
|
76
|
+
python-version: "3.12"
|
|
77
|
+
cache: pip
|
|
78
|
+
cache-dependency-path: pyproject.toml
|
|
79
|
+
|
|
80
|
+
- name: Install build tooling
|
|
81
|
+
run: python -m pip install build twine
|
|
82
|
+
|
|
83
|
+
- name: Build sdist and wheel
|
|
84
|
+
run: python -m build
|
|
85
|
+
|
|
86
|
+
- name: Check metadata
|
|
87
|
+
run: twine check --strict dist/*
|
|
88
|
+
|
|
89
|
+
- uses: actions/upload-artifact@v4
|
|
90
|
+
with:
|
|
91
|
+
name: dist
|
|
92
|
+
path: dist/
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
name: Build distributions
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
|
|
19
|
+
- name: Install build tooling
|
|
20
|
+
run: python -m pip install build twine
|
|
21
|
+
|
|
22
|
+
- name: Build sdist and wheel
|
|
23
|
+
run: python -m build
|
|
24
|
+
|
|
25
|
+
- name: Check metadata
|
|
26
|
+
run: twine check --strict dist/*
|
|
27
|
+
|
|
28
|
+
- uses: actions/upload-artifact@v4
|
|
29
|
+
with:
|
|
30
|
+
name: dist
|
|
31
|
+
path: dist/
|
|
32
|
+
|
|
33
|
+
publish:
|
|
34
|
+
name: Publish to PyPI
|
|
35
|
+
needs: build
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
environment:
|
|
38
|
+
name: pypi
|
|
39
|
+
url: https://pypi.org/p/adaptive-motion-preprocessing
|
|
40
|
+
permissions:
|
|
41
|
+
id-token: write # required for PyPI trusted publishing
|
|
42
|
+
steps:
|
|
43
|
+
- uses: actions/download-artifact@v4
|
|
44
|
+
with:
|
|
45
|
+
name: dist
|
|
46
|
+
path: dist/
|
|
47
|
+
|
|
48
|
+
- name: Publish
|
|
49
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Byte-compiled / optimized
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Distribution / packaging
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
*.egg
|
|
11
|
+
wheels/
|
|
12
|
+
|
|
13
|
+
# Virtual environments
|
|
14
|
+
.venv/
|
|
15
|
+
venv/
|
|
16
|
+
env/
|
|
17
|
+
|
|
18
|
+
# Test / coverage / lint caches
|
|
19
|
+
.pytest_cache/
|
|
20
|
+
.ruff_cache/
|
|
21
|
+
.coverage
|
|
22
|
+
.coverage.*
|
|
23
|
+
htmlcov/
|
|
24
|
+
.tox/
|
|
25
|
+
|
|
26
|
+
# Editors / OS
|
|
27
|
+
.vscode/
|
|
28
|
+
.idea/
|
|
29
|
+
.DS_Store
|
|
30
|
+
Thumbs.db
|
|
@@ -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.
|
|
@@ -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,94 @@
|
|
|
1
|
+
# 🎞️ Adaptive Motion Preprocessing
|
|
2
|
+
|
|
3
|
+
Turn video into motion images your neural network can read.
|
|
4
|
+
|
|
5
|
+
While something moves, each window of frames becomes one grayscale picture:
|
|
6
|
+
older frames dim, newer ones bright, so a single image shows where the
|
|
7
|
+
subject went and how fast. Every image has the same shape, so your model
|
|
8
|
+
never gets a surprise.
|
|
9
|
+
|
|
10
|
+
## 📦 Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
pip install adaptive-motion-preprocessing
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Then `import amprep`.
|
|
17
|
+
|
|
18
|
+
## 🚀 Quick start
|
|
19
|
+
|
|
20
|
+
You bring the frames (any iterable of `uint8` BGR arrays) and the package
|
|
21
|
+
does the rest:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import cv2
|
|
25
|
+
|
|
26
|
+
from amprep import AdaptiveMotionPreprocessor
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def frames_from(path):
|
|
30
|
+
capture = cv2.VideoCapture(path)
|
|
31
|
+
try:
|
|
32
|
+
if not capture.isOpened():
|
|
33
|
+
raise OSError(f"cannot open video: {path}")
|
|
34
|
+
while True:
|
|
35
|
+
ok, frame = capture.read()
|
|
36
|
+
if not ok:
|
|
37
|
+
return
|
|
38
|
+
yield frame
|
|
39
|
+
finally:
|
|
40
|
+
capture.release()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
for image in AdaptiveMotionPreprocessor().process(frames_from("clip.mp4")):
|
|
44
|
+
print(image.data.shape)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 🎥 Try it on your webcam
|
|
48
|
+
|
|
49
|
+
See your camera and the motion images side by side, live:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
git clone https://github.com/sdrfsh/adaptive-motion-preprocessing
|
|
53
|
+
cd adaptive-motion-preprocessing
|
|
54
|
+
pip install -e .
|
|
55
|
+
python examples/live_camera.py
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Stay out of shot for a second while it learns the background, then move.
|
|
59
|
+
Press `q` or `Esc` to quit. Add `--camera 1` for an external webcam, or
|
|
60
|
+
`--threshold 0.03` if it triggers when nothing is moving.
|
|
61
|
+
|
|
62
|
+
## ⚙️ Settings
|
|
63
|
+
|
|
64
|
+
All optional keyword arguments of `AdaptiveMotionPreprocessor(...)`:
|
|
65
|
+
|
|
66
|
+
| Setting | Default | What it does |
|
|
67
|
+
| --- | --- | --- |
|
|
68
|
+
| `motion_threshold` | `0.01` | Share of the frame that must be moving before frames are collected |
|
|
69
|
+
| `window_frames` | `10` | Frames per window, and one image per full window |
|
|
70
|
+
| `sample_frames` | `4` | Frames painted into each image (at most `window_frames`) |
|
|
71
|
+
| `width`, `height` | `None` | Output size; leave unset to keep the frame size, or set both |
|
|
72
|
+
| `noise_reducer` | median filter | Your own `NoiseReducer` subclass |
|
|
73
|
+
| `background_subtractor` | KNN | Your own `BackgroundSubtractor` subclass |
|
|
74
|
+
|
|
75
|
+
## 💡 Good to know
|
|
76
|
+
|
|
77
|
+
- ⏱️ **Frames, not seconds.** 10 frames is about 0.33 s at 30 fps and 1 s at
|
|
78
|
+
10 fps. The package never reads the frame rate, so that math is yours.
|
|
79
|
+
- 🔁 **A steady stream.** While motion lasts you get one image every
|
|
80
|
+
`window_frames` frames. A half-full window is dropped when motion stops.
|
|
81
|
+
- 🌱 **Warm-up.** The default subtractor spends its first 4 frames learning
|
|
82
|
+
the background, so they never produce images. Change it with
|
|
83
|
+
`KNNBackgroundSubtractor(warmup_frames=...)`.
|
|
84
|
+
- 🎬 **New scene?** Call `reset()`. It forgets the background, any half-built
|
|
85
|
+
window and the frame size. Otherwise state carries over between
|
|
86
|
+
`process()` calls.
|
|
87
|
+
- 📐 **One frame size per scene.** Frames that change size mid-stream raise a
|
|
88
|
+
`ValueError`. Call `reset()` first if the change is on purpose.
|
|
89
|
+
|
|
90
|
+
## 📚 Examples
|
|
91
|
+
|
|
92
|
+
- [examples/live_camera.py](examples/live_camera.py): watch it live on your webcam, camera and motion image side by side
|
|
93
|
+
- [examples/from_video_file.py](examples/from_video_file.py): run it on a video file
|
|
94
|
+
- [examples/custom_background_subtractor.py](examples/custom_background_subtractor.py): plug in your own stage
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Plug your own background subtractor into the pipeline.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python examples/custom_background_subtractor.py
|
|
5
|
+
|
|
6
|
+
Subclass ``BackgroundSubtractor``, implement ``_apply`` and ``reset``, and
|
|
7
|
+
pass an instance by keyword. Nothing else changes: the same motion trigger,
|
|
8
|
+
windowing, sampling and encoding run behind it, and the motion images come
|
|
9
|
+
out in the same shape. The script runs the default pipeline and the custom
|
|
10
|
+
one over the same synthetic clip so the two can be compared.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import cv2
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from amprep import AdaptiveMotionPreprocessor, BackgroundSubtractor
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FrameDifferenceSubtractor(BackgroundSubtractor):
|
|
20
|
+
"""Foreground is whatever changed since the previous frame.
|
|
21
|
+
|
|
22
|
+
Far cruder than the default KNN model, but it is useful from its very
|
|
23
|
+
first frame, so it has no warm-up to declare.
|
|
24
|
+
|
|
25
|
+
``_apply`` receives a ``uint8`` ``(H, W, 3)`` BGR frame and must return a
|
|
26
|
+
``uint8`` ``(H, W)`` mask. ``apply``, which you do not override, checks
|
|
27
|
+
both. ``reset`` forgets the previous frame when the scene changes.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, threshold: int = 25) -> None:
|
|
31
|
+
self._threshold = threshold
|
|
32
|
+
self._previous: np.ndarray | None = None
|
|
33
|
+
|
|
34
|
+
def _apply(self, frame: np.ndarray) -> np.ndarray:
|
|
35
|
+
grey = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
36
|
+
if self._previous is None:
|
|
37
|
+
mask = np.zeros_like(grey)
|
|
38
|
+
else:
|
|
39
|
+
changed = cv2.absdiff(grey, self._previous) > self._threshold
|
|
40
|
+
mask = np.where(changed, np.uint8(255), np.uint8(0))
|
|
41
|
+
self._previous = grey
|
|
42
|
+
return mask
|
|
43
|
+
|
|
44
|
+
def reset(self) -> None:
|
|
45
|
+
self._previous = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def synthetic_clip() -> list[np.ndarray]:
|
|
49
|
+
"""Ten still frames, a square sliding right for thirty, ten still again."""
|
|
50
|
+
frames = []
|
|
51
|
+
for index in range(50):
|
|
52
|
+
frame = np.full((120, 240, 3), 90, dtype=np.uint8)
|
|
53
|
+
if 10 <= index < 40:
|
|
54
|
+
left = 10 + (index - 10) * 6
|
|
55
|
+
frame[40:80, left : left + 40] = 200
|
|
56
|
+
frames.append(frame)
|
|
57
|
+
return frames
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def show(title: str, preprocessor: AdaptiveMotionPreprocessor) -> None:
|
|
61
|
+
print(title)
|
|
62
|
+
for image in preprocessor.process(synthetic_clip()):
|
|
63
|
+
print(
|
|
64
|
+
f" shape {image.data.shape}, dtype {image.data.dtype}, "
|
|
65
|
+
f"frames {image.frame_indices}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main() -> None:
|
|
70
|
+
show("Default background subtractor (KNN):", AdaptiveMotionPreprocessor())
|
|
71
|
+
show(
|
|
72
|
+
"Custom background subtractor (FrameDifferenceSubtractor):",
|
|
73
|
+
AdaptiveMotionPreprocessor(background_subtractor=FrameDifferenceSubtractor()),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
main()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Run the preprocessor over a video file.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python examples/from_video_file.py path/to/clip.mp4
|
|
5
|
+
|
|
6
|
+
Prints the shape of each motion image the moment it is produced.
|
|
7
|
+
|
|
8
|
+
Reading video is the caller's job: the package takes any iterable of
|
|
9
|
+
``uint8`` BGR frames and never opens a file or camera itself. ``frames_from``
|
|
10
|
+
below is the few lines that bridge the two. Keep its ``try``/``finally``:
|
|
11
|
+
without it the capture handle leaks whenever the loop is left early.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
import cv2
|
|
18
|
+
|
|
19
|
+
from amprep import AdaptiveMotionPreprocessor
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def frames_from(path):
|
|
23
|
+
capture = cv2.VideoCapture(path)
|
|
24
|
+
try:
|
|
25
|
+
if not capture.isOpened():
|
|
26
|
+
raise OSError(f"cannot open video: {path}")
|
|
27
|
+
while True:
|
|
28
|
+
ok, frame = capture.read()
|
|
29
|
+
if not ok:
|
|
30
|
+
return
|
|
31
|
+
yield frame
|
|
32
|
+
finally:
|
|
33
|
+
capture.release()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main() -> None:
|
|
37
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
38
|
+
parser.add_argument("path", help="video file to read")
|
|
39
|
+
args = parser.parse_args()
|
|
40
|
+
|
|
41
|
+
preprocessor = AdaptiveMotionPreprocessor()
|
|
42
|
+
try:
|
|
43
|
+
for number, image in enumerate(preprocessor.process(frames_from(args.path))):
|
|
44
|
+
print(f"motion image {number}: shape {image.data.shape}", flush=True)
|
|
45
|
+
except OSError as error:
|
|
46
|
+
sys.exit(str(error))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
main()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Watch the preprocessor work on your webcam, live.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python examples/live_camera.py
|
|
5
|
+
python examples/live_camera.py --camera 1 --window-frames 15
|
|
6
|
+
|
|
7
|
+
One window shows the camera on the left and the latest motion image on
|
|
8
|
+
the right. Move in front of the camera: while you keep moving, a new
|
|
9
|
+
motion image appears every ``window_frames`` frames. Press q or Esc, or
|
|
10
|
+
close the window, to quit.
|
|
11
|
+
|
|
12
|
+
The first few frames only teach the background, so stay out of shot for
|
|
13
|
+
a moment after starting.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
import cv2
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from amprep import AdaptiveMotionPreprocessor, MotionImage
|
|
23
|
+
|
|
24
|
+
WINDOW = "amprep live: camera | motion image"
|
|
25
|
+
QUIT_KEYS = {ord("q"), 27} # q, Esc
|
|
26
|
+
MAX_DISPLAY_WIDTH = 1600
|
|
27
|
+
"""Wider views are shrunk for the screen. Only the display, never the input."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def compose(frame: np.ndarray, image: MotionImage | None, count: int) -> np.ndarray:
|
|
31
|
+
"""Put the camera frame and the latest motion image side by side."""
|
|
32
|
+
left = frame.copy()
|
|
33
|
+
if image is None:
|
|
34
|
+
right = np.zeros_like(frame)
|
|
35
|
+
caption = "waiting for motion..."
|
|
36
|
+
else:
|
|
37
|
+
right = cv2.cvtColor(image.data, cv2.COLOR_GRAY2BGR)
|
|
38
|
+
caption = f"motion image #{count} frames {image.frame_indices}"
|
|
39
|
+
_label(left, "camera")
|
|
40
|
+
_label(right, caption)
|
|
41
|
+
return np.hstack([left, right])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _label(canvas: np.ndarray, text: str) -> None:
|
|
45
|
+
cv2.rectangle(canvas, (0, 0), (canvas.shape[1], 28), (0, 0, 0), thickness=-1)
|
|
46
|
+
cv2.putText(
|
|
47
|
+
canvas, text, (8, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LiveView:
|
|
52
|
+
"""The on-screen window, plus the latest motion image to draw in it."""
|
|
53
|
+
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
self.image: MotionImage | None = None
|
|
56
|
+
self.count = 0
|
|
57
|
+
|
|
58
|
+
def show(self, frame: np.ndarray) -> bool:
|
|
59
|
+
"""Draw one frame. Returns ``False`` once the user wants to quit."""
|
|
60
|
+
view = compose(frame, self.image, self.count)
|
|
61
|
+
if view.shape[1] > MAX_DISPLAY_WIDTH:
|
|
62
|
+
scale = MAX_DISPLAY_WIDTH / view.shape[1]
|
|
63
|
+
view = cv2.resize(view, None, fx=scale, fy=scale)
|
|
64
|
+
cv2.imshow(WINDOW, view)
|
|
65
|
+
|
|
66
|
+
if cv2.waitKey(1) & 0xFF in QUIT_KEYS:
|
|
67
|
+
return False
|
|
68
|
+
# Closing the window with its X button makes it invisible.
|
|
69
|
+
return cv2.getWindowProperty(WINDOW, cv2.WND_PROP_VISIBLE) >= 1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def camera_frames(index: int, view: LiveView):
|
|
73
|
+
"""Yield webcam frames, drawing each one after the pipeline has seen it.
|
|
74
|
+
|
|
75
|
+
Drawing happens here rather than in the loop over ``process()``,
|
|
76
|
+
because that loop only runs when a motion image comes out, while the
|
|
77
|
+
live video has to update on every frame.
|
|
78
|
+
"""
|
|
79
|
+
capture = cv2.VideoCapture(index)
|
|
80
|
+
try:
|
|
81
|
+
if not capture.isOpened():
|
|
82
|
+
raise OSError(f"cannot open camera {index}")
|
|
83
|
+
while True:
|
|
84
|
+
ok, frame = capture.read()
|
|
85
|
+
if not ok:
|
|
86
|
+
return
|
|
87
|
+
yield frame
|
|
88
|
+
# By now the pipeline has processed this frame, and any motion
|
|
89
|
+
# image it produced is already in the view.
|
|
90
|
+
if not view.show(frame):
|
|
91
|
+
return
|
|
92
|
+
finally:
|
|
93
|
+
capture.release()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main() -> None:
|
|
97
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
98
|
+
parser.add_argument("--camera", type=int, default=0, help="camera index")
|
|
99
|
+
parser.add_argument("--window-frames", type=int, default=10)
|
|
100
|
+
parser.add_argument("--threshold", type=float, default=0.01)
|
|
101
|
+
args = parser.parse_args()
|
|
102
|
+
|
|
103
|
+
preprocessor = AdaptiveMotionPreprocessor(
|
|
104
|
+
motion_threshold=args.threshold, window_frames=args.window_frames
|
|
105
|
+
)
|
|
106
|
+
view = LiveView()
|
|
107
|
+
try:
|
|
108
|
+
for image in preprocessor.process(camera_frames(args.camera, view)):
|
|
109
|
+
view.image = image
|
|
110
|
+
view.count += 1
|
|
111
|
+
except OSError as error:
|
|
112
|
+
sys.exit(str(error))
|
|
113
|
+
finally:
|
|
114
|
+
cv2.destroyAllWindows()
|
|
115
|
+
|
|
116
|
+
print(f"{view.count} motion images")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
main()
|