argus-proof 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {argus_proof-0.2.0 → argus_proof-0.2.2}/.env.example +7 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/.github/workflows/release.yml +46 -1
- argus_proof-0.2.2/CHANGELOG.md +94 -0
- argus_proof-0.2.2/CLAUDE.md +53 -0
- argus_proof-0.2.2/Dockerfile +39 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/PKG-INFO +9 -6
- {argus_proof-0.2.0 → argus_proof-0.2.2}/README.md +3 -1
- {argus_proof-0.2.0 → argus_proof-0.2.2}/pyproject.toml +6 -4
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/_version.py +2 -2
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/scorers/safety.py +69 -28
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_scorers_safety.py +76 -6
- argus_proof-0.2.0/Dockerfile +0 -27
- {argus_proof-0.2.0 → argus_proof-0.2.2}/.copier-answers.yml +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/.github/workflows/ci.yml +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/.gitignore +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/LICENSE +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/Makefile +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/schema/proof-wire.schema.json +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/__init__.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/acceptance.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/archive.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/backends/__init__.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/backends/a1111.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/backends/base.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/backends/comfyui.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/backends/diffusers.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/backends/http.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/backends/pnginfo.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/backends/remote.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/backends/workflow.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/cli.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/crossrun.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/evaluate.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/experiment.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/explore.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/grid.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/hashing.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/models.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/moderation.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/py.typed +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/recommend.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/refinement.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/reports.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/__init__.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/base.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/gate.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/orchestrator.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/scorers/__init__.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/scorers/_util.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/scorers/identity.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/scorers/phash.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/scorers/quality.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/summary.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/server/__init__.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/server/app.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/stats.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/templates/comfyui_sdxl_lora.json +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/fakebackend.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/pnghelpers.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_a1111_backend.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_acceptance.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_archive.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_backends.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_cli.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_crossrun.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_diffusers_backend.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_evaluate.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_experiment.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_explore.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_grid.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_models.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_moderation.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_pnginfo.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_recommend.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_refinement.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_remote_backend.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_reports.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_scorer_util.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_scorers_identity.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_scorers_phash.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_scorers_quality.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_scoring.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_server.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_smoke.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_stats.py +0 -0
- {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_workflow.py +0 -0
|
@@ -41,6 +41,13 @@ PROOF_MODELS_DIR=~/models
|
|
|
41
41
|
COMFYUI_BASE_URL=http://127.0.0.1:8188
|
|
42
42
|
A1111_BASE_URL=http://127.0.0.1:7860
|
|
43
43
|
|
|
44
|
+
# ── Safety scoring ───────────────────────────────────────────────────
|
|
45
|
+
# NudeNet server for the safety scorer (its origin; /infer is appended), e.g.
|
|
46
|
+
# ghcr.io/notai-tech/nudenet or any server speaking its /infer API. Unset = the
|
|
47
|
+
# safety scorer is skipped. Proof never imports NudeNet (AGPL-3.0).
|
|
48
|
+
#PROOF_NUDENET_URL=http://nudenet:8080
|
|
49
|
+
#PROOF_NUDENET_TIMEOUT=30
|
|
50
|
+
|
|
44
51
|
# remote (cloud) backend — the hosted generation endpoint + its bearer token.
|
|
45
52
|
PROOF_REMOTE_URL=https://your-proof-gen.example.com
|
|
46
53
|
PROOF_REMOTE_API_KEY=
|
|
@@ -62,7 +62,15 @@ jobs:
|
|
|
62
62
|
|
|
63
63
|
publish-pypi:
|
|
64
64
|
runs-on: ubuntu-latest
|
|
65
|
-
|
|
65
|
+
# Ordered *after* the TestPyPI rehearsal so its outcome is visible first, but
|
|
66
|
+
# deliberately not gated on it. A TestPyPI-only fault — a missing trusted
|
|
67
|
+
# publisher, an index outage — must not silently withhold the real release:
|
|
68
|
+
# that failure mode shipped argus-proof images for two tags with no wheel on
|
|
69
|
+
# PyPI, and nothing surfaced it because publish-image is independent.
|
|
70
|
+
# Only the build + install gate is required; a TestPyPI failure still marks
|
|
71
|
+
# the whole run failed, so the problem stays loud.
|
|
72
|
+
needs: [test-install, publish-testpypi]
|
|
73
|
+
if: "!cancelled() && needs.test-install.result == 'success'"
|
|
66
74
|
environment: pypi
|
|
67
75
|
steps:
|
|
68
76
|
- uses: actions/download-artifact@v4
|
|
@@ -104,3 +112,40 @@ jobs:
|
|
|
104
112
|
labels: ${{ steps.meta.outputs.labels }}
|
|
105
113
|
cache-from: type=gha
|
|
106
114
|
cache-to: type=gha,mode=max
|
|
115
|
+
|
|
116
|
+
github-release:
|
|
117
|
+
# The GitHub Release is the last step, so it only appears once the version is
|
|
118
|
+
# actually on PyPI. Its notes are this version's CHANGELOG.md section.
|
|
119
|
+
runs-on: ubuntu-latest
|
|
120
|
+
needs: publish-pypi
|
|
121
|
+
permissions:
|
|
122
|
+
contents: write
|
|
123
|
+
steps:
|
|
124
|
+
- uses: actions/checkout@v4
|
|
125
|
+
- uses: actions/download-artifact@v4
|
|
126
|
+
with:
|
|
127
|
+
name: dist
|
|
128
|
+
path: dist/
|
|
129
|
+
- name: Extract release notes from CHANGELOG.md
|
|
130
|
+
run: |
|
|
131
|
+
version="${GITHUB_REF_NAME#v}"
|
|
132
|
+
awk -v head="## [${version}]" 'index($0, head) == 1 {found = 1; next} found && /^## \[/ {exit} found' \
|
|
133
|
+
CHANGELOG.md > notes.md
|
|
134
|
+
if ! grep -q '[^[:space:]]' notes.md; then
|
|
135
|
+
echo "::error::CHANGELOG.md has no section for ${version}"
|
|
136
|
+
exit 1
|
|
137
|
+
fi
|
|
138
|
+
- name: Create or update the GitHub Release
|
|
139
|
+
env:
|
|
140
|
+
GH_TOKEN: ${{ github.token }}
|
|
141
|
+
run: |
|
|
142
|
+
tag="${GITHUB_REF_NAME}"
|
|
143
|
+
prerelease=""
|
|
144
|
+
case "$tag" in *-*|*rc*|*a[0-9]*|*b[0-9]*) prerelease="--prerelease" ;; esac
|
|
145
|
+
if gh release view "$tag" >/dev/null 2>&1; then
|
|
146
|
+
# Re-run of the workflow: refresh notes and assets rather than failing.
|
|
147
|
+
gh release edit "$tag" --notes-file notes.md
|
|
148
|
+
gh release upload "$tag" dist/* --clobber
|
|
149
|
+
else
|
|
150
|
+
gh release create "$tag" dist/* --verify-tag --title "$tag" --notes-file notes.md $prerelease
|
|
151
|
+
fi
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.2.2] - 2026-10-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `CHANGELOG.md`, with sections for every published version (#52).
|
|
14
|
+
- `release.yml` creates a **GitHub Release** for each `v*` tag once the version is on PyPI (#52).
|
|
15
|
+
Release notes come from that version's section in this file, and a missing section fails the job.
|
|
16
|
+
The built wheel and sdist are attached. Re-running the job updates the existing Release instead
|
|
17
|
+
of failing. Tags containing `-`, `rc`, `a<n>` or `b<n>` become pre-releases.
|
|
18
|
+
|
|
19
|
+
### Security
|
|
20
|
+
- The Docker image no longer trusts **emSign ECC Root CA - G3** after the eMudhra/emSign
|
|
21
|
+
certificate incident
|
|
22
|
+
([details](https://discuss.python.org/t/information-for-python-users-regarding-emudhra-emsign-ca-certificate-security-incident/109576)).
|
|
23
|
+
uv is pinned to 0.13.5 instead of `:latest`, because a cached `:latest` can still be a
|
|
24
|
+
pre-0.13.4 digest. The system CA bundle, which urllib callers such as the ComfyUI backend use,
|
|
25
|
+
disables emSign ECC Root CA - G3 and the Comodo AAA cross-sign root. The image installs pip 26.2.2
|
|
26
|
+
(hash-pinned) and `certifi>=2026.10.19`.
|
|
27
|
+
- `certifi>=2026.10.19` is pinned in the `dev` extra.
|
|
28
|
+
|
|
29
|
+
## [0.2.1] - 2026-07-22
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- Release workflow: a TestPyPI failure no longer blocks the real PyPI publish. `publish-pypi` still
|
|
33
|
+
runs after the TestPyPI rehearsal but only requires the build and install-test jobs, and a
|
|
34
|
+
TestPyPI failure still fails the run. Before this, v0.1.0 and v0.2.0 shipped container images
|
|
35
|
+
with no wheel on PyPI.
|
|
36
|
+
- `httpx2` added to the `dev` extra. Starlette's `TestClient` prefers it and raises when neither
|
|
37
|
+
`httpx2` nor `httpx` is installed.
|
|
38
|
+
|
|
39
|
+
## [0.2.0] - 2026-07-21
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
- **Replay / read-only mode** (#45). `ARGUS_PROOF_READ_ONLY=1` (or `serve --read-only`) serves
|
|
43
|
+
stored reports but refuses live GPU evaluation and other writes with 403.
|
|
44
|
+
`/health` reports `read_only`. Off by default.
|
|
45
|
+
- `GET /scorers` lists the scorers and whether each one is available.
|
|
46
|
+
`GET /report/{run_id}/image_at/{index}` serves a run's image by position, a seed-free URL for
|
|
47
|
+
blind review.
|
|
48
|
+
- **Policy-taxonomy moderation** (#41). `argus_proof.moderation` scores images and input prompts
|
|
49
|
+
against a category taxonomy (sexual, violence, hate, self-harm, weapons, shocking, illegal) with
|
|
50
|
+
per-category tail stats. A Llama Guard adapter is available behind the new `[moderation]` extra.
|
|
51
|
+
- `RejectReason.category` attributes a HITL reject to a policy category. This bumps
|
|
52
|
+
`PROOF_VERSION` to 1.3 (additive).
|
|
53
|
+
- Cross-run stats can slice by `step_config` and by experiment labels (`label:<key>`) (#36).
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
- `GridConfig` and `ExperimentMatrix` share a common `GridAxes` base instead of duplicating its
|
|
57
|
+
fields (#37).
|
|
58
|
+
- The server uses the shared `argus_cortex.server` write guard (#47) instead of a local
|
|
59
|
+
`BaseHTTPMiddleware`, which buffered the `/run/stream` NDJSON stream. Requires
|
|
60
|
+
`argus-cortex>=0.2.0`.
|
|
61
|
+
|
|
62
|
+
### Security
|
|
63
|
+
- `image_at` validates the image id it gets from a stored report, which closes a path traversal
|
|
64
|
+
that allowed arbitrary image reads. A 404 no longer echoes the image id.
|
|
65
|
+
|
|
66
|
+
## [0.1.0] - 2026-07-17
|
|
67
|
+
|
|
68
|
+
First release: the MVP evaluation pipeline for trained LoRAs.
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
- Versioned wire contract (`PROOF_VERSION` 1.2) with a generated JSON Schema
|
|
72
|
+
(`schema/proof-wire.schema.json`).
|
|
73
|
+
- Generation backends behind one protocol: ComfyUI, in-process diffusers, AUTOMATIC1111, and a
|
|
74
|
+
remote endpoint that speaks the proof wire. Every run records a model manifest with hashes.
|
|
75
|
+
- Prompt-grid builder that reads curator export captions.
|
|
76
|
+
- Scoring spine (composite gate with auto-pass, auto-fail and HITL bands, plus near-duplicate
|
|
77
|
+
collapse) and the scorers: phash dedup and diversity, ArcFace identity against a held-out
|
|
78
|
+
reference set, CLIPScore / pyiqa / ImageReward quality, and NudeNet safety.
|
|
79
|
+
- HITL review and an optional refinement re-rank of the passing subset.
|
|
80
|
+
- Reject archival.
|
|
81
|
+
- CI acceptance gate with Wilson intervals (`argus-proof gate`, exit 0/1/2).
|
|
82
|
+
- Cross-run stats store (parquet) with pooled pass-rate intervals per slice and Krippendorff's alpha.
|
|
83
|
+
- Routed recommendations (`argus-proof recommend`).
|
|
84
|
+
- A/B experiment matrix with a GPU-hour budget guardrail and optional Optuna search.
|
|
85
|
+
- FiftyOne exploration (`argus-proof explore`) behind the `[fiftyone]` extra.
|
|
86
|
+
- CLI verbs `run`, `score`, `report`, `inspect`, plus a FastAPI server (`argus-proof serve`) with
|
|
87
|
+
`/run/stream`, report/HITL/refine endpoints, and model and export listings.
|
|
88
|
+
- Container image `ghcr.io/smk762/argus-proof`.
|
|
89
|
+
|
|
90
|
+
[Unreleased]: https://github.com/smk762/argus-proof/compare/v0.2.2...HEAD
|
|
91
|
+
[0.2.2]: https://github.com/smk762/argus-proof/compare/v0.2.1...v0.2.2
|
|
92
|
+
[0.2.1]: https://github.com/smk762/argus-proof/compare/v0.2.0...v0.2.1
|
|
93
|
+
[0.2.0]: https://github.com/smk762/argus-proof/compare/v0.1.0...v0.2.0
|
|
94
|
+
[0.1.0]: https://github.com/smk762/argus-proof/releases/tag/v0.1.0
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# CLAUDE.md — argus-proof
|
|
2
|
+
|
|
3
|
+
Guidance for AI agents working in this repo. Human-facing usage lives in [README.md](README.md); this file is the orientation an agent needs to change code safely.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
7
|
+
The **post-training evaluation/optimisation** stage — it closes the suite loop: generate a sample grid from a trained LoRA, score it against the curated dataset it was trained from, and emit a pass/fail verdict that feeds back to curation, captioning, and training.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
argus-quarry -> argus-curator -> argus-lens -> argus-forge -> your trainer -> argus-proof
|
|
11
|
+
acquire curate/export caption configs LoRA evaluate + optimise
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
**The forge→proof handoff is a soft join, not an embed.** A `RunManifest` links back to the curator export via `source_manifest` (the `manifest.jsonl`) and, when the LoRA came through the suite, to forge's `training_run_id` (== forge `RunEvent.run_id`). Both are optional — a LoRA can be supplied ad hoc. Proof owns its own `run_id` (the eval run), distinct from the forge training run. README.md is the source of truth for behaviour; it is unusually detailed.
|
|
15
|
+
|
|
16
|
+
## Layout
|
|
17
|
+
|
|
18
|
+
`src/argus_proof/`:
|
|
19
|
+
|
|
20
|
+
- `models.py` — Pydantic v2 wire types, the three versioned top-level shapes (`RunManifest`, `EvalReport`, `RejectArchive`) plus `RunSpec`/grid/gate/acceptance models. **This is the API contract**; `wire_schema()`/`render_wire_schema()` back `argus-proof schema` and the committed `schema/proof-wire.schema.json`.
|
|
21
|
+
- `grid.py` / `experiment.py` — the prompt-grid builder (axes × prompts × seeds → `RunSpec`s) and the A/B `ExperimentMatrix` that varies base-checkpoint × step-config as outer factors.
|
|
22
|
+
- `backends/` — pluggable generation engines over `base.GenBackend`: `comfyui` (shipped), `diffusers`, `a1111`, `remote`; `workflow.py`/`pnginfo.py`/`http.py` are shared helpers. `get_backend(name, ...)` selects by config (`_BACKENDS` registry).
|
|
23
|
+
- `scoring/` — `base.py` (the `ImageScorer`/`Deduper`/`DiversityScorer` protocols + `METRIC_FIELDS`), `orchestrator.py` (`score_run`), `gate.py`, `summary.py`; concrete scorers under `scoring/scorers/` (phash, identity, quality behind the `[score]` extra; safety posts to a NudeNet server at `PROOF_NUDENET_URL` through argus-cortex's `NudeNetClient` — **never import `nudenet` in-process: it is AGPL-3.0**).
|
|
24
|
+
- `reports.py` (`ReportStore`, `apply_hitl`), `refinement.py`, `acceptance.py`, `recommend.py`, `crossrun.py`, `archive.py`, `stats.py`, `moderation.py`, `explore.py` — the report store + the optimisation layers on top of a scored run.
|
|
25
|
+
- `cli.py` — Typer app (`inspect`, `run`, `score`, `report`, `gate`, `recommend`, `experiment`, `explore`, `schema`, `serve`).
|
|
26
|
+
- `server/app.py` — FastAPI micro-server on **:8104** (peer to lens :8100, curator :8101, quarry :8102, forge :8103); backs the argus-studio `/proof` view. Optional `[server]` extra.
|
|
27
|
+
- `templates/comfyui_sdxl_lora.json` — the shipped parametric ComfyUI workflow (`$placeholder` graph).
|
|
28
|
+
|
|
29
|
+
## Commands
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
make install # uv venv + editable install with [dev,server,cli]
|
|
33
|
+
make test # uv run --no-sync pytest --tb=short -q
|
|
34
|
+
make lint # ruff 0.15.16 check + format --check
|
|
35
|
+
make format # ruff format + check --fix
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Run a single test: `uv run --no-sync pytest tests/test_scoring.py::test_name -q`.
|
|
39
|
+
|
|
40
|
+
## Conventions & gotchas
|
|
41
|
+
|
|
42
|
+
- **The wire schema is checked in CI** (`argus-proof schema --check` runs post-test). If you touch `WIRE_MODELS`/types in `models.py`, regenerate: `argus-proof schema > schema/proof-wire.schema.json`. Bump `PROOF_VERSION` (minor = additive field/metric, major = breaking) and mind `SUPPORTED_PROOF_MAJORS`.
|
|
43
|
+
- **Pass-rate is computed over near-duplicate *groups*, not raw frames** (`n_passed / n_groups`), so a Monte-Carlo cluster counts once. Never revert this to per-image — it's the core anti-inflation invariant. `n_groups == n_images` when no deduper ran.
|
|
44
|
+
- **Scorers return normalised `[0,1]` (higher = better) or `None`** — never a fabricated `0.0` for an axis that didn't run. At most one scorer per metric (`METRIC_FIELDS`); the orchestrator raises if two target the same one. The `[0,1]` quality-scorer normalisation `lo`/`hi` defaults are **placeholders** — calibrate against real generations.
|
|
45
|
+
- **Every model file is pinned by SHA256** in the `RunManifest`, not by filename — a run must reconstruct exactly. Backends resolve a requested name to a file and hash it; don't record a name without its digest. `RejectArchive`/`RejectRecord` are **image-free by construction** (keyed by `(run_id, seed)` — seed + manifest reconstructs the pixels); never add a path/thumbnail field there.
|
|
46
|
+
- **Refinement is a separate layer.** `ImageScores.refinement` never overwrites the first-pass `hitl_rating`/`reject_reasons` or the verdict; refining an image not in the passing subset is refused.
|
|
47
|
+
- **Backend swap is a config change, not a code change** — scoring/report code is engine-agnostic (`PROOF_BACKEND` selects). Keep new engines behind `GenBackend` + the `_BACKENDS` registry; heavy adapters stay lazy-imported and `is_available()`-guarded (same for optional scorers/moderation/explore).
|
|
48
|
+
- **Server safety — don't loosen either boundary:**
|
|
49
|
+
- Images are addressed by `(run_id, image_id)`, both validated against a strict charset (`_SAFE_ID`) — **no client-supplied filesystem path is ever resolved**. Keep it that way; there is no traversal surface by design.
|
|
50
|
+
- Read-only/replay mode (`ARGUS_PROOF_READ_ONLY`, `serve --read-only`): every mutating request — `POST /run/stream` (live GPU eval) above all — is refused with `403` via the shared `argus_cortex.server.WriteGuard`. `/health` echoes the flag. It fails *safe*.
|
|
51
|
+
- Concurrent report writes are serialised by a per-run `flock` sidecar (`reports.py`), so a streaming run's write and a reviewer's HITL save can't drop updates. On non-POSIX it degrades to a no-op — don't rely on it for multi-host.
|
|
52
|
+
- Shared suite code (taxonomy, wire-schema tooling, the server write-guard/env-flag helpers, `RemoteBackend`) lives in **argus-cortex** (`argus-cortex[server]`) — reuse it, don't re-implement.
|
|
53
|
+
- Versioning is git-tag-derived (`hatch-vcs`); `_version.py` is generated (gitignored). `structlog` logging, Pydantic v2, async server (`asyncio_mode = auto`), ruff line-length 120. Scaffolded from `argus-pkg-template` — `copier update` pulls tooling changes.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
FROM python:3.11-slim
|
|
3
|
+
|
|
4
|
+
# Pin past the eMudhra/emSign incident. A floating :latest tag can stay cached
|
|
5
|
+
# on a pre-0.13.4 digest, which still trusts emSign ECC Root CA - G3.
|
|
6
|
+
# https://discuss.python.org/t/information-for-python-users-regarding-emudhra-emsign-ca-certificate-security-incident/109576
|
|
7
|
+
COPY --from=ghcr.io/astral-sh/uv:0.13.5 /uv /uvx /bin/
|
|
8
|
+
|
|
9
|
+
WORKDIR /app
|
|
10
|
+
|
|
11
|
+
# hatch-vcs derives the version from git history, so the build context must
|
|
12
|
+
# include .git (keep it out of .dockerignore for this image) and the image
|
|
13
|
+
# needs the git binary for setuptools-scm to read it (slim ships without it).
|
|
14
|
+
# ComfyUI and the other urllib callers trust this bundle, not certifi.
|
|
15
|
+
# Distrust emSign ECC Root CA - G3 and the Comodo AAA cross-sign root.
|
|
16
|
+
RUN apt-get update \
|
|
17
|
+
&& apt-get install -y --no-install-recommends ca-certificates git \
|
|
18
|
+
&& sed -i \
|
|
19
|
+
-e 's|^mozilla/emSign_ECC_Root_CA_-_G3|!mozilla/emSign_ECC_Root_CA_-_G3|' \
|
|
20
|
+
-e 's|^mozilla/Comodo_AAA|!mozilla/Comodo_AAA|' \
|
|
21
|
+
/etc/ca-certificates.conf \
|
|
22
|
+
&& update-ca-certificates --fresh >/dev/null \
|
|
23
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
24
|
+
|
|
25
|
+
COPY . .
|
|
26
|
+
|
|
27
|
+
# Extras baked into the image. Default keeps it light; build with
|
|
28
|
+
# EXTRAS="server,cli,score" for the full scorer stack (torch, insightface, …).
|
|
29
|
+
ARG EXTRAS="server,cli"
|
|
30
|
+
|
|
31
|
+
# pillow + imagehash ride along regardless: they power phash near-dup collapse
|
|
32
|
+
# and diversity (honest group pass-rates) at negligible image cost.
|
|
33
|
+
RUN python -m pip install --no-cache-dir \
|
|
34
|
+
"pip @ https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl#sha256=fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70" \
|
|
35
|
+
&& uv pip install --system --no-cache "certifi>=2026.10.19" ".[${EXTRAS}]" pillow imagehash
|
|
36
|
+
|
|
37
|
+
EXPOSE 8104
|
|
38
|
+
CMD ["argus-proof", "serve", "--port", "8104", "--cors"]
|
|
39
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: argus-proof
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Post-training LoRA evaluation and optimisation: generate samples from a trained LoRA and score them against the curated dataset it was trained from
|
|
5
5
|
Project-URL: Repository, https://github.com/smk762/argus-proof
|
|
6
6
|
Author: smk762
|
|
@@ -14,13 +14,15 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
15
|
Classifier: Typing :: Typed
|
|
16
16
|
Requires-Python: >=3.11
|
|
17
|
-
Requires-Dist: argus-cortex
|
|
17
|
+
Requires-Dist: argus-cortex<0.5,>=0.4
|
|
18
18
|
Requires-Dist: pydantic>=2.7
|
|
19
19
|
Requires-Dist: structlog>=24.0
|
|
20
20
|
Provides-Extra: cli
|
|
21
21
|
Requires-Dist: rich>=13.0; extra == 'cli'
|
|
22
22
|
Requires-Dist: typer>=0.12; extra == 'cli'
|
|
23
23
|
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: certifi>=2026.10.19; extra == 'dev'
|
|
25
|
+
Requires-Dist: httpx2>=2.7; extra == 'dev'
|
|
24
26
|
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
25
27
|
Requires-Dist: imagehash>=4.3; extra == 'dev'
|
|
26
28
|
Requires-Dist: krippendorff>=0.6; extra == 'dev'
|
|
@@ -51,14 +53,13 @@ Provides-Extra: score
|
|
|
51
53
|
Requires-Dist: image-reward>=1.5; extra == 'score'
|
|
52
54
|
Requires-Dist: imagehash>=4.3; extra == 'score'
|
|
53
55
|
Requires-Dist: insightface>=0.7; extra == 'score'
|
|
54
|
-
Requires-Dist: nudenet>=3.0; extra == 'score'
|
|
55
56
|
Requires-Dist: onnxruntime>=1.16; extra == 'score'
|
|
56
57
|
Requires-Dist: pillow>=10.0; extra == 'score'
|
|
57
58
|
Requires-Dist: pyiqa>=0.1.10; extra == 'score'
|
|
58
59
|
Requires-Dist: torch>=2.0; extra == 'score'
|
|
59
60
|
Requires-Dist: torchmetrics>=1.0; extra == 'score'
|
|
60
61
|
Provides-Extra: server
|
|
61
|
-
Requires-Dist: argus-cortex[server]
|
|
62
|
+
Requires-Dist: argus-cortex[server]<0.5,>=0.4; extra == 'server'
|
|
62
63
|
Requires-Dist: fastapi>=0.110; extra == 'server'
|
|
63
64
|
Requires-Dist: python-multipart>=0.0.7; extra == 'server'
|
|
64
65
|
Requires-Dist: uvicorn>=0.27; extra == 'server'
|
|
@@ -219,7 +220,7 @@ skips it when the extra is absent). Shipped:
|
|
|
219
220
|
- **dedup + diversity** — `PhashDeduper`, `PhashDiversityScorer` (perceptual hash, CPU-only)
|
|
220
221
|
- **identity** — `IdentityScorer` (InsightFace ArcFace cosine vs a held-out reference set)
|
|
221
222
|
- **quality / adherence** — `clip_score_scorer()` (CLIPScore), `pyiqa_scorer()` (CLIP-IQA), `image_reward_scorer()` (ImageReward), each normalising its raw score to `[0,1]`
|
|
222
|
-
- **safety** — `SafetyScorer` (NudeNet ensemble, `1 - unsafe`); set a `safety` hard gate to auto-fail unsafe images. `safety_tail_aggregate()` surfaces the any-hit/min/percentile tail
|
|
223
|
+
- **safety** — `SafetyScorer` (NudeNet ensemble, `1 - unsafe`); set a `safety` hard gate to auto-fail unsafe images. `safety_tail_aggregate()` surfaces the any-hit/min/percentile tail. NudeNet runs as a **server over HTTP** (`PROOF_NUDENET_URL`, via argus-cortex's `NudeNetClient`), never in-process (it is AGPL-3.0); without a URL the scorer is skipped. "Unsafe" is the suite's exposed-nudity set (exposed buttocks, breasts, genitalia, anus), the same as curator's
|
|
223
224
|
|
|
224
225
|
```python
|
|
225
226
|
from argus_proof.scoring import score_run
|
|
@@ -450,6 +451,8 @@ make lint
|
|
|
450
451
|
|
|
451
452
|
- **CI** runs via the shared [`argus-ci`](https://github.com/smk762/argus-ci) reusable workflow.
|
|
452
453
|
- **Release** publishes to PyPI (OIDC trusted publishing) and GHCR on `v*` tags.
|
|
454
|
+
- Once PyPI has the version, a **GitHub Release** is created with that version's
|
|
455
|
+
`CHANGELOG.md` section as its notes. Add the section before you tag; the job fails without it.
|
|
453
456
|
- Versioning is derived from git tags via `hatch-vcs` — tag `vX.Y.Z` to cut a release.
|
|
454
457
|
|
|
455
458
|
This repo was scaffolded from [`argus-pkg-template`](https://github.com/smk762/argus-pkg-template).
|
|
@@ -150,7 +150,7 @@ skips it when the extra is absent). Shipped:
|
|
|
150
150
|
- **dedup + diversity** — `PhashDeduper`, `PhashDiversityScorer` (perceptual hash, CPU-only)
|
|
151
151
|
- **identity** — `IdentityScorer` (InsightFace ArcFace cosine vs a held-out reference set)
|
|
152
152
|
- **quality / adherence** — `clip_score_scorer()` (CLIPScore), `pyiqa_scorer()` (CLIP-IQA), `image_reward_scorer()` (ImageReward), each normalising its raw score to `[0,1]`
|
|
153
|
-
- **safety** — `SafetyScorer` (NudeNet ensemble, `1 - unsafe`); set a `safety` hard gate to auto-fail unsafe images. `safety_tail_aggregate()` surfaces the any-hit/min/percentile tail
|
|
153
|
+
- **safety** — `SafetyScorer` (NudeNet ensemble, `1 - unsafe`); set a `safety` hard gate to auto-fail unsafe images. `safety_tail_aggregate()` surfaces the any-hit/min/percentile tail. NudeNet runs as a **server over HTTP** (`PROOF_NUDENET_URL`, via argus-cortex's `NudeNetClient`), never in-process (it is AGPL-3.0); without a URL the scorer is skipped. "Unsafe" is the suite's exposed-nudity set (exposed buttocks, breasts, genitalia, anus), the same as curator's
|
|
154
154
|
|
|
155
155
|
```python
|
|
156
156
|
from argus_proof.scoring import score_run
|
|
@@ -381,6 +381,8 @@ make lint
|
|
|
381
381
|
|
|
382
382
|
- **CI** runs via the shared [`argus-ci`](https://github.com/smk762/argus-ci) reusable workflow.
|
|
383
383
|
- **Release** publishes to PyPI (OIDC trusted publishing) and GHCR on `v*` tags.
|
|
384
|
+
- Once PyPI has the version, a **GitHub Release** is created with that version's
|
|
385
|
+
`CHANGELOG.md` section as its notes. Add the section before you tag; the job fails without it.
|
|
384
386
|
- Versioning is derived from git tags via `hatch-vcs` — tag `vX.Y.Z` to cut a release.
|
|
385
387
|
|
|
386
388
|
This repo was scaffolded from [`argus-pkg-template`](https://github.com/smk762/argus-pkg-template).
|
|
@@ -22,7 +22,10 @@ classifiers = [
|
|
|
22
22
|
dependencies = [
|
|
23
23
|
# Shared suite foundation (taxonomy, wire-schema tooling, AI-backend contract,
|
|
24
24
|
# and the server write-guard scaffolding used by server/app.py — 0.2.0+).
|
|
25
|
-
|
|
25
|
+
# 0.4+: argus_cortex.moderation (the NudeNet client the safety scorer uses).
|
|
26
|
+
# Capped below the next minor: argus-cortex is pre-1.0, where a minor bump
|
|
27
|
+
# carries no compatibility guarantee.
|
|
28
|
+
"argus-cortex>=0.4,<0.5",
|
|
26
29
|
"pydantic>=2.7",
|
|
27
30
|
"structlog>=24.0",
|
|
28
31
|
]
|
|
@@ -30,7 +33,7 @@ dependencies = [
|
|
|
30
33
|
[project.optional-dependencies]
|
|
31
34
|
# argus-cortex[server] pulls starlette for the shared write-guard (argus_cortex.server);
|
|
32
35
|
# fastapi provides it too, but the extra declares the dependency honestly.
|
|
33
|
-
server = ["fastapi>=0.110", "uvicorn>=0.27", "python-multipart>=0.0.7", "argus-cortex[server]>=0.
|
|
36
|
+
server = ["fastapi>=0.110", "uvicorn>=0.27", "python-multipart>=0.0.7", "argus-cortex[server]>=0.4,<0.5"]
|
|
34
37
|
cli = ["typer>=0.12", "rich>=13.0"]
|
|
35
38
|
# Concrete scorers (implement argus_proof.scoring protocols): dedup/diversity
|
|
36
39
|
# (phash) + identity (InsightFace ArcFace) + quality (CLIPScore/pyiqa/ImageReward).
|
|
@@ -45,7 +48,6 @@ score = [
|
|
|
45
48
|
"torchmetrics>=1.0",
|
|
46
49
|
"pyiqa>=0.1.10",
|
|
47
50
|
"image-reward>=1.5",
|
|
48
|
-
"nudenet>=3.0",
|
|
49
51
|
]
|
|
50
52
|
# Cross-run stats store (pass-rate CIs use the dependency-free argus_proof.stats;
|
|
51
53
|
# the parquet store needs polars, inter-rater reliability the krippendorff pkg).
|
|
@@ -67,7 +69,7 @@ diffusers = ["diffusers>=0.27", "torch>=2.0", "transformers>=4.38", "accelerate>
|
|
|
67
69
|
# outputs + input prompts. LLM-weight, so opt-in; lazy-imported by
|
|
68
70
|
# argus_proof.moderation and is_available()-guarded (absent -> a clear hint).
|
|
69
71
|
moderation = ["transformers>=4.43", "torch>=2.0", "accelerate>=0.27", "pillow>=10.0"]
|
|
70
|
-
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "ruff==0.15.16", "httpx>=0.27", "pillow>=10.0", "imagehash>=4.3", "polars>=1.0", "krippendorff>=0.6", "optuna>=3.0"]
|
|
72
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "ruff==0.15.16", "httpx>=0.27", "certifi>=2026.10.19", "pillow>=10.0", "imagehash>=4.3", "polars>=1.0", "krippendorff>=0.6", "optuna>=3.0", "httpx2>=2.7"]
|
|
71
73
|
|
|
72
74
|
[project.scripts]
|
|
73
75
|
argus-proof = "argus_proof.cli:app"
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.2.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
21
|
+
__version__ = version = '0.2.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 2)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -7,7 +7,8 @@ set a ``safety`` hard gate to auto-fail (and flag ``unsafe``) anything too risky
|
|
|
7
7
|
|
|
8
8
|
An **ensemble** of detectors is combined conservatively (worst/most-unsafe wins).
|
|
9
9
|
The detector is injectable, so the scoring logic is CI-tested with a fake; the
|
|
10
|
-
real :class:`NudeNetDetector`
|
|
10
|
+
real :class:`NudeNetDetector` calls a NudeNet server (``PROOF_NUDENET_URL``)
|
|
11
|
+
and is unavailable without one.
|
|
11
12
|
|
|
12
13
|
:func:`safety_tail_aggregate` gives the run-level tail view (any-hit / min-safety
|
|
13
14
|
/ low percentile) that matters for safety, where the mean hides a single bad frame.
|
|
@@ -15,12 +16,21 @@ real :class:`NudeNetDetector` is lazy and behind the ``[score]`` extra.
|
|
|
15
16
|
|
|
16
17
|
from __future__ import annotations
|
|
17
18
|
|
|
19
|
+
import os
|
|
18
20
|
from collections.abc import Sequence
|
|
19
21
|
from pathlib import Path
|
|
20
22
|
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
21
23
|
|
|
24
|
+
from argus_cortex.moderation import (
|
|
25
|
+
DEFAULT_EXPOSED_LABELS,
|
|
26
|
+
CheckFailed,
|
|
27
|
+
ModerationConfigError,
|
|
28
|
+
NudeNetClient,
|
|
29
|
+
exposed_score,
|
|
30
|
+
)
|
|
31
|
+
|
|
22
32
|
from argus_proof.models import ScorerProvenance
|
|
23
|
-
from argus_proof.scoring.scorers._util import clamp01
|
|
33
|
+
from argus_proof.scoring.scorers._util import clamp01
|
|
24
34
|
from argus_proof.scoring.scorers._util import percentile as _percentile
|
|
25
35
|
|
|
26
36
|
if TYPE_CHECKING:
|
|
@@ -28,6 +38,12 @@ if TYPE_CHECKING:
|
|
|
28
38
|
from argus_proof.scoring.base import ScoreContext
|
|
29
39
|
|
|
30
40
|
|
|
41
|
+
# The NudeNet server (its origin) and per-image timeout, kept in lock-step with .env.example.
|
|
42
|
+
ENV_NUDENET_URL = "PROOF_NUDENET_URL"
|
|
43
|
+
ENV_NUDENET_TIMEOUT = "PROOF_NUDENET_TIMEOUT"
|
|
44
|
+
DEFAULT_NUDENET_TIMEOUT = 30.0
|
|
45
|
+
|
|
46
|
+
|
|
31
47
|
@runtime_checkable
|
|
32
48
|
class SafetyDetector(Protocol):
|
|
33
49
|
"""Returns an image's unsafe probability in ``[0, 1]`` (or ``None`` if unknown)."""
|
|
@@ -81,45 +97,70 @@ class SafetyScorer:
|
|
|
81
97
|
|
|
82
98
|
|
|
83
99
|
class NudeNetDetector:
|
|
84
|
-
"""Unsafe-probability
|
|
85
|
-
|
|
86
|
-
NudeNet 3.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
100
|
+
"""Unsafe-probability from a NudeNet server over HTTP (max exposed-class score).
|
|
101
|
+
|
|
102
|
+
Proof never imports NudeNet (AGPL-3.0): it posts each image to a NudeNet
|
|
103
|
+
server through argus-cortex's :class:`~argus_cortex.moderation.NudeNetClient`.
|
|
104
|
+
The server is ``PROOF_NUDENET_URL`` (its origin, e.g. ``http://nudenet:8080``)
|
|
105
|
+
unless *base_url* is given; without one the detector is unavailable and the
|
|
106
|
+
orchestrator skips it.
|
|
107
|
+
|
|
108
|
+
The unsafe score is the highest score among the suite's exposed-nudity
|
|
109
|
+
classes (:data:`~argus_cortex.moderation.DEFAULT_EXPOSED_LABELS`: exposed
|
|
110
|
+
buttocks, breasts, genitalia, anus), shared with curator. Faces, covered
|
|
111
|
+
parts, and bare feet, belly, armpits or male chest do not count. A failed
|
|
112
|
+
check on one image returns ``None`` (skip it) rather than crashing the run.
|
|
91
113
|
"""
|
|
92
114
|
|
|
93
115
|
name = "nudenet"
|
|
94
116
|
|
|
95
|
-
def __init__(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
unsafe_classes: frozenset[str] | None = None,
|
|
120
|
+
base_url: str | None = None,
|
|
121
|
+
timeout: float | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
# Override to widen/narrow what counts as unsafe (NudeNet class names).
|
|
124
|
+
self._unsafe_classes = unsafe_classes if unsafe_classes is not None else frozenset(DEFAULT_EXPOSED_LABELS)
|
|
125
|
+
self._base_url = base_url
|
|
126
|
+
self._timeout = timeout
|
|
127
|
+
self._client: NudeNetClient | None = None
|
|
128
|
+
|
|
129
|
+
def _url(self) -> str:
|
|
130
|
+
return (self._base_url if self._base_url is not None else os.environ.get(ENV_NUDENET_URL, "")).strip()
|
|
100
131
|
|
|
101
132
|
def is_available(self) -> bool:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
133
|
+
if not self._url():
|
|
134
|
+
return False
|
|
135
|
+
try:
|
|
136
|
+
self._load()
|
|
137
|
+
except ModerationConfigError:
|
|
138
|
+
return False
|
|
139
|
+
return True
|
|
107
140
|
|
|
108
|
-
|
|
109
|
-
|
|
141
|
+
def _load(self) -> NudeNetClient:
|
|
142
|
+
if self._client is None:
|
|
143
|
+
timeout = self._timeout or _float_env(ENV_NUDENET_TIMEOUT, DEFAULT_NUDENET_TIMEOUT)
|
|
144
|
+
self._client = NudeNetClient(self._url(), timeout=timeout)
|
|
145
|
+
return self._client
|
|
110
146
|
|
|
111
147
|
def _is_unsafe(self, label: str) -> bool:
|
|
112
|
-
|
|
113
|
-
return label in self._unsafe_classes
|
|
114
|
-
return label.endswith("_EXPOSED")
|
|
148
|
+
return label in self._unsafe_classes
|
|
115
149
|
|
|
116
150
|
def unsafe_probability(self, image_path: Path) -> float | None:
|
|
117
151
|
try:
|
|
118
|
-
|
|
119
|
-
except
|
|
152
|
+
result = self._load().check(Path(image_path).read_bytes(), Path(image_path).name)
|
|
153
|
+
except (CheckFailed, ModerationConfigError, OSError): # one bad image must not abort the run
|
|
120
154
|
return None
|
|
121
|
-
|
|
122
|
-
|
|
155
|
+
return exposed_score(result.labels, sorted(self._unsafe_classes)) # no exposed detection -> 0.0 (safe)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _float_env(name: str, default: float) -> float:
|
|
159
|
+
try:
|
|
160
|
+
value = float(os.environ.get(name, ""))
|
|
161
|
+
except ValueError:
|
|
162
|
+
return default
|
|
163
|
+
return value if value > 0 else default
|
|
123
164
|
|
|
124
165
|
|
|
125
166
|
def safety_tail_aggregate(report: EvalReport, *, unsafe_below: float = 0.5) -> dict[str, float]:
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
3
7
|
from pathlib import Path
|
|
4
8
|
|
|
5
9
|
import pytest
|
|
@@ -121,26 +125,92 @@ def test_tail_aggregate_no_safety_scores() -> None:
|
|
|
121
125
|
|
|
122
126
|
|
|
123
127
|
# --------------------------------------------------------------------------
|
|
124
|
-
# NudeNetDetector
|
|
128
|
+
# NudeNetDetector — a NudeNet server over HTTP (argus-cortex's client)
|
|
125
129
|
# --------------------------------------------------------------------------
|
|
126
130
|
|
|
127
131
|
|
|
128
|
-
|
|
132
|
+
class _NudeNet(BaseHTTPRequestHandler):
|
|
133
|
+
"""Speaks NudeNet's /infer API; answers by the uploaded file's name."""
|
|
134
|
+
|
|
135
|
+
answers = {
|
|
136
|
+
"topless.png": [
|
|
137
|
+
{"class": "FEMALE_BREAST_EXPOSED", "score": 0.81, "box": [0, 0, 5, 5]},
|
|
138
|
+
{"class": "FACE_FEMALE", "score": 0.93, "box": [0, 0, 5, 5]},
|
|
139
|
+
],
|
|
140
|
+
"beach.png": [
|
|
141
|
+
{"class": "FEET_EXPOSED", "score": 0.9, "box": [0, 0, 5, 5]},
|
|
142
|
+
{"class": "MALE_BREAST_EXPOSED", "score": 0.7, "box": [0, 0, 5, 5]},
|
|
143
|
+
],
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
def log_message(self, *args) -> None: # keep test output quiet
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
def do_POST(self) -> None:
|
|
150
|
+
body = self.rfile.read(int(self.headers["Content-Length"]))
|
|
151
|
+
name = next(n for n in self.answers if f'filename="{n}"'.encode() in body) if b"broken" not in body else None
|
|
152
|
+
if name is None:
|
|
153
|
+
self.send_response(500)
|
|
154
|
+
self.end_headers()
|
|
155
|
+
return
|
|
156
|
+
out = json.dumps({"prediction": [self.answers[name]], "success": True}).encode()
|
|
157
|
+
self.send_response(200)
|
|
158
|
+
self.send_header("Content-Type", "application/json")
|
|
159
|
+
self.send_header("Content-Length", str(len(out)))
|
|
160
|
+
self.end_headers()
|
|
161
|
+
self.wfile.write(out)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@pytest.fixture
|
|
165
|
+
def nudenet_url() -> Iterator[str]:
|
|
166
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), _NudeNet)
|
|
167
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
168
|
+
try:
|
|
169
|
+
yield f"http://127.0.0.1:{server.server_port}"
|
|
170
|
+
finally:
|
|
171
|
+
server.shutdown()
|
|
172
|
+
server.server_close()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_nudenet_counts_only_the_suite_exposed_classes() -> None:
|
|
129
176
|
d = NudeNetDetector()
|
|
130
177
|
assert d._is_unsafe("FEMALE_BREAST_EXPOSED") is True
|
|
131
178
|
assert d._is_unsafe("BUTTOCKS_EXPOSED") is True
|
|
132
179
|
assert d._is_unsafe("FACE_FEMALE") is False
|
|
133
180
|
assert d._is_unsafe("FEMALE_BREAST_COVERED") is False
|
|
181
|
+
assert d._is_unsafe("FEET_EXPOSED") is False # bare feet / chest are not nudity
|
|
182
|
+
assert d._is_unsafe("MALE_BREAST_EXPOSED") is False
|
|
134
183
|
|
|
135
184
|
|
|
136
185
|
def test_nudenet_unsafe_class_override() -> None:
|
|
137
186
|
d = NudeNetDetector(unsafe_classes=frozenset({"CUSTOM"}))
|
|
138
187
|
assert d._is_unsafe("CUSTOM") is True
|
|
139
|
-
assert d._is_unsafe("FEMALE_BREAST_EXPOSED") is False # override replaces the
|
|
188
|
+
assert d._is_unsafe("FEMALE_BREAST_EXPOSED") is False # override replaces the default set
|
|
140
189
|
|
|
141
190
|
|
|
142
|
-
def
|
|
191
|
+
def test_nudenet_scores_through_the_server(nudenet_url: str, tmp_path: Path) -> None:
|
|
192
|
+
for name in ("topless.png", "beach.png", "broken.png"):
|
|
193
|
+
(tmp_path / name).write_bytes(b"\x89PNG fake")
|
|
194
|
+
d = NudeNetDetector(base_url=nudenet_url, timeout=5)
|
|
195
|
+
assert d.is_available()
|
|
196
|
+
assert d.unsafe_probability(tmp_path / "topless.png") == 0.81 # the face (0.93) does not count
|
|
197
|
+
assert d.unsafe_probability(tmp_path / "beach.png") == 0.0
|
|
198
|
+
assert d.unsafe_probability(tmp_path / "broken.png") is None # failed check: skipped, not safe
|
|
199
|
+
assert d.unsafe_probability(tmp_path / "missing.png") is None
|
|
200
|
+
wide = NudeNetDetector(unsafe_classes=frozenset({"FEET_EXPOSED"}), base_url=nudenet_url)
|
|
201
|
+
assert wide.unsafe_probability(tmp_path / "beach.png") == 0.9
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_nudenet_url_from_env(nudenet_url: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
205
|
+
monkeypatch.setenv("PROOF_NUDENET_URL", nudenet_url)
|
|
206
|
+
assert NudeNetDetector().is_available()
|
|
207
|
+
monkeypatch.setenv("PROOF_NUDENET_URL", "ftp://nudenet")
|
|
208
|
+
assert NudeNetDetector().is_available() is False
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_nudenet_unavailable_without_a_server_and_degrades(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
212
|
+
monkeypatch.delenv("PROOF_NUDENET_URL", raising=False)
|
|
143
213
|
d = NudeNetDetector()
|
|
144
|
-
assert d.is_available() is False #
|
|
145
|
-
# calling anyway must not crash — the
|
|
214
|
+
assert d.is_available() is False # no NudeNet server configured
|
|
215
|
+
# calling anyway must not crash — the config error is caught -> None
|
|
146
216
|
assert d.unsafe_probability(Path("nope.png")) is None
|
argus_proof-0.2.0/Dockerfile
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
# syntax=docker/dockerfile:1
|
|
2
|
-
FROM python:3.11-slim
|
|
3
|
-
|
|
4
|
-
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
|
5
|
-
|
|
6
|
-
WORKDIR /app
|
|
7
|
-
|
|
8
|
-
# hatch-vcs derives the version from git history, so the build context must
|
|
9
|
-
# include .git (keep it out of .dockerignore for this image) and the image
|
|
10
|
-
# needs the git binary for setuptools-scm to read it (slim ships without it).
|
|
11
|
-
RUN apt-get update \
|
|
12
|
-
&& apt-get install -y --no-install-recommends git \
|
|
13
|
-
&& rm -rf /var/lib/apt/lists/*
|
|
14
|
-
|
|
15
|
-
COPY . .
|
|
16
|
-
|
|
17
|
-
# Extras baked into the image. Default keeps it light; build with
|
|
18
|
-
# EXTRAS="server,cli,score" for the full scorer stack (torch, insightface, …).
|
|
19
|
-
ARG EXTRAS="server,cli"
|
|
20
|
-
|
|
21
|
-
# pillow + imagehash ride along regardless: they power phash near-dup collapse
|
|
22
|
-
# and diversity (honest group pass-rates) at negligible image cost.
|
|
23
|
-
RUN uv pip install --system --no-cache ".[${EXTRAS}]" pillow imagehash
|
|
24
|
-
|
|
25
|
-
EXPOSE 8104
|
|
26
|
-
CMD ["argus-proof", "serve", "--port", "8104", "--cors"]
|
|
27
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|