{"id":50454735,"url":"https://github.com/ehewes/pyframe","last_synced_at":"2026-06-01T02:00:44.853Z","repository":{"id":337275984,"uuid":"1152485487","full_name":"ehewes/pyframe","owner":"ehewes","description":"PyFrame splits GIFs into equal time windows and picks the frame with the highest motion delta from each one. This way you get good scene coverage and catch peak frames without sending every frame to AWS Rekognition cuts costs by ~93% with minimal accuracy loss.","archived":false,"fork":false,"pushed_at":"2026-06-01T00:07:48.000Z","size":513,"stargazers_count":15,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-01T02:00:20.467Z","etag":null,"topics":["aws","aws-image-moderation","aws-rek","huggingface","huggingface-transformers","image-moderation","moderation","opencv","opencv-python","pillow","python"],"latest_commit_sha":null,"homepage":"https://ellishewes.com/blogs/PyFrame","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ehewes.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-08T00:02:43.000Z","updated_at":"2026-06-01T00:07:16.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/ehewes/pyframe","commit_stats":null,"previous_names":["ehewes/pyframe"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/ehewes/pyframe","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ehewes%2Fpyframe","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ehewes%2Fpyframe/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ehewes%2Fpyframe/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ehewes%2Fpyframe/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ehewes","download_url":"https://codeload.github.com/ehewes/pyframe/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ehewes%2Fpyframe/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33756577,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-01T02:00:06.963Z","response_time":115,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["aws","aws-image-moderation","aws-rek","huggingface","huggingface-transformers","image-moderation","moderation","opencv","opencv-python","pillow","python"],"created_at":"2026-06-01T02:00:22.898Z","updated_at":"2026-06-01T02:00:44.847Z","avatar_url":"https://github.com/ehewes.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# PyFrame\n\nNSFW moderation for GIFs, videos, and images using local [HuggingFace](https://huggingface.co) models and/or [AWS Rekognition](https://aws.amazon.com/rekognition/content-moderation/).\n\nPyFrame 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.\n\n## Install\n\n```bash\npip install \"pyframe-gif-video-image-moderation[local]\"   # free local HuggingFace backend\npip install \"pyframe-gif-video-image-moderation[aws]\"      # AWS Rekognition backend\npip install \"pyframe-gif-video-image-moderation[all]\"      # everything (local + aws + video)\n```\n\nOr with [uv](https://docs.astral.sh/uv/):\n\n```bash\nuv add \"pyframe-gif-video-image-moderation[local]\"\n# or, ad-hoc:  uv pip install \"pyframe-gif-video-image-moderation[local]\"\n```\n\nThe base install is intentionally light (just `opencv-python-headless`, `numpy`, `Pillow`); the heavy backends (`boto3`, `transformers`/`torch`, `moviepy`) are optional extras you only pull in if you use them.\n\n## Python API\n\n`Pipe` is the high-level facade: build it, call `run()`.\n\n```python\nfrom pyframe import Pipe\n\nresult = Pipe(\"clip.gif\", backend=\"local\").run()\n\nprint(result.verdict)   # clean\nprint(result.is_nsfw)   # False\n```\n\nSwap the backend, or turn on the two-pass cascade:\n\n```python\nPipe(\"clip.gif\", backend=\"aws\").run()                  # AWS Rekognition\nPipe(\"clip.gif\", backend=\"aws\", prescreen=True).run()  # local screens, AWS confirms\n```\n\n### Tuning the two-pass\n\nEvery knob is a `Pipe` param with a sensible default:\n\n```python\nPipe(\n    \"clip.gif\",\n    backend=\"aws\",            # precise backend used on escalation\n    prescreen=True,           # two-pass cascade on\n    escalate_threshold=0.15,  # escalate on the faintest local signal (lower = more recall, more cost)\n    max_escalations=2,        # hard cap on AWS calls per file\n    frames_per_batch=2,       # frames merged into each grid sent to AWS\n    screen_fps=2.0,           # soft-screen sample rate\n    min_confidence=0.5,       # NSFW threshold (defaults to the backend's recall-safe value)\n).run()\n```\n\n## CLI\n\nThe same pipeline as a command, no script to edit:\n\n```bash\npyframe clip.gif                                   # auto backend, prints a verdict\npyframe clip.gif --backend local                   # free local model\npyframe clip.gif --backend aws --region us-east-1  # AWS Rekognition\npyframe clip.gif --prescreen --backend aws         # cascade: local gate then AWS\npyframe a.gif b.gif c.png --json                   # batch, machine-readable\n```\n\nExit 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`.\n\n### Options\n\n| Flag | Default | Meaning |\n|------|---------|---------|\n| `--backend` | `auto` | `local`, `aws`, or `local:\u003cmodel-id\u003e` |\n| `--model` | model default | HuggingFace model id (local backend) |\n| `--region` | `us-east-1` | AWS region (aws backend) |\n| `--max-frames` | `10` | frames to extract from a GIF/video |\n| `--min-confidence` | backend default | NSFW threshold (0-1); `0.5` local, `0.8` aws |\n| `--sampler` | `motion` | `motion` (bucketing) or `dense` (uniform) |\n| `--prescreen` | off | enable the two-stage cascade |\n| `--escalate-threshold` | `0.15` | cascade gate (low = recall-safe) |\n| `--max-escalations` | `2` | hard cap on precise (AWS) calls per file |\n| `--screen-fps` | `2.0` | soft-screen sample rate |\n| `--use-merged` / `--frames-per-batch` | off / `2` | merge frames into a grid before classifying |\n| `--save-frames DIR` | off | write the classified frames to `DIR` |\n| `--json` / `--fail-on` | off / `nsfw` | output format / exit-code policy |\n\n## How it works\n\n- `Pipe` - facade you construct (mirrors the old main.py flow)\n  - `Scanner` - engine: single-pass, or the two-stage cascade\n    - `Backend` - local (HuggingFace) or aws (Rekognition), normalized results\n    - `Sampler` - motion bucketing, dense uniform, or suspicion\n\n**Single-pass** (default): extract `max_frames` via motion bucketing, then classify each with one backend.\n\n**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.\n\n## Cost\n\nAWS Rekognition bills ~$1.00 / 1,000 images. A 150-frame GIF costs $0.15 to moderate every frame; PyFrame's 10-bucket extraction drops that to ~$0.01 (a ~93% reduction). With `--prescreen`, clean clips cost $0 (local only) and flagged clips incur at most `--max-escalations` AWS calls (default 2), so the cascade never costs more than a single-pass scan.\n\n\u003e Tune the cascade on labeled data before relying on it: the local gate's recall bounds the system's recall. Keep `--escalate-threshold` low (catch anything *potentially* NSFW) and sample densely enough (`--screen-fps`) that brief events don't fall between samples.\n\n## Pipeline\n\nA 150-frame GIF flows through temporal segmentation down to a handful of extracted frames, optionally merged into grids, then sent to the backend:\n\n![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)\n\n## Notes\n\n- 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`).\n- `[video]` (video to GIF) needs `moviepy`, which requires a system **ffmpeg** (`brew install ffmpeg`).\n- HuggingFace **model weights** have their own licenses, separate from this package's MIT license.\n\n## Development\n\n```bash\nuv pip install -e \".[dev]\"   # or: pip install -e \".[dev]\"\npytest\npython -m build              # or: uv build\ntwine check dist/*           # or: uv publish  (to PyPI)\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fehewes%2Fpyframe","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fehewes%2Fpyframe","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fehewes%2Fpyframe/lists"}