From 98b676f6f399892e3ef30038e4015dbfbf208c08 Mon Sep 17 00:00:00 2001 From: Ellis Hewes Date: Sun, 7 Jun 2026 21:46:14 +0100 Subject: [PATCH 1/3] Preserve time-coverage floor in motion sampler Replace direct MotionBucketSampler selection for the 'motion' sampler with a new _motion_select_with_floor method that always includes a time-coverage floor (DenseUniformSampler at prescreen.screen_fps) and then fills remaining budget with highest-motion frames not already in the floor. If the floor already fills the budget, motion sampling trims the floor with MotionBucketSampler. Returns frames sorted by index. Add a unit test to ensure the floor is never dropped, the max_frames cap is respected, and results are index-ordered. --- src/pyframe/scanner.py | 25 ++++++++++++++++++++++++- tests/test_scanner.py | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/pyframe/scanner.py b/src/pyframe/scanner.py index c0f6a96..d454a2f 100644 --- a/src/pyframe/scanner.py +++ b/src/pyframe/scanner.py @@ -66,7 +66,7 @@ def _single_pass(self, source, kind, frames, start) -> ScanResult: if len(selected) > cfg.max_frames: selected = MotionBucketSampler().select(selected, cfg.max_frames) else: - selected = MotionBucketSampler().select(frames, cfg.max_frames) + selected = self._motion_select_with_floor(frames) if cfg.use_merged: verdicts = self._classify_merged(selected) @@ -74,6 +74,29 @@ def _single_pass(self, source, kind, frames, start) -> ScanResult: verdicts = self.precise.classify_batch(selected, min_confidence=self.min_confidence) return self._aggregate(source, kind, verdicts, [], len(frames), start) + def _motion_select_with_floor(self, frames): + # Recall floor for the default (motion) sampler. The uniform-by-time sample at + # screen_fps bounds the sampling stride, so no NSFW event longer than that stride + # can fall entirely between selected frames. Motion is content-blind (it can keep + # a moving SFW frame over a static NSFW one in the same region), so it only ever + # spends the *spare* budget, never replaces the time-coverage floor. + cfg = self.config + floor = DenseUniformSampler(cfg.prescreen.screen_fps).select(frames) + if len(floor) >= cfg.max_frames: + # The floor already fills the budget; motion only decides what to drop, + # exactly as the `dense` path trims its own uniform sample. + return MotionBucketSampler().select(floor, cfg.max_frames) + # Spare budget: keep the whole time-coverage floor, then fill the remainder with + # the highest-motion frames the floor did not already include. + have = {f.index for f in floor} + extra = sorted( + (f for f in frames if f.index not in have), + key=lambda f: f.motion_score, + reverse=True, + ) + selected = floor + extra[: cfg.max_frames - len(floor)] + return sorted(selected, key=lambda f: f.index) + def _cascade(self, source, kind, frames, start) -> ScanResult: cfg = self.config pc = cfg.prescreen diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 98a629e..ee60945 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -116,3 +116,26 @@ def _score(self, image): scanner = _scanner(FakeBackend("aws", cost=0.001), screen=BrokenScreen(), enabled=True, fail_open=True) result = scanner._cascade("clip.gif", MediaKind.ANIMATION, frames, time.perf_counter()) assert result.frames_classified > 0 # errors were escalated, not silently cleared + + +def test_motion_sampler_always_includes_time_coverage_floor(): + from pyframe.sampling import DenseUniformSampler + + # 40 frames at 10 fps (4s). Motion is concentrated in the first 10 frames; the + # rest are static. Pure motion bucketing over-picks the busy head and leaves whole + # static time slices unsampled. The recall floor must still cover them. + frames = [ + Frame(index=i, timestamp=i / 10.0, image=np.zeros((8, 8, 3), np.uint8), + motion_score=100.0 if i < 10 else 0.0) + for i in range(40) + ] + cfg = Config(sampler="motion", max_frames=10, prescreen=PrescreenConfig(screen_fps=2.0)) + scanner = Scanner(FakeBackend(), config=cfg) + + selected = scanner._motion_select_with_floor(frames) + floor_idx = {f.index for f in DenseUniformSampler(2.0).select(frames)} + selected_idx = {f.index for f in selected} + + assert floor_idx <= selected_idx # time-coverage floor is never dropped for motion + assert len(selected) <= cfg.max_frames # cost cap still respected + assert [f.index for f in selected] == sorted(selected_idx) # returned in index order From 8b95f4afae028c011226557d666f48b274a53eda Mon Sep 17 00:00:00 2001 From: Ellis Hewes Date: Sun, 7 Jun 2026 21:51:38 +0100 Subject: [PATCH 2/3] Add temporal action segmentation reference Document that the temporal sampling and windowing design is informed by Ding et al. (2023). Adds a References section and bibtex entry to docs/README.md, an explanatory module docstring to src/pyframe/sampling.py, and a short inline reference comment in src/pyframe/scanner.py to link the implementation rationale to the survey. --- README.md | 4 ++++ docs/README.md | 20 ++++++++++++++++++++ src/pyframe/sampling.py | 13 +++++++++++++ src/pyframe/scanner.py | 2 ++ 4 files changed, 39 insertions(+) diff --git a/README.md b/README.md index d3fe62d..5b96a90 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,10 @@ The documentation home is **[eden.report/docs](https://www.eden.report/docs)**: Reference docs also live in [`docs/`](https://github.com/ehewes/pyframe/tree/main/docs); start with the [output reference](https://github.com/ehewes/pyframe/blob/main/docs/output.md) for the complete JSON / `ScanResult` schema. +## Citation + +PyFrame's temporal sampling design is informed by the temporal action segmentation survey of Ding, Sener, and Yao ([arXiv:2210.10352](https://arxiv.org/abs/2210.10352)). Full citation and BibTeX: [`docs/README.md`](https://github.com/ehewes/pyframe/blob/main/docs/README.md#references). + ## Notes - The `aws` backend needs credentials: install with `pip install "pyframe-gif-video-image-moderation[aws]"`, then run `aws configure` (or set `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_DEFAULT_REGION`). diff --git a/docs/README.md b/docs/README.md index b124e90..d432f02 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,3 +14,23 @@ here and link them from the list below. - [eden.report/docs](https://www.eden.report/docs) - the documentation website: expanded guides and an annotated live pipeline diagram. - Project [README](../README.md) - install, quickstart, CLI, and the pipeline diagram. + +## References + +The temporal sampling and windowing design (uniform-by-time coverage as a recall floor, +motion as a content-blind cost lever, and grouping flagged frames into temporal windows) +is informed by the temporal action segmentation survey: + +> Guodong Ding, Fadime Sener, and Angela Yao. "Temporal Action Segmentation: An Analysis of Modern Techniques." arXiv:2210.10352, 2023. https://arxiv.org/abs/2210.10352 + +```bibtex +@misc{ding2023temporalactionsegmentationanalysis, + title={Temporal Action Segmentation: An Analysis of Modern Techniques}, + author={Guodong Ding and Fadime Sener and Angela Yao}, + year={2023}, + eprint={2210.10352}, + archivePrefix={arXiv}, + primaryClass={cs.CV}, + url={https://arxiv.org/abs/2210.10352}, +} +``` diff --git a/src/pyframe/sampling.py b/src/pyframe/sampling.py index 4fe8587..8a59e77 100644 --- a/src/pyframe/sampling.py +++ b/src/pyframe/sampling.py @@ -1,3 +1,16 @@ +"""Frame sampling strategies for temporal coverage and cost control. + +The temporal sampling and windowing design here is informed by the temporal action +segmentation survey of Ding, Sener, and Yao: its treatment of temporal coverage and +local-continuity priors, and the observation that motion features are decoupled from +static semantic content. Accordingly we use uniform-by-time sampling as a recall floor +(DenseUniformSampler), treat motion as a content-blind cost lever (MotionBucketSampler), +and group flagged frames into temporal windows (group_flagged_into_windows). + + Ding, G., Sener, F., and Yao, A. "Temporal Action Segmentation: An Analysis of + Modern Techniques." arXiv:2210.10352, 2023. https://arxiv.org/abs/2210.10352 +""" + from __future__ import annotations from typing import Iterable, Mapping, Sequence diff --git a/src/pyframe/scanner.py b/src/pyframe/scanner.py index d454a2f..6ae9c3b 100644 --- a/src/pyframe/scanner.py +++ b/src/pyframe/scanner.py @@ -80,6 +80,8 @@ def _motion_select_with_floor(self, frames): # can fall entirely between selected frames. Motion is content-blind (it can keep # a moving SFW frame over a static NSFW one in the same region), so it only ever # spends the *spare* budget, never replaces the time-coverage floor. + # cf. Ding, Sener, and Yao, arXiv:2210.10352 (temporal coverage as a prior, and + # the decoupling of motion from static semantic content). cfg = self.config floor = DenseUniformSampler(cfg.prescreen.screen_fps).select(frames) if len(floor) >= cfg.max_frames: From 7d6dda6f0f9bd68f420919c3e7c83440e9cf5ea2 Mon Sep 17 00:00:00 2001 From: Ellis Hewes Date: Mon, 8 Jun 2026 19:02:35 +0100 Subject: [PATCH 3/3] Bump version to 0.4.0 --- .claude/skills/bump-package/SKILL.md | 84 ++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/pyframe/__init__.py | 4 +- 3 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 .claude/skills/bump-package/SKILL.md diff --git a/.claude/skills/bump-package/SKILL.md b/.claude/skills/bump-package/SKILL.md new file mode 100644 index 0000000..18bb715 --- /dev/null +++ b/.claude/skills/bump-package/SKILL.md @@ -0,0 +1,84 @@ +--- +name: bump-package +description: Review everything committed since the last version bump, decide the semver increment from this repo's standards and bump history, apply it across every version file, refresh the editable install metadata, and return a ready-to-use commit message. Use when asked to "bump the package/version", cut a release, or add a version bump to a PR. +--- + +# Bump package version + +Decide and apply the next version for this package, grounded in what actually changed since the last bump, then hand back a commit message. Do not invent a version; derive it from the diff and the repo's history. + +Optional argument: an explicit level (`major` / `minor` / `patch`) or an exact version (`0.4.0`) overrides the decision in step 3. With no argument, decide it. + +## 1. Find the current version and the last bump + +- Current version: the `version = "X.Y.Z"` line in `pyproject.toml` (the `[project]` table). +- Last bump commit (the commit that last changed that line): + ```bash + git log --oneline -G '^version = ' -- pyproject.toml | head -1 + ``` + Fall back to scanning `git log --oneline` for the most recent `Bump version` / `package bump` commit. + +## 2. Review what landed since then + +```bash +git log ..HEAD --oneline +git diff ..HEAD --stat +``` +Read the substantive diffs, not just the stat. Classify what changed: +- Public API: any symbol added, removed, renamed, or re-signatured among `src/pyframe/__init__.py`'s `__all__` and the modules it exports. +- Default behavior: anything that changes what the library does out of the box (samplers, thresholds, backends, default output shape, exit codes). +- Docs / comments / tests / internal refactor only. + +## 3. Decide the increment + +This package is `0.x` (alpha), and every historical bump has been a minor `0.x.0` (there is no patch-level precedent). Use semver, adjusted for that convention: + +- **Major** (`X+1.0.0`): a breaking change a user must react to. While the project is `0.x` this is normally still shipped as a minor; only go major after `1.0`. +- **Minor** (`0.Y+1.0`): a new backward-compatible feature, or a change to default runtime behavior (for example a sampler or threshold change). This is the common case here. +- **Patch** (`0.Y.Z+1`): docs-only, internal refactor, or a pure bug fix with no behavior or API change. + +When in doubt and the change is user-visible, prefer **minor** to match this repo's cadence. State the chosen level and a one-line reason. + +## 4. Apply the bump in every location + +Update every version string, not just `pyproject.toml`: +- `pyproject.toml` -> `version = "X.Y.Z"` +- `src/pyframe/__init__.py` -> both fallback lines `__version__ = "X.Y.Z"` + +Then confirm nothing was missed: +```bash +grep -rn "" --include="*.py" --include="*.toml" --include="*.md" . | grep -v "/.venv/" +``` + +## 5. Refresh the installed metadata + +`__version__` resolves from the installed dist metadata first, so an editable install keeps reporting the OLD version until it is reinstalled: +```bash +.venv/bin/python -m pip install -e . --no-deps -q # use the project venv if one exists +.venv/bin/python -c "import pyframe; print(pyframe.__version__)" # must print X.Y.Z +.venv/bin/pyframe --version # must print: pyframe X.Y.Z +``` +Run the tests to confirm nothing broke: `.venv/bin/python -m pytest -q`. + +## 6. Return the commit message + +Match the repo's style: subject `Bump version to X.Y.Z`, imperative and short. Add a brief body summarizing what landed since the last bump and why this increment. Do not use em dashes anywhere in the message (user preference). + +Example: +``` +Bump version to 0.4.0 + +Recall-floor fix to the default motion sampler now guarantees time coverage, +a user-visible default-behavior change, plus the temporal action segmentation +reference. +``` + +## Output + +Present, in this order: +1. current -> new version, with the increment level and its one-line reason +2. the files changed (and the grep confirming no stray old version strings) +3. the verification result (`pyframe --version` and tests) +4. the commit message in a copy-pasteable block + +Do not run `git commit` unless explicitly asked. Leave the bump staged for the user to commit, and offer to commit it if they want. diff --git a/pyproject.toml b/pyproject.toml index 10cb848..ef45000 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyframe-gif-video-image-moderation" -version = "0.3.0" +version = "0.4.0" description = "Two-stage NSFW moderation for GIFs, videos, and images via local HuggingFace models and/or AWS Rekognition." readme = "README.md" requires-python = ">=3.10" diff --git a/src/pyframe/__init__.py b/src/pyframe/__init__.py index feef6ea..aa7961d 100644 --- a/src/pyframe/__init__.py +++ b/src/pyframe/__init__.py @@ -20,9 +20,9 @@ try: __version__ = version("pyframe-gif-video-image-moderation") except PackageNotFoundError: - __version__ = "0.3.0" + __version__ = "0.4.0" except Exception: - __version__ = "0.3.0" + __version__ = "0.4.0" __all__ = [ "Pipe",