adaptive-motion-preprocessing 1.0.0rc2__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/.github/workflows/ci.yml +7 -7
  2. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/.github/workflows/publish.yml +7 -7
  3. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/PKG-INFO +5 -1
  4. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/README.md +4 -0
  5. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/pyproject.toml +1 -1
  6. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/src/amprep/frame_window.py +14 -16
  7. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/src/amprep/motion_trigger.py +1 -0
  8. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/src/amprep/preprocessor.py +15 -1
  9. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/src/amprep/types.py +14 -23
  10. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/tests/test_adaptive_sampler.py +2 -3
  11. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/tests/test_frame_window.py +24 -32
  12. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/tests/test_motion_history_encoder.py +1 -2
  13. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/tests/test_pipeline.py +55 -0
  14. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/.github/dependabot.yml +0 -0
  15. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/.gitignore +0 -0
  16. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/LICENSE +0 -0
  17. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/examples/custom_background_subtractor.py +0 -0
  18. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/examples/from_video_file.py +0 -0
  19. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/examples/live_camera.py +0 -0
  20. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/src/amprep/__init__.py +0 -0
  21. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/src/amprep/adaptive_sampler.py +0 -0
  22. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/src/amprep/background_subtractor.py +0 -0
  23. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/src/amprep/motion_history_encoder.py +0 -0
  24. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/src/amprep/noise_reducer.py +0 -0
  25. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/src/amprep/py.typed +0 -0
  26. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/tests/test_background_subtractor.py +0 -0
  27. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/tests/test_end_to_end.py +0 -0
  28. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/tests/test_examples.py +0 -0
  29. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/tests/test_import.py +0 -0
  30. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/tests/test_motion_trigger.py +0 -0
  31. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/tests/test_noise_reducer.py +0 -0
  32. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/tests/test_plugin_contract.py +0 -0
  33. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/tests/test_process_input.py +0 -0
  34. {adaptive_motion_preprocessing-1.0.0rc2 → adaptive_motion_preprocessing-1.1.0}/tests/test_types.py +0 -0
@@ -23,9 +23,9 @@ jobs:
23
23
  name: Lint
24
24
  runs-on: ubuntu-latest
25
25
  steps:
26
- - uses: actions/checkout@v4
26
+ - uses: actions/checkout@v7
27
27
 
28
- - uses: actions/setup-python@v5
28
+ - uses: actions/setup-python@v7
29
29
  with:
30
30
  python-version: "3.12"
31
31
  cache: pip
@@ -49,9 +49,9 @@ jobs:
49
49
  os: [ubuntu-latest, windows-latest, macos-latest]
50
50
  python-version: ["3.10", "3.11", "3.12", "3.13"]
51
51
  steps:
52
- - uses: actions/checkout@v4
52
+ - uses: actions/checkout@v7
53
53
 
54
- - uses: actions/setup-python@v5
54
+ - uses: actions/setup-python@v7
55
55
  with:
56
56
  python-version: ${{ matrix.python-version }}
57
57
  cache: pip
@@ -73,9 +73,9 @@ jobs:
73
73
  name: Build distributions
74
74
  runs-on: ubuntu-latest
75
75
  steps:
76
- - uses: actions/checkout@v4
76
+ - uses: actions/checkout@v7
77
77
 
78
- - uses: actions/setup-python@v5
78
+ - uses: actions/setup-python@v7
79
79
  with:
80
80
  python-version: "3.12"
81
81
  cache: pip
@@ -90,7 +90,7 @@ jobs:
90
90
  - name: Check metadata
