@mcptoolshop/style-dataset-lab 3.3.0 → 3.5.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 +60 -0
- package/README.es.md +117 -55
- package/README.fr.md +125 -65
- package/README.hi.md +117 -68
- package/README.it.md +119 -58
- package/README.ja.md +124 -74
- package/README.md +73 -7
- package/README.pt-BR.md +128 -63
- package/README.zh.md +116 -65
- package/bin/sdlab.js +100 -4
- package/lib/adapters/ai-toolkit.js +224 -35
- package/lib/adapters/comfyui-runner.js +79 -26
- package/lib/adapters/comfyui-workflows.js +94 -11
- 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/asset-ingest.js +575 -0
- package/lib/asset-masks.js +323 -0
- package/lib/asset-source.js +1176 -0
- 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-author.js +350 -0
- package/lib/canon-build/build.js +146 -93
- package/lib/canon-review-gate.js +205 -0
- package/lib/captions.js +57 -0
- package/lib/card.js +74 -2
- package/lib/colab-launcher-guards.js +285 -0
- package/lib/comfyui.js +116 -18
- package/lib/config.js +116 -12
- package/lib/critique-engine.js +36 -2
- 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 +16 -3
- package/lib/freeze-stamp.js +28 -3
- package/lib/generated-provenance.js +194 -6
- package/lib/implementation-packs.js +95 -17
- package/lib/ingest.js +396 -0
- package/lib/judgment-provenance.js +220 -0
- package/lib/lane1-render.js +668 -0
- package/lib/measure.js +422 -0
- package/lib/memory-store.js +663 -0
- package/lib/model-licences.js +305 -0
- package/lib/npy-meta.js +130 -0
- package/lib/paths.js +196 -7
- package/lib/png-meta.js +512 -0
- package/lib/records.js +54 -8
- package/lib/reingest-selected.js +113 -17
- package/lib/reingest.js +27 -3
- package/lib/run-manifest.js +159 -25
- package/lib/run-summary.js +12 -6
- package/lib/runtime-runs.js +45 -4
- package/lib/snapshot.js +114 -15
- package/lib/split.js +364 -48
- package/lib/train-runs.js +214 -0
- package/lib/training-manifests.js +114 -21
- package/lib/training-packages.js +147 -15
- package/lib/verifier.js +3 -0
- package/lib/verify-chain.js +367 -0
- package/lib/workflow-profiles.js +24 -3
- package/package.json +4 -3
- 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/asset-ingest.js +160 -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-draft.js +82 -0
- package/scripts/canon-drift.js +16 -7
- package/scripts/canon-freeze-status.js +14 -6
- package/scripts/canon-freeze.js +31 -11
- package/scripts/canon-unfreeze.js +17 -9
- package/scripts/card.js +2 -2
- package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
- package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
- package/scripts/colab/colab_window.sh +587 -0
- package/scripts/colab/lane1_render.py +970 -0
- package/scripts/colab/launch_guards.py +564 -0
- package/scripts/colab/launch_lane1_stub.sh +309 -0
- package/scripts/colab/launch_lane3_stub.sh +366 -0
- package/scripts/colab/remote_runner.py +59 -0
- package/scripts/compare.js +2 -2
- package/scripts/critique.js +16 -1
- package/scripts/curate.js +2 -2
- package/scripts/doctor.js +182 -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 +86 -17
- 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 +138 -20
- package/scripts/reingest.js +2 -2
- 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/verify.js +67 -0
- package/scripts/workflow-list.js +2 -1
- package/scripts/workflow-show.js +2 -1
package/lib/measure.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measure — orchestration for `sdlab measure`: resolve which images to
|
|
3
|
+
* measure, shell out to scripts/measure_image.py for the actual pixel math,
|
|
4
|
+
* and write the resulting numbers onto records.
|
|
5
|
+
*
|
|
6
|
+
* MEASUREMENT, NOT VERDICT (same spirit as lib/critique-engine.js's H2 fix):
|
|
7
|
+
* this module attaches a `measurements` block to a record. It never touches
|
|
8
|
+
* `judgment`, `canon`, or any pass/fail/approved/rejected field, and it
|
|
9
|
+
* never invents one. See scripts/measure_image.py's module docstring for
|
|
10
|
+
* the full port rationale (ported from the salt-road measure-palette.py /
|
|
11
|
+
* measure-texture.py instruments; DISTS/StyleLoss perceptual distance is
|
|
12
|
+
* deliberately out of scope — needs torch+piq).
|
|
13
|
+
*
|
|
14
|
+
* Python is an OPTIONAL runtime dependency of this ONE feature, not of the
|
|
15
|
+
* package (mirrors scripts/qwen_generate.py's relationship to the rest of
|
|
16
|
+
* sdlab, and tests/cli-scripts/qwen-python-parity.test.js's detection
|
|
17
|
+
* pattern, adopted here as findPython()/checkPythonDeps() so both
|
|
18
|
+
* scripts/measure.js and tests/lib-dataset/measure.test.js share one
|
|
19
|
+
* implementation instead of each hand-rolling their own).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
23
|
+
import {
|
|
24
|
+
existsSync, readFileSync, statSync, readdirSync, writeFileSync, mkdtempSync, rmSync,
|
|
25
|
+
} from 'node:fs';
|
|
26
|
+
import { readFile, writeFile, rename, unlink } from 'node:fs/promises';
|
|
27
|
+
import { join, relative } from 'node:path';
|
|
28
|
+
import { tmpdir } from 'node:os';
|
|
29
|
+
import { getPackageRoot, resolveSafeProjectPath } from './paths.js';
|
|
30
|
+
import { inputError, runtimeError } from './errors.js';
|
|
31
|
+
|
|
32
|
+
// ─── Identity / provenance constants (mirrors lib/verifier.js's
|
|
33
|
+
// RULE_BASED_JUDGE / HUMAN_JUDGE naming discipline — "who produced this
|
|
34
|
+
// number" gets a stable id the same way "who judged this" does) ──────────
|
|
35
|
+
|
|
36
|
+
export const MEASURE_TOOL_ID = 'sdlab-measure-v1';
|
|
37
|
+
export const MEASURE_SCHEMA_VERSION = '1.0.0';
|
|
38
|
+
|
|
39
|
+
const IMAGE_EXT_RE = /\.(png|jpe?g)$/i;
|
|
40
|
+
|
|
41
|
+
// Ported verbatim from measure-palette.py's saturated-pixel gate — see
|
|
42
|
+
// scripts/measure_image.py's module docstring for the "ignore greys/darks"
|
|
43
|
+
// rationale. Kept in sync with the Python-side defaults by hand (both files
|
|
44
|
+
// document the same origin; there is no cross-language pinning contract
|
|
45
|
+
// here the way there is for comfy_workflow_sha, since these are STATIC
|
|
46
|
+
// defaults, not per-run computed values).
|
|
47
|
+
const DEFAULT_SAT_MIN = 0.22;
|
|
48
|
+
const DEFAULT_VAL_MIN = 0.18;
|
|
49
|
+
const DEFAULT_HUE_TOLERANCE_DEG = 20;
|
|
50
|
+
|
|
51
|
+
const REQUIRED_PY_MODULES = ['PIL', 'numpy', 'scipy'];
|
|
52
|
+
|
|
53
|
+
const MEASURE_SCRIPT = join(getPackageRoot(), 'scripts', 'measure_image.py');
|
|
54
|
+
|
|
55
|
+
// ─── Python environment detection ──────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Locate a working Python interpreter. Precedence mirrors the rest of this
|
|
59
|
+
* codebase's env-var-override convention (paths.js's SDLAB_ROOT resolution:
|
|
60
|
+
* cheapest/most-explicit signal first): SDLAB_PYTHON env var if set, else
|
|
61
|
+
* try "python" then "python3" on PATH. Returns the command string to spawn,
|
|
62
|
+
* or null if nothing worked.
|
|
63
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
64
|
+
* @returns {string|null}
|
|
65
|
+
*/
|
|
66
|
+
export function findPython(env = process.env) {
|
|
67
|
+
const candidates = env.SDLAB_PYTHON ? [env.SDLAB_PYTHON] : ['python', 'python3'];
|
|
68
|
+
for (const cmd of candidates) {
|
|
69
|
+
const res = spawnSync(cmd, ['--version'], { encoding: 'utf-8' });
|
|
70
|
+
if (res.status === 0) return cmd;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Probe whether `pythonCmd` has the packages measure_image.py needs.
|
|
77
|
+
* Reports EVERY missing module in one pass (not just the first import
|
|
78
|
+
* failure) so the resulting error names all of them at once.
|
|
79
|
+
* @param {string} pythonCmd
|
|
80
|
+
* @param {{modules?: string[]}} [opts] — override the probed module list (tests use this)
|
|
81
|
+
* @returns {{ok: boolean, missing: string[]}}
|
|
82
|
+
*/
|
|
83
|
+
export function checkPythonDeps(pythonCmd, { modules = REQUIRED_PY_MODULES } = {}) {
|
|
84
|
+
const probe =
|
|
85
|
+
'import json\n' +
|
|
86
|
+
'mods = {}\n' +
|
|
87
|
+
`for m in ${JSON.stringify(modules)}:\n` +
|
|
88
|
+
' try:\n' +
|
|
89
|
+
' __import__(m)\n' +
|
|
90
|
+
' mods[m] = True\n' +
|
|
91
|
+
' except ImportError:\n' +
|
|
92
|
+
' mods[m] = False\n' +
|
|
93
|
+
'print(json.dumps(mods))\n';
|
|
94
|
+
const res = spawnSync(pythonCmd, ['-c', probe], { encoding: 'utf-8' });
|
|
95
|
+
if (res.status !== 0) {
|
|
96
|
+
return { ok: false, missing: [...modules], raw: (res.stderr || res.error?.message || 'probe failed').trim() };
|
|
97
|
+
}
|
|
98
|
+
let parsed;
|
|
99
|
+
try {
|
|
100
|
+
parsed = JSON.parse((res.stdout || '').trim());
|
|
101
|
+
} catch {
|
|
102
|
+
return { ok: false, missing: [...modules], raw: (res.stdout || '').trim() };
|
|
103
|
+
}
|
|
104
|
+
const missing = modules.filter((m) => !parsed[m]);
|
|
105
|
+
return { ok: missing.length === 0, missing };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Throwing convenience wrapper for the CLI boundary: locate Python and
|
|
110
|
+
* verify its deps, or fail with a clear, actionable SdlabError naming
|
|
111
|
+
* exactly what is missing. This is the ONLY function in this module that
|
|
112
|
+
* throws on a missing/incomplete Python environment — findPython() and
|
|
113
|
+
* checkPythonDeps() stay pure so tests can drive every branch directly.
|
|
114
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
115
|
+
* @returns {string} the python command to use
|
|
116
|
+
*/
|
|
117
|
+
export function assertMeasurementPrereqs(env = process.env) {
|
|
118
|
+
const python = findPython(env);
|
|
119
|
+
if (!python) {
|
|
120
|
+
throw runtimeError(
|
|
121
|
+
'RUNTIME_PYTHON_MISSING',
|
|
122
|
+
'Python was not found on PATH (tried "python", "python3").',
|
|
123
|
+
'sdlab measure needs Python 3.9+ with Pillow, numpy, and scipy for image measurement ' +
|
|
124
|
+
'(see scripts/measure_image.py). Install Python and ensure it is on PATH, or set ' +
|
|
125
|
+
'SDLAB_PYTHON to an explicit interpreter path.'
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const deps = checkPythonDeps(python);
|
|
129
|
+
if (!deps.ok) {
|
|
130
|
+
throw runtimeError(
|
|
131
|
+
'RUNTIME_PYTHON_DEPS_MISSING',
|
|
132
|
+
`Python found (${python}) but missing required package(s): ${deps.missing.join(', ')}.`,
|
|
133
|
+
`Install them: pip install pillow numpy scipy${deps.raw ? ` (probe said: ${deps.raw})` : ''}`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return python;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── Target resolution ──────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Minimal `*`/`?` glob-to-RegExp. No npm glob dependency exists in this
|
|
143
|
+
* package (checked before writing this — lib/ and scripts/ have none) and
|
|
144
|
+
* this file may not add one (package.json is outside this feature's
|
|
145
|
+
* exclusive file ownership), so record-id matching gets a small hand-rolled
|
|
146
|
+
* matcher instead of a library.
|
|
147
|
+
*/
|
|
148
|
+
function globToRegExp(pattern) {
|
|
149
|
+
let out = '';
|
|
150
|
+
for (const ch of pattern) {
|
|
151
|
+
if (ch === '*') out += '.*';
|
|
152
|
+
else if (ch === '?') out += '.';
|
|
153
|
+
else out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
154
|
+
}
|
|
155
|
+
return new RegExp(`^${out}$`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve the `<dir-or-record-glob>` positional into a concrete list of
|
|
160
|
+
* {id, recordPath, imagePath} targets.
|
|
161
|
+
*
|
|
162
|
+
* - If it resolves to an existing directory (inside the project — see
|
|
163
|
+
* resolveSafeProjectPath), every *.png/*.jpg/*.jpeg inside is paired
|
|
164
|
+
* with records/<stem>.json by filename stem. Images with no matching
|
|
165
|
+
* record are reported in `skipped`, never silently dropped (mirrors
|
|
166
|
+
* lib/runtime-runs.js listRuns()'s B01 fix: always report a count,
|
|
167
|
+
* detail under --verbose).
|
|
168
|
+
* - Otherwise it is treated as a glob PATTERN matched against record ids
|
|
169
|
+
* under <project>/records/*.json; each match's image comes from that
|
|
170
|
+
* record's own `asset_path` (a trusted, already-on-disk field — joined
|
|
171
|
+
* plainly, same as curate.js does with record.asset_path, not run
|
|
172
|
+
* through resolveSafeProjectPath a second time).
|
|
173
|
+
*
|
|
174
|
+
* @param {string} projectRoot
|
|
175
|
+
* @param {string} targetArg
|
|
176
|
+
* @returns {{targets: Array<{id:string, recordPath:string, imagePath:string}>, skipped: Array<{id:string, reason:string}>}}
|
|
177
|
+
*/
|
|
178
|
+
export function resolveMeasureTargets(projectRoot, targetArg) {
|
|
179
|
+
if (!targetArg) {
|
|
180
|
+
throw inputError(
|
|
181
|
+
'INPUT_MISSING_ARGS',
|
|
182
|
+
'sdlab measure requires a <dir-or-record-glob> positional argument.',
|
|
183
|
+
'Example: sdlab measure outputs/approved --project salt-road, or ' +
|
|
184
|
+
'sdlab measure "styleset_p*" --project salt-road (quote glob patterns so your shell does not expand them).'
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const recordsDir = join(projectRoot, 'records');
|
|
189
|
+
// Security boundary FIRST: whether targetArg turns out to be a directory
|
|
190
|
+
// or a glob pattern, an escape attempt (`..`, an absolute path outside
|
|
191
|
+
// the project) is rejected before either interpretation is considered.
|
|
192
|
+
const asPath = resolveSafeProjectPath(projectRoot, targetArg, { flagName: 'target' });
|
|
193
|
+
|
|
194
|
+
const targets = [];
|
|
195
|
+
const skipped = [];
|
|
196
|
+
const isDir = existsSync(asPath) && statSync(asPath).isDirectory();
|
|
197
|
+
|
|
198
|
+
if (isDir) {
|
|
199
|
+
const files = readdirSync(asPath, { withFileTypes: true })
|
|
200
|
+
.filter((e) => e.isFile() && IMAGE_EXT_RE.test(e.name))
|
|
201
|
+
.map((e) => e.name)
|
|
202
|
+
.sort();
|
|
203
|
+
for (const file of files) {
|
|
204
|
+
const id = file.replace(IMAGE_EXT_RE, '');
|
|
205
|
+
const recordPath = join(recordsDir, `${id}.json`);
|
|
206
|
+
if (existsSync(recordPath)) {
|
|
207
|
+
targets.push({ id, recordPath, imagePath: join(asPath, file) });
|
|
208
|
+
} else {
|
|
209
|
+
skipped.push({ id, reason: `no matching record at records/${id}.json` });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
const re = globToRegExp(targetArg);
|
|
214
|
+
const files = existsSync(recordsDir) ? readdirSync(recordsDir).filter((f) => f.endsWith('.json')) : [];
|
|
215
|
+
for (const file of files) {
|
|
216
|
+
const id = file.slice(0, -'.json'.length);
|
|
217
|
+
if (!re.test(id)) continue;
|
|
218
|
+
const recordPath = join(recordsDir, file);
|
|
219
|
+
const record = JSON.parse(readFileSync(recordPath, 'utf-8'));
|
|
220
|
+
if (!record.asset_path) {
|
|
221
|
+
skipped.push({ id, reason: 'record has no asset_path' });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
targets.push({ id, recordPath, imagePath: join(projectRoot, record.asset_path) });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (targets.length === 0) {
|
|
229
|
+
throw inputError(
|
|
230
|
+
'INPUT_NO_MATCHES',
|
|
231
|
+
`No records matched "${targetArg}" under ${recordsDir}.`,
|
|
232
|
+
isDir
|
|
233
|
+
? 'The directory exists but no image inside it has a matching records/<id>.json.'
|
|
234
|
+
: 'Check the glob pattern (quote it so your shell does not expand it), or pass an existing directory of images instead.'
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { targets, skipped };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ─── Anchors ─────────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Load a palette anchor config from `--anchors <path>`.
|
|
247
|
+
*
|
|
248
|
+
* constitution.json carries no hex/numeric palette data for either salt-road
|
|
249
|
+
* or star-freight (checked directly before building this — both files are
|
|
250
|
+
* qualitative rule descriptions with rationale templates, not colour
|
|
251
|
+
* values; the actual hex anchors salt-road uses live in prose in
|
|
252
|
+
* canon/salt-road-art-contract.md, not in machine-readable config). So this
|
|
253
|
+
* is a real `--anchors` flag, not a schema invented to route around a
|
|
254
|
+
* missing feature.
|
|
255
|
+
*
|
|
256
|
+
* Unlike --source/--outputs elsewhere in this codebase (which legitimately
|
|
257
|
+
* reference shared cross-project directories, hence their REPO_ROOT-scoped
|
|
258
|
+
* containment), --anchors is scoped to the PROJECT root: anchor definitions
|
|
259
|
+
* are project-specific canon data, same as constitution.json/lanes.json.
|
|
260
|
+
*
|
|
261
|
+
* @param {string|undefined} anchorsFlag — raw --anchors value, or undefined
|
|
262
|
+
* @param {string} projectRoot
|
|
263
|
+
* @returns {{anchors: Array<{name:string,hex:string}>, hue_tolerance_deg: number, sat_min: number, val_min: number, source: string|null}}
|
|
264
|
+
*/
|
|
265
|
+
export function loadAnchors(anchorsFlag, projectRoot) {
|
|
266
|
+
if (!anchorsFlag) {
|
|
267
|
+
return {
|
|
268
|
+
anchors: [],
|
|
269
|
+
hue_tolerance_deg: DEFAULT_HUE_TOLERANCE_DEG,
|
|
270
|
+
sat_min: DEFAULT_SAT_MIN,
|
|
271
|
+
val_min: DEFAULT_VAL_MIN,
|
|
272
|
+
source: null,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const absPath = resolveSafeProjectPath(projectRoot, anchorsFlag, { flagName: 'anchors' });
|
|
277
|
+
if (!existsSync(absPath)) {
|
|
278
|
+
throw inputError('INPUT_ANCHORS_NOT_FOUND', `--anchors file not found: ${absPath}`);
|
|
279
|
+
}
|
|
280
|
+
let data;
|
|
281
|
+
try {
|
|
282
|
+
data = JSON.parse(readFileSync(absPath, 'utf-8'));
|
|
283
|
+
} catch (err) {
|
|
284
|
+
throw inputError('INPUT_INVALID_JSON', `--anchors file is not valid JSON: ${absPath} (${err.message})`);
|
|
285
|
+
}
|
|
286
|
+
const anchors = Array.isArray(data) ? data : data.anchors;
|
|
287
|
+
if (!Array.isArray(anchors) || anchors.length === 0) {
|
|
288
|
+
throw inputError(
|
|
289
|
+
'INPUT_BAD_ANCHORS',
|
|
290
|
+
`${absPath} must contain a non-empty "anchors" array of {name, hex}.`,
|
|
291
|
+
'Example: { "anchors": [{"name":"ochre-warm","hex":"#c9a877"}] }'
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
for (const a of anchors) {
|
|
295
|
+
if (!a || typeof a.name !== 'string' || !a.name || typeof a.hex !== 'string' || !HEX_RE.test(a.hex)) {
|
|
296
|
+
throw inputError(
|
|
297
|
+
'INPUT_BAD_ANCHORS',
|
|
298
|
+
`Invalid anchor entry in ${absPath}: ${JSON.stringify(a)}`,
|
|
299
|
+
'Each anchor needs a "name" string and a "hex" color like "#c9a877".'
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
anchors,
|
|
305
|
+
hue_tolerance_deg: data.hue_tolerance_deg ?? DEFAULT_HUE_TOLERANCE_DEG,
|
|
306
|
+
sat_min: data.sat_min ?? DEFAULT_SAT_MIN,
|
|
307
|
+
val_min: data.val_min ?? DEFAULT_VAL_MIN,
|
|
308
|
+
source: relative(projectRoot, absPath),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ─── Python invocation ───────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Spawn scripts/measure_image.py once for a whole batch of images (one
|
|
316
|
+
* process, not one-per-image — keeps interpreter-startup overhead off the
|
|
317
|
+
* per-image cost). Writes the request to a temp file, mirroring
|
|
318
|
+
* scripts/qwen_generate.py's `--wave <path>` handoff shape.
|
|
319
|
+
*
|
|
320
|
+
* @param {string} pythonCmd
|
|
321
|
+
* @param {{images: Array<{id:string,path:string}>, anchors?: Array, hue_tolerance_deg?: number, sat_min?: number, val_min?: number}} request
|
|
322
|
+
* @returns {{schema_version, tool, results: Array, errors: Array, ok_count: number, error_count: number}}
|
|
323
|
+
*/
|
|
324
|
+
export function runPythonMeasurement(pythonCmd, request) {
|
|
325
|
+
const dir = mkdtempSync(join(tmpdir(), 'sdlab-measure-'));
|
|
326
|
+
try {
|
|
327
|
+
const requestPath = join(dir, 'request.json');
|
|
328
|
+
const outPath = join(dir, 'result.json');
|
|
329
|
+
writeFileSync(requestPath, JSON.stringify(request, null, 2));
|
|
330
|
+
|
|
331
|
+
const res = spawnSync(pythonCmd, [MEASURE_SCRIPT, '--request', requestPath, '--out', outPath], {
|
|
332
|
+
encoding: 'utf-8',
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
if (!existsSync(outPath)) {
|
|
336
|
+
throw runtimeError(
|
|
337
|
+
'RUNTIME_MEASURE_FAILED',
|
|
338
|
+
`measure_image.py did not produce a result (exit ${res.status ?? 'unknown'}).`,
|
|
339
|
+
(res.stderr || res.stdout || '').trim().slice(0, 2000) || 'Run with --debug for detail.'
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return JSON.parse(readFileSync(outPath, 'utf-8'));
|
|
343
|
+
} finally {
|
|
344
|
+
rmSync(dir, { recursive: true, force: true });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ─── Record writing (atomic) ─────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Temp-file-in-same-dir + rename — the exact pattern documented in
|
|
352
|
+
* lib/runtime-runs.js's atomicWriteJson (that function is module-private
|
|
353
|
+
* there, and is duplicated ad hoc in lib/batch-runs.js and
|
|
354
|
+
* lib/reingest-selected.js too — this file cannot import a shared version
|
|
355
|
+
* because none is exported anywhere in the codebase; extracting one would
|
|
356
|
+
* mean editing those files, which sit outside this feature's exclusive file
|
|
357
|
+
* ownership). Same tmp-name scheme as all three existing copies, for
|
|
358
|
+
* consistency: `${targetPath}.tmp-${pid}-${timestamp}`.
|
|
359
|
+
*/
|
|
360
|
+
async function atomicWriteJson(targetPath, obj) {
|
|
361
|
+
const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
|
|
362
|
+
const body = JSON.stringify(obj, null, 2) + '\n';
|
|
363
|
+
try {
|
|
364
|
+
await writeFile(tmpPath, body);
|
|
365
|
+
await rename(tmpPath, targetPath);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
try {
|
|
368
|
+
await unlink(tmpPath);
|
|
369
|
+
} catch {
|
|
370
|
+
/* ignore */
|
|
371
|
+
}
|
|
372
|
+
throw err;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Build the `measurements` block and (unless dryRun) write it onto each
|
|
378
|
+
* matched record. Idempotent by design — an unchanged image + unchanged
|
|
379
|
+
* code reproduces the exact same palette/texture numbers on every run;
|
|
380
|
+
* only `measured_at` moves forward, the same way re-running `sdlab curate`
|
|
381
|
+
* or `sdlab bind` overwrites the prior judgment/binding rather than
|
|
382
|
+
* appending to a history log (see the sdlab compensators table).
|
|
383
|
+
*
|
|
384
|
+
* @param {Array<{id:string, recordPath:string}>} targets
|
|
385
|
+
* @param {{results: Array, errors: Array}} pyResult
|
|
386
|
+
* @param {{anchorsSource: string|null, dryRun: boolean}} opts
|
|
387
|
+
* @returns {Promise<{applied: Array<{id:string, measurements:Object}>, failed: Array<{id:string, error:string}>}>}
|
|
388
|
+
*/
|
|
389
|
+
export async function applyMeasurements(targets, pyResult, { anchorsSource = null, dryRun = false } = {}) {
|
|
390
|
+
const byId = new Map((pyResult.results || []).map((r) => [r.id, r]));
|
|
391
|
+
const errById = new Map((pyResult.errors || []).map((e) => [e.id, e]));
|
|
392
|
+
const applied = [];
|
|
393
|
+
const failed = [];
|
|
394
|
+
|
|
395
|
+
for (const t of targets) {
|
|
396
|
+
const ok = byId.get(t.id);
|
|
397
|
+
if (!ok) {
|
|
398
|
+
const err = errById.get(t.id);
|
|
399
|
+
failed.push({ id: t.id, error: err?.error || 'no result returned for this image' });
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const measurements = {
|
|
404
|
+
measured_at: new Date().toISOString(),
|
|
405
|
+
tool: MEASURE_TOOL_ID,
|
|
406
|
+
schema_version: MEASURE_SCHEMA_VERSION,
|
|
407
|
+
anchors_source: anchorsSource,
|
|
408
|
+
image: ok.image,
|
|
409
|
+
palette: ok.palette,
|
|
410
|
+
texture: ok.texture,
|
|
411
|
+
};
|
|
412
|
+
applied.push({ id: t.id, measurements });
|
|
413
|
+
|
|
414
|
+
if (!dryRun) {
|
|
415
|
+
const record = JSON.parse(await readFile(t.recordPath, 'utf-8'));
|
|
416
|
+
record.measurements = measurements;
|
|
417
|
+
await atomicWriteJson(t.recordPath, record);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return { applied, failed };
|
|
422
|
+
}
|