@rig-c/parts 0.0.0-stage → 1.5.4
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/LICENSE +21 -0
- package/NOTICE.md +134 -0
- package/README.md +433 -2
- package/bin/rig-parts.cjs +43 -0
- package/cli.ts +1240 -0
- package/docs/AUTHORING.md +2387 -0
- package/docs/MIGRATION.md +150 -0
- package/docs/STABILITY.md +262 -0
- package/package.json +70 -4
- package/skills/rig-parts/SKILL.md +167 -0
- package/src/apng.ts +381 -0
- package/src/assemble.ts +2259 -0
- package/src/autoamplitude.ts +194 -0
- package/src/autoenvelope.ts +404 -0
- package/src/automesh.ts +585 -0
- package/src/automotion.ts +447 -0
- package/src/autoreplay.ts +516 -0
- package/src/build.ts +1240 -0
- package/src/check.ts +2942 -0
- package/src/comfy/client.ts +245 -0
- package/src/comfy/index.ts +19 -0
- package/src/comfy/painting.ts +136 -0
- package/src/comfy/seethrough.ts +171 -0
- package/src/config.ts +2283 -0
- package/src/contour.ts +1901 -0
- package/src/coords.ts +26 -0
- package/src/diagnostics.ts +198 -0
- package/src/errors.ts +39 -0
- package/src/gif.ts +213 -0
- package/src/graphs.ts +302 -0
- package/src/headbox.ts +173 -0
- package/src/heat.ts +313 -0
- package/src/inputs.ts +100 -0
- package/src/instruments.ts +1145 -0
- package/src/keypoints.ts +296 -0
- package/src/layers.ts +598 -0
- package/src/localweights.ts +217 -0
- package/src/mesh.ts +362 -0
- package/src/motion.ts +311 -0
- package/src/palette.ts +208 -0
- package/src/parts.ts +410 -0
- package/src/propose.ts +2540 -0
- package/src/pyfmt.ts +135 -0
- package/src/raster/blur.ts +80 -0
- package/src/raster/components.ts +165 -0
- package/src/raster/composite.ts +81 -0
- package/src/raster/index.ts +9 -0
- package/src/raster/morph.ts +116 -0
- package/src/raster/png.ts +82 -0
- package/src/raster/poly.ts +166 -0
- package/src/raster/resize.ts +301 -0
- package/src/raster/types.ts +89 -0
- package/src/raster/warp.ts +121 -0
- package/src/requirements.ts +1240 -0
- package/src/rig.ts +1440 -0
- package/src/round.ts +62 -0
- package/src/scene.ts +1414 -0
- package/src/sheet.ts +128 -0
- package/src/skeleton.ts +400 -0
- package/src/structure.ts +858 -0
- package/src/tags.ts +121 -0
- package/src/weights.ts +124 -0
- package/workflows/seethrough.json +68 -0
package/src/graphs.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ComfyUI graphs the optional adapter submits, built without touching the
|
|
3
|
+
* network: the prompt words, the painting graph, the See-through template
|
|
4
|
+
* filled in, and the check of a graph against a box's `/object_info`.
|
|
5
|
+
*
|
|
6
|
+
* Everything here is pure so the purity scan (`TY06`) covers it; the HTTP
|
|
7
|
+
* that carries these graphs to a box lives in `src/comfy/`, and nothing there
|
|
8
|
+
* decides a word or a node.
|
|
9
|
+
*
|
|
10
|
+
* ## The prompt
|
|
11
|
+
*
|
|
12
|
+
* positive = the quality head, then `trigger`, `identity`, `costume`, the pose
|
|
13
|
+
* words and `style`, split on commas and re-joined with every `DUP_WORDS` term
|
|
14
|
+
* and every empty term dropped. negative = the negative head, `negative_extra`
|
|
15
|
+
* and `negative_pose`, joined with ", " (an empty `negative_extra` is left
|
|
16
|
+
* out). With a non-empty `negative_extra` both strings are the reference's
|
|
17
|
+
* byte for byte.
|
|
18
|
+
*
|
|
19
|
+
* 🚨 **The pose words come from the config or from the skeleton, and from
|
|
20
|
+
* nothing else.** `generation.pose`, when it is set, is the whole of them.
|
|
21
|
+
* When it is absent the config loader has already required a
|
|
22
|
+
* `generation.control`, and the pose words are `FRAMING` followed by that
|
|
23
|
+
* skeleton's own `words` (`src/skeleton.ts`). The reference appended
|
|
24
|
+
* expression and costume words of one private cast after the skeleton's words
|
|
25
|
+
* in that case; none of them is a property of a skeleton, so none is here, and
|
|
26
|
+
* the selftest plants one to show the control goes red.
|
|
27
|
+
*
|
|
28
|
+
* The two heads are the reference's verbatim, including the safety terms of
|
|
29
|
+
* the negative head (no nudity, no minors): they are a guard, not a cast.
|
|
30
|
+
*/
|
|
31
|
+
import type { Control, Generation, Lora, Sampler } from './config.ts';
|
|
32
|
+
import type { Problem } from './errors.ts';
|
|
33
|
+
import { isSkeletonName, SKELETONS } from './skeleton.ts';
|
|
34
|
+
|
|
35
|
+
export const POSITIVE_HEAD = 'score_9, score_8_up, score_7_up, source_anime, masterpiece, best quality, highly detailed';
|
|
36
|
+
export const NEGATIVE_HEAD =
|
|
37
|
+
'score_4, score_3, score_2, score_1, worst quality, low quality, lowres, blurry, jpeg artifacts, ' +
|
|
38
|
+
'nsfw, nude, naked, cleavage, underwear, sexual, suggestive, child, loli, shota, kid, toddler, ' +
|
|
39
|
+
'text, watermark, signature, logo, bad anatomy, bad hands, extra fingers, deformed, mutated';
|
|
40
|
+
|
|
41
|
+
/** Camera framing for a full-body figure, prepended to a skeleton's words. Framing, not pose and not costume. */
|
|
42
|
+
export const FRAMING = 'full body, head to toe, entire body in frame, front view, facing viewer, looking at viewer';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Terms that ask for a mirrored or repeated layout, dropped from every
|
|
46
|
+
* positive (compared case-insensitively, per comma-separated term). With an
|
|
47
|
+
* identity LoRA they tipped the reference's prompt-only batches into two
|
|
48
|
+
* figures side by side in 21 of 24 images.
|
|
49
|
+
*/
|
|
50
|
+
export const DUP_WORDS: readonly string[] = ['symmetrical composition', 'symmetry', 'symmetrical', 'multiple views', 'reference sheet', 'character sheet'];
|
|
51
|
+
|
|
52
|
+
/** The ControlNet a skeleton is drawn for: xinsir's, whose stick-scale rule `src/skeleton.ts` follows. Used when `control.model` is absent. */
|
|
53
|
+
export const CONTROL_MODEL = 'controlnet-openpose-sdxl-1.0.safetensors';
|
|
54
|
+
export const UPSCALE_MODEL = '4x-AnimeSharp.pth';
|
|
55
|
+
/** 4x model then 0.5 = a painting twice the latent size. */
|
|
56
|
+
export const UPSCALE_THEN = 0.5;
|
|
57
|
+
|
|
58
|
+
/** The reference's `strip_words`: split on commas, trim, drop DUP_WORDS and empty terms, re-join with ", ". */
|
|
59
|
+
export function stripWords(text: string, words: readonly string[] = DUP_WORDS): { text: string; dropped: string[] } {
|
|
60
|
+
const keep: string[] = [];
|
|
61
|
+
const dropped: string[] = [];
|
|
62
|
+
for (const t of text.split(',').map((s) => s.trim())) (words.includes(t.toLowerCase()) ? dropped : keep).push(t);
|
|
63
|
+
return { text: keep.filter((t) => t !== '').join(', '), dropped };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function poseWords(g: Pick<Generation, 'pose' | 'control'>): { words: string; from: string } {
|
|
67
|
+
if (g.pose !== undefined) return { words: g.pose, from: 'generation.pose' };
|
|
68
|
+
if (g.control !== undefined && isSkeletonName(g.control.skeleton)) return { words: `${FRAMING}, ${SKELETONS[g.control.skeleton].words}`, from: `the ${g.control.skeleton} skeleton` };
|
|
69
|
+
// parseConfig refuses both other cases — no pose and no control, and a pose file (which carries no words, issue #198)
|
|
70
|
+
// with no pose; reaching here means a config that skipped the loader.
|
|
71
|
+
throw new Error('poseWords: generation has neither pose nor a built-in control skeleton; load the config through parseConfig');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface Prompts {
|
|
75
|
+
positive: string;
|
|
76
|
+
negative: string;
|
|
77
|
+
dropped: string[];
|
|
78
|
+
poseFrom: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function buildPrompts(g: Generation): Prompts {
|
|
82
|
+
const pose = poseWords(g);
|
|
83
|
+
const raw = [POSITIVE_HEAD, g.trigger, g.identity, g.costume, pose.words, g.style].join(', ');
|
|
84
|
+
const { text, dropped } = stripWords(raw);
|
|
85
|
+
const negative = [NEGATIVE_HEAD, g.negative_extra, g.negative_pose].filter((s) => s !== '').join(', ');
|
|
86
|
+
return { positive: text, negative, dropped, poseFrom: pose.from };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// graphs
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
export type Link = [string, number];
|
|
94
|
+
export type InputValue = string | number | boolean | Link;
|
|
95
|
+
export interface GraphNode {
|
|
96
|
+
class_type: string;
|
|
97
|
+
inputs: Record<string, InputValue>;
|
|
98
|
+
}
|
|
99
|
+
export type Graph = Record<string, GraphNode>;
|
|
100
|
+
|
|
101
|
+
/** A LoRA as the graph uses it: `strength_clip` defaults to `strength`, which is what ComfyUI's LoraLoader was given in the reference. */
|
|
102
|
+
export function resolvedLoras(loras: readonly Lora[]): Array<Required<Lora>> {
|
|
103
|
+
return loras.map((l) => ({ name: l.name, strength: l.strength, strength_clip: l.strength_clip ?? l.strength }));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function resolvedControl(c: Control): Required<Control> {
|
|
107
|
+
return { skeleton: c.skeleton, strength: c.strength, end_percent: c.end_percent, model: c.model ?? CONTROL_MODEL };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The sampler as the graph uses it: its four fields by name, in this order.
|
|
112
|
+
* The config's own object also carries whatever annotations and `x-` records
|
|
113
|
+
* its author wrote, and the loader vouches for the four fields only, so a
|
|
114
|
+
* writer that copied the object whole would carry those into an output.
|
|
115
|
+
*/
|
|
116
|
+
export function resolvedSampler(s: Sampler): Sampler {
|
|
117
|
+
return { steps: s.steps, cfg: s.cfg, sampler: s.sampler, scheduler: s.scheduler };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The painting graph: checkpoint -> LoRAs in order -> the two text encoders ->
|
|
122
|
+
* (ControlNetApplyAdvanced over the uploaded skeleton) -> KSampler -> VAE
|
|
123
|
+
* decode -> 4x-AnimeSharp -> 0.5 lanczos -> SaveImage. Node ids are the
|
|
124
|
+
* reference's, so a graph can be diffed against its records.
|
|
125
|
+
*/
|
|
126
|
+
export function paintingGraph(g: Generation, seed: number, prompts: Prompts, prefix: string, skeletonRef: string | null): Graph {
|
|
127
|
+
const [w, h] = g.latent;
|
|
128
|
+
const n: Graph = {
|
|
129
|
+
'4': { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: g.checkpoint } },
|
|
130
|
+
'5': { class_type: 'EmptyLatentImage', inputs: { width: w, height: h, batch_size: 1 } },
|
|
131
|
+
'6': { class_type: 'CLIPTextEncode', inputs: { text: prompts.positive, clip: ['4', 1] } },
|
|
132
|
+
'7': { class_type: 'CLIPTextEncode', inputs: { text: prompts.negative, clip: ['4', 1] } },
|
|
133
|
+
'3': {
|
|
134
|
+
class_type: 'KSampler',
|
|
135
|
+
inputs: {
|
|
136
|
+
seed,
|
|
137
|
+
steps: g.sampler.steps,
|
|
138
|
+
cfg: g.sampler.cfg,
|
|
139
|
+
sampler_name: g.sampler.sampler,
|
|
140
|
+
scheduler: g.sampler.scheduler,
|
|
141
|
+
denoise: 1.0,
|
|
142
|
+
model: ['4', 0],
|
|
143
|
+
positive: ['6', 0],
|
|
144
|
+
negative: ['7', 0],
|
|
145
|
+
latent_image: ['5', 0],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
'8': { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['4', 2] } },
|
|
149
|
+
'12': { class_type: 'UpscaleModelLoader', inputs: { model_name: UPSCALE_MODEL } },
|
|
150
|
+
'13': { class_type: 'ImageUpscaleWithModel', inputs: { upscale_model: ['12', 0], image: ['8', 0] } },
|
|
151
|
+
'14': { class_type: 'ImageScaleBy', inputs: { upscale_method: 'lanczos', scale_by: UPSCALE_THEN, image: ['13', 0] } },
|
|
152
|
+
'9': { class_type: 'SaveImage', inputs: { filename_prefix: prefix, images: ['14', 0] } },
|
|
153
|
+
};
|
|
154
|
+
let model: Link = ['4', 0];
|
|
155
|
+
let clip: Link = ['4', 1];
|
|
156
|
+
resolvedLoras(g.loras).forEach((lo, i) => {
|
|
157
|
+
const id = String(20 + i);
|
|
158
|
+
n[id] = { class_type: 'LoraLoader', inputs: { lora_name: lo.name, strength_model: lo.strength, strength_clip: lo.strength_clip, model, clip } };
|
|
159
|
+
model = [id, 0];
|
|
160
|
+
clip = [id, 1];
|
|
161
|
+
});
|
|
162
|
+
n['3'].inputs.model = model;
|
|
163
|
+
n['6'].inputs.clip = clip;
|
|
164
|
+
n['7'].inputs.clip = clip;
|
|
165
|
+
if (g.control !== undefined) {
|
|
166
|
+
if (skeletonRef === null) throw new Error('paintingGraph: generation.control is set and no skeleton was uploaded');
|
|
167
|
+
const c = resolvedControl(g.control);
|
|
168
|
+
n['30'] = { class_type: 'LoadImage', inputs: { image: skeletonRef } };
|
|
169
|
+
n['31'] = { class_type: 'ControlNetLoader', inputs: { control_net_name: c.model } };
|
|
170
|
+
n['32'] = {
|
|
171
|
+
class_type: 'ControlNetApplyAdvanced',
|
|
172
|
+
inputs: { positive: ['6', 0], negative: ['7', 0], control_net: ['31', 0], image: ['30', 0], strength: c.strength, start_percent: 0.0, end_percent: c.end_percent },
|
|
173
|
+
};
|
|
174
|
+
n['3'].inputs.positive = ['32', 0];
|
|
175
|
+
n['3'].inputs.negative = ['32', 1];
|
|
176
|
+
}
|
|
177
|
+
return n;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface SeeThroughParams {
|
|
181
|
+
image: string;
|
|
182
|
+
seed: number;
|
|
183
|
+
resolution: number;
|
|
184
|
+
steps: number;
|
|
185
|
+
quant: 'none' | 'nf4';
|
|
186
|
+
offload: boolean;
|
|
187
|
+
lama: boolean;
|
|
188
|
+
prefix: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Fill `workflows/seethrough.json`: a string that IS a placeholder becomes the
|
|
193
|
+
* typed value; `%PREFIX%` inside a longer string is substituted in place.
|
|
194
|
+
* Top-level keys starting with `_` are annotations and are dropped, since
|
|
195
|
+
* `/prompt` reads every top-level key as a node. A placeholder the template
|
|
196
|
+
* holds and this call does not fill is refused rather than sent.
|
|
197
|
+
*/
|
|
198
|
+
export function fillSeeThrough(template: unknown, p: SeeThroughParams): Graph {
|
|
199
|
+
const values: Record<string, string | number | boolean> = {
|
|
200
|
+
'%IMAGE%': p.image,
|
|
201
|
+
'%SEED%': p.seed,
|
|
202
|
+
'%RESOLUTION%': p.resolution,
|
|
203
|
+
'%STEPS%': p.steps,
|
|
204
|
+
'%QUANT%': p.quant,
|
|
205
|
+
'%OFFLOAD%': p.offload,
|
|
206
|
+
'%LAMA%': p.lama,
|
|
207
|
+
'%PREFIX%': p.prefix,
|
|
208
|
+
};
|
|
209
|
+
const left: string[] = [];
|
|
210
|
+
const walk = (v: unknown): unknown => {
|
|
211
|
+
if (Array.isArray(v)) return v.map(walk);
|
|
212
|
+
if (typeof v === 'object' && v !== null) {
|
|
213
|
+
const o: Record<string, unknown> = {};
|
|
214
|
+
for (const [k, x] of Object.entries(v)) o[k] = walk(x);
|
|
215
|
+
return o;
|
|
216
|
+
}
|
|
217
|
+
if (typeof v === 'string') {
|
|
218
|
+
if (v in values) return values[v];
|
|
219
|
+
const out = v.replaceAll('%PREFIX%', p.prefix);
|
|
220
|
+
const stray = /%[A-Z_]+%/.exec(out);
|
|
221
|
+
if (stray !== null) left.push(stray[0]);
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
return v;
|
|
225
|
+
};
|
|
226
|
+
if (typeof template !== 'object' || template === null || Array.isArray(template)) throw new Error('fillSeeThrough: the template is not an object of nodes');
|
|
227
|
+
const graph: Record<string, unknown> = {};
|
|
228
|
+
for (const [id, node] of Object.entries(template)) if (!id.startsWith('_')) graph[id] = walk(node);
|
|
229
|
+
if (left.length > 0) throw new Error(`fillSeeThrough: the template holds ${[...new Set(left)].join(', ')}, which nothing fills`);
|
|
230
|
+
return graph as Graph;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// the graph against a box
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
/** One node class as `/object_info` describes it; only what the check reads. */
|
|
238
|
+
interface NodeSpec {
|
|
239
|
+
input?: { required?: Record<string, unknown>; optional?: Record<string, unknown>; hidden?: Record<string, unknown> };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The choices of an enum input, in either spelling `/object_info` uses (`[[...]]` or `["COMBO", {options}]`), or null. */
|
|
243
|
+
function enumChoices(spec: unknown): string[] | null {
|
|
244
|
+
if (!Array.isArray(spec) || spec.length === 0) return null;
|
|
245
|
+
if (Array.isArray(spec[0])) return spec[0].map(String);
|
|
246
|
+
if (spec[0] === 'COMBO' && typeof spec[1] === 'object' && spec[1] !== null) {
|
|
247
|
+
const options = (spec[1] as Record<string, unknown>).options;
|
|
248
|
+
if (Array.isArray(options)) return options.map(String);
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Every problem a box would have with this graph, found before anything is
|
|
255
|
+
* queued: a node class the box does not have (the See-through wrapper not
|
|
256
|
+
* installed is exactly this), an input the class does not take, a required
|
|
257
|
+
* input left unset, and an enum value — a checkpoint, a LoRA, an upscale
|
|
258
|
+
* model — the box does not list. `uploaded` names the node inputs whose value
|
|
259
|
+
* is an image this run uploads, whose enum is not checked.
|
|
260
|
+
*/
|
|
261
|
+
export function checkGraph(graph: Graph, objectInfo: Record<string, unknown>, host: string, uploaded: ReadonlySet<string> = new Set()): Problem[] {
|
|
262
|
+
const problems: Problem[] = [];
|
|
263
|
+
for (const [id, node] of Object.entries(graph)) {
|
|
264
|
+
const spec = objectInfo[node.class_type] as NodeSpec | undefined;
|
|
265
|
+
if (spec === undefined) {
|
|
266
|
+
problems.push({
|
|
267
|
+
code: 'COMFY_NODE_PRESENT',
|
|
268
|
+
object: `graph node ${id}`,
|
|
269
|
+
detail: `is a ${node.class_type}, which ${host}/object_info does not list; a box with that node class installed is required`,
|
|
270
|
+
});
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const required = spec.input?.required ?? {};
|
|
274
|
+
const optional = { ...(spec.input?.hidden ?? {}), ...(spec.input?.optional ?? {}) };
|
|
275
|
+
for (const [name, value] of Object.entries(node.inputs)) {
|
|
276
|
+
const inSpec = name in required ? required[name] : name in optional ? optional[name] : undefined;
|
|
277
|
+
if (inSpec === undefined) {
|
|
278
|
+
problems.push({
|
|
279
|
+
code: 'COMFY_INPUT_KNOWN',
|
|
280
|
+
object: `graph node ${id} (${node.class_type}) input "${name}"`,
|
|
281
|
+
detail: `is not an input of ${node.class_type} on this box; it takes ${[...Object.keys(required), ...Object.keys(optional)].join(', ')}`,
|
|
282
|
+
});
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const choices = enumChoices(inSpec);
|
|
286
|
+
if (choices !== null && typeof value === 'string' && !uploaded.has(`${id}.${name}`) && !choices.includes(value)) {
|
|
287
|
+
const shown = choices.length > 12 ? `${choices.slice(0, 12).join(', ')}, … (${choices.length} in all)` : choices.join(', ');
|
|
288
|
+
problems.push({
|
|
289
|
+
code: 'COMFY_CHOICE_PRESENT',
|
|
290
|
+
object: `graph node ${id} (${node.class_type}) input "${name}"`,
|
|
291
|
+
detail: `is "${value}", which this box does not list; one of ${shown} is required`,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (const name of Object.keys(required)) {
|
|
296
|
+
if (!(name in node.inputs)) {
|
|
297
|
+
problems.push({ code: 'COMFY_INPUT_SET', object: `graph node ${id} (${node.class_type}) input "${name}"`, detail: 'is required by the box and the graph does not set it' });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return problems;
|
|
302
|
+
}
|
package/src/headbox.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The head-box proposer: `seethrough.head_box`, the square crop of the source
|
|
3
|
+
* painting the second, head-only See-through run is fed, read off the FULL
|
|
4
|
+
* run's layers.
|
|
5
|
+
*
|
|
6
|
+
* The rule is the reference's (`gen_painting.py --propose-head-box`): take the
|
|
7
|
+
* union of the `face`, `back hair`, `front hair`, `headwear` and `earwear`
|
|
8
|
+
* layers after the ghost clean-up, cut it off 0.35 face heights below the chin
|
|
9
|
+
* (a long lock must not drag the box down), add a 12 % margin, square it on
|
|
10
|
+
* the union's centre, and map it from the full run's canvas to source pixels
|
|
11
|
+
* through the run's own pad (`squarePad`: the source is centred on a square of
|
|
12
|
+
* its longer side, so a portrait painting's x carries the pad and a landscape
|
|
13
|
+
* one's y does; the reference took x only, its paintings being portrait).
|
|
14
|
+
*
|
|
15
|
+
* Two departures, both because the reference's box could not be fed back in:
|
|
16
|
+
*
|
|
17
|
+
* - 🔲 **The box is square by construction.** The reference rounded each edge
|
|
18
|
+
* on its own, so the two sides could come out a pixel apart (measured: 3 of
|
|
19
|
+
* the reference's 10 corpus boxes, e.g. 502x503), and the config loader
|
|
20
|
+
* refuses a non-square head box. Here the near edges are rounded as the
|
|
21
|
+
* reference rounds them and the side is the larger of its two rounded
|
|
22
|
+
* extents: square, never smaller than the reference's box, and equal to it
|
|
23
|
+
* wherever that was already square.
|
|
24
|
+
* - 📐 **The box is held inside the painting.** The reference's box on the
|
|
25
|
+
* public `demo` character started 118 px above the canvas, and the crop
|
|
26
|
+
* filled that band with black, which See-through then decomposed. A box that
|
|
27
|
+
* leaves the canvas is shifted back inside at the same size, and the shift is
|
|
28
|
+
* reported; a box larger than the painting's short side cannot be held
|
|
29
|
+
* inside at that size and is refused.
|
|
30
|
+
*/
|
|
31
|
+
import { type Problem, refuseIfAny } from './errors.ts';
|
|
32
|
+
import { squarePad } from './inputs.ts';
|
|
33
|
+
import { type LayerSet, OPAQUE_ALPHA_ABOVE } from './layers.ts';
|
|
34
|
+
import { pyRound } from './pyfmt.ts';
|
|
35
|
+
import { connectedComponents } from './raster/components.ts';
|
|
36
|
+
import { newMask } from './raster/types.ts';
|
|
37
|
+
|
|
38
|
+
/** The layers whose union is the head. */
|
|
39
|
+
export const HEAD_LAYERS = ['face', 'back hair', 'front hair', 'headwear', 'earwear'] as const;
|
|
40
|
+
/** A layer with fewer opaque pixels than this after the clean-up is not counted as present. */
|
|
41
|
+
export const HEAD_LAYER_MIN_PX = 150;
|
|
42
|
+
|
|
43
|
+
export type Box = [number, number, number, number];
|
|
44
|
+
|
|
45
|
+
export interface HeadBoxProposal {
|
|
46
|
+
/** The square box, inside the painting: what goes into `seethrough.head_box`. */
|
|
47
|
+
head_box: Box;
|
|
48
|
+
/** The same box before it was held inside the painting (equal to `head_box` when no shift was needed). */
|
|
49
|
+
unclamped: Box;
|
|
50
|
+
/** How far the box was moved to stay inside, in source px. */
|
|
51
|
+
shift: [number, number];
|
|
52
|
+
/** The layers that contributed, with their opaque pixel counts after the clean-up. */
|
|
53
|
+
layers: Array<[string, number]>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The reference assembler's ghost rule, on one layer: keep the 8-connected
|
|
58
|
+
* components of alpha > 8 whose area is at least 40 px and at least 1 % of the
|
|
59
|
+
* largest; zero the rest. Returns the kept opaque pixels as (x, y) lists in the
|
|
60
|
+
* layer's own coordinates.
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ The assemble stage owns the same rule for the parts it writes; this copy
|
|
63
|
+
* reads only which pixels survive, for the box, and says so rather than
|
|
64
|
+
* reaching into a stage that is not this one.
|
|
65
|
+
*/
|
|
66
|
+
function cleanedOpaque(pixels: { width: number; height: number; data: Uint8ClampedArray }): { xs: number[]; ys: number[] } {
|
|
67
|
+
const m = newMask(pixels.width, pixels.height);
|
|
68
|
+
for (let i = 0; i < m.data.length; i++) m.data[i] = pixels.data[i * 4 + 3] > OPAQUE_ALPHA_ABOVE ? 1 : 0;
|
|
69
|
+
const cc = connectedComponents(m, 8);
|
|
70
|
+
const xs: number[] = [];
|
|
71
|
+
const ys: number[] = [];
|
|
72
|
+
if (cc.count <= 1) return { xs, ys };
|
|
73
|
+
let big = 0;
|
|
74
|
+
for (let i = 1; i < cc.count; i++) big = Math.max(big, cc.stats[i].area);
|
|
75
|
+
const floor = Math.max(40, 0.01 * big);
|
|
76
|
+
const keep = cc.stats.map((s, i) => i > 0 && s.area >= floor);
|
|
77
|
+
for (let y = 0; y < m.height; y++) {
|
|
78
|
+
for (let x = 0; x < m.width; x++) {
|
|
79
|
+
if (keep[cc.labels[y * m.width + x]]) {
|
|
80
|
+
xs.push(x);
|
|
81
|
+
ys.push(y);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { xs, ys };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Propose the head box from the full run's layers for a painting of
|
|
90
|
+
* `source.w` x `source.h` pixels. Every refusal names the value found and the
|
|
91
|
+
* value required.
|
|
92
|
+
*/
|
|
93
|
+
export function proposeHeadBox(full: LayerSet, source: { w: number; h: number }): HeadBoxProposal {
|
|
94
|
+
const problems: Problem[] = [];
|
|
95
|
+
if (!(Number.isInteger(source.w) && Number.isInteger(source.h) && source.w > 0 && source.h > 0)) {
|
|
96
|
+
problems.push({ code: 'HEADBOX_CANVAS_SIZE', object: '--canvas', detail: `is ${source.w}x${source.h}; the painting's size in positive integer pixels is required` });
|
|
97
|
+
}
|
|
98
|
+
if (full.canvas.w !== full.canvas.h) {
|
|
99
|
+
problems.push({ code: 'HEADBOX_RUN_SQUARE', object: full.source, detail: `the full run's canvas is ${full.canvas.w}x${full.canvas.h}; See-through's full run is square` });
|
|
100
|
+
}
|
|
101
|
+
refuseIfAny(problems);
|
|
102
|
+
|
|
103
|
+
const pad = squarePad(source.w, source.h);
|
|
104
|
+
const k = pad.side / full.canvas.w;
|
|
105
|
+
// The union does not depend on the order the layers are read in; a name
|
|
106
|
+
// that appears twice is last-wins in draw order (the reference's dict was
|
|
107
|
+
// last-wins in manifest order, and no wrapper output read so far repeats one).
|
|
108
|
+
const boxes = new Map<string, { left: number; top: number; xs: number[]; ys: number[] }>();
|
|
109
|
+
for (const L of full.layers) {
|
|
110
|
+
if (!(HEAD_LAYERS as readonly string[]).includes(L.name)) continue;
|
|
111
|
+
const { xs, ys } = cleanedOpaque(L.pixels);
|
|
112
|
+
if (xs.length < HEAD_LAYER_MIN_PX) continue;
|
|
113
|
+
boxes.set(L.name, { left: L.left, top: L.top, xs, ys });
|
|
114
|
+
}
|
|
115
|
+
const face = boxes.get('face');
|
|
116
|
+
if (face === undefined) {
|
|
117
|
+
refuseIfAny([
|
|
118
|
+
{
|
|
119
|
+
code: 'HEADBOX_FACE_PRESENT',
|
|
120
|
+
object: full.source,
|
|
121
|
+
detail: `holds no "face" layer with at least ${HEAD_LAYER_MIN_PX} opaque px after the ghost clean-up (found: ${[...boxes.keys()].join(', ') || 'none of the head layers'}); the cut below the chin is measured from it, so the box is not guessed`,
|
|
122
|
+
},
|
|
123
|
+
]);
|
|
124
|
+
}
|
|
125
|
+
const f = face as { left: number; top: number; xs: number[]; ys: number[] };
|
|
126
|
+
let fy0 = Infinity;
|
|
127
|
+
let fy1 = -Infinity;
|
|
128
|
+
for (const y of f.ys) {
|
|
129
|
+
if (f.top + y < fy0) fy0 = f.top + y;
|
|
130
|
+
if (f.top + y > fy1) fy1 = f.top + y;
|
|
131
|
+
}
|
|
132
|
+
const cut = fy1 + 0.35 * (fy1 - fy0);
|
|
133
|
+
let x0 = Infinity;
|
|
134
|
+
let x1 = -Infinity;
|
|
135
|
+
let y0 = Infinity;
|
|
136
|
+
let y1 = -Infinity;
|
|
137
|
+
for (const b of boxes.values()) {
|
|
138
|
+
for (let i = 0; i < b.xs.length; i++) {
|
|
139
|
+
const y = b.top + b.ys[i];
|
|
140
|
+
if (y > cut) continue;
|
|
141
|
+
const x = b.left + b.xs[i];
|
|
142
|
+
if (x < x0) x0 = x;
|
|
143
|
+
if (x > x1) x1 = x;
|
|
144
|
+
if (y < y0) y0 = y;
|
|
145
|
+
if (y > y1) y1 = y;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const size = Math.max(x1 - x0, y1 - y0) * 1.12;
|
|
149
|
+
const cx = (x0 + x1) / 2;
|
|
150
|
+
const cy = (y0 + y1) / 2;
|
|
151
|
+
// Each edge rounded as the reference rounds it; then the side is the larger
|
|
152
|
+
// of the two rounded extents, so the box is square, never holds less than
|
|
153
|
+
// the reference's, and equals it wherever the reference's was square.
|
|
154
|
+
const ex = [pyRound((cx - size / 2) * k), pyRound((cx + size / 2) * k)];
|
|
155
|
+
const ey = [pyRound((cy - size / 2) * k), pyRound((cy + size / 2) * k)];
|
|
156
|
+
const edge = Math.max(ex[1] - ex[0], ey[1] - ey[0]);
|
|
157
|
+
const bx = ex[0] - pad.left;
|
|
158
|
+
const by = ey[0] - pad.top;
|
|
159
|
+
const unclamped: Box = [bx, by, bx + edge, by + edge];
|
|
160
|
+
if (edge > source.w || edge > source.h) {
|
|
161
|
+
refuseIfAny([
|
|
162
|
+
{
|
|
163
|
+
code: 'HEADBOX_FITS_CANVAS',
|
|
164
|
+
object: 'head box',
|
|
165
|
+
detail: `is ${edge}x${edge} source px, proposed at [${unclamped.join(', ')}]; the ${source.w}x${source.h} painting cannot hold it at that size, and shrinking it would cut the head`,
|
|
166
|
+
},
|
|
167
|
+
]);
|
|
168
|
+
}
|
|
169
|
+
const hold = (lo: number, extent: number): number => (lo < 0 ? -lo : lo + edge > extent ? extent - edge - lo : 0);
|
|
170
|
+
const shift: [number, number] = [hold(bx, source.w), hold(by, source.h)];
|
|
171
|
+
const head_box: Box = [bx + shift[0], by + shift[1], bx + shift[0] + edge, by + shift[1] + edge];
|
|
172
|
+
return { head_box, unclamped, shift, layers: [...boxes.entries()].map(([n, b]) => [n, b.xs.length]) };
|
|
173
|
+
}
|