diff --git a/README.md b/README.md
index 1be55ba..d3fe62d 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,17 @@ NSFW moderation for GIFs, videos, and images using local [HuggingFace](https://h
PyFrame uses **temporal segmentation** to avoid moderating every frame: it splits an animation into equal time buckets and extracts the most significant frame from each, capturing diverse scene coverage at a fraction of the cost. It also offers an optional **two-stage cascade** (`--prescreen`): a free local model soft-screens densely, and only the flagged time windows get escalated to the precise (e.g. AWS) backend. See the [pipeline diagram](#pipeline) for a visual of the approach.
+
+
+[](https://pypi.org/project/pyframe-gif-video-image-moderation/)
+[](https://pepy.tech/project/pyframe-gif-video-image-moderation)
+[](https://pypi.org/project/pyframe-gif-video-image-moderation/)
+[](https://github.com/ehewes/pyframe/blob/main/LICENSE)
+[](https://github.com/ehewes/pyframe/actions/workflows/ci.yml)
+[](https://www.eden.report/docs)
+
+
+
## Install
```bash
@@ -41,6 +52,14 @@ Pipe("clip.gif", backend="aws").run() # AWS Rekognition
Pipe("clip.gif", backend="aws", prescreen=True).run() # local screens, AWS confirms
```
+Scan raw bytes (e.g. a download) with **no disk touched** at all:
+
+```python
+from pyframe import scan_bytes
+
+result = scan_bytes(gif_bytes, backend="local") # GIF/image decoded in memory
+```
+
### Tuning the two-pass
Every knob is a `Pipe` param with a sensible default:
@@ -87,7 +106,6 @@ Exit code: `0` clean, `1` NSFW (per `--fail-on`), `2` bad input, `3` backend not
| `--max-escalations` | `2` | hard cap on precise (AWS) calls per file |
| `--screen-fps` | `2.0` | soft-screen sample rate |
| `--use-merged` / `--frames-per-batch` | off / `2` | merge frames into a grid before classifying |
-| `--save-frames DIR` | off | write the classified frames to `DIR` |
| `--json` / `--fail-on` | off / `nsfw` | output format / exit-code policy |
## How it works
@@ -113,6 +131,14 @@ A 150-frame GIF flows through temporal segmentation down to a handful of extract

+A short, annotated **live** version of this diagram is at **[eden.report/docs](https://www.eden.report/docs)**.
+
+## Documentation
+
+The documentation home is **[eden.report/docs](https://www.eden.report/docs)**: the fullest guides plus a short annotated live diagram of the pipeline.
+
+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.
+
## 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
new file mode 100644
index 0000000..b124e90
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,16 @@
+# PyFrame documentation
+
+Reference documentation for PyFrame. This folder grows over time; add new pages
+here and link them from the list below.
+
+> **Documentation home:** [eden.report/docs](https://www.eden.report/docs) hosts the fullest version of these docs, including a short annotated live diagram of the pipeline. The pages in this folder are the in-repo reference mirror.
+
+## Contents
+
+- [Output reference](output.md) - every field in the JSON / `ScanResult`, explained.
+- [Performance](performance.md) - measured throughput, per-stage timing, capacity sizing, and a results log.
+
+## See also
+
+- [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.
diff --git a/docs/output.md b/docs/output.md
new file mode 100644
index 0000000..7d527a0
--- /dev/null
+++ b/docs/output.md
@@ -0,0 +1,151 @@
+# Output reference
+
+Every PyFrame scan returns a `ScanResult`. This page documents its full JSON
+shape, field by field.
+
+## Getting the output
+
+CLI (machine-readable):
+
+```bash
+pyframe clip.gif --backend local --json
+```
+
+Python:
+
+```python
+from pyframe import Pipe
+
+result = Pipe("clip.gif", backend="local").run()
+
+result.to_json() # JSON string
+result.to_dict() # plain dict
+result.is_nsfw # bool -> the authoritative pass/fail
+result.verdict # Severity -> prints as "clean" / "uncertain" / "nsfw" / "error"
+result.max_score # float 0..1
+result.flagged_frames # list of per-frame results where is_nsfw is True
+```
+
+## Example
+
+A clean image scanned with the local backend:
+
+```json
+{
+ "source": "media/example.jpeg",
+ "media_kind": "image",
+ "verdict": "clean",
+ "is_nsfw": false,
+ "max_score": 0.0204,
+ "worst_frame": {
+ "score": 0.0204,
+ "is_nsfw": false,
+ "backend": "local",
+ "frame_index": 0,
+ "timestamp": 0.0,
+ "labels": [
+ { "name": "sfw", "confidence": 0.9795508384704590 },
+ { "name": "nsfw", "confidence": 0.0204491075128317 }
+ ]
+ },
+ "frames": [ { "...": "same shape as worst_frame" } ],
+ "backends_used": ["local"],
+ "frames_total": 1,
+ "frames_screened": 0,
+ "frames_classified": 1,
+ "cost_usd": 0.0,
+ "prescreen_used": false,
+ "escalated": null,
+ "windows": 0,
+ "elapsed_s": 0.656
+}
+```
+
+## Top-level fields
+
+| Field | Type | Meaning |
+|-------|------|---------|
+| `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. |
+| `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. |
+| `backends_used` | string[] | Which backends produced scores, e.g. `["local"]` or `["local", "aws"]` for a cascade. |
+| `frames_total` | int | Total frames decoded from the media (1 for an image). |
+| `frames_screened` | int | Frames the cheap soft-screen looked at. `0` in single-pass. |
+| `frames_classified` | int | Frames/grids the precise backend classified. In a cascade this equals the number of precise (e.g. AWS) calls made. |
+| `cost_usd` | float | Estimated total cost: precise calls plus screen calls, each times that backend's per-image price. `0.0` for local-only. |
+| `prescreen_used` | bool | Whether the two-pass cascade was enabled. |
+| `escalated` | bool \| null | Cascade only: `true` if it escalated to the precise backend, `false` if it short-circuited clean. `null` for single-pass and images. |
+| `windows` | int | Cascade only: how many distinct flagged time-regions the soft-screen found. `0` otherwise. Informational; it does not drive cost. |
+| `elapsed_s` | float \| null | Wall-clock seconds for the scan. |
+
+## Frame object
+
+Each entry in `frames` (and `worst_frame`) is one classified frame:
+
+| Field | Type | Meaning |
+|-------|------|---------|
+| `score` | float | NSFW score 0..1 for this frame, rounded to 4 dp. |
+| `is_nsfw` | bool | `true` if `score` met the active threshold (`min_confidence`). |
+| `backend` | string | Which backend scored it: `"local"` or `"aws"`. |
+| `frame_index` | int | Index of the frame in the decoded sequence. `-1` or a batch index for a merged grid. |
+| `timestamp` | float | Seconds into the clip, rounded to 3 dp. `0.0` for images. |
+| `labels` | array | Raw [labels](#label-object) the backend returned. |
+| `error` | string | Present only if the backend errored on this frame (then `score` is `0` and `is_nsfw` is `false`). |
+
+## Label object
+
+| Field | Type | Meaning |
+|-------|------|---------|
+| `name` | string | Backend label. Local models use their own set (e.g. `sfw`/`nsfw`); AWS uses moderation names (e.g. `Explicit Nudity`). |
+| `confidence` | float | The backend's confidence for that label, 0..1 (full precision). |
+| `taxonomy` | string | Optional parent category. AWS only (its `ParentName`); absent for local backends. |
+
+## Verdict values
+
+`verdict` is derived from `max_score` and the thresholds. `is_nsfw` is the
+boolean version; `verdict` adds an "uncertain" band:
+
+| Value | Condition |
+|-------|-----------|
+| `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 |
+
+## Single-pass vs cascade
+
+The same schema is returned either way; these fields change with the mode:
+
+| Field | Single-pass (default) | Cascade (`prescreen=True`) |
+|-------|-----------------------|----------------------------|
+| `prescreen_used` | `false` | `true` |
+| `frames_screened` | `0` | number of soft-screen frames |
+| `escalated` | `null` | `true` (hit precise backend) or `false` (short-circuited clean) |
+| `windows` | `0` | number of flagged regions found |
+| `frames_classified` | up to `max_frames` | up to `max_escalations` (merged grids) |
+| `backends_used` | the one backend | `["local"]` if clean, `["local", "aws"]` if escalated |
+| `cost_usd` | per-frame precise cost | `0` if short-circuited, else up to `max_escalations` precise calls |
+
+## CLI exit codes
+
+When run as `pyframe ... ` (without `--json` you still get these), the process
+exit code encodes the outcome so it slots into shell gates:
+
+| Code | Meaning |
+|------|---------|
+| `0` | clean |
+| `1` | NSFW (subject to `--fail-on`) |
+| `2` | bad input (unsupported type, decode error, missing file) |
+| `3` | backend not installed (missing optional extra) |
+
+```bash
+pyframe upload.gif --backend local || echo "rejected"
+```
+
+---
+
+More docs, including a short annotated live diagram of the pipeline, are at **[eden.report/docs](https://www.eden.report/docs)**.
diff --git a/docs/performance.md b/docs/performance.md
new file mode 100644
index 0000000..83cbcb5
--- /dev/null
+++ b/docs/performance.md
@@ -0,0 +1,82 @@
+# Performance
+
+How PyFrame's GIF moderation path (decode -> motion-sample -> local ViT prescreen ->
+gate) performs, and how to reproduce it.
+
+Absolute numbers are **illustrative** (single CPU core, single-threaded) and scale with
+hardware and model. The library-relevant takeaway is the *shape*, which is
+hardware-independent: the pipeline is **inference-bound**.
+
+Pinned config for every number below: `prescreen` on, `screen_fps=2.0`,
+`escalate_threshold=0.15`, `frames_per_batch=2`, model `AdamCodd/vit-base-nsfw-detector`,
+torch CPU single-threaded, AWS stubbed (so this is local throughput only).
+
+## Per-stage timing (the defining characteristic)
+
+
+
+| Stage | Share | Illustrative |
+|-------|-------|--------------|
+| decode + sample | ~9% | 14 ms |
+| preprocess (to PIL) | ~0.1% | 0.2 ms |
+| **inference (ViT)** | **~91%** | 239 ms |
+| gate | ~0% | <0.01 ms |
+
+The ViT forward pass is essentially the entire cost (`f ~ 0.91`). Decode, sampling, and
+the gate are negligible, so the only things that move throughput are the **model**
+(smaller / quantized) and the **backend** (CPU vs GPU). The proportions hold across
+hardware; only the absolute milliseconds change.
+
+## Throughput & latency (single worker, one core)
+
+
+
+- **~3 GIFs/s per core** (~11k GIFs/hr), single-threaded.
+- per-GIF latency: p50 ~250 ms, p95 ~1.1 s. The spread tracks GIF size (more frames =
+ more ViT calls), not load.
+
+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.
+
+## Decode: file path vs in-memory (`scan_bytes`)
+
+60-frame 320px GIF, 25 runs:
+
+| Decoder | Median |
+|---------|--------|
+| cv2 file path (`iter_frames`) | 39 ms |
+| in-memory bytes (`scan_bytes`) | 31 ms |
+
+In-memory is ~21% faster (skips cv2/ffmpeg per-open overhead) and never touches disk.
+Against ~239 ms of inference, the difference is noise.
+
+## Notes
+
+- **Inference-bound (`f ~ 0.91`):** the model and the backend are the only real levers;
+ decode and sampling are already negligible.
+- **Concurrency scaling is hardware-dependent.** Many single-threaded workers contend on
+ memory bandwidth, so per-worker throughput can drop under load. Measure on your own
+ target rather than multiplying the single-core number blindly.
+
+## Reproduce
+
+```bash
+python scripts/bench_decode.py # decode comparison
+```
+
+## Results log
+
+Append a row when you measure on new hardware.
+
+| Run | Backend / threads | GIFs/s per core | f (inference share) | RSS/worker |
+|-----|-------------------|-----------------|---------------------|------------|
+| reference | torch CPU, 1 thread | ~3.1 | ~0.91 | ~0.5 GB |
+| | | | | |
+
+---
+
+More docs, including a short annotated live diagram of the pipeline, are at **[eden.report/docs](https://www.eden.report/docs)**.
diff --git a/media/perf_latency.png b/media/perf_latency.png
new file mode 100644
index 0000000..5f81bbd
Binary files /dev/null and b/media/perf_latency.png differ
diff --git a/media/perf_stages.png b/media/perf_stages.png
new file mode 100644
index 0000000..6d9af86
Binary files /dev/null and b/media/perf_stages.png differ
diff --git a/pyproject.toml b/pyproject.toml
index 89bf1a9..369c918 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "pyframe-gif-video-image-moderation"
-version = "0.1.0"
+version = "0.2.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"
@@ -61,7 +61,7 @@ Issues = "https://github.com/ehewes/pyframe/issues"
packages = ["src/pyframe"]
[tool.hatch.build.targets.sdist]
-include = ["src/pyframe", "README.md", "LICENSE", "tests"]
+only-include = ["src/pyframe", "tests", "README.md", "LICENSE"]
[tool.pytest.ini_options]
testpaths = ["tests"]
diff --git a/scripts/bench_decode.py b/scripts/bench_decode.py
new file mode 100644
index 0000000..4de1ca7
--- /dev/null
+++ b/scripts/bench_decode.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+"""Micro-benchmark: cv2 file-path decode vs in-memory Pillow decode (the scan_bytes path).
+
+Shows the per-GIF decode cost of each, so you can see the millisecond delta against
+the ~789 ms ViT inference that dominates total time.
+
+ python scripts/bench_decode.py [path/to.gif] # synthesizes one if omitted
+"""
+
+import os
+import statistics
+import sys
+import tempfile
+import time
+
+import numpy as np
+from PIL import Image
+
+from pyframe.media import iter_frames, iter_frames_from_bytes
+
+
+def synth(path, n=60, w=320):
+ h = int(w * 0.6)
+ frames = []
+ for i in range(n):
+ a = np.random.randint(0, 255, (h, w, 3), np.uint8)
+ x = int(i / n * (w - 24))
+ a[h // 2 - 8 : h // 2 + 8, x : x + 24] = 255
+ frames.append(Image.fromarray(a))
+ frames[0].save(path, save_all=True, append_images=frames[1:], duration=66, loop=0)
+
+
+if __name__ == "__main__":
+ path = sys.argv[1] if len(sys.argv) > 1 else None
+ if not path:
+ path = os.path.join(tempfile.mkdtemp(), "decode_bench.gif")
+ synth(path)
+ data = open(path, "rb").read()
+
+ N = 25
+ cv2_ms, mem_ms = [], []
+ for _ in range(N):
+ t = time.perf_counter()
+ n1 = len(list(iter_frames(path)))
+ cv2_ms.append((time.perf_counter() - t) * 1000)
+ t = time.perf_counter()
+ n2 = len(list(iter_frames_from_bytes(data)))
+ mem_ms.append((time.perf_counter() - t) * 1000)
+
+ c, m = statistics.median(cv2_ms), statistics.median(mem_ms)
+ print(f"GIF: {n1} frames (cv2) / {n2} frames (mem), {len(data) / 1e6:.2f} MB, {N} runs")
+ print(f" cv2 file-path decode: median {c:6.1f} ms")
+ print(f" in-memory bytes decode: median {m:6.1f} ms")
+ delta = m - c
+ print(f" delta: {delta:+.1f} ms ({delta / c * 100:+.0f}%) -- vs ~789 ms ViT inference, this is noise")
diff --git a/src/pyframe/__init__.py b/src/pyframe/__init__.py
index a582a42..7800fc1 100644
--- a/src/pyframe/__init__.py
+++ b/src/pyframe/__init__.py
@@ -9,8 +9,8 @@
UnsupportedMediaError,
)
from .image_utils import merge_images_to_grid, merge_to_grid
-from .media import Frame, MediaKind, iter_frames, media_kind
-from .pipe import Pipe, scan
+from .media import Frame, MediaKind, iter_frames, iter_frames_from_bytes, media_kind
+from .pipe import Pipe, scan, scan_bytes
from .results import Label, ScanResult, Severity, Verdict
from .scanner import Scanner
@@ -20,13 +20,14 @@
try:
__version__ = version("pyframe-gif-video-image-moderation")
except PackageNotFoundError:
- __version__ = "0.1.0"
+ __version__ = "0.2.0"
except Exception:
- __version__ = "0.1.0"
+ __version__ = "0.2.0"
__all__ = [
"Pipe",
"scan",
+ "scan_bytes",
"Scanner",
"Config",
"PrescreenConfig",
@@ -39,6 +40,7 @@
"Frame",
"MediaKind",
"iter_frames",
+ "iter_frames_from_bytes",
"media_kind",
"merge_to_grid",
"merge_images_to_grid",
diff --git a/src/pyframe/cli.py b/src/pyframe/cli.py
index 57f868f..162a9a3 100644
--- a/src/pyframe/cli.py
+++ b/src/pyframe/cli.py
@@ -55,7 +55,6 @@ def build_parser() -> argparse.ArgumentParser:
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("--screen-fps", type=float, default=2.0, help="soft-screen sample rate")
- parser.add_argument("--save-frames", default=None, metavar="DIR", help="write the classified frames to DIR")
parser.add_argument("--json", action="store_true", help="machine-readable output")
parser.add_argument("--fail-on", choices=("nsfw", "uncertain", "never"), default="nsfw")
return parser
@@ -82,7 +81,6 @@ def main() -> int:
escalate_threshold=args.escalate_threshold,
max_escalations=args.max_escalations,
screen_fps=args.screen_fps,
- save_frames=args.save_frames,
)
except BackendUnavailableError as exc:
print(exc, file=sys.stderr)
diff --git a/src/pyframe/config.py b/src/pyframe/config.py
index 98dde0c..ae61de0 100644
--- a/src/pyframe/config.py
+++ b/src/pyframe/config.py
@@ -27,5 +27,4 @@ class Config:
frames_per_batch: int = 2
screen_backend: object = "local"
screen_model: str | None = None
- save_frames: str | None = None
prescreen: PrescreenConfig = field(default_factory=PrescreenConfig)
diff --git a/src/pyframe/media.py b/src/pyframe/media.py
index 6cf4336..16d848e 100644
--- a/src/pyframe/media.py
+++ b/src/pyframe/media.py
@@ -98,3 +98,37 @@ def iter_frames(source: str | os.PathLike) -> Iterator[Frame]:
index += 1
finally:
cap.release()
+
+
+def iter_frames_from_bytes(data: bytes) -> Iterator[Frame]:
+ """Decode a GIF / static image from memory (no disk). Pillow only; for video
+ bytes use the path-based API. Motion + timestamps match iter_frames."""
+ import io
+
+ from PIL import Image
+
+ try:
+ img = Image.open(io.BytesIO(data))
+ n_frames = getattr(img, "n_frames", 1)
+ except Exception as exc:
+ raise MediaDecodeError(
+ f"could not decode bytes in memory: {exc} "
+ "(video bytes are not supported by scan_bytes; use the path-based API)"
+ ) from exc
+
+ if n_frames <= 1:
+ rgb = np.asarray(img.convert("RGB"))
+ yield Frame(index=0, timestamp=0.0, image=cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR))
+ return
+
+ prev_gray = None
+ timestamp = 0.0
+ for index in range(n_frames):
+ img.seek(index)
+ rgb = np.asarray(img.convert("RGB"))
+ frame = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
+ gray = cv2.cvtColor(cv2.resize(frame, (64, 64)), cv2.COLOR_BGR2GRAY)
+ motion = 0.0 if prev_gray is None else float(np.sum(cv2.absdiff(gray, prev_gray)))
+ prev_gray = gray
+ yield Frame(index=index, timestamp=timestamp, image=frame, motion_score=motion)
+ timestamp += (img.info.get("duration") or 100) / 1000.0 # per-frame GIF duration (ms)
diff --git a/src/pyframe/pipe.py b/src/pyframe/pipe.py
index 269a993..524fb00 100644
--- a/src/pyframe/pipe.py
+++ b/src/pyframe/pipe.py
@@ -28,7 +28,6 @@ def __init__(
window_pad=4,
max_escalations=2,
fail_open=True,
- save_frames=None,
):
self.input_path = input_path
self.config = Config(
@@ -43,7 +42,6 @@ def __init__(
frames_per_batch=frames_per_batch,
screen_backend=screen_backend,
screen_model=screen_model,
- save_frames=save_frames,
prescreen=PrescreenConfig(
enabled=prescreen,
screen_fps=screen_fps,
@@ -61,3 +59,9 @@ def run(self) -> ScanResult:
def scan(source, **kwargs) -> ScanResult:
return Pipe(source, **kwargs).run()
+
+
+def scan_bytes(data, *, label="", **kwargs) -> ScanResult:
+ """Scan a GIF/image from raw bytes (e.g. a download) without touching disk."""
+ config = Pipe(label, **kwargs).config # reuse Pipe's config building; path unused
+ return Scanner.from_config(config).scan_bytes(data, label=label)
diff --git a/src/pyframe/scanner.py b/src/pyframe/scanner.py
index 66d8cad..c0f6a96 100644
--- a/src/pyframe/scanner.py
+++ b/src/pyframe/scanner.py
@@ -1,14 +1,11 @@
from __future__ import annotations
-import os
import time
-import cv2
-
from .backends import Backend, load_backend
from .config import Config
from .image_utils import merge_to_grid
-from .media import MediaKind, iter_frames, media_kind
+from .media import MediaKind, iter_frames, iter_frames_from_bytes, media_kind
from .results import ScanResult, Severity, Verdict
from .sampling import (
DenseUniformSampler,
@@ -41,10 +38,18 @@ def scan(self, source) -> ScanResult:
start = time.perf_counter()
kind = media_kind(source)
frames = list(iter_frames(source))
+ return self._scan_frames(str(source), kind, frames, start)
+
+ def scan_bytes(self, data, *, label: str = "") -> ScanResult:
+ """Scan a GIF/image decoded from memory, no disk touched."""
+ start = time.perf_counter()
+ frames = list(iter_frames_from_bytes(data))
+ kind = MediaKind.ANIMATION if len(frames) > 1 else MediaKind.IMAGE
+ return self._scan_frames(label, kind, frames, start)
+ def _scan_frames(self, source, kind, frames, start) -> ScanResult:
if kind is MediaKind.IMAGE:
verdicts = self.precise.classify_batch(frames, min_confidence=self.min_confidence)
- self._save(verdicts, frames, source)
return self._aggregate(source, kind, verdicts, [], len(frames), start)
if not frames:
@@ -67,7 +72,6 @@ def _single_pass(self, source, kind, frames, start) -> ScanResult:
verdicts = self._classify_merged(selected)
else:
verdicts = self.precise.classify_batch(selected, min_confidence=self.min_confidence)
- self._save(verdicts, selected, source)
return self._aggregate(source, kind, verdicts, [], len(frames), start)
def _cascade(self, source, kind, frames, start) -> ScanResult:
@@ -100,7 +104,6 @@ def _cascade(self, source, kind, frames, start) -> ScanResult:
# Send the top suspicious frames to the precise backend as merged grids.
precise = self._classify_merged(selected)
- self._save(precise, selected, source)
windows = group_flagged_into_windows(flagged, len(frames), pc.group_gap, pc.window_pad)
return self._aggregate(
@@ -139,16 +142,6 @@ def _classify_merged(self, frames) -> list[Verdict]:
)
return verdicts
- def _save(self, verdicts, frames, source) -> None:
- out_dir = self.config.save_frames
- if not out_dir:
- return
- os.makedirs(out_dir, exist_ok=True)
- stem = os.path.splitext(os.path.basename(str(source)))[0]
- for rank, frame in enumerate(frames):
- name = f"{stem}_{rank:02d}_frame{frame.index:04d}.jpg"
- cv2.imwrite(os.path.join(out_dir, name), frame.image)
-
def _aggregate(
self, source, kind, classified, screen_verdicts, frames_total, start,
*, escalated=None, windows=0,
diff --git a/tests/test_scanner.py b/tests/test_scanner.py
index a0ba04e..98a629e 100644
--- a/tests/test_scanner.py
+++ b/tests/test_scanner.py
@@ -81,6 +81,29 @@ def test_cascade_pads_to_full_grid_when_one_frame_flagged():
assert result.is_nsfw
+def test_scan_bytes_decodes_in_memory():
+ import io
+
+ from PIL import Image
+
+ from pyframe.media import iter_frames_from_bytes
+
+ # distinct fills so PIL's GIF optimizer doesn't collapse identical frames
+ pil = [Image.fromarray(np.full((16, 16, 3), v, np.uint8)) for v in (10, 60, 250, 120, 30)]
+ buf = io.BytesIO()
+ pil[0].save(buf, format="GIF", save_all=True, append_images=pil[1:], duration=80, loop=0)
+ data = buf.getvalue()
+
+ decoded = list(iter_frames_from_bytes(data))
+ assert len(decoded) == 5 # decoded from memory, no disk
+ assert any(f.motion_score > 0 for f in decoded)
+
+ scanner = _scanner(FakeBackend("aws", 0.001), screen=FakeBackend("local"), enabled=True)
+ result = scanner.scan_bytes(data, label="x.gif")
+ assert result.media_kind == "animation"
+ assert result.frames_total == 5
+
+
def test_cascade_fail_open_escalates_on_error():
class BrokenScreen(Backend):
name = "local"