Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
Expand All @@ -27,7 +27,7 @@ jobs:
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Lint
run: ruff check src tests main.py
run: ruff check src tests scripts main.py
- name: Test
run: pytest -q

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ pyframe clip.gif --prescreen --backend aws # cascade: local gate then AW
pyframe a.gif b.gif c.png --json # batch, machine-readable
```

Exit code: `0` clean, `1` NSFW (per `--fail-on`), `2` bad input, `3` backend not installed, so it drops straight into a shell gate: `pyframe upload.gif || reject`. Equivalent module form: `python -m pyframe clip.gif`.
Exit code: `0` clean, `1` NSFW (per `--fail-on`), `2` bad input, `3` backend not installed, `4` could not classify, so it drops straight into a shell gate: `pyframe upload.gif || reject`. A broken backend exits `4` rather than `0` — if nothing scored, nothing was cleared. Equivalent module form: `python -m pyframe clip.gif`.

### Options

Expand All @@ -98,12 +98,12 @@ Exit code: `0` clean, `1` NSFW (per `--fail-on`), `2` bad input, `3` backend not
| `--backend` | `auto` | `local`, `aws`, or `local:<model-id>` |
| `--model` | model default | HuggingFace model id (local backend) |
| `--region` | `us-east-1` | AWS region (aws backend) |
| `--max-frames` | `10` | frames to extract from a GIF/video |
| `--max-frames` | `10` | frames to extract from a GIF/video (must be ≥ 1) |
| `--min-confidence` | backend default | NSFW threshold (0-1); `0.5` local, `0.8` aws |
| `--sampler` | `motion` | `motion` (bucketing) or `dense` (uniform) |
| `--prescreen` | off | enable the two-stage cascade |
| `--escalate-threshold` | `0.15` | cascade gate (low = recall-safe) |
| `--max-escalations` | `2` | hard cap on precise (AWS) calls per file |
| `--max-escalations` | `2` | hard cap on precise (AWS) calls per file (must be ≥ 1) |
| `--screen-fps` | `2.0` | soft-screen sample rate |
| `--use-merged` / `--frames-per-batch` | off / `2` | merge frames into a grid before classifying |
| `--json` / `--fail-on` | off / `nsfw` | output format / exit-code policy |
Expand All @@ -117,6 +117,8 @@ Exit code: `0` clean, `1` NSFW (per `--fail-on`), `2` bad input, `3` backend not

**Single-pass** (default): extract `max_frames` via motion bucketing, then classify each with one backend.

Decoding is split in two so memory tracks the sample rather than the clip: one pass reads the whole timeline as per-frame metadata (index, timestamp, motion), sampling picks from that, and a second pass fetches pixels for the selected frames only. A 900-frame 640x480 video scans in ~12 MB of frame memory instead of ~444 MB, so long videos are bounded rather than fatal. See [performance](docs/performance.md#memory) for the measurements and what the second pass costs.

**Cascade** (`--prescreen`): a free local model densely soft-screens the whole clip; if any frame scores above `--escalate-threshold` (a deliberately *low* recall gate), the most-suspicious frames are merged into grids and sent to the precise backend, capped at `--max-escalations` calls per file (default 2) so a heavily-flagged clip can never cost more than a single-pass scan. Clean media short-circuits to ~$0 and never hits the expensive backend. Because the soft-screen looks at *content* (not motion), it won't discard a unique suspicious frame the way motion bucketing can, and it fails *open*: a decode/inference error escalates rather than silently clearing.

## Cost
Expand Down
16 changes: 13 additions & 3 deletions docs/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ A clean image scanned with the local backend:
| `source` | string | The input path exactly as passed in. |
| `media_kind` | string | `"image"` or `"animation"` (GIF/video). |
| `verdict` | string | Overall category: `clean`, `uncertain`, `nsfw`, or `error`. See [Verdict values](#verdict-values). |
| `is_nsfw` | bool | The authoritative pass/fail: `true` if any classified frame met the NSFW threshold. Branch on this. |
| `is_nsfw` | bool | The authoritative pass/fail: `true` if and only if `verdict` is `nsfw`. Branch on this. |
| `max_score` | float | Highest NSFW score (0..1) across the classified frames, rounded to 4 dp. |
| `worst_frame` | object \| null | The single highest-scoring frame (a [frame object](#frame-object)), or `null` if nothing was classified. |
| `frames` | array | The [frame objects](#frame-object) that were classified. In a short-circuited clean cascade these are the soft-screen frames. |
Expand Down Expand Up @@ -114,7 +114,10 @@ boolean version; `verdict` adds an "uncertain" band:
| `nsfw` | `max_score >= min_confidence` (threshold default: 0.5 local, 0.8 aws) |
| `uncertain` | `uncertain_threshold <= max_score < min_confidence` (default `uncertain_threshold` 0.3) |
| `clean` | `max_score < uncertain_threshold` |
| `error` | every classified frame failed to score |
| `error` | every classified frame failed to score (CLI exit `4`) |

Media that decodes to zero frames is not `clean` — it raises `MediaDecodeError` (CLI
exit `2`), since nothing ever looked at it.

## Single-pass vs cascade

Expand All @@ -139,8 +142,15 @@ exit code encodes the outcome so it slots into shell gates:
|------|---------|
| `0` | clean |
| `1` | NSFW (subject to `--fail-on`) |
| `2` | bad input (unsupported type, decode error, missing file) |
| `2` | bad input (unsupported type, decode error, missing file, unknown `--backend`, out-of-range option) |
| `3` | backend not installed (missing optional extra) |
| `4` | could not classify: every frame failed to score (`verdict` is `error`) |

Code `4` exists so a broken backend can't be mistaken for a clean file. If credentials
expire or the model fails to load, nothing was ever cleared, so `pyframe upload.gif || reject`
must reject rather than accept. The reason is printed to stderr. `--fail-on never` still
exits `0` in every case, including this one — it's the explicit "don't gate me, I only
want the JSON" escape hatch.

```bash
pyframe upload.gif --backend local || echo "rejected"
Expand Down
29 changes: 27 additions & 2 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,33 @@ Per-core throughput is the unit that transfers between machines, not a box total

