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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<div align="center">

[![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)

</div>

## Install

```bash
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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`).
Expand Down
16 changes: 16 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -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.
151 changes: 151 additions & 0 deletions docs/output.md
Original file line number Diff line number Diff line change
@@ -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)**.
82 changes: 82 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
@@ -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)**.
Binary file added media/perf_latency.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added media/perf_stages.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions 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.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"
Expand Down Expand Up @@ -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"]
55 changes: 55 additions & 0 deletions scripts/bench_decode.py
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading