@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,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ingest.js — Bring a directory of pre-existing images into the pipeline
|
|
5
|
+
* as bare, uncurated candidate records.
|
|
6
|
+
*
|
|
7
|
+
* Closes the gap where generation happened OUTSIDE sdlab entirely (a hand
|
|
8
|
+
* batch, external Python against Comfy Cloud, salvage from somewhere else)
|
|
9
|
+
* and there was no CLI path to get those images into records/ for review.
|
|
10
|
+
* Unlike `sdlab reingest generated` (which requires a trained LoRA's
|
|
11
|
+
* training manifest) or `sdlab reingest selected` (which requires a
|
|
12
|
+
* selection id only the production loop can mint), this takes any folder
|
|
13
|
+
* of images and any project — no pipeline history required.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* sdlab ingest <dir> --project <name>
|
|
17
|
+
* sdlab ingest <dir> --project <name> --wave inputs/prompts/wave-v2.json
|
|
18
|
+
* sdlab ingest <dir> --project <name> --dry-run
|
|
19
|
+
* sdlab ingest <dir> --project <name> --json
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { parseArgs, getProjectName } from '../lib/args.js';
|
|
23
|
+
import { REPO_ROOT, getProjectRoot, resolveSafeProjectPath } from '../lib/paths.js';
|
|
24
|
+
import { inputError, handleCliError } from '../lib/errors.js';
|
|
25
|
+
import { ingestDirectory } from '../lib/ingest.js';
|
|
26
|
+
import { result } from '../lib/log.js';
|
|
27
|
+
|
|
28
|
+
const USAGE = `Usage: sdlab ingest <dir> --project <name> [--wave <wave.json>] [--dry-run] [--json]
|
|
29
|
+
|
|
30
|
+
Scan <dir> for images and create bare candidate records (judgment: null,
|
|
31
|
+
canon: null, provenance.source: "external") ready for "sdlab curate".
|
|
32
|
+
|
|
33
|
+
Positional:
|
|
34
|
+
dir Directory of images to ingest (png/jpg/jpeg/webp)
|
|
35
|
+
|
|
36
|
+
Flags:
|
|
37
|
+
--project <name> Project to operate on
|
|
38
|
+
--wave <path> Optional wave JSON (inputs/prompts/*.json shape) to
|
|
39
|
+
attach real generation provenance (seed, prompt, ...)
|
|
40
|
+
by matching each image's filename stem to an item id.
|
|
41
|
+
A sibling receipts.jsonl next to <dir> is picked up
|
|
42
|
+
automatically when present (prompt_id, seconds, status).
|
|
43
|
+
--dry-run Preview what would be created — no files written
|
|
44
|
+
--json Emit the result as JSON on stdout
|
|
45
|
+
|
|
46
|
+
Next steps after ingesting:
|
|
47
|
+
1. Review: sdlab curate <id> approved/rejected "explanation"
|
|
48
|
+
2. Bind: sdlab bind --project <name>`;
|
|
49
|
+
|
|
50
|
+
export async function run(argv = process.argv.slice(2)) {
|
|
51
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
52
|
+
console.log(USAGE);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const { flags, positionals } = parseArgs(argv, {
|
|
57
|
+
flags: {
|
|
58
|
+
project: { type: 'string' },
|
|
59
|
+
wave: { type: 'string' },
|
|
60
|
+
'dry-run': { type: 'boolean', default: false },
|
|
61
|
+
json: { type: 'boolean', default: false },
|
|
62
|
+
},
|
|
63
|
+
deprecated: { game: 'project' },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const dirArg = positionals[0];
|
|
67
|
+
if (!dirArg) {
|
|
68
|
+
throw inputError(
|
|
69
|
+
'INPUT_MISSING_ARGS',
|
|
70
|
+
'A source directory is required.',
|
|
71
|
+
USAGE
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const projectName = flags.project || getProjectName(argv);
|
|
76
|
+
const projectRoot = getProjectRoot(projectName);
|
|
77
|
+
const dryRun = flags['dry-run'] || flags.dryRun;
|
|
78
|
+
|
|
79
|
+
// Mirrors scripts/reingest.js's --source handling: the source directory
|
|
80
|
+
// of pre-existing images is allowed to live anywhere under the workspace
|
|
81
|
+
// root, not just inside this specific project (e.g. a shared staging
|
|
82
|
+
// inbox), while still refusing to escape the workspace entirely.
|
|
83
|
+
const safeSourceDir = resolveSafeProjectPath(projectRoot, dirArg, { baseRoot: REPO_ROOT, flagName: 'dir' });
|
|
84
|
+
|
|
85
|
+
// Mirrors scripts/generate.js's pack-path handling: a --wave file is
|
|
86
|
+
// expected to live inside THIS project (inputs/prompts/*.json), so its
|
|
87
|
+
// containment root defaults to the project root, not the whole workspace.
|
|
88
|
+
const safeWavePath = flags.wave
|
|
89
|
+
? resolveSafeProjectPath(projectRoot, flags.wave, { flagName: 'wave' })
|
|
90
|
+
: null;
|
|
91
|
+
|
|
92
|
+
if (!flags.json) {
|
|
93
|
+
console.log(`\x1b[1msdlab ingest\x1b[0m — ${projectName}`);
|
|
94
|
+
console.log(` Source: ${dirArg}`);
|
|
95
|
+
if (flags.wave) console.log(` Wave: ${flags.wave}`);
|
|
96
|
+
if (dryRun) console.log(` Mode: DRY RUN (no files written)`);
|
|
97
|
+
console.log('');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const { created, skipped, rejected, receiptsFile } = await ingestDirectory(projectRoot, safeSourceDir, {
|
|
101
|
+
wavePath: safeWavePath,
|
|
102
|
+
dryRun,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (flags.json) {
|
|
106
|
+
console.log(JSON.stringify({ created, skipped, rejected, receiptsFile, dryRun }, null, 2));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (receiptsFile) {
|
|
111
|
+
console.log(` \x1b[2mreceipts: ${receiptsFile}\x1b[0m`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(` \x1b[32m✓\x1b[0m Created: ${created.length} record(s)${dryRun ? ' (dry run — nothing written)' : ''}`);
|
|
115
|
+
if (created.length > 0 && created.length <= 25) {
|
|
116
|
+
for (const id of created) console.log(` ✓ ${id}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (skipped.length > 0) {
|
|
120
|
+
console.log(` Skipped: ${skipped.length} (record already exists)`);
|
|
121
|
+
if (skipped.length <= 25) {
|
|
122
|
+
for (const id of skipped) console.log(` ○ ${id}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (rejected.length > 0) {
|
|
127
|
+
console.log(` \x1b[33m⚠\x1b[0m Rejected: ${rejected.length} (unsafe id derived from filename — rename and re-run)`);
|
|
128
|
+
for (const r of rejected) console.log(` ✗ ${r.file}: ${r.reason}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!dryRun && created.length > 0) {
|
|
132
|
+
console.log('');
|
|
133
|
+
result(`Candidates: outputs/candidates/`);
|
|
134
|
+
result(`Records: records/`);
|
|
135
|
+
console.log('');
|
|
136
|
+
console.log(' Next steps:');
|
|
137
|
+
console.log(' 1. Review: sdlab curate <id> approved/rejected "explanation"');
|
|
138
|
+
console.log(' 2. Bind: sdlab bind --project ' + projectName);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Direct execution guard
|
|
143
|
+
if (process.argv[1] && (process.argv[1].endsWith('ingest.js') || process.argv[1].endsWith('ingest'))) {
|
|
144
|
+
run().catch(handleCliError);
|
|
145
|
+
}
|
package/scripts/init.js
CHANGED
|
@@ -12,10 +12,16 @@
|
|
|
12
12
|
import { mkdir, readdir, readFile, writeFile, copyFile } from 'node:fs/promises';
|
|
13
13
|
import { join, basename } from 'node:path';
|
|
14
14
|
import { existsSync } from 'node:fs';
|
|
15
|
-
import { REPO_ROOT } from '../lib/paths.js';
|
|
15
|
+
import { REPO_ROOT, getPackageRoot } from '../lib/paths.js';
|
|
16
16
|
import { inputError, handleCliError } from '../lib/errors.js';
|
|
17
|
+
import { takeFlagValue } from '../lib/args.js';
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
// Two DIFFERENT roots — see getPackageRoot() in lib/paths.js.
|
|
20
|
+
// `templates/` ships inside the npm tarball, so it is package-relative;
|
|
21
|
+
// `projects/` is the user's own work, so it is workspace-relative. In a repo
|
|
22
|
+
// checkout these coincide, which is why resolving both against REPO_ROOT
|
|
23
|
+
// passed every in-repo test while breaking `init` for every installed user.
|
|
24
|
+
const TEMPLATES_DIR = join(getPackageRoot(), 'templates');
|
|
19
25
|
const PROJECTS_DIR = join(REPO_ROOT, 'projects');
|
|
20
26
|
|
|
21
27
|
const PROJECT_DIRS = [
|
|
@@ -64,11 +70,15 @@ async function listDomains() {
|
|
|
64
70
|
export async function run(argv = process.argv.slice(2)) {
|
|
65
71
|
const projectName = argv.find(a => !a.startsWith('--'));
|
|
66
72
|
|
|
67
|
-
// Parse --domain
|
|
73
|
+
// Parse --domain. H4: previously a hand-rolled `argv.indexOf('--domain')`
|
|
74
|
+
// that only recognized the space-separated form — `sdlab init foo
|
|
75
|
+
// --domain=game-art` silently fell through to the 'generic' default
|
|
76
|
+
// because no argv token was EXACTLY "--domain". takeFlagValue() (already
|
|
77
|
+
// hardened for both `--flag value` and `--flag=value`) closes that gap.
|
|
68
78
|
let domain = 'generic';
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
domain =
|
|
79
|
+
const domainValue = takeFlagValue(argv, 'domain');
|
|
80
|
+
if (domainValue !== undefined) {
|
|
81
|
+
domain = domainValue;
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
if (!projectName) {
|
|
@@ -133,6 +143,34 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
133
143
|
}
|
|
134
144
|
console.log(` \x1b[32m✓\x1b[0m Created ${PROJECT_DIRS.length} directories`);
|
|
135
145
|
|
|
146
|
+
// SDL-M6: the generate → curate → reingest loop fills inbox/generated/ and
|
|
147
|
+
// the outputs/ subtrees with PNGs (see lib/reingest-selected.js), but this
|
|
148
|
+
// repo's OWN .gitignore (which happens to cover those paths in dev) is not
|
|
149
|
+
// in the npm `files` allow-list and ships to nobody. Without a per-project
|
|
150
|
+
// .gitignore, every downstream `sdlab init` user stages regenerable blobs
|
|
151
|
+
// by construction — measured at 55 MB of untracked PNGs in this repo before
|
|
152
|
+
// this fix. Never clobber a .gitignore the user already created by hand.
|
|
153
|
+
const gitignorePath = join(projectDir, '.gitignore');
|
|
154
|
+
if (!existsSync(gitignorePath)) {
|
|
155
|
+
const gitignoreLines = [
|
|
156
|
+
'# Generated by `sdlab init` — regenerable blobs for this project.',
|
|
157
|
+
'# Records/config JSON are tracked; only pixels and derived artifacts are not.',
|
|
158
|
+
'',
|
|
159
|
+
'inbox/**/*.png',
|
|
160
|
+
'inbox/**/*.jpg',
|
|
161
|
+
'inbox/**/*.jpeg',
|
|
162
|
+
'inbox/**/*.webp',
|
|
163
|
+
'outputs/**',
|
|
164
|
+
'exports/',
|
|
165
|
+
'snapshots/',
|
|
166
|
+
'splits/',
|
|
167
|
+
'canon-build/*/',
|
|
168
|
+
'',
|
|
169
|
+
];
|
|
170
|
+
await writeFile(gitignorePath, gitignoreLines.join('\n'));
|
|
171
|
+
console.log(` \x1b[32m✓\x1b[0m .gitignore (regenerable outputs/inbox/exports/snapshots/splits)`);
|
|
172
|
+
}
|
|
173
|
+
|
|
136
174
|
// Copy config files from domain template or generate generic ones
|
|
137
175
|
const configFiles = ['project.json', 'constitution.json', 'lanes.json', 'rubric.json', 'terminology.json'];
|
|
138
176
|
|
|
@@ -331,21 +369,43 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
331
369
|
console.log('');
|
|
332
370
|
console.log(`\x1b[32m✓\x1b[0m Project "${projectName}" initialized`);
|
|
333
371
|
console.log('');
|
|
372
|
+
// Two DIFFERENT path bases are printed in this block, for two DIFFERENT
|
|
373
|
+
// audiences — do not collapse them back into one helper:
|
|
374
|
+
// - "Edit <file>" / "Full quick-start" lines are opened by the user in
|
|
375
|
+
// an editor/file-browser from wherever they ran `init` (the workspace
|
|
376
|
+
// root), so they need workspace-relative paths: projects/<name>/....
|
|
377
|
+
// - The `sdlab generate <pack>` line's argument is consumed by
|
|
378
|
+
// generate.js via resolveSafeProjectPath(GAME_ROOT, packPath) — GAME_ROOT
|
|
379
|
+
// IS projects/<name>, so that path must be PROJECT-relative
|
|
380
|
+
// (inputs/prompts/...), not workspace-relative. Printing the
|
|
381
|
+
// workspace-relative form here doubles the path
|
|
382
|
+
// (projects/<name>/projects/<name>/...) and generate.js 404s with
|
|
383
|
+
// INPUT_FILE_NOT_FOUND. Regression introduced in 27c42c6 while fixing
|
|
384
|
+
// the sibling "Edit" lines (which legitimately needed the workspace-
|
|
385
|
+
// relative switch) — see tests/cli-scripts/init-generate-path.test.js.
|
|
386
|
+
const rel = (...segments) => ['projects', projectName, ...segments].join('/');
|
|
387
|
+
const projectRel = (...segments) => segments.join('/');
|
|
388
|
+
|
|
334
389
|
console.log('Next steps:');
|
|
335
390
|
let step = 1;
|
|
336
391
|
if (wroteCanon['constitution.md']) {
|
|
337
|
-
console.log(` ${step++}. Edit ${
|
|
392
|
+
console.log(` ${step++}. Edit ${rel('canon', 'constitution.md')}`);
|
|
393
|
+
console.log(` Describe your visual rules in prose — this is the canon the pipeline binds to.`);
|
|
338
394
|
}
|
|
339
|
-
console.log(` ${step++}. Edit ${
|
|
395
|
+
console.log(` ${step++}. Edit ${rel('constitution.json')}`);
|
|
396
|
+
console.log(` Encode those rules as machine-checkable entries for canon-bind.`);
|
|
340
397
|
if (wroteExamplePack) {
|
|
341
|
-
console.log(` ${step++}.
|
|
398
|
+
console.log(` ${step++}. Generate your first candidates:`);
|
|
399
|
+
console.log(` sdlab generate ${projectRel('inputs', 'prompts', 'example-wave.json')} --project ${projectName}`);
|
|
342
400
|
} else {
|
|
343
|
-
console.log(` ${step++}.
|
|
344
|
-
console.log(` ${step++}.
|
|
401
|
+
console.log(` ${step++}. Add a prompt pack under ${rel('inputs', 'prompts')}/`);
|
|
402
|
+
console.log(` ${step++}. Generate your first candidates:`);
|
|
403
|
+
console.log(` sdlab generate <pack> --project ${projectName}`);
|
|
345
404
|
}
|
|
346
|
-
console.log(` ${step++}.
|
|
405
|
+
console.log(` ${step++}. Check the project is wired correctly:`);
|
|
406
|
+
console.log(` sdlab project doctor --project ${projectName}`);
|
|
347
407
|
console.log('');
|
|
348
|
-
console.log(`
|
|
408
|
+
console.log(`Full quick-start: ${rel('README.md')}`);
|
|
349
409
|
}
|
|
350
410
|
|
|
351
411
|
// Direct execution guard
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* measure.js — Attach deterministic pixel measurements to candidate records.
|
|
5
|
+
*
|
|
6
|
+
* Until now nothing in this tool ever opened an image. `lib/critique-engine.js`
|
|
7
|
+
* builds its entire drift report from brief and workflow TEXT — keyword
|
|
8
|
+
* matching against prompt strings — and stamps every candidate
|
|
9
|
+
* `overall_fit: 'usable'` without looking at a single pixel. Every question
|
|
10
|
+
* about what an image actually looks like was answered by eyeballing it.
|
|
11
|
+
*
|
|
12
|
+
* The instruments here are a port, not an invention. They come from the
|
|
13
|
+
* measurement scripts written for the salt-road project
|
|
14
|
+
* (`projects/salt-road/inputs/prompts/measure-palette.py`, `measure-texture.py`),
|
|
15
|
+
* which were built and validated against real production waves and carry
|
|
16
|
+
* their own written rationale for why simpler proxies were rejected.
|
|
17
|
+
*
|
|
18
|
+
* MEASUREMENT, NOT VERDICT. This command attaches numbers. It never sets
|
|
19
|
+
* `judgment`, never sets `overall_fit`, never approves or rejects. That line
|
|
20
|
+
* is deliberate and load-bearing: this repo deleted three scripts in its
|
|
21
|
+
* Stage A health pass for minting judgments nobody made, and 948 of
|
|
22
|
+
* star-freight's 1,182 records still carry that damage. Numbers inform a
|
|
23
|
+
* human; they do not replace one.
|
|
24
|
+
*
|
|
25
|
+
* Usage:
|
|
26
|
+
* sdlab measure <dir-or-record-glob> --project <name>
|
|
27
|
+
* sdlab measure outputs/approved --project salt-road
|
|
28
|
+
* sdlab measure "styleset_p*" --project salt-road --anchors canon/anchors.json
|
|
29
|
+
* sdlab measure outputs/candidates --project salt-road --dry-run
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { parseArgs, getProjectName } from '../lib/args.js';
|
|
33
|
+
import { getProjectRoot } from '../lib/paths.js';
|
|
34
|
+
import { handleCliError } from '../lib/errors.js';
|
|
35
|
+
import { warn, result } from '../lib/log.js';
|
|
36
|
+
import {
|
|
37
|
+
assertMeasurementPrereqs,
|
|
38
|
+
resolveMeasureTargets,
|
|
39
|
+
loadAnchors,
|
|
40
|
+
runPythonMeasurement,
|
|
41
|
+
applyMeasurements,
|
|
42
|
+
} from '../lib/measure.js';
|
|
43
|
+
|
|
44
|
+
const USAGE = `Usage: sdlab measure <dir-or-record-glob> --project <name> [--anchors <path>] [--dry-run] [--json]
|
|
45
|
+
|
|
46
|
+
Measure candidate images (palette conformance, texture character) and attach
|
|
47
|
+
the numbers to their records. Measurement only — never sets a judgment.
|
|
48
|
+
|
|
49
|
+
Positional:
|
|
50
|
+
dir-or-record-glob A directory of images, or a record-id glob
|
|
51
|
+
(quote globs so your shell does not expand them)
|
|
52
|
+
|
|
53
|
+
Flags:
|
|
54
|
+
--project <name> Project to operate on
|
|
55
|
+
--anchors <path> Palette anchor definitions, relative to the project
|
|
56
|
+
root. Without it, palette measures that need anchors
|
|
57
|
+
are skipped rather than guessed.
|
|
58
|
+
--dry-run Measure and report, write nothing
|
|
59
|
+
--json Emit the raw result as JSON
|
|
60
|
+
|
|
61
|
+
Requires Python 3.9+ with Pillow, numpy and scipy (see scripts/measure_image.py).
|
|
62
|
+
Set SDLAB_PYTHON to pin an explicit interpreter.`;
|
|
63
|
+
|
|
64
|
+
export async function run(argv = process.argv.slice(2)) {
|
|
65
|
+
const { flags, positionals } = parseArgs(argv, {
|
|
66
|
+
flags: {
|
|
67
|
+
project: { type: 'string' },
|
|
68
|
+
anchors: { type: 'string' },
|
|
69
|
+
'dry-run': { type: 'boolean' },
|
|
70
|
+
json: { type: 'boolean' },
|
|
71
|
+
help: { type: 'boolean' },
|
|
72
|
+
},
|
|
73
|
+
deprecated: { game: 'project' },
|
|
74
|
+
allowUnknown: true,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (flags.help) {
|
|
78
|
+
console.log(USAGE);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const projectName = flags.project || getProjectName(argv);
|
|
83
|
+
const projectRoot = getProjectRoot(projectName);
|
|
84
|
+
const dryRun = Boolean(flags['dry-run']);
|
|
85
|
+
|
|
86
|
+
// Fail on a missing interpreter or package BEFORE resolving targets, so an
|
|
87
|
+
// operator without the Python side gets one clear actionable error rather
|
|
88
|
+
// than a successful scan followed by a confusing failure.
|
|
89
|
+
const python = assertMeasurementPrereqs();
|
|
90
|
+
|
|
91
|
+
const { targets, skipped } = resolveMeasureTargets(projectRoot, positionals[0]);
|
|
92
|
+
|
|
93
|
+
console.log(`\x1b[1msdlab measure\x1b[0m — ${projectName}`);
|
|
94
|
+
console.log(` Target: ${positionals[0]}`);
|
|
95
|
+
console.log(` Images: ${targets.length}${dryRun ? ' (dry run — nothing will be written)' : ''}`);
|
|
96
|
+
|
|
97
|
+
const anchorSpec = loadAnchors(flags.anchors, projectRoot);
|
|
98
|
+
if (anchorSpec.source) {
|
|
99
|
+
console.log(` Anchors: ${anchorSpec.anchors.length} from ${flags.anchors}`);
|
|
100
|
+
} else {
|
|
101
|
+
// Say so plainly. A palette measure with no anchors is not a failed
|
|
102
|
+
// measurement, but it is a narrower one, and silently returning fewer
|
|
103
|
+
// fields than the operator expected is how a tool loses trust.
|
|
104
|
+
console.log(` Anchors: none — pass --anchors <path> for palette-conformance measures`);
|
|
105
|
+
}
|
|
106
|
+
console.log('');
|
|
107
|
+
|
|
108
|
+
if (skipped.length > 0) {
|
|
109
|
+
warn(`${skipped.length} target(s) skipped: ${skipped.slice(0, 3).map((s) => `${s.id} (${s.reason})`).join(', ')}${skipped.length > 3 ? ', …' : ''}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (targets.length === 0) {
|
|
113
|
+
console.log(' Nothing to measure.');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const pyResult = runPythonMeasurement(python, {
|
|
118
|
+
images: targets.map((t) => ({ id: t.id, path: t.imagePath })),
|
|
119
|
+
anchors: anchorSpec.anchors,
|
|
120
|
+
hue_tolerance_deg: anchorSpec.hue_tolerance_deg,
|
|
121
|
+
sat_min: anchorSpec.sat_min,
|
|
122
|
+
val_min: anchorSpec.val_min,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const { applied, failed } = await applyMeasurements(targets, pyResult, {
|
|
126
|
+
anchorsSource: anchorSpec.source,
|
|
127
|
+
dryRun,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (flags.json) {
|
|
131
|
+
result(JSON.stringify({ applied, failed, skipped }, null, 2));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log(` \x1b[32m✓\x1b[0m Measured: ${applied.length}`);
|
|
136
|
+
if (failed.length > 0) {
|
|
137
|
+
console.log(` \x1b[31m✗\x1b[0m Failed: ${failed.length}`);
|
|
138
|
+
for (const f of failed.slice(0, 5)) {
|
|
139
|
+
console.log(` ${f.id}: ${f.error}`);
|
|
140
|
+
}
|
|
141
|
+
if (failed.length > 5) console.log(` … and ${failed.length - 5} more`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (applied.length > 0) {
|
|
145
|
+
// Show one real measurement rather than only a count — a number the
|
|
146
|
+
// operator can sanity-check against the image in front of them is worth
|
|
147
|
+
// more than a success tally.
|
|
148
|
+
const sample = applied[0];
|
|
149
|
+
const keys = Object.keys(sample.measurements || {}).filter((k) => !k.startsWith('_'));
|
|
150
|
+
if (keys.length > 0) {
|
|
151
|
+
console.log('');
|
|
152
|
+
console.log(` Sample (${sample.id}):`);
|
|
153
|
+
for (const k of keys.slice(0, 6)) {
|
|
154
|
+
const v = sample.measurements[k];
|
|
155
|
+
const shown = typeof v === 'number' ? v.toFixed(4) : JSON.stringify(v);
|
|
156
|
+
console.log(` ${k.padEnd(28)} ${shown}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (dryRun) {
|
|
162
|
+
console.log('');
|
|
163
|
+
console.log(' (dry run — no records were modified)');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Direct execution guard
|
|
168
|
+
if (process.argv[1] && (process.argv[1].endsWith('measure.js') || process.argv[1].endsWith('measure'))) {
|
|
169
|
+
run().catch(handleCliError);
|
|
170
|
+
}
|