## Memory

~0.5 GB resident per worker (model weights + buffers). Memory is not the bottleneck for
this path.
~0.5 GB resident per worker (model weights + buffers). Decoding adds only the sample, not
the clip: frames are selected against per-frame metadata first, and pixels are fetched in
a second pass for the selected frames alone. Peak decode memory is therefore bounded by
`max_frames` (or by `frames_per_batch * max_escalations` on an escalation), not by the
length of the media.

Measured on a 900 frame 640x480 clip, `max_frames=10`, backend stubbed:

| | peak RSS | growth over baseline |
|---|---|---|
| holding every decoded frame | 504 MB | 444 MB |
| two pass decode | 82 MB | 12 MB |

The clip holds 829 MB of raw frame data, so the old shape scaled with the file: a 60
second 1080p30 video is roughly 11 GB and does not complete.

### What the second pass costs

Two sequential decodes instead of one, unconditionally. Unwanted frames are skipped with
`grab()` rather than `read()`, and a third pass happens only when the cascade escalates,
so clean media stops at two.

On the same clip that is 3.4s to 6.0s of wall clock, because a stubbed backend makes the
run purely decode bound. That ratio is the worst case, not the typical one: the per-stage
table above measures inference at ~91% of a real GIF scan, so a second decode moves a
much smaller share when a model is actually running. It is the right trade either way,
since the alternative on a long video is not a faster scan but an exhausted machine.

## Decode: file path vs in-memory (`scan_bytes`)

Expand Down
13 changes: 12 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "pyframe-gif-video-image-moderation"
version = "0.4.0"
version = "0.5.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"
Expand All @@ -28,6 +28,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Operating System :: OS Independent",
"Topic :: Multimedia :: Graphics",
"Topic :: Scientific/Engineering :: Image Recognition",
Expand Down Expand Up @@ -65,3 +66,13 @@ only-include = ["src/pyframe", "tests", "README.md", "LICENSE"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.ruff]
line-length = 110
target-version = "py310"

[tool.ruff.lint]
# Ruff's current defaults, pinned explicitly so a future release changing them can't
# silently widen or narrow what CI enforces. Widening the set (I, UP, B) is worth doing
# but touches files unrelated to any bug, so it belongs in its own change.
select = ["E4", "E7", "E9", "F"]
24 changes: 21 additions & 3 deletions src/pyframe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,19 @@
UnsupportedMediaError,
)
from .image_utils import merge_images_to_grid, merge_to_grid
from .media import Frame, MediaKind, iter_frames, iter_frames_from_bytes, media_kind
from .media import (
Frame,
FrameLike,
FrameMeta,
MediaKind,
iter_frame_meta,
iter_frame_meta_from_bytes,
iter_frames,
iter_frames_at,
iter_frames_from_bytes,
iter_frames_from_bytes_at,
media_kind,
)
from .pipe import Pipe, scan, scan_bytes
from .results import Label, ScanResult, Severity, Verdict
from .scanner import Scanner
Expand All @@ -20,9 +32,9 @@
try:
__version__ = version("pyframe-gif-video-image-moderation")
except PackageNotFoundError:
__version__ = "0.4.0"
__version__ = "0.5.0"
except Exception:
__version__ = "0.4.0"
__version__ = "0.5.0"

