argus-proof 0.2.0__py3-none-any.whl
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/__init__.py +16 -0
- argus_proof/_version.py +24 -0
- argus_proof/acceptance.py +118 -0
- argus_proof/archive.py +168 -0
- argus_proof/backends/__init__.py +67 -0
- argus_proof/backends/a1111.py +150 -0
- argus_proof/backends/base.py +170 -0
- argus_proof/backends/comfyui.py +228 -0
- argus_proof/backends/diffusers.py +256 -0
- argus_proof/backends/http.py +94 -0
- argus_proof/backends/pnginfo.py +73 -0
- argus_proof/backends/remote.py +157 -0
- argus_proof/backends/workflow.py +144 -0
- argus_proof/cli.py +467 -0
- argus_proof/crossrun.py +341 -0
- argus_proof/evaluate.py +269 -0
- argus_proof/experiment.py +314 -0
- argus_proof/explore.py +283 -0
- argus_proof/grid.py +324 -0
- argus_proof/hashing.py +37 -0
- argus_proof/models.py +661 -0
- argus_proof/moderation.py +356 -0
- argus_proof/py.typed +1 -0
- argus_proof/recommend.py +280 -0
- argus_proof/refinement.py +117 -0
- argus_proof/reports.py +258 -0
- argus_proof/scoring/__init__.py +33 -0
- argus_proof/scoring/base.py +94 -0
- argus_proof/scoring/gate.py +66 -0
- argus_proof/scoring/orchestrator.py +135 -0
- argus_proof/scoring/scorers/__init__.py +42 -0
- argus_proof/scoring/scorers/_util.py +84 -0
- argus_proof/scoring/scorers/identity.py +156 -0
- argus_proof/scoring/scorers/phash.py +138 -0
- argus_proof/scoring/scorers/quality.py +196 -0
- argus_proof/scoring/scorers/safety.py +144 -0
- argus_proof/scoring/summary.py +111 -0
- argus_proof/server/__init__.py +5 -0
- argus_proof/server/app.py +451 -0
- argus_proof/stats.py +83 -0
- argus_proof/templates/comfyui_sdxl_lora.json +55 -0
- argus_proof-0.2.0.dist-info/METADATA +456 -0
- argus_proof-0.2.0.dist-info/RECORD +46 -0
- argus_proof-0.2.0.dist-info/WHEEL +4 -0
- argus_proof-0.2.0.dist-info/entry_points.txt +2 -0
- argus_proof-0.2.0.dist-info/licenses/LICENSE +21 -0
argus_proof/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""argus-proof — Post-training LoRA evaluation and optimisation: generate samples from a trained LoRA and score them against the curated dataset it was trained from"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
# Written by hatch-vcs at build time (see pyproject [tool.hatch.build.hooks.vcs]).
|
|
7
|
+
from argus_proof._version import __version__
|
|
8
|
+
except ImportError: # running from a source checkout that hasn't been built
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version("argus-proof")
|
|
13
|
+
except PackageNotFoundError:
|
|
14
|
+
__version__ = "0.0.0+unknown"
|
|
15
|
+
|
|
16
|
+
__all__ = ["__version__"]
|
argus_proof/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""The CI acceptance gate (#12): a machine-readable pass/fail on an EvalReport.
|
|
2
|
+
|
|
3
|
+
Closes the loop for CI — "was this dataset/LoRA good enough?" becomes an
|
|
4
|
+
automatable yes/no against declared :class:`~argus_proof.models.AcceptanceThresholds`
|
|
5
|
+
(a min pass-rate, a statistically-defensible pass-rate lower bound, an identity
|
|
6
|
+
floor, a safety ceiling). Only the configured checks run; the run is accepted
|
|
7
|
+
iff every check passes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from argus_proof.models import (
|
|
13
|
+
AcceptanceThresholds,
|
|
14
|
+
EvalReport,
|
|
15
|
+
GateCheck,
|
|
16
|
+
GateResult,
|
|
17
|
+
)
|
|
18
|
+
from argus_proof.stats import wilson_lower_bound
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _unsafe_rate(report: EvalReport, safety_floor: float) -> float:
|
|
22
|
+
"""Fraction of images that are unsafe.
|
|
23
|
+
|
|
24
|
+
An image counts as unsafe if its ``safety`` metric is below *safety_floor*
|
|
25
|
+
OR it carries an ``unsafe`` reject reason. Keying off the metric (not only
|
|
26
|
+
the reject reason) matters: the ``unsafe`` reason is only produced when the
|
|
27
|
+
gate has a ``safety`` hard gate, so a run scored with the default gate could
|
|
28
|
+
otherwise report a 0% unsafe rate while containing unsafe imagery.
|
|
29
|
+
"""
|
|
30
|
+
n = len(report.images)
|
|
31
|
+
if n == 0:
|
|
32
|
+
return 0.0
|
|
33
|
+
unsafe = sum(
|
|
34
|
+
1
|
|
35
|
+
for img in report.images
|
|
36
|
+
if (img.metrics.safety is not None and img.metrics.safety < safety_floor)
|
|
37
|
+
or any(r.code == "unsafe" for r in img.reject_reasons)
|
|
38
|
+
)
|
|
39
|
+
return unsafe / n
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def evaluate_acceptance(report: EvalReport, thresholds: AcceptanceThresholds) -> GateResult:
|
|
43
|
+
"""Evaluate *report* against *thresholds* → a :class:`GateResult`.
|
|
44
|
+
|
|
45
|
+
A configured metric that wasn't measured (e.g. ``min_identity_mean`` with no
|
|
46
|
+
identity scorer) fails its check rather than passing silently — you can't
|
|
47
|
+
accept on evidence you don't have.
|
|
48
|
+
"""
|
|
49
|
+
checks: list[GateCheck] = []
|
|
50
|
+
agg = report.aggregate
|
|
51
|
+
# The pass-rate is over near-dup groups when deduped, else over images.
|
|
52
|
+
n = agg.n_groups if agg.n_groups is not None else agg.n_images
|
|
53
|
+
n_passed = min(agg.n_passed, n) # defensive: never let successes exceed the denominator
|
|
54
|
+
|
|
55
|
+
if thresholds.min_pass_rate is not None:
|
|
56
|
+
ok = agg.pass_rate >= thresholds.min_pass_rate
|
|
57
|
+
checks.append(
|
|
58
|
+
GateCheck(
|
|
59
|
+
name="pass_rate",
|
|
60
|
+
passed=ok,
|
|
61
|
+
actual=agg.pass_rate,
|
|
62
|
+
threshold=thresholds.min_pass_rate,
|
|
63
|
+
detail=f"pass_rate {agg.pass_rate:.3f} {'>=' if ok else '<'} {thresholds.min_pass_rate:.3f}",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if thresholds.min_pass_rate_ci_lower is not None:
|
|
68
|
+
lb = wilson_lower_bound(n_passed, n, thresholds.confidence)
|
|
69
|
+
ok = lb >= thresholds.min_pass_rate_ci_lower
|
|
70
|
+
checks.append(
|
|
71
|
+
GateCheck(
|
|
72
|
+
name="pass_rate_ci_lower",
|
|
73
|
+
passed=ok,
|
|
74
|
+
actual=lb,
|
|
75
|
+
threshold=thresholds.min_pass_rate_ci_lower,
|
|
76
|
+
detail=(
|
|
77
|
+
f"{thresholds.confidence:.0%} Wilson lower bound {lb:.3f} "
|
|
78
|
+
f"({n_passed}/{n}) {'>=' if ok else '<'} {thresholds.min_pass_rate_ci_lower:.3f}"
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if thresholds.min_identity_mean is not None:
|
|
84
|
+
val = agg.means.identity
|
|
85
|
+
ok = val is not None and val >= thresholds.min_identity_mean
|
|
86
|
+
detail = (
|
|
87
|
+
"identity not measured"
|
|
88
|
+
if val is None
|
|
89
|
+
else f"identity mean {val:.3f} {'>=' if ok else '<'} {thresholds.min_identity_mean:.3f}"
|
|
90
|
+
)
|
|
91
|
+
checks.append(
|
|
92
|
+
GateCheck(
|
|
93
|
+
name="identity_mean", passed=ok, actual=val, threshold=thresholds.min_identity_mean, detail=detail
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if thresholds.max_unsafe_rate is not None:
|
|
98
|
+
rate = _unsafe_rate(report, thresholds.unsafe_safety_floor)
|
|
99
|
+
ok = rate <= thresholds.max_unsafe_rate
|
|
100
|
+
checks.append(
|
|
101
|
+
GateCheck(
|
|
102
|
+
name="unsafe_rate",
|
|
103
|
+
passed=ok,
|
|
104
|
+
actual=rate,
|
|
105
|
+
threshold=thresholds.max_unsafe_rate,
|
|
106
|
+
detail=f"unsafe rate {rate:.3f} {'<=' if ok else '>'} {thresholds.max_unsafe_rate:.3f}",
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if not checks:
|
|
111
|
+
# No thresholds configured -> nothing was verified. Refuse rather than
|
|
112
|
+
# accept on zero evidence (all([]) would otherwise be True).
|
|
113
|
+
return GateResult(
|
|
114
|
+
passed=False,
|
|
115
|
+
checks=[GateCheck(name="no_checks", passed=False, detail="no acceptance thresholds configured")],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return GateResult(passed=all(c.passed for c in checks), checks=checks)
|
argus_proof/archive.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Metadata-only archival of a reviewed run (#9).
|
|
2
|
+
|
|
3
|
+
Keep the analytical value of a run without hoarding imagery: after review, a run
|
|
4
|
+
directory is reduced to
|
|
5
|
+
|
|
6
|
+
* ``manifest.json`` + ``eval_report.json`` — the run's params and scores;
|
|
7
|
+
* ``passing_images.zip`` — the images that passed, retained zipped;
|
|
8
|
+
* ``reject_archive.json`` — a :class:`~argus_proof.models.RejectArchive`: params
|
|
9
|
+
(the embedded :class:`~argus_proof.models.RunManifest`) + scores + structured
|
|
10
|
+
reject reasons + rater id for every rejected image, and **no image or
|
|
11
|
+
thumbnail reference** (explicit product decision — the metadata is the useful
|
|
12
|
+
signal for diagnosing failure modes and could later train an auto-classifier).
|
|
13
|
+
|
|
14
|
+
With ``blank_slate=True`` the loose per-image files that were archived (passing →
|
|
15
|
+
zipped, rejected → recorded) are deleted, leaving only the artifacts above.
|
|
16
|
+
Images still awaiting review (``passed is None``) are left untouched. Every
|
|
17
|
+
record joins back to its run by ``run_id``, so a reject is traceable to its exact
|
|
18
|
+
config without the pixels.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import zipfile
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import structlog
|
|
28
|
+
|
|
29
|
+
from argus_proof.models import (
|
|
30
|
+
EvalReport,
|
|
31
|
+
GeneratedImage,
|
|
32
|
+
RejectArchive,
|
|
33
|
+
RejectRecord,
|
|
34
|
+
RunManifest,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
logger = structlog.get_logger()
|
|
38
|
+
|
|
39
|
+
# Version of the on-disk archive layout (the set/naming of artifacts below).
|
|
40
|
+
# Bump if the directory contract changes so consumers can refuse an old layout.
|
|
41
|
+
ARCHIVE_VERSION = "1.0"
|
|
42
|
+
|
|
43
|
+
MANIFEST_NAME = "manifest.json"
|
|
44
|
+
REPORT_NAME = "eval_report.json"
|
|
45
|
+
PASSING_ZIP_NAME = "passing_images.zip"
|
|
46
|
+
REJECT_ARCHIVE_NAME = "reject_archive.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ArchiveResult:
|
|
51
|
+
"""What :func:`archive_run` produced: counts + the artifact paths."""
|
|
52
|
+
|
|
53
|
+
run_id: str
|
|
54
|
+
n_passing: int
|
|
55
|
+
n_rejected: int
|
|
56
|
+
n_pending: int # images left for review (passed is None)
|
|
57
|
+
passing_zip: Path | None
|
|
58
|
+
reject_archive_path: Path
|
|
59
|
+
deleted_images: list[str] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_reject_archive(report: EvalReport, manifest: RunManifest, *, rater_id: str | None = None) -> RejectArchive:
|
|
63
|
+
"""A :class:`RejectArchive` of the report's rejected images (``passed is False``).
|
|
64
|
+
|
|
65
|
+
Embeds *manifest* (keyed by ``run_id``) so the archive is self-describing, and
|
|
66
|
+
stamps ``rater_id`` on each record when a human reviewed the run. Carries zero
|
|
67
|
+
image references by construction.
|
|
68
|
+
"""
|
|
69
|
+
records = [
|
|
70
|
+
RejectRecord(
|
|
71
|
+
run_id=report.run_id,
|
|
72
|
+
seed=img.seed,
|
|
73
|
+
metrics=img.metrics,
|
|
74
|
+
hitl_rating=img.hitl_rating,
|
|
75
|
+
reasons=img.reject_reasons,
|
|
76
|
+
rater_id=rater_id,
|
|
77
|
+
)
|
|
78
|
+
for img in report.images
|
|
79
|
+
if img.passed is False
|
|
80
|
+
]
|
|
81
|
+
return RejectArchive(manifests={manifest.run_id: manifest}, records=records)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def archive_run(
|
|
85
|
+
run_dir: Path,
|
|
86
|
+
images: list[GeneratedImage],
|
|
87
|
+
report: EvalReport,
|
|
88
|
+
manifest: RunManifest,
|
|
89
|
+
*,
|
|
90
|
+
rater_id: str | None = None,
|
|
91
|
+
blank_slate: bool = True,
|
|
92
|
+
) -> ArchiveResult:
|
|
93
|
+
"""Reduce a reviewed *run_dir* to metadata + a zip of the passing images.
|
|
94
|
+
|
|
95
|
+
*images* supplies the on-disk paths (joined to *report* by ``image_id``).
|
|
96
|
+
Writes ``manifest.json``, ``eval_report.json``, ``passing_images.zip``, and
|
|
97
|
+
``reject_archive.json``; with ``blank_slate`` deletes the loose image files
|
|
98
|
+
that were archived (passing + rejected), leaving pending images in place.
|
|
99
|
+
"""
|
|
100
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
verdict_by_id = {img.image_id: img.passed for img in report.images}
|
|
102
|
+
by_id = {img.image_id: img for img in images}
|
|
103
|
+
|
|
104
|
+
(run_dir / MANIFEST_NAME).write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
|
|
105
|
+
(run_dir / REPORT_NAME).write_text(report.model_dump_json(indent=2), encoding="utf-8")
|
|
106
|
+
|
|
107
|
+
reject_archive = build_reject_archive(report, manifest, rater_id=rater_id)
|
|
108
|
+
reject_path = run_dir / REJECT_ARCHIVE_NAME
|
|
109
|
+
reject_path.write_text(reject_archive.model_dump_json(indent=2), encoding="utf-8")
|
|
110
|
+
|
|
111
|
+
# Warn about report images with no matching file — they can't be archived.
|
|
112
|
+
missing = [i.image_id for i in report.images if i.passed is not None and i.image_id not in by_id]
|
|
113
|
+
if missing:
|
|
114
|
+
logger.warning("archive.missing_images", run_id=report.run_id, image_ids=missing)
|
|
115
|
+
|
|
116
|
+
# Zip the passing images (that still exist on disk). Arcname is the unique
|
|
117
|
+
# image_id (+ suffix) so two passing images that share a basename in
|
|
118
|
+
# different dirs don't collide and silently drop one.
|
|
119
|
+
passing = [img for img in images if verdict_by_id.get(img.image_id) is True]
|
|
120
|
+
passing_zip: Path | None = None
|
|
121
|
+
if passing:
|
|
122
|
+
passing_zip = run_dir / PASSING_ZIP_NAME
|
|
123
|
+
with zipfile.ZipFile(passing_zip, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
124
|
+
for img in passing:
|
|
125
|
+
src = Path(img.path)
|
|
126
|
+
if src.is_file():
|
|
127
|
+
zf.write(src, arcname=f"{img.image_id}{src.suffix}")
|
|
128
|
+
|
|
129
|
+
run_resolved = run_dir.resolve()
|
|
130
|
+
n_pending = sum(1 for img in report.images if img.passed is None)
|
|
131
|
+
deleted: list[str] = []
|
|
132
|
+
if blank_slate:
|
|
133
|
+
# Delete the loose files that are now archived: passing (in the zip) and
|
|
134
|
+
# rejected (recorded as metadata). Pending images are left for review.
|
|
135
|
+
# Only touch files inside run_dir — never delete an image the run doesn't own.
|
|
136
|
+
for image_id, passed in verdict_by_id.items():
|
|
137
|
+
if passed is None:
|
|
138
|
+
continue # not yet reviewed
|
|
139
|
+
img = by_id.get(image_id)
|
|
140
|
+
if img is None:
|
|
141
|
+
continue
|
|
142
|
+
src = Path(img.path)
|
|
143
|
+
if not src.is_file():
|
|
144
|
+
continue
|
|
145
|
+
if not src.resolve().is_relative_to(run_resolved):
|
|
146
|
+
logger.warning("archive.skip_delete_outside_run_dir", run_id=report.run_id, path=str(src))
|
|
147
|
+
continue
|
|
148
|
+
src.unlink()
|
|
149
|
+
deleted.append(src.name)
|
|
150
|
+
|
|
151
|
+
result = ArchiveResult(
|
|
152
|
+
run_id=report.run_id,
|
|
153
|
+
n_passing=len(passing),
|
|
154
|
+
n_rejected=len(reject_archive.records),
|
|
155
|
+
n_pending=n_pending,
|
|
156
|
+
passing_zip=passing_zip,
|
|
157
|
+
reject_archive_path=reject_path,
|
|
158
|
+
deleted_images=deleted,
|
|
159
|
+
)
|
|
160
|
+
logger.info(
|
|
161
|
+
"archive.run",
|
|
162
|
+
run_id=result.run_id,
|
|
163
|
+
passing=result.n_passing,
|
|
164
|
+
rejected=result.n_rejected,
|
|
165
|
+
pending=result.n_pending,
|
|
166
|
+
blank_slate=blank_slate,
|
|
167
|
+
)
|
|
168
|
+
return result
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Generation backends — one module per engine, selected by name (config).
|
|
2
|
+
|
|
3
|
+
Swapping the engine that generates the eval grid is a config change, not a code
|
|
4
|
+
change: a caller names a backend and constructs it through :func:`get_backend`,
|
|
5
|
+
and scoring/report code is unchanged regardless of which one produced the run.
|
|
6
|
+
|
|
7
|
+
* ``comfyui`` — a running ComfyUI instance (the first adapter).
|
|
8
|
+
* ``diffusers`` — in-process diffusers SDXL pipeline (deterministic, no service).
|
|
9
|
+
* ``a1111`` — an AUTOMATIC1111 / SD.Next ``/sdapi`` server.
|
|
10
|
+
* ``remote`` — a hosted/cloud endpoint that speaks the proof wire.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from argus_proof.backends.a1111 import A1111Backend
|
|
16
|
+
from argus_proof.backends.base import (
|
|
17
|
+
BackendError,
|
|
18
|
+
GenBackend,
|
|
19
|
+
GenResult,
|
|
20
|
+
ModelResolver,
|
|
21
|
+
ProgressSink,
|
|
22
|
+
build_local_manifest,
|
|
23
|
+
hash_model,
|
|
24
|
+
)
|
|
25
|
+
from argus_proof.backends.comfyui import ComfyUIBackend
|
|
26
|
+
from argus_proof.backends.diffusers import DiffusersBackend
|
|
27
|
+
from argus_proof.backends.remote import RemoteBackend
|
|
28
|
+
|
|
29
|
+
# The backends this build knows how to construct, by name.
|
|
30
|
+
_BACKENDS: dict[str, type] = {
|
|
31
|
+
"comfyui": ComfyUIBackend,
|
|
32
|
+
"diffusers": DiffusersBackend,
|
|
33
|
+
"a1111": A1111Backend,
|
|
34
|
+
"remote": RemoteBackend,
|
|
35
|
+
}
|
|
36
|
+
KNOWN_BACKENDS: tuple[str, ...] = tuple(_BACKENDS)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_backend(name: str, **kwargs: object) -> GenBackend:
|
|
40
|
+
"""Construct the backend called *name*, forwarding ``kwargs`` to it.
|
|
41
|
+
|
|
42
|
+
Raises :class:`BackendError` for an unknown name so a bad config fails
|
|
43
|
+
loudly instead of silently doing nothing.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
backend_cls = _BACKENDS[name]
|
|
47
|
+
except KeyError:
|
|
48
|
+
known = ", ".join(KNOWN_BACKENDS)
|
|
49
|
+
raise BackendError(f"unknown generation backend {name!r} (known: {known})") from None
|
|
50
|
+
return backend_cls(**kwargs) # type: ignore[arg-type]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"KNOWN_BACKENDS",
|
|
55
|
+
"A1111Backend",
|
|
56
|
+
"BackendError",
|
|
57
|
+
"ComfyUIBackend",
|
|
58
|
+
"DiffusersBackend",
|
|
59
|
+
"GenBackend",
|
|
60
|
+
"GenResult",
|
|
61
|
+
"ModelResolver",
|
|
62
|
+
"ProgressSink",
|
|
63
|
+
"RemoteBackend",
|
|
64
|
+
"build_local_manifest",
|
|
65
|
+
"get_backend",
|
|
66
|
+
"hash_model",
|
|
67
|
+
]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""AUTOMATIC1111 / SD.Next generation backend — drives their ``/sdapi`` HTTP API.
|
|
2
|
+
|
|
3
|
+
Submits a ``txt2img`` request per seed, decodes the base64 PNG(s) it returns, and
|
|
4
|
+
emits a reproducible :class:`~argus_proof.models.RunManifest`. The base checkpoint
|
|
5
|
+
is selected per-request via ``override_settings`` and LoRA(s) via the A1111
|
|
6
|
+
``<lora:name:weight>`` prompt syntax. Model files are SHA256-pinned from disk (via
|
|
7
|
+
``resolve_model``), so this targets an A1111/SD.Next whose models are locally
|
|
8
|
+
resolvable — the manifest reconstructs the run exactly.
|
|
9
|
+
|
|
10
|
+
The HTTP layer is the shared injectable :class:`~argus_proof.backends.http.Transport`,
|
|
11
|
+
so the adapter is unit-testable without a live A1111.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import structlog
|
|
19
|
+
|
|
20
|
+
from argus_proof.backends.base import (
|
|
21
|
+
BackendError,
|
|
22
|
+
GenResult,
|
|
23
|
+
ModelResolver,
|
|
24
|
+
ProgressSink,
|
|
25
|
+
build_local_manifest,
|
|
26
|
+
write_manifest,
|
|
27
|
+
)
|
|
28
|
+
from argus_proof.backends.http import Transport, UrllibTransport, decode_base64_image
|
|
29
|
+
from argus_proof.backends.pnginfo import read_dimensions
|
|
30
|
+
from argus_proof.models import BackendCapabilities, GeneratedImage, ProgressEvent, RunSpec
|
|
31
|
+
|
|
32
|
+
logger = structlog.get_logger()
|
|
33
|
+
|
|
34
|
+
BACKEND_NAME = "a1111"
|
|
35
|
+
DEFAULT_BASE_URL = "http://127.0.0.1:7860"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class A1111Backend:
|
|
39
|
+
"""Generate images through an AUTOMATIC1111 / SD.Next ``/sdapi`` server.
|
|
40
|
+
|
|
41
|
+
``resolve_model`` hashes the base checkpoint + LoRA files into the manifest;
|
|
42
|
+
``transport`` defaults to :class:`UrllibTransport` against ``base_url`` and is
|
|
43
|
+
injectable for testing. ``engine_version`` is recorded on the manifest (A1111
|
|
44
|
+
exposes no portable version endpoint); pass it to pin the server build.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
resolve_model: ModelResolver,
|
|
50
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
51
|
+
transport: Transport | None = None,
|
|
52
|
+
engine_version: str | None = None,
|
|
53
|
+
timeout: float = 600.0,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.resolve_model = resolve_model
|
|
56
|
+
self.transport = transport or UrllibTransport(base_url, timeout=timeout, label="A1111")
|
|
57
|
+
self._engine_version = engine_version or "unknown"
|
|
58
|
+
|
|
59
|
+
def capabilities(self) -> BackendCapabilities:
|
|
60
|
+
return BackendCapabilities(
|
|
61
|
+
name=BACKEND_NAME,
|
|
62
|
+
supports_seed_set=True,
|
|
63
|
+
max_loras=None,
|
|
64
|
+
reads_pnginfo=False,
|
|
65
|
+
streams_progress=True,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def generate(self, spec: RunSpec, out_dir: Path, progress: ProgressSink | None = None) -> GenResult:
|
|
69
|
+
"""Run *spec*, writing one image per seed (+ manifest.json) under *out_dir*."""
|
|
70
|
+
logger.debug("a1111.generate", run_id=spec.run_id, seeds=len(spec.seeds), out_dir=str(out_dir))
|
|
71
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
|
|
73
|
+
def emit(event: ProgressEvent) -> None:
|
|
74
|
+
if progress is not None:
|
|
75
|
+
progress(event)
|
|
76
|
+
|
|
77
|
+
total = len(spec.seeds)
|
|
78
|
+
emit(ProgressEvent(run_id=spec.run_id, type="start", total=total))
|
|
79
|
+
|
|
80
|
+
images: list[GeneratedImage] = []
|
|
81
|
+
try:
|
|
82
|
+
manifest = build_local_manifest(
|
|
83
|
+
spec, resolve_model=self.resolve_model, engine=BACKEND_NAME, engine_version=self._engine_version
|
|
84
|
+
)
|
|
85
|
+
for done, seed in enumerate(spec.seeds, start=1):
|
|
86
|
+
images.extend(self._generate_seed(spec, seed, out_dir, emit))
|
|
87
|
+
emit(ProgressEvent(run_id=spec.run_id, type="progress", completed=done, total=total))
|
|
88
|
+
except BackendError as exc:
|
|
89
|
+
emit(ProgressEvent(run_id=spec.run_id, type="error", message=str(exc)))
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
write_manifest(out_dir, manifest)
|
|
93
|
+
emit(ProgressEvent(run_id=spec.run_id, type="done", completed=total, total=total))
|
|
94
|
+
return GenResult(manifest=manifest, images=images)
|
|
95
|
+
|
|
96
|
+
def _generate_seed(self, spec: RunSpec, seed: int, out_dir: Path, emit: ProgressSink) -> list[GeneratedImage]:
|
|
97
|
+
resp = self.transport.post_json("/sdapi/v1/txt2img", self._payload(spec, seed))
|
|
98
|
+
encoded = resp.get("images") or []
|
|
99
|
+
if not encoded:
|
|
100
|
+
raise BackendError(f"A1111 produced no images for seed {seed}")
|
|
101
|
+
produced: list[GeneratedImage] = []
|
|
102
|
+
for index, b64 in enumerate(encoded):
|
|
103
|
+
data = decode_base64_image(b64, context=f"A1111 seed {seed}")
|
|
104
|
+
suffix = "" if index == 0 else f"-{index}"
|
|
105
|
+
image_id = f"{spec.run_id}-{seed}{suffix}"
|
|
106
|
+
path = out_dir / f"{image_id}.png"
|
|
107
|
+
path.write_bytes(data)
|
|
108
|
+
dims = read_dimensions(data) or (spec.sampling.width, spec.sampling.height)
|
|
109
|
+
produced.append(
|
|
110
|
+
GeneratedImage(
|
|
111
|
+
image_id=image_id,
|
|
112
|
+
run_id=spec.run_id,
|
|
113
|
+
seed=seed,
|
|
114
|
+
path=str(path),
|
|
115
|
+
width=dims[0],
|
|
116
|
+
height=dims[1],
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
emit(ProgressEvent(run_id=spec.run_id, type="image", seed=seed, image_id=image_id))
|
|
120
|
+
return produced
|
|
121
|
+
|
|
122
|
+
def _payload(self, spec: RunSpec, seed: int) -> dict:
|
|
123
|
+
# A1111 applies LoRAs via the prompt: "<lora:name:weight>" — keep any
|
|
124
|
+
# subdirectory (with_suffix drops only the extension) so a nested LoRA
|
|
125
|
+
# (loras/char/subject.safetensors) resolves as "<lora:char/subject:...>".
|
|
126
|
+
lora_tags = "".join(f" <lora:{Path(lo.name).with_suffix('')}:{lo.weight}>" for lo in spec.loras)
|
|
127
|
+
# Pin the checkpoint, VAE, and clip-skip via override_settings so the run
|
|
128
|
+
# matches the manifest; restore_afterwards=False keeps the checkpoint loaded
|
|
129
|
+
# across the run's seeds instead of reloading it per request.
|
|
130
|
+
override: dict = {
|
|
131
|
+
"sd_model_checkpoint": spec.base_checkpoint,
|
|
132
|
+
"CLIP_stop_at_last_layers": spec.sampling.clip_skip,
|
|
133
|
+
}
|
|
134
|
+
if spec.vae:
|
|
135
|
+
override["sd_vae"] = spec.vae
|
|
136
|
+
return {
|
|
137
|
+
"prompt": spec.prompt + lora_tags,
|
|
138
|
+
"negative_prompt": spec.negative_prompt,
|
|
139
|
+
"steps": spec.sampling.steps,
|
|
140
|
+
"cfg_scale": spec.sampling.cfg,
|
|
141
|
+
"width": spec.sampling.width,
|
|
142
|
+
"height": spec.sampling.height,
|
|
143
|
+
"seed": seed,
|
|
144
|
+
"sampler_name": spec.sampling.sampler,
|
|
145
|
+
"scheduler": spec.sampling.scheduler,
|
|
146
|
+
"batch_size": 1,
|
|
147
|
+
"n_iter": 1,
|
|
148
|
+
"override_settings": override,
|
|
149
|
+
"override_settings_restore_afterwards": False,
|
|
150
|
+
}
|