91
91
  run: twine check --strict dist/*
92
92
 
93
- - uses: actions/upload-artifact@v4
93
+ - uses: actions/upload-artifact@v7
94
94
  with:
95
95
  name: dist
96
96
  path: dist/
@@ -27,9 +27,9 @@ jobs:
27
27
  is-new: ${{ steps.version.outputs.is-new }}
28
28
  prerelease: ${{ steps.version.outputs.prerelease }}
29
29
  steps:
30
- - uses: actions/checkout@v4
30
+ - uses: actions/checkout@v7
31
31
 
32
- - uses: actions/setup-python@v5
32
+ - uses: actions/setup-python@v7
33
33
  with:
34
34
  python-version: "3.12"
35
35
 
@@ -79,9 +79,9 @@ jobs:
79
79
  if: needs.check-version.outputs.is-new == 'true'
80
80
  runs-on: ubuntu-latest
81
81
  steps:
82
- - uses: actions/checkout@v4
82
+ - uses: actions/checkout@v7
83
83
 
84
- - uses: actions/setup-python@v5
84
+ - uses: actions/setup-python@v7
85
85
  with:
86
86
  python-version: "3.12"
87
87
 
@@ -94,7 +94,7 @@ jobs:
94
94
  - name: Check metadata
95
95
  run: twine check --strict dist/*
96
96
 
97
- - uses: actions/upload-artifact@v4
97
+ - uses: actions/upload-artifact@v7
98
98
  with:
99
99
  name: dist
100
100
  path: dist/
@@ -110,7 +110,7 @@ jobs:
110
110
  permissions:
111
111
  id-token: write # required for PyPI trusted publishing
112
112
  steps:
113
- - uses: actions/download-artifact@v4
113
+ - uses: actions/download-artifact@v8
114
114
  with:
115
115
  name: dist
116
116
  path: dist/
@@ -126,7 +126,7 @@ jobs:
126
126
  permissions:
127
127
  contents: write # create the tag and release
128
128
  steps:
129
- - uses: actions/download-artifact@v4
129
+ - uses: actions/download-artifact@v8
130
130
  with:
131
131
  name: dist
132
132
  path: dist/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: adaptive-motion-preprocessing
3
- Version: 1.0.0rc2
3
+ Version: 1.1.0
4
4
  Summary: Adaptive motion preprocessing: video frames in, encoded motion images out.
5
5
  Project-URL: Homepage, https://github.com/sdrfsh/adaptive-motion-preprocessing
6
6
  Project-URL: Repository, https://github.com/sdrfsh/adaptive-motion-preprocessing
@@ -114,6 +114,10 @@ All optional keyword arguments of `AdaptiveMotionPreprocessor(...)`:
114
114
  - 🎬 **New scene?** Call `reset()`. It forgets the background, any half-built
115
115
  window and the frame size. Otherwise state carries over between
116
116
  `process()` calls.
117
+ - 🎚️ **Tuning the threshold.** `preprocessor.last_motion_fraction` is the
118
+ share of the latest frame that was moving. Read it once with the scene
119
+ still and once with something moving, and set `motion_threshold`
120
+ between the two.
117
121
  - 📐 **One frame size per scene.** Frames that change size mid-stream raise a
118
122
  `ValueError`. Call `reset()` first if the change is on purpose.
119
123
 
@@ -84,6 +84,10 @@ All optional keyword arguments of `AdaptiveMotionPreprocessor(...)`:
84
84
  - 🎬 **New scene?** Call `reset()`. It forgets the background, any half-built
85
85
  window and the frame size. Otherwise state carries over between
86
86
  `process()` calls.
87
+ - 🎚️ **Tuning the threshold.** `preprocessor.last_motion_fraction` is the
88
+ share of the latest frame that was moving. Read it once with the scene
89
+ still and once with something moving, and set `motion_threshold`
90
+ between the two.
87
91
  - 📐 **One frame size per scene.** Frames that change size mid-stream raise a
88
92
  `ValueError`. Call `reset()` first if the change is on purpose.
89
93
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "adaptive-motion-preprocessing"
7
- version = "1.0.0rc2"
7
+ version = "1.1.0"
8
8
  description = "Adaptive motion preprocessing: video frames in, encoded motion images out."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -15,8 +15,8 @@ the two is the caller's arithmetic.
15
15
  MIN_WINDOW_FRAMES = 2
16
16
  """Fewest frames a window can be asked to hold.
17
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.
18
+ The adaptive sampler measures change between consecutive masks, which
19
+ needs at least one pair of frames to be a comparison at all.
20
20
  """
21
21
 
22
22
 
@@ -32,9 +32,9 @@ class FrameWindowCollector:
32
32
  trigger: idle ACTIVE x 10 ACTIVE x 10 idle
33
33
  window: [1..10] -> out [1..10] -> out (partial, dropped)
34
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.
35
+ Only the foreground masks are kept. Both stages downstream, the
36
+ sampler and the encoder, read masks alone, so holding on to the
37
+ full-colour frames as well would cost memory for nothing.
38
38
 
39
39
  A partial window is discarded when motion stops. Velocity is measured
40
40
  across a window, so a window cut short describes a slower movement
@@ -74,7 +74,7 @@ class FrameWindowCollector:
74
74
  Zero between windows as well as while idle: a window that is
75
75
  handed over leaves nothing behind it.
76
76
  """
77
- return len(self._frames)
77
+ return len(self._masks)
78
78
 
79
79
  def reset(self) -> None:
80
80
  """Drop whatever was being collected and start again empty.
@@ -93,15 +93,14 @@ class FrameWindowCollector:
93
93
  just handed a full one over. Should ``reset`` ever grow behaviour
94
94
  that belongs to a new scene, it must not fire on those.
95
95
  """
96
- self._frames: list[Frame] = []
97
96
  self._masks: list[Frame] = []
98
97
 
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.
98
+ def update(self, mask: Frame, active: bool) -> Window | None:
99
+ """Take one frame's mask, and return a window if it completed one.
101
100
 
102
101
  Args:
103
- frame: The frame captured at this moment. See ``Frame``.
104
- mask: Its foreground mask, from the background subtractor.
102
+ mask: The foreground mask of the frame captured at this
103
+ moment, from the background subtractor.
105
104
  active: The trigger's verdict on this frame. ``False`` drops
106
105
  this frame and discards anything part-collected with it.
107
106
 
@@ -111,18 +110,17 @@ class FrameWindowCollector:
111
110
  so consecutive windows share no frames.
112
111
  """
113
112
  if not active:
114
- if self._frames:
115
- _log.debug("motion ended, dropping %d frames", len(self._frames))
113
+ if self._masks:
114
+ _log.debug("motion ended, dropping %d frames", len(self._masks))
116
115
  self._clear()
117
116
  return None
118
117
 
119
- self._frames.append(frame)
120
118
  self._masks.append(mask)
121
119
 
122
- if len(self._frames) < self._window_frames:
120
+ if len(self._masks) < self._window_frames:
123
121
  return None
124
122
 
125
- window = Window(frames=tuple(self._frames), masks=tuple(self._masks))
123
+ window = Window(masks=tuple(self._masks))
126
124
  # Emptied rather than kept, so the next frame opens the next
127
125
  # window: that is what "no cooldown" means here.
128
126
  self._clear()
@@ -41,6 +41,7 @@ class MotionTrigger:
41
41
 
42
42
  @property
43
43
  def is_active(self) -> bool:
44
+ """Whether the last mask counted as motion."""
44
45
  return self._active
45
46
 
46
47
  @property
@@ -88,6 +88,20 @@ class AdaptiveMotionPreprocessor:
88
88
  self._frames_seen = 0
89
89
  self._frame_shape: tuple[int, ...] | None = None
90
90
 
91
+ @property
92
+ def last_motion_fraction(self) -> float:
93
+ """Share of the most recent frame that was moving, in ``[0, 1]``.
94
+
95
+ The reading ``motion_threshold`` is compared against, so this is
96
+ the number to watch when tuning it on real footage: print it while
97
+ the scene is still and while something moves, and set the
98
+ threshold between the two.
99
+
100
+ Zero before the first frame is measured, during the background
101
+ subtractor's warm-up, and after ``reset()``.
102
+ """
103
+ return self._trigger.last_fraction
104
+
91
105
  def process(self, frames: Iterable[Frame]) -> Iterator[MotionImage]:
92
106
  """Turn a stream of frames into encoded motion images.
93
107
 
@@ -135,7 +149,7 @@ class AdaptiveMotionPreprocessor:
135
149
  continue
136
150
 
137
151
  active = self._trigger.update(mask)
138
- window = self._collector.update(frame, mask, active)
152
+ window = self._collector.update(mask, active)
139
153
  if window is not None:
140
154
  yield self._encoder.encode(window, self._sampler.select(window))
141
155
 
@@ -54,41 +54,32 @@ class MotionImage:
54
54
 
55
55
  @dataclass(frozen=True)
56
56
  class Window:
57
- """A full run of consecutive frames, collected while motion lasted.
57
+ """A full run of consecutive foreground masks, collected while motion lasted.
58
58
 
59
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.
60
+ the adaptive sampler measures how fast the foreground changes across
61
+ ``masks``, and the encoder paints the masks the sampler picked. Both
62
+ work on masks alone, so the frames themselves are not carried: a
63
+ window of full-colour frames would cost memory and nothing would
64
+ read it.
66
65
 
67
66
  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.
67
+ ``masks`` is in capture order, one per frame, each a ``uint8``
68
+ ``(H, W)`` array. A window is never empty and never partial: the
69
+ collector emits one only once it is full, so a stage reading this
70
+ never has to ask whether it got a whole one.
73
71
 
74
72
  Attributes:
75
- frames: The frames, in capture order. See ``Frame``.
76
- masks: The foreground mask for each frame, in the same order.
73
+ masks: The foreground mask of each frame, in capture order.
77
74
  """
78
75
 
79
- frames: tuple[Frame, ...]
80
76
  masks: tuple[Frame, ...]
81
77
 
82
78
  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:
79
+ """Ensure there is anything here."""
80
+ if not self.masks:
90
81
  raise ValueError("Window must not be empty")
91
82
 
92
83
  def __len__(self) -> int:
93
84
  """The number of frames in the window."""
94
- return len(self.frames)
85
+ return len(self.masks)
@@ -27,9 +27,8 @@ standing still: a property of the scaffold, not of the sampler.
27
27
 
28
28
 
29
29
  def _window(masks: list[np.ndarray]) -> Window:
30
- """Wrap masks in a window, with blank frames the sampler never reads."""
31
- blanks = tuple(np.zeros((*mask.shape, 3), np.uint8) for mask in masks)
32
- return Window(frames=blanks, masks=tuple(masks))
30
+ """Wrap masks in a window."""
31
+ return Window(masks=tuple(masks))
33
32
 
34
33
 
35
34
  def _moving_window(frames: int, pixels_per_frame: float) -> Window:
@@ -13,27 +13,19 @@ SHAPE = (4, 4)
13
13
  """Small enough to be free, large enough to be a real array."""
14
14
 
15
15
 
16
- def _frame(value: int) -> Frame:
17
- """A frame carrying ``value``, so its place in a window is readable."""
18
- return np.full((*SHAPE, 3), value, dtype=np.uint8)
19
-
20
-
21
16
  def _mask(value: int) -> Frame:
22
- """The matching mask for ``_frame(value)``, carrying the same value."""
17
+ """A mask carrying ``value``, so its place in a window is readable."""
23
18
  return np.full(SHAPE, value, dtype=np.uint8)
24
19
 
25
20
 
26
21
  def _feed(collector: FrameWindowCollector, count: int, active: bool = True, start=0):
27
22
  """Push ``count`` numbered frames through and return what came out."""
28
- return [
29
- collector.update(_frame(i), _mask(i), active)
30
- for i in range(start, start + count)
31
- ]
23
+ return [collector.update(_mask(i), active) for i in range(start, start + count)]
32
24
 
33
25
 
34
26
  def _values(window: Window) -> list[int]:
35
- """The numbers the window's frames were built from, in order."""
36
- return [int(frame[0, 0, 0]) for frame in window.frames]
27
+ """The numbers the window's masks were built from, in order."""
28
+ return [int(mask[0, 0]) for mask in window.masks]
37
29
 
38
30
 
39
31
  def test_default_window_is_ten_frames():
@@ -102,15 +94,21 @@ def test_the_window_carries_its_frames_in_order():
102
94
  assert _values(window) == [0, 1, 2, 3]
103
95
 
104
96
 
105
- def test_every_frame_keeps_its_mask():
106
- """Frames and masks line up by index, which is the whole point of pairing."""
107
- collector = FrameWindowCollector(window_frames=4)
97
+ def test_the_window_holds_the_masks_it_was_given():
98
+ """The very arrays handed in come out, not copies or derivations."""
99
+ collector = FrameWindowCollector(window_frames=3)
100
+ masks = [_mask(i) for i in range(3)]
108
101
 
109
- window = _feed(collector, 4)[-1]
102
+ window = [collector.update(mask, True) for mask in masks][-1]
110
103
 
111
- assert len(window.frames) == len(window.masks) == 4
112
- for frame, mask in zip(window.frames, window.masks, strict=True):
113
- assert int(mask[0, 0]) == int(frame[0, 0, 0])
104
+ assert all(a is b for a, b in zip(window.masks, masks, strict=True))
105
+
106
+
107
+ def test_the_window_carries_no_frames():
108
+ """Nothing downstream reads frames, so none are held in memory."""
109
+ window = _feed(FrameWindowCollector(window_frames=2), 2)[-1]
110
+
111
+ assert not hasattr(window, "frames")
114
112
 
115
113
 
116
114
  def test_the_next_window_starts_on_the_very_next_frame():
@@ -151,7 +149,7 @@ def test_a_partial_window_is_discarded_when_motion_stops():
151
149
 
152
150
  assert collector.pending == 6
153
151
 
154
- assert collector.update(_frame(99), _mask(99), active=False) is None
152
+ assert collector.update(_mask(99), active=False) is None
155
153
  assert collector.pending == 0
156
154
 
157
155
 
@@ -159,7 +157,7 @@ def test_a_discarded_window_is_never_emitted_later():
159
157
  """The dropped fragment does not reappear at the front of the next window."""
160
158
  collector = FrameWindowCollector(window_frames=4)
161
159
  _feed(collector, 3)
162
- collector.update(_frame(99), _mask(99), active=False)
160
+ collector.update(_mask(99), active=False)
163
161
 
164
162
  window = _feed(collector, 4, start=10)[-1]
165
163
 
@@ -179,7 +177,7 @@ def test_motion_stopping_on_a_full_window_loses_nothing():
179
177
  collector = FrameWindowCollector(window_frames=3)
180
178
 
181
179
  results = _feed(collector, 3)
182
- after = collector.update(_frame(99), _mask(99), active=False)
180
+ after = collector.update(_mask(99), active=False)
183
181
 
184
182
  assert isinstance(results[-1], Window)
185
183
  assert after is None
@@ -190,7 +188,7 @@ def test_bursts_are_collected_independently():
190
188
  collector = FrameWindowCollector(window_frames=3)
191
189
 
192
190
  _feed(collector, 2)
193
- collector.update(_frame(99), _mask(99), active=False)
191
+ collector.update(_mask(99), active=False)
194
192
  window = _feed(collector, 3, start=20)[-1]
195
193
 
196
194
  assert _values(window) == [20, 21, 22]
@@ -229,21 +227,15 @@ def test_the_properties_are_read_only(name):
229
227
  setattr(collector, name, 3)
230
228
 
231
229
 
232
- def test_window_rejects_a_mask_count_that_does_not_match():
233
- """A window with a frame missing its mask is not constructible."""
234
- with pytest.raises(ValueError, match="one mask per frame"):
235
- Window(frames=(_frame(0), _frame(1)), masks=(_mask(0),))
236
-
237
-
238
230
  def test_window_rejects_being_empty():
239
231
  """An empty window describes nothing and is never emitted."""
240
232
  with pytest.raises(ValueError, match="must not be empty"):
241
- Window(frames=(), masks=())
233
+ Window(masks=())
242
234
 
243
235
 
244
236
  def test_window_is_frozen():
245
237
  """A window is a handover, not a workspace for the stage that gets it."""
246
- window = Window(frames=(_frame(0),), masks=(_mask(0),))
238
+ window = Window(masks=(_mask(0),))
247
239
 
248
240
  with pytest.raises(AttributeError):
249
- window.frames = ()
241
+ window.masks = ()
@@ -6,8 +6,7 @@ from amprep.types import Window
6
6
 
7
7
 
8
8
  def _window(masks):
9
- frames = tuple(np.zeros((*m.shape, 3), np.uint8) for m in masks)
10
- return Window(frames=frames, masks=tuple(masks))
9
+ return Window(masks=tuple(masks))
11
10
 
12
11
 
13
12
  def _full(h, w, count):
@@ -173,3 +173,58 @@ def test_reset_allows_a_new_frame_size():
173
173
  preprocessor.reset()
174
174
 
175
175
  list(preprocessor.process([np.zeros((30, 40, 3), np.uint8)] * 3)) # no error
176
+
177
+
178
+ # ---- last_motion_fraction ----
179
+
180
+
181
+ def test_last_motion_fraction_starts_at_zero():
182
+ assert AdaptiveMotionPreprocessor().last_motion_fraction == 0.0
183
+
184
+
185
+ def test_last_motion_fraction_reads_the_latest_frame():
186
+ """The block is 20x20 of a 60x80 frame, so about a twelfth of it moves.
187
+
188
+ About, because the default median filter rounds the block's corners
189
+ off before the mask is taken.
190
+ """
191
+ preprocessor = AdaptiveMotionPreprocessor(background_subtractor=_Threshold())
192
+ moving = _scene(0, 1, 0)
193
+
194
+ list(preprocessor.process(moving))
195
+
196
+ assert preprocessor.last_motion_fraction == pytest.approx(
197
+ 20 * 20 / (H * W), rel=0.05
198
+ )
199
+
200
+
201
+ def test_last_motion_fraction_drops_to_zero_on_a_still_frame():
202
+ preprocessor = AdaptiveMotionPreprocessor(background_subtractor=_Threshold())
203
+
204
+ list(preprocessor.process(_scene(0, 1, 0)))
205
+ list(preprocessor.process([np.full((H, W, 3), 120, np.uint8)]))
206
+
207
+ assert preprocessor.last_motion_fraction == 0.0
208
+
209
+
210
+ def test_last_motion_fraction_is_untouched_during_warmup():
211
+ """Warm-up masks are not trusted, so they are not reported either."""
212
+ preprocessor = AdaptiveMotionPreprocessor()
213
+
214
+ list(preprocessor.process(_scene(0, 4, 0))) # all four are warm-up
215
+
216
+ assert preprocessor.last_motion_fraction == 0.0
217
+
218
+
219
+ def test_reset_clears_last_motion_fraction():
220
+ preprocessor = AdaptiveMotionPreprocessor(background_subtractor=_Threshold())
221
+ list(preprocessor.process(_scene(0, 1, 0)))
222
+
223
+ preprocessor.reset()
224
+
225
+ assert preprocessor.last_motion_fraction == 0.0
226
+
227
+
228
+ def test_last_motion_fraction_is_read_only():
229
+ with pytest.raises(AttributeError):
230
+ AdaptiveMotionPreprocessor().last_motion_fraction = 0.5