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. +
+ +[![PyPI version](https://img.shields.io/pypi/v/pyframe-gif-video-image-moderation)](https://pypi.org/project/pyframe-gif-video-image-moderation/) +[![PyPI Downloads](https://img.shields.io/pepy/dt/pyframe-gif-video-image-moderation?label=PyPI%20Downloads)](https://pepy.tech/project/pyframe-gif-video-image-moderation) +[![Python versions](https://img.shields.io/pypi/pyversions/pyframe-gif-video-image-moderation)](https://pypi.org/project/pyframe-gif-video-image-moderation/) +[![License: MIT](https://img.shields.io/badge/License-MIT-green)](https://github.com/ehewes/pyframe/blob/main/LICENSE) +[![CI](https://img.shields.io/github/actions/workflow/status/ehewes/pyframe/ci.yml?branch=main&label=CI)](https://github.com/ehewes/pyframe/actions/workflows/ci.yml) +[![Docs](https://img.shields.io/badge/docs-eden.report/docs-blueviolet)](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 ![PyFrame pipeline: GIF frames to temporal buckets to extracted frames to merged grids to AWS Rekognition](https://raw.githubusercontent.com/ehewes/pyframe/main/media/HCBHD36W0AI3Hz4.jpeg) +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) + +![PyFrame per-stage timing](https://raw.githubusercontent.com/ehewes/pyframe/main/media/perf_stages.png) + +| 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) + +![PyFrame latency percentiles](https://raw.githubusercontent.com/ehewes/pyframe/main/media/perf_latency.png) + +- **~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"