__all__ = [
"Pipe",
Expand All @@ -39,9 +51,15 @@
"load_backend",
"clear_backend_cache",
"Frame",
"FrameMeta",
"FrameLike",
"MediaKind",
"iter_frames",
"iter_frames_from_bytes",
"iter_frame_meta",
"iter_frame_meta_from_bytes",
"iter_frames_at",
"iter_frames_from_bytes_at",
"media_kind",
"merge_to_grid",
"merge_images_to_grid",
Expand Down
10 changes: 8 additions & 2 deletions src/pyframe/backends/base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import abc
from typing import Sequence
from collections.abc import Iterable

from ..media import Frame
from ..results import Label, Verdict
Expand Down Expand Up @@ -53,5 +53,11 @@ def classify(self, frame: Frame, *, min_confidence: float = 0.8) -> Verdict:
timestamp=frame.timestamp,
)

def classify_batch(self, frames: Sequence[Frame], *, min_confidence: float = 0.8) -> list[Verdict]:
def classify_batch(self, frames: Iterable[Frame], *, min_confidence: float = 0.8) -> list[Verdict]:
"""Score frames in order.

`frames` may be a lazy iterable that decodes as it is consumed, so an override
should iterate it once and not index, len() or retain it. Materialising it puts
the whole sample in memory, which on a long video is the thing this avoids.
"""
return [self.classify(f, min_confidence=min_confidence) for f in frames]
21 changes: 19 additions & 2 deletions src/pyframe/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ def _print_human(result: ScanResult) -> None:
head += ", escalated)" if result.escalated else ", short-circuit clean)"
print(head)

if result.verdict is Severity.ERROR:
reason = next((f.error for f in result.frames if f.error), None)
if reason:
# Flush first: stdout is block-buffered when piped but stderr isn't, so
# without this the reason lands above the line it explains.
sys.stdout.flush()
print(f" could not classify: {reason}", file=sys.stderr)

for frame in result.flagged_frames:
names = ", ".join(label.name for label in frame.labels) or "flagged"
print(f" t={frame.timestamp:.2f}s {frame.score:.2f} {names}")
Expand Down Expand Up @@ -53,7 +61,7 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--frames-per-batch", type=int, default=2)
parser.add_argument("--prescreen", action="store_true", help="enable the two-stage cascade")
parser.add_argument("--escalate-threshold", type=float, default=0.15, help="cascade gate (low = recall-safe)")
parser.add_argument("--max-escalations", type=int, default=2, help="max precise (AWS) calls per file")
parser.add_argument("--max-escalations", type=int, default=2, help="max precise (AWS) calls per file (>= 1)")
parser.add_argument("--screen-fps", type=float, default=2.0, help="soft-screen sample rate")
parser.add_argument("--json", action="store_true", help="machine-readable output")
parser.add_argument("--fail-on", choices=("nsfw", "uncertain", "never"), default="nsfw")
Expand Down Expand Up @@ -90,6 +98,10 @@ def main() -> int:
except BackendUnavailableError as exc:
print(exc, file=sys.stderr)
return 3
except ValueError as exc:
# Unknown --backend, or an out-of-range knob: bad input, not a missing extra.
print(f"error: {exc}", file=sys.stderr)
return 2

rc = 0
for path in args.paths:
Expand All @@ -105,7 +117,12 @@ def main() -> int:
else:
_print_human(result)

if args.fail_on == "nsfw" and result.is_nsfw:
if result.verdict is Severity.ERROR and args.fail_on != "never":
# Nothing scored, so nothing was cleared. Exiting 0 here would make
# `pyframe upload.gif || reject` accept every upload while the backend is
# down. --fail-on never stays the explicit "don't gate me" escape hatch.
rc = max(rc, 4)
elif args.fail_on == "nsfw" and result.is_nsfw:
rc = max(rc, 1)
elif args.fail_on == "uncertain" and result.verdict in (Severity.NSFW, Severity.UNCERTAIN):
rc = max(rc, 1)
Expand Down
Loading
Loading