@mcptoolshop/style-dataset-lab 3.2.0 → 3.4.0
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.
- package/CHANGELOG.md +58 -1
- package/README.es.md +40 -19
- package/README.fr.md +74 -53
- package/README.hi.md +67 -46
- package/README.it.md +42 -21
- package/README.ja.md +44 -23
- package/README.md +25 -4
- package/README.pt-BR.md +42 -21
- package/README.zh.md +66 -45
- package/bin/sdlab.js +51 -4
- package/lib/adapters/ai-toolkit.js +37 -5
- package/lib/adapters/comfyui-runner.js +114 -27
- package/lib/adapters/comfyui-workflows.js +10 -2
- package/lib/adapters/diffusers-lora.js +37 -5
- package/lib/adapters/generic-image-caption.js +43 -5
- package/lib/args.js +16 -1
- package/lib/batch-modes.js +29 -8
- package/lib/batch-runs.js +29 -1
- package/lib/batch-sheet-render.js +13 -3
- package/lib/brief-compiler.js +19 -0
- package/lib/candidate-sheet.js +327 -0
- package/lib/canon-build/build.js +146 -93
- package/lib/captions.js +57 -0
- package/lib/card.js +74 -2
- package/lib/comfyui.js +116 -18
- package/lib/config.js +116 -12
- package/lib/critique-engine.js +82 -3
- package/lib/critique-render.js +45 -0
- package/lib/eligibility.js +121 -9
- package/lib/eval-pack.js +14 -14
- package/lib/eval-runs.js +33 -0
- package/lib/export.js +122 -34
- package/lib/freeze-gate.js +69 -16
- package/lib/freeze-stamp.js +28 -3
- package/lib/generated-provenance.js +16 -1
- package/lib/implementation-packs.js +95 -17
- package/lib/ingest.js +396 -0
- package/lib/judgment-provenance.js +220 -0
- package/lib/measure.js +422 -0
- package/lib/paths.js +196 -7
- package/lib/reingest-selected.js +98 -16
- package/lib/run-manifest.js +394 -0
- package/lib/run-summary.js +12 -6
- package/lib/runtime-runs.js +45 -4
- package/lib/snapshot.js +121 -16
- package/lib/split.js +364 -48
- package/lib/training-manifests.js +114 -21
- package/lib/training-packages.js +147 -15
- package/lib/verifier.js +67 -0
- package/lib/workflow-profiles.js +24 -3
- package/package.json +3 -2
- package/runtime/comfyui/txt2img-qwen.json +38 -0
- package/runtime/comfyui/txt2img-standard.json +21 -0
- package/scripts/_resume.js +62 -0
- package/scripts/_shared-args.js +44 -0
- package/scripts/brief-compile.js +2 -1
- package/scripts/brief-show.js +2 -1
- package/scripts/canon-bind.js +2 -2
- package/scripts/canon-build.js +27 -20
- package/scripts/canon-drift.js +16 -7
- package/scripts/canon-freeze-status.js +14 -6
- package/scripts/canon-freeze.js +27 -11
- package/scripts/canon-unfreeze.js +17 -9
- package/scripts/card.js +2 -2
- package/scripts/compare.js +2 -2
- package/scripts/critique.js +48 -8
- package/scripts/curate.js +23 -2
- package/scripts/doctor.js +181 -55
- package/scripts/eligibility.js +42 -2
- package/scripts/eval-pack.js +2 -2
- package/scripts/eval-run.js +2 -2
- package/scripts/export.js +2 -2
- package/scripts/generate-controlnet.js +45 -25
- package/scripts/generate-identity.js +53 -23
- package/scripts/generate-ipadapter.js +44 -23
- package/scripts/generate.js +72 -5
- package/scripts/implementation-pack.js +2 -2
- package/scripts/ingest.js +145 -0
- package/scripts/init.js +73 -13
- package/scripts/measure.js +170 -0
- package/scripts/measure_image.py +458 -0
- package/scripts/migrate-records.js +142 -0
- package/scripts/painterly-test.js +4 -3
- package/scripts/painterly.js +7 -3
- package/scripts/qwen_generate.py +334 -13
- package/scripts/reingest.js +2 -2
- package/scripts/run-generate.js +3 -0
- package/scripts/sheet.js +142 -0
- package/scripts/snapshot.js +2 -2
- package/scripts/split.js +2 -2
- package/scripts/training-manifest.js +2 -2
- package/scripts/training-package.js +2 -2
- package/scripts/training-profile.js +2 -2
- package/scripts/workflow-list.js +2 -1
- package/scripts/workflow-show.js +2 -1
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""measure_image — numeric palette + texture measurement for candidate images.
|
|
3
|
+
|
|
4
|
+
MEASUREMENT, NOT VERDICT. Every number this script produces is a signal to
|
|
5
|
+
attach to a record — nothing here classifies, thresholds, or emits a
|
|
6
|
+
pass/fail/approved/rejected verdict. That judgment stays with a human (or a
|
|
7
|
+
future LLM judge). This repo deleted three scripts in Stage A for fabricating
|
|
8
|
+
judgments from keyword matching instead of looking at the pixels; this script
|
|
9
|
+
exists to give the human (or a future judge) real pixels to look at, not to
|
|
10
|
+
replace them.
|
|
11
|
+
|
|
12
|
+
PORTED FROM (read these for the full rationale before touching the math):
|
|
13
|
+
projects/salt-road/inputs/prompts/measure-palette.py
|
|
14
|
+
projects/salt-road/inputs/prompts/measure-texture.py
|
|
15
|
+
These are the Director-validated, production-used salt-road audit
|
|
16
|
+
instruments. This script ports their MEASUREMENT CORE (the per-image pixel
|
|
17
|
+
math) — not the one-off audit drivers those files also contain (sweep-
|
|
18
|
+
directory globbing, filename-regex config classification, cross-run
|
|
19
|
+
comparison tables). sdlab measure operates per-record; the salt-road scripts
|
|
20
|
+
audit a specific sweep. Both are left untouched by this port and remain
|
|
21
|
+
available for that project-specific work.
|
|
22
|
+
|
|
23
|
+
DELIBERATELY NOT PORTED: the DISTS/StyleLoss perceptual cross-check in
|
|
24
|
+
projects/salt-road/inputs/prompts/crosscheck-dists.py. That instrument needs
|
|
25
|
+
torch+piq — a GPU-class dependency this CLI does not take on. Briefly, for
|
|
26
|
+
context (see that file for the full citations): it uses DISTS (Ding, Ma, Wang
|
|
27
|
+
& Simoncelli 2020, arXiv:2004.07728, TPAMI — separates texture similarity
|
|
28
|
+
from structure similarity, tolerant of texture resampling) and StyleLoss
|
|
29
|
+
(Gatys et al. 2015, arXiv:1505.07376 — Gram-matrix distance over VGG
|
|
30
|
+
features, legitimate for comparing scenes with different content), and it
|
|
31
|
+
explicitly rejects FID (poor sample complexity at small n — Jayasumana 2024,
|
|
32
|
+
arXiv:2401.09603; Chong & Forsyth 2020, arXiv:1911.07023) and DreamSim
|
|
33
|
+
(engineered to look past low-level colour/texture — Fu 2023, arXiv:2306.09344
|
|
34
|
+
— which would be blind to the thing under test here). A future `--perceptual`
|
|
35
|
+
mode should start from that file, not from scratch.
|
|
36
|
+
|
|
37
|
+
Deliberate changes from the ported originals (see also the sdlab feature
|
|
38
|
+
report for the full list):
|
|
39
|
+
- The two hardcoded salt-road ochre anchors (measure-palette.py's
|
|
40
|
+
`#c9a877` / `#a86b4c`, with hand-picked disjoint hue bands 8-42/42-70)
|
|
41
|
+
become an N-anchor system driven by the caller's `anchors` list (project
|
|
42
|
+
canon has no numeric palette data to read this from automatically — see
|
|
43
|
+
the sdlab feature report). Each gated pixel is assigned to whichever
|
|
44
|
+
anchor's hue is circularly nearest, provided that distance is within
|
|
45
|
+
`hue_tolerance_deg`; otherwise it is "off_anchor". This is a
|
|
46
|
+
generalization, not a re-derivation: for salt-road's own two anchors
|
|
47
|
+
(hue ~19deg and ~33deg) at the default 20deg tolerance it reproduces
|
|
48
|
+
materially the same combined catchment the original hand-tuned 8-42deg
|
|
49
|
+
band covered, while working for any project's own anchor set.
|
|
50
|
+
- measure-texture.py's `yellow_pct`/`ochre_pct`/`green_pct` (a second,
|
|
51
|
+
duplicate hue-binning pass baked into the texture measurer so "every
|
|
52
|
+
image measured for texture is measured for colour in the same pass") is
|
|
53
|
+
replaced by calling the SAME hue/anchor code the palette measurer uses.
|
|
54
|
+
Same discipline (one image load, texture + colour measured together),
|
|
55
|
+
without a second hardcoded hue-band implementation living in two files.
|
|
56
|
+
- `facade_rep` (row-of-identical-facades autocorrelation) is intentionally
|
|
57
|
+
NOT ported. It is a composition/repetition detector conditioned on
|
|
58
|
+
subject ("only meaningful on wide exteriors... meaningless for an
|
|
59
|
+
interior or a close stone study" — measure-texture.py), not a palette or
|
|
60
|
+
texture measure in the sense this CLI is scoped to (palette + texture,
|
|
61
|
+
per the feature's approved scope). Left out; see the feature report.
|
|
62
|
+
- The vectorised numpy hue/sat/val computation is adopted from
|
|
63
|
+
measure-texture.py's `hue_bands()` (itself a numerically-verified,
|
|
64
|
+
vectorised reproduction of measure-palette.py's slower colorsys
|
|
65
|
+
per-pixel loop — see that function's docstring) rather than
|
|
66
|
+
reimplementing the slow loop a second time. One hue/sat/val
|
|
67
|
+
implementation, used by both this script's palette and texture passes.
|
|
68
|
+
|
|
69
|
+
python measure_image.py --request <request.json> --out <result.json>
|
|
70
|
+
|
|
71
|
+
Request JSON shape (written by lib/measure.js):
|
|
72
|
+
{
|
|
73
|
+
"images": [{"id": "...", "path": "..."}, ...],
|
|
74
|
+
"anchors": [{"name": "ochre-warm", "hex": "#c9a877"}, ...], # optional, [] or omitted is fine
|
|
75
|
+
"hue_tolerance_deg": 20, # optional
|
|
76
|
+
"sat_min": 0.22, # optional — ported default, see measure_palette()
|
|
77
|
+
"val_min": 0.18 # optional — ported default, see measure_palette()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
Result JSON shape (written to --out):
|
|
81
|
+
{
|
|
82
|
+
"schema_version": "1.0.0", "tool": "measure_image.py", "generated_at": "...",
|
|
83
|
+
"results": [{"id", "path", "status": "ok", "image": {...}, "palette": {...}, "texture": {...}}],
|
|
84
|
+
"errors": [{"id", "path", "error"}],
|
|
85
|
+
"ok_count": N, "error_count": N
|
|
86
|
+
}
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
from __future__ import annotations
|
|
90
|
+
|
|
91
|
+
import argparse
|
|
92
|
+
import colorsys
|
|
93
|
+
import json
|
|
94
|
+
import math
|
|
95
|
+
import os
|
|
96
|
+
import re
|
|
97
|
+
import time
|
|
98
|
+
from datetime import datetime, timezone
|
|
99
|
+
from pathlib import Path
|
|
100
|
+
|
|
101
|
+
import numpy as np
|
|
102
|
+
from PIL import Image
|
|
103
|
+
from scipy.ndimage import gaussian_filter, sobel
|
|
104
|
+
|
|
105
|
+
MEASURE_SCHEMA_VERSION = "1.0.0"
|
|
106
|
+
|
|
107
|
+
# Ported verbatim from measure-palette.py's saturated-pixel gate: "ignore
|
|
108
|
+
# greys/darks — stone, tar, shadow". Comparability with that instrument's
|
|
109
|
+
# published numbers depends on these two values, so they are the default
|
|
110
|
+
# rather than something re-derived here.
|
|
111
|
+
DEFAULT_SAT_MIN = 0.22
|
|
112
|
+
DEFAULT_VAL_MIN = 0.18
|
|
113
|
+
|
|
114
|
+
# Not from either original (neither had a per-anchor tolerance concept — see
|
|
115
|
+
# module docstring). A deliberate new default: half-width, in degrees, of the
|
|
116
|
+
# circular-hue catchment around each anchor's own hue. Project owners tune
|
|
117
|
+
# this via the anchors JSON's "hue_tolerance_deg" field.
|
|
118
|
+
DEFAULT_HUE_TOLERANCE_DEG = 20.0
|
|
119
|
+
|
|
120
|
+
_HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _utc_now_iso() -> str:
|
|
124
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.") + \
|
|
125
|
+
f"{datetime.now(timezone.utc).microsecond // 1000:03d}Z"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _json_safe(obj):
|
|
129
|
+
"""Replace non-finite floats with None, recursively.
|
|
130
|
+
|
|
131
|
+
Python's json.dumps emits bare `NaN` / `Infinity` for these — a
|
|
132
|
+
non-standard extension that RFC 8259 does not allow and that JavaScript's
|
|
133
|
+
JSON.parse rejects outright, taking the WHOLE result down with it, not
|
|
134
|
+
just the offending field. The Node caller (lib/measure.js) then reports
|
|
135
|
+
`Unexpected token 'N'` and the operator learns nothing about which image
|
|
136
|
+
or which measure produced it.
|
|
137
|
+
|
|
138
|
+
Non-finite values are legitimate here rather than a bug to prevent: a
|
|
139
|
+
perfectly flat image has zero luminance variance, so a measure that
|
|
140
|
+
normalizes by it is genuinely undefined. Found in Phase 9 by measuring
|
|
141
|
+
three solid-colour images, which every unit test had missed because they
|
|
142
|
+
all paired a flat image with a textured one.
|
|
143
|
+
|
|
144
|
+
`null` is the honest encoding of "this measure is undefined for this
|
|
145
|
+
image" — the same choice palette conformance already makes when no
|
|
146
|
+
anchors are supplied. It is emphatically not 0, which would read as a
|
|
147
|
+
real measurement of a real property.
|
|
148
|
+
"""
|
|
149
|
+
if isinstance(obj, float):
|
|
150
|
+
return obj if math.isfinite(obj) else None
|
|
151
|
+
if isinstance(obj, dict):
|
|
152
|
+
return {k: _json_safe(v) for k, v in obj.items()}
|
|
153
|
+
if isinstance(obj, (list, tuple)):
|
|
154
|
+
return [_json_safe(v) for v in obj]
|
|
155
|
+
return obj
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _atomic_write_json(path: Path, obj) -> None:
|
|
159
|
+
"""Temp file in the same dir + os.replace — the Python-side mirror of
|
|
160
|
+
lib/runtime-runs.js's atomicWriteJson (temp+rename): a crash mid-write
|
|
161
|
+
can never leave a half-written result.json in place of a good one.
|
|
162
|
+
os.replace (not os.rename) because it atomically overwrites the
|
|
163
|
+
destination on Windows too, matching Node's fs.rename semantics."""
|
|
164
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
tmp = path.parent / f"{path.name}.tmp-{os.getpid()}-{int(time.time() * 1000)}"
|
|
166
|
+
try:
|
|
167
|
+
# allow_nan=False makes a leaked non-finite value a loud Python-side
|
|
168
|
+
# ValueError here rather than a bare `NaN` token that only explodes
|
|
169
|
+
# later in the Node caller's JSON.parse, where the message names no
|
|
170
|
+
# image and no measure. _json_safe should have caught them all; this
|
|
171
|
+
# is the assertion that it did.
|
|
172
|
+
tmp.write_text(
|
|
173
|
+
json.dumps(_json_safe(obj), indent=2, allow_nan=False) + "\n",
|
|
174
|
+
encoding="utf-8",
|
|
175
|
+
)
|
|
176
|
+
os.replace(tmp, path)
|
|
177
|
+
except Exception:
|
|
178
|
+
try:
|
|
179
|
+
tmp.unlink()
|
|
180
|
+
except OSError:
|
|
181
|
+
pass
|
|
182
|
+
raise
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ─── Anchor validation ─────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
def _validate_anchors(anchors) -> None:
|
|
188
|
+
if anchors is None:
|
|
189
|
+
return
|
|
190
|
+
if not isinstance(anchors, list):
|
|
191
|
+
raise ValueError("anchors must be a list of {name, hex} objects")
|
|
192
|
+
for a in anchors:
|
|
193
|
+
if not isinstance(a, dict) or "name" not in a or "hex" not in a:
|
|
194
|
+
raise ValueError(f"invalid anchor entry (needs name + hex): {a!r}")
|
|
195
|
+
if not _HEX_RE.match(str(a["hex"])):
|
|
196
|
+
raise ValueError(
|
|
197
|
+
f"anchor {a.get('name')!r} has invalid hex color {a.get('hex')!r} (expected #rrggbb)"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ─── Shared hue/sat/val (ported from measure-texture.py's hue_bands) ──────
|
|
202
|
+
|
|
203
|
+
def _hsv_arrays(im: Image.Image, size=(240, 137)):
|
|
204
|
+
"""Vectorised hue(deg)/saturation/value over a resized copy.
|
|
205
|
+
|
|
206
|
+
Adopted from measure-texture.py's hue_bands() — itself a numerically
|
|
207
|
+
verified, vectorised reproduction of measure-palette.py's per-pixel
|
|
208
|
+
colorsys loop (see that function's docstring: "reproducing
|
|
209
|
+
measure-palette.py EXACTLY -- same 240x137 downscale, same s>=0.22 /
|
|
210
|
+
v>=0.18 gate ... Vectorised, but deliberately not 'improved'"). Returns
|
|
211
|
+
raw (hue_deg, sat, val) arrays instead of pre-binned percentages so the
|
|
212
|
+
caller can bucket by an arbitrary, project-supplied anchor list instead
|
|
213
|
+
of the two hardcoded salt-road bands.
|
|
214
|
+
|
|
215
|
+
The downscale is the same deliberate choice as the original: palette is
|
|
216
|
+
a low-frequency, gross-color-mass question, so shrinking first is fine
|
|
217
|
+
(contrast measure_texture below, which explicitly does NOT resize).
|
|
218
|
+
"""
|
|
219
|
+
a = np.asarray(im.convert("RGB").resize(size), dtype=np.float64) / 255.0
|
|
220
|
+
mx, mn = a.max(2), a.min(2)
|
|
221
|
+
d = mx - mn
|
|
222
|
+
s = np.where(mx > 0, d / np.maximum(mx, 1e-12), 0.0)
|
|
223
|
+
r, g, b = a[..., 0], a[..., 1], a[..., 2]
|
|
224
|
+
h = np.zeros_like(mx)
|
|
225
|
+
nz = d > 1e-12
|
|
226
|
+
with np.errstate(invalid="ignore"):
|
|
227
|
+
h = np.where(nz & (mx == r), ((g - b) / np.where(nz, d, 1)) % 6, h)
|
|
228
|
+
h = np.where(nz & (mx == g), (b - r) / np.where(nz, d, 1) + 2, h)
|
|
229
|
+
h = np.where(nz & (mx == b), (r - g) / np.where(nz, d, 1) + 4, h)
|
|
230
|
+
hd = (h * 60.0) % 360.0
|
|
231
|
+
return hd, s, mx # mx doubles as V in HSV
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _anchor_hue_deg(hex_color: str) -> float:
|
|
235
|
+
"""Ported verbatim from measure-palette.py's anchor_hue()."""
|
|
236
|
+
r, g, b = (int(hex_color[i:i + 2], 16) / 255 for i in (1, 3, 5))
|
|
237
|
+
return colorsys.rgb_to_hsv(r, g, b)[0] * 360.0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _circular_hue_distance(a, b):
|
|
241
|
+
d = np.abs(a - b) % 360.0
|
|
242
|
+
return np.minimum(d, 360.0 - d)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ─── Palette measurement ────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
def measure_palette(im: Image.Image, anchors, hue_tolerance_deg=DEFAULT_HUE_TOLERANCE_DEG,
|
|
248
|
+
sat_min=DEFAULT_SAT_MIN, val_min=DEFAULT_VAL_MIN) -> dict:
|
|
249
|
+
"""Numeric palette signal. `anchors` is a (possibly empty) list of
|
|
250
|
+
{name, hex}; with no anchors, only the gate stats are returned (still
|
|
251
|
+
useful signal: how much of the frame is "colorful" at all, and its mean
|
|
252
|
+
saturation/value) — this lets sdlab measure run on a project with no
|
|
253
|
+
authored anchor file yet instead of refusing outright.
|
|
254
|
+
|
|
255
|
+
Percentages are relative to the GATED pixel count (pixels that passed
|
|
256
|
+
the saturation/value gate), not the whole frame — ported from
|
|
257
|
+
measure-palette.py's `tot = yellow + redochre + other` (a sum of gated
|
|
258
|
+
bins only). A sky or a shadow dominating raw pixel count would otherwise
|
|
259
|
+
dilute the signal this measure exists to read.
|
|
260
|
+
"""
|
|
261
|
+
hd, s, v = _hsv_arrays(im)
|
|
262
|
+
keep = (s >= sat_min) & (v >= val_min)
|
|
263
|
+
gated = int(keep.sum())
|
|
264
|
+
frame_total = int(hd.size)
|
|
265
|
+
|
|
266
|
+
result = {
|
|
267
|
+
"gated_pct": round(100.0 * gated / frame_total, 2) if frame_total else 0.0,
|
|
268
|
+
"mean_saturation": round(float(s[keep].mean()), 4) if gated else 0.0,
|
|
269
|
+
"mean_value": round(float(v[keep].mean()), 4) if gated else 0.0,
|
|
270
|
+
"hue_tolerance_deg": hue_tolerance_deg,
|
|
271
|
+
"anchors": {},
|
|
272
|
+
"off_anchor_pct": None,
|
|
273
|
+
}
|
|
274
|
+
if not anchors or gated == 0:
|
|
275
|
+
return result
|
|
276
|
+
|
|
277
|
+
hd_gated = hd[keep]
|
|
278
|
+
names = [a["name"] for a in anchors]
|
|
279
|
+
anchor_hues = [_anchor_hue_deg(a["hex"]) for a in anchors]
|
|
280
|
+
|
|
281
|
+
# Nearest-anchor assignment within tolerance — see module docstring for
|
|
282
|
+
# why this generalizes measure-palette.py's two hand-picked bands.
|
|
283
|
+
dists = np.stack([_circular_hue_distance(hd_gated, hue) for hue in anchor_hues], axis=0)
|
|
284
|
+
nearest_idx = np.argmin(dists, axis=0)
|
|
285
|
+
nearest_dist = np.min(dists, axis=0)
|
|
286
|
+
within = nearest_dist <= hue_tolerance_deg
|
|
287
|
+
|
|
288
|
+
for i, name in enumerate(names):
|
|
289
|
+
count = int(((nearest_idx == i) & within).sum())
|
|
290
|
+
result["anchors"][name] = round(100.0 * count / gated, 2)
|
|
291
|
+
result["off_anchor_pct"] = round(100.0 * int((~within).sum()) / gated, 2)
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ─── Texture measurement (ported from measure-texture.py's measure()) ────
|
|
296
|
+
|
|
297
|
+
def measure_texture(im: Image.Image) -> dict:
|
|
298
|
+
"""Numeric texture/impasto signal, ported from measure-texture.py's
|
|
299
|
+
measure(). Deliberately kept at NATIVE resolution — no resize, no
|
|
300
|
+
re-encode: per that file's docstring, "downscaling is the exact
|
|
301
|
+
operation that destroys the signal we are looking for". See that file
|
|
302
|
+
for the full rationale on why each measure exists (flat_hf is the
|
|
303
|
+
headline: impasto is texture that survives into nominally flat regions,
|
|
304
|
+
which is what separates "the surface is painted" from "the frame has a
|
|
305
|
+
lot of stuff in it").
|
|
306
|
+
|
|
307
|
+
Excludes facade_rep and the yellow/ochre/green hue bands — see the
|
|
308
|
+
module docstring's "Deliberate changes" section.
|
|
309
|
+
"""
|
|
310
|
+
if im.mode not in ("RGB", "RGBA"):
|
|
311
|
+
raise ValueError(f"unexpected image mode {im.mode!r} (expected RGB or RGBA)")
|
|
312
|
+
if im.width < 8 or im.height < 8:
|
|
313
|
+
# Not present in the original (real 1344x768 salt-road plates never
|
|
314
|
+
# underflowed the percentile windows below) — added defensively so a
|
|
315
|
+
# tiny/degenerate image fails with a clear message instead of a
|
|
316
|
+
# cryptic numpy exception from np.percentile on a near-empty array.
|
|
317
|
+
raise ValueError(f"image too small to measure ({im.width}x{im.height}); minimum 8x8")
|
|
318
|
+
|
|
319
|
+
a = np.asarray(im.convert("RGB"), dtype=np.float64)
|
|
320
|
+
lum = 0.2126 * a[..., 0] + 0.7152 * a[..., 1] + 0.0722 * a[..., 2]
|
|
321
|
+
|
|
322
|
+
# --- high-pass residual: everything finer than ~1.5px ---
|
|
323
|
+
hp = lum - gaussian_filter(lum, sigma=1.5)
|
|
324
|
+
|
|
325
|
+
# --- large-scale structure gradient: where are the object edges? ---
|
|
326
|
+
struct = gaussian_filter(lum, sigma=6.0)
|
|
327
|
+
gx, gy = sobel(struct, axis=1), sobel(struct, axis=0)
|
|
328
|
+
sgrad = np.hypot(gx, gy)
|
|
329
|
+
|
|
330
|
+
# flattest 40% of the frame by large-scale gradient
|
|
331
|
+
flat = sgrad <= np.percentile(sgrad, 40)
|
|
332
|
+
|
|
333
|
+
# --- laplacian variance ---
|
|
334
|
+
lap = (-4 * lum
|
|
335
|
+
+ np.roll(lum, 1, 0) + np.roll(lum, -1, 0)
|
|
336
|
+
+ np.roll(lum, 1, 1) + np.roll(lum, -1, 1))[1:-1, 1:-1]
|
|
337
|
+
|
|
338
|
+
# --- edge density on the raw luminance, fixed absolute threshold ---
|
|
339
|
+
ex, ey = sobel(lum, axis=1), sobel(lum, axis=0)
|
|
340
|
+
egrad = np.hypot(ex, ey) / 4.0 # sobel kernel gain
|
|
341
|
+
|
|
342
|
+
# --- acutance: how much do the strongest edges survive a sigma-2 blur? ---
|
|
343
|
+
blur = gaussian_filter(lum, 2.0)
|
|
344
|
+
bgrad = np.hypot(sobel(blur, axis=1), sobel(blur, axis=0)) / 4.0
|
|
345
|
+
strong = egrad >= np.percentile(egrad, 98)
|
|
346
|
+
edge_hard = float(egrad[strong].mean() / max(bgrad[strong].mean(), 1e-9)) if strong.any() else 0.0
|
|
347
|
+
|
|
348
|
+
# --- directionality of the fine residual inside the flat mask ---
|
|
349
|
+
hx, hy = sobel(hp, axis=1), sobel(hp, axis=0)
|
|
350
|
+
Jxx = gaussian_filter(hx * hx, 4.0)
|
|
351
|
+
Jyy = gaussian_filter(hy * hy, 4.0)
|
|
352
|
+
Jxy = gaussian_filter(hx * hy, 4.0)
|
|
353
|
+
disc = np.sqrt(((Jxx - Jyy) / 2.0) ** 2 + Jxy ** 2)
|
|
354
|
+
tr = (Jxx + Jyy) / 2.0
|
|
355
|
+
coh = 2.0 * disc / np.maximum(tr * 2.0, 1e-9)
|
|
356
|
+
|
|
357
|
+
# --- radial power spectrum ---
|
|
358
|
+
win = np.hanning(lum.shape[0])[:, None] * np.hanning(lum.shape[1])[None, :]
|
|
359
|
+
F = np.fft.fftshift(np.abs(np.fft.fft2((lum - lum.mean()) * win)) ** 2)
|
|
360
|
+
cy, cx = (np.array(F.shape) - 1) / 2.0
|
|
361
|
+
yy, xx = np.ogrid[:F.shape[0], :F.shape[1]]
|
|
362
|
+
r = np.hypot((yy - cy) / (F.shape[0] / 2.0), (xx - cx) / (F.shape[1] / 2.0))
|
|
363
|
+
tot = F.sum()
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
"lap_var": round(float(lap.var()), 6),
|
|
367
|
+
"hf_ratio": round(float(hp.std() / max(lum.std(), 1e-9)), 6),
|
|
368
|
+
"flat_hf": round(float(hp[flat].std()), 6),
|
|
369
|
+
"edge_dens": round(float((egrad > 12.0).mean()), 6),
|
|
370
|
+
"band_hi": round(float(F[r > 0.30].sum() / tot), 6),
|
|
371
|
+
"band_mid": round(float(F[(r > 0.10) & (r <= 0.30)].sum() / tot), 6),
|
|
372
|
+
"edge_hard": round(edge_hard, 6),
|
|
373
|
+
"flat_coh": round(float(coh[flat].mean()), 6),
|
|
374
|
+
"lum_std": round(float(lum.std()), 6),
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ─── Per-image measurement + CLI driver ────────────────────────────────
|
|
379
|
+
|
|
380
|
+
def measure_one(path: str, anchors, hue_tolerance_deg, sat_min, val_min) -> dict:
|
|
381
|
+
"""Open the image ONCE and run both passes over it — mirrors
|
|
382
|
+
measure-texture.py's own discipline ("every image measured for texture
|
|
383
|
+
is measured for colour in the same pass"), generalized to palette+texture
|
|
384
|
+
instead of texture+yellow-band."""
|
|
385
|
+
with Image.open(path) as im:
|
|
386
|
+
im.load()
|
|
387
|
+
texture = measure_texture(im)
|
|
388
|
+
palette = measure_palette(im, anchors, hue_tolerance_deg, sat_min, val_min)
|
|
389
|
+
return {
|
|
390
|
+
"image": {"width": im.width, "height": im.height, "mode": im.mode},
|
|
391
|
+
"palette": palette,
|
|
392
|
+
"texture": texture,
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def main() -> None:
|
|
397
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
398
|
+
ap.add_argument("--request", required=True, type=Path)
|
|
399
|
+
ap.add_argument("--out", required=True, type=Path)
|
|
400
|
+
args = ap.parse_args()
|
|
401
|
+
|
|
402
|
+
try:
|
|
403
|
+
request = json.loads(args.request.read_text(encoding="utf-8"))
|
|
404
|
+
except FileNotFoundError:
|
|
405
|
+
raise SystemExit(f"request file not found: {args.request}")
|
|
406
|
+
except json.JSONDecodeError as exc:
|
|
407
|
+
raise SystemExit(f"request file is not valid JSON: {args.request}: {exc}")
|
|
408
|
+
|
|
409
|
+
images = request.get("images", [])
|
|
410
|
+
anchors = request.get("anchors") or []
|
|
411
|
+
hue_tolerance_deg = request.get("hue_tolerance_deg", DEFAULT_HUE_TOLERANCE_DEG)
|
|
412
|
+
sat_min = request.get("sat_min", DEFAULT_SAT_MIN)
|
|
413
|
+
val_min = request.get("val_min", DEFAULT_VAL_MIN)
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
_validate_anchors(anchors)
|
|
417
|
+
except ValueError as exc:
|
|
418
|
+
raise SystemExit(f"invalid anchors in request: {exc}")
|
|
419
|
+
|
|
420
|
+
results = []
|
|
421
|
+
errors = []
|
|
422
|
+
for item in images:
|
|
423
|
+
iid = item.get("id")
|
|
424
|
+
path = item.get("path")
|
|
425
|
+
try:
|
|
426
|
+
measured = measure_one(path, anchors, hue_tolerance_deg, sat_min, val_min)
|
|
427
|
+
results.append({"id": iid, "path": path, "status": "ok", **measured})
|
|
428
|
+
print(f" {iid} -> ok", flush=True)
|
|
429
|
+
except Exception as exc: # noqa: BLE001 — one bad image must not sink the batch
|
|
430
|
+
errors.append({"id": iid, "path": path, "error": f"{type(exc).__name__}: {exc}"})
|
|
431
|
+
print(f" {iid} -> ERROR: {exc}", flush=True)
|
|
432
|
+
|
|
433
|
+
payload = {
|
|
434
|
+
"schema_version": MEASURE_SCHEMA_VERSION,
|
|
435
|
+
"tool": "measure_image.py",
|
|
436
|
+
"generated_at": _utc_now_iso(),
|
|
437
|
+
"results": results,
|
|
438
|
+
"errors": errors,
|
|
439
|
+
"ok_count": len(results),
|
|
440
|
+
"error_count": len(errors),
|
|
441
|
+
}
|
|
442
|
+
_atomic_write_json(args.out, payload)
|
|
443
|
+
|
|
444
|
+
summary = f"\n{len(results)}/{len(images)} image(s) measured -> {args.out}"
|
|
445
|
+
if errors:
|
|
446
|
+
summary += f" ({len(errors)} error(s) - see {args.out} for per-item detail)"
|
|
447
|
+
print(summary)
|
|
448
|
+
|
|
449
|
+
# Mirror qwen_generate.py's convention: a run where EVERY item failed
|
|
450
|
+
# exits non-zero (nothing to salvage); a partial failure still exits 0 —
|
|
451
|
+
# per-item errors are visible above and in the written result file, and
|
|
452
|
+
# individual item failure is tolerated everywhere else in this pipeline.
|
|
453
|
+
if images and len(errors) == len(images):
|
|
454
|
+
raise SystemExit(f"all {len(errors)} measurement attempt(s) failed - see {args.out} for per-item errors")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
if __name__ == "__main__":
|
|
458
|
+
main()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* migrate-records.js — One-time migration for curator-divergent records (Shape 2 + Shape 4)
|
|
4
|
+
*
|
|
5
|
+
* Normalizes verdict/scores/explanation/failures/curated_at/curator at top level
|
|
6
|
+
* into the standard judgment.{status,criteria_scores,explanation,failure_modes,reviewed_at,reviewer} shape.
|
|
7
|
+
*
|
|
8
|
+
* Also fixes asset_path from outputs/candidates/ to outputs/approved/ when the image exists in approved/.
|
|
9
|
+
*
|
|
10
|
+
* DOES NOT TOUCH Shape 3 (identity packet) records — those have judgment=null by design.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* node scripts/migrate-records.js --dry-run # Preview changes
|
|
14
|
+
* node scripts/migrate-records.js # Apply changes
|
|
15
|
+
* sdlab project migrate --dry-run --project star-freight
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readdir, readFile, writeFile, access } from 'node:fs/promises';
|
|
19
|
+
import { join, basename } from 'node:path';
|
|
20
|
+
import { getProjectName } from '../lib/args.js';
|
|
21
|
+
import { getProjectRoot } from '../lib/paths.js';
|
|
22
|
+
import { handleCliError } from '../lib/errors.js';
|
|
23
|
+
|
|
24
|
+
async function fileExists(p) {
|
|
25
|
+
try { await access(p); return true; } catch { return false; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isCuratorDivergent(rec) {
|
|
29
|
+
return rec.judgment === null && typeof rec.verdict === 'string' && !rec.identity;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isIdentityRecord(rec) {
|
|
33
|
+
return rec.identity != null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function migrateRecord(filePath, GAME_ROOT, DRY_RUN) {
|
|
37
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
38
|
+
const rec = JSON.parse(raw);
|
|
39
|
+
|
|
40
|
+
if (isIdentityRecord(rec)) return { file: basename(filePath), action: 'skip-identity' };
|
|
41
|
+
|
|
42
|
+
if (!isCuratorDivergent(rec)) {
|
|
43
|
+
if (rec.asset_path && rec.asset_path.includes('outputs/candidates/')) {
|
|
44
|
+
const approvedPath = rec.asset_path.replace('outputs/candidates/', 'outputs/approved/');
|
|
45
|
+
const fullApproved = join(GAME_ROOT, approvedPath);
|
|
46
|
+
if (await fileExists(fullApproved)) {
|
|
47
|
+
rec.asset_path = approvedPath;
|
|
48
|
+
if (!DRY_RUN) await writeFile(filePath, JSON.stringify(rec, null, 2) + '\n');
|
|
49
|
+
return { file: basename(filePath), action: 'path-fix-only', from: 'candidates/', to: 'approved/' };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { file: basename(filePath), action: 'skip-standard' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const changes = [];
|
|
56
|
+
|
|
57
|
+
rec.judgment = {
|
|
58
|
+
status: rec.verdict || 'unknown',
|
|
59
|
+
reviewer: rec.curator || 'wave25-script',
|
|
60
|
+
reviewed_at: rec.curated_at || new Date().toISOString(),
|
|
61
|
+
explanation: rec.explanation || '',
|
|
62
|
+
criteria_scores: rec.scores || {},
|
|
63
|
+
failure_modes: rec.failures || [],
|
|
64
|
+
improvement_notes: null,
|
|
65
|
+
confidence: 0.8,
|
|
66
|
+
};
|
|
67
|
+
changes.push(`verdict="${rec.verdict}" → judgment.status`);
|
|
68
|
+
|
|
69
|
+
delete rec.verdict;
|
|
70
|
+
delete rec.scores;
|
|
71
|
+
delete rec.explanation;
|
|
72
|
+
delete rec.failures;
|
|
73
|
+
delete rec.curated_at;
|
|
74
|
+
delete rec.curator;
|
|
75
|
+
changes.push('removed top-level verdict/scores/explanation/failures/curated_at/curator');
|
|
76
|
+
|
|
77
|
+
if (rec.asset_path && rec.asset_path.includes('outputs/candidates/')) {
|
|
78
|
+
const approvedPath = rec.asset_path.replace('outputs/candidates/', 'outputs/approved/');
|
|
79
|
+
const fullApproved = join(GAME_ROOT, approvedPath);
|
|
80
|
+
if (await fileExists(fullApproved)) {
|
|
81
|
+
rec.asset_path = approvedPath;
|
|
82
|
+
changes.push(`asset_path: candidates/ → approved/`);
|
|
83
|
+
} else {
|
|
84
|
+
changes.push(`asset_path: candidates/ (image not in approved/, kept as-is)`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!DRY_RUN) {
|
|
89
|
+
await writeFile(filePath, JSON.stringify(rec, null, 2) + '\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { file: basename(filePath), action: 'migrated', changes };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function run(argv = process.argv.slice(2)) {
|
|
96
|
+
const projectName = getProjectName(argv);
|
|
97
|
+
const GAME_ROOT = getProjectRoot(projectName);
|
|
98
|
+
const RECORDS_DIR = join(GAME_ROOT, 'records');
|
|
99
|
+
const DRY_RUN = argv.includes('--dry-run');
|
|
100
|
+
|
|
101
|
+
console.log(DRY_RUN ? '=== DRY RUN ===' : '=== APPLYING MIGRATION ===');
|
|
102
|
+
|
|
103
|
+
const files = (await readdir(RECORDS_DIR)).filter(f => f.endsWith('.json'));
|
|
104
|
+
console.log(`Found ${files.length} record files\n`);
|
|
105
|
+
|
|
106
|
+
const results = { skipped: 0, migrated: 0, pathFixed: 0, identity: 0, errors: 0 };
|
|
107
|
+
|
|
108
|
+
for (const file of files) {
|
|
109
|
+
try {
|
|
110
|
+
const result = await migrateRecord(join(RECORDS_DIR, file), GAME_ROOT, DRY_RUN);
|
|
111
|
+
switch (result.action) {
|
|
112
|
+
case 'skip-standard': results.skipped++; break;
|
|
113
|
+
case 'skip-identity': results.identity++; break;
|
|
114
|
+
case 'migrated':
|
|
115
|
+
results.migrated++;
|
|
116
|
+
console.log(`MIGRATE: ${result.file}`);
|
|
117
|
+
result.changes.forEach(c => console.log(` → ${c}`));
|
|
118
|
+
break;
|
|
119
|
+
case 'path-fix-only':
|
|
120
|
+
results.pathFixed++;
|
|
121
|
+
console.log(`PATH-FIX: ${result.file} (${result.from} → ${result.to})`);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
} catch (err) {
|
|
125
|
+
results.errors++;
|
|
126
|
+
console.error(`ERROR: ${file}: ${err.message}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log(`\n=== Summary ===`);
|
|
131
|
+
console.log(`Standard (unchanged): ${results.skipped}`);
|
|
132
|
+
console.log(`Identity (untouched): ${results.identity}`);
|
|
133
|
+
console.log(`Migrated (schema fix): ${results.migrated}`);
|
|
134
|
+
console.log(`Path-fixed only: ${results.pathFixed}`);
|
|
135
|
+
console.log(`Errors: ${results.errors}`);
|
|
136
|
+
console.log(`Total: ${files.length}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Direct execution guard
|
|
140
|
+
if (process.argv[1] && (process.argv[1].endsWith('migrate-records.js') || process.argv[1].endsWith('migrate-records'))) {
|
|
141
|
+
run().catch(handleCliError);
|
|
142
|
+
}
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
import { writeFile, mkdir } from "node:fs/promises";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { getProjectName } from "../lib/args.js";
|
|
15
|
-
import {
|
|
15
|
+
import { getProjectRoot } from "../lib/paths.js";
|
|
16
16
|
import { submitAndWait, downloadImage, uploadImage } from "../lib/comfyui.js";
|
|
17
|
+
import { handleCliError } from "../lib/errors.js";
|
|
17
18
|
|
|
18
19
|
const PAINTERLY_PROMPT = [
|
|
19
20
|
"oil painting, visible brushstrokes, painterly concept art,",
|
|
@@ -72,7 +73,7 @@ function buildWorkflow(imagePath, denoise, loraWeight, seed) {
|
|
|
72
73
|
|
|
73
74
|
export async function run(argv = process.argv.slice(2)) {
|
|
74
75
|
const projectName = getProjectName(argv);
|
|
75
|
-
const GAME_ROOT =
|
|
76
|
+
const GAME_ROOT = getProjectRoot(projectName);
|
|
76
77
|
const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
|
|
77
78
|
|
|
78
79
|
const outDir = join(GAME_ROOT, "outputs/painterly-test");
|
|
@@ -118,5 +119,5 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
118
119
|
|
|
119
120
|
// Direct execution guard
|
|
120
121
|
if (process.argv[1] && (process.argv[1].endsWith('painterly-test.js') || process.argv[1].endsWith('painterly-test'))) {
|
|
121
|
-
run().catch(
|
|
122
|
+
run().catch(handleCliError);
|
|
122
123
|
}
|
package/scripts/painterly.js
CHANGED
|
@@ -19,7 +19,7 @@ import { writeFile, mkdir, readdir } from "node:fs/promises";
|
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { existsSync } from "node:fs";
|
|
21
21
|
import { getProjectName, parseNumberFlag } from "../lib/args.js";
|
|
22
|
-
import {
|
|
22
|
+
import { getProjectRoot, resolveSafeProjectPath } from "../lib/paths.js";
|
|
23
23
|
import { runtimeError, handleCliError } from "../lib/errors.js";
|
|
24
24
|
import { comfyHealth, submitAndWait, downloadImage, uploadImage } from "../lib/comfyui.js";
|
|
25
25
|
|
|
@@ -145,7 +145,7 @@ function buildImg2ImgWorkflow(imagePath, seed) {
|
|
|
145
145
|
|
|
146
146
|
export async function run(argv = process.argv.slice(2)) {
|
|
147
147
|
const projectName = getProjectName(argv);
|
|
148
|
-
const GAME_ROOT =
|
|
148
|
+
const GAME_ROOT = getProjectRoot(projectName);
|
|
149
149
|
const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
|
|
150
150
|
|
|
151
151
|
const dryRun = argv.includes("--dry-run");
|
|
@@ -163,7 +163,11 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
163
163
|
|
|
164
164
|
const fullSourceDir = resolveSafeProjectPath(GAME_ROOT, sourceDir, { flagName: 'source' });
|
|
165
165
|
const outDir = join(GAME_ROOT, "outputs/painterly");
|
|
166
|
-
|
|
166
|
+
// SDL-M11: only create the output dir on a real run — a --dry-run preview
|
|
167
|
+
// must be side-effect-free (README.md promises "preview the effect first").
|
|
168
|
+
if (!dryRun) {
|
|
169
|
+
await mkdir(outDir, { recursive: true });
|
|
170
|
+
}
|
|
167
171
|
|
|
168
172
|
// Collect source PNGs
|
|
169
173
|
const allFiles = (await readdir(fullSourceDir))
|