@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/assemble.ts
ADDED
|
@@ -0,0 +1,2259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The assemble stage: two See-through runs over one painting — a FULL-body run
|
|
3
|
+
* and a HEAD-crop run — merged into one set of rig-space parts, each part one
|
|
4
|
+
* RGBA image cropped to its own alpha box, plus the `parts.json` measurement
|
|
5
|
+
* record (`src/parts.ts`) and a flat recomposite to compare with the painting.
|
|
6
|
+
*
|
|
7
|
+
* Rig space is source pixels times `assemble.rig_scale`, y down, origin
|
|
8
|
+
* top-left; the rig canvas is `trunc(w * s) x trunc(h * s)`. The painting is
|
|
9
|
+
* resampled into it once (`sourceInRig`) and every "source" pixel below is a
|
|
10
|
+
* pixel of that resample.
|
|
11
|
+
*
|
|
12
|
+
* Each See-through layer goes through five steps, each its own function with
|
|
13
|
+
* its invariant stated where it is written:
|
|
14
|
+
*
|
|
15
|
+
* 1. `cleanGhosts` — drop sub-threshold specks from a run-space layer;
|
|
16
|
+
* 2. `layerToRig` — resample the layer into rig space through its run's geometry;
|
|
17
|
+
* 3. `projectSource` — where the layer is the top-most opaque layer of its run,
|
|
18
|
+
* take the painting's pixel instead of See-through's repaint;
|
|
19
|
+
* 4. `mergeBelowCrop` — a head-run part below the head crop is continued from a
|
|
20
|
+
* full-run layer (whole components, `belowCrop`), and its
|
|
21
|
+
* seam closed by `growRim`;
|
|
22
|
+
* 4b. `pushBackFringe` — a fringe below alpha 250 that keeps the stack off the
|
|
23
|
+
* painting where a part beneath holds the pixel in the
|
|
24
|
+
* painting's colour is cleared (not in the reference);
|
|
25
|
+
* 5. `seamOverride` — where the flat composite of the parts still differs from
|
|
26
|
+
* the painting, the top-most part takes the painting's colour.
|
|
27
|
+
*
|
|
28
|
+
* This is a port of a reference implementation, and it is faithful to it by
|
|
29
|
+
* default — measured against its outputs (ORACLE_assemble.md at the root of
|
|
30
|
+
* the change that added this file). The places it deliberately is NOT are
|
|
31
|
+
* named here, so none is found by accident:
|
|
32
|
+
*
|
|
33
|
+
* - **Refusals where the reference guessed or skipped.** A plan or extend
|
|
34
|
+
* entry naming a tag its run does not hold (the reference raised a KeyError),
|
|
35
|
+
* a part left with no opaque pixel (the reference printed `EMPTY` and wrote a
|
|
36
|
+
* `parts.json` without it), a head box outside the painting (the reference
|
|
37
|
+
* read row `-1` as the last row), a run whose canvas is not the configured
|
|
38
|
+
* resolution (the reference assumed 1024).
|
|
39
|
+
* - **A landscape painting is mapped back, not refused.** The reference
|
|
40
|
+
* refused one as a bare exit; here the full run's input is the painting on a
|
|
41
|
+
* square of its longer side (`squarePad`, `src/inputs.ts`), and the full
|
|
42
|
+
* run's map carries the vertical pad exactly as it carries the horizontal
|
|
43
|
+
* one. A portrait painting's vertical pad is 0, so its map is the
|
|
44
|
+
* reference's.
|
|
45
|
+
* - **`seamRule: 'silhouette'`** is available beside the faithful
|
|
46
|
+
* `'near-white'` rule. The reference's seam override skips every pixel whose
|
|
47
|
+
* painting colour is near-white (min channel above 235), which protects the
|
|
48
|
+
* background and also every white garment. The silhouette rule skips a
|
|
49
|
+
* near-white pixel only OUTSIDE the figure (`figureSilhouette`). Which one
|
|
50
|
+
* is the default is decided by measurement, recorded beside
|
|
51
|
+
* `DEFAULT_SEAM_RULE`.
|
|
52
|
+
* - **`projectRule: 'visible'`** is available beside the faithful `'core'`
|
|
53
|
+
* rule (`ProjectRule`): the reference erodes every layer's top-most opaque
|
|
54
|
+
* area before projecting, so a part a few pixels wide takes nothing from the
|
|
55
|
+
* painting; `visible` erodes only along a rim with a later layer in front.
|
|
56
|
+
* - **The visibility counts** (`visible_px`, `occluded_px`,
|
|
57
|
+
* `visible_not_projected_px`) are this port's, derived from the masks
|
|
58
|
+
* `projectSource` and `mergeBelowCrop` already build (`visibilityCounts`).
|
|
59
|
+
* - **The recomposite's holes and error map** (`uncoveredHoles`,
|
|
60
|
+
* `recompositeErrorMap`) are this port's: the reference printed the
|
|
61
|
+
* uncovered count alone, which says a hole exists but not where.
|
|
62
|
+
* - **The fringe push-back** (`pushBackFringe`, issue #119) is this port's,
|
|
63
|
+
* and it is on by default: the reference left a fringe below alpha 250 in
|
|
64
|
+
* another part's colour on the rig, where neither projection nor the seam
|
|
65
|
+
* override (both at alpha >= 250) could reach it. Its effect on the public
|
|
66
|
+
* examples is counted in the pull request that added it; a stack it does
|
|
67
|
+
* not touch assembles to the reference's bytes.
|
|
68
|
+
* - **Patches** (`assemble.patches`, `cutPatch`) are this port's: an extra
|
|
69
|
+
* part cut from the painting itself, for a piece of the figure no layer
|
|
70
|
+
* holds, recorded as `painting:<name>` and 100 % source.
|
|
71
|
+
* - **Cuts** (`assemble.cuts`, `cutPart`, issue #170) are this port's: a
|
|
72
|
+
* declared polygon takes a plan part's pixels out of it into a new part,
|
|
73
|
+
* recorded with the same `<run>:<tag>` (its pixels came from that layer).
|
|
74
|
+
* The cut is made after step 4, so the new part takes the merged pixels and
|
|
75
|
+
* every later step treats it as a part of its own.
|
|
76
|
+
* - **What lies under a piece** (`cuts[].under`, `fillUnder`, issue #193) is
|
|
77
|
+
* this port's: an authored image the base takes where the cut removed its
|
|
78
|
+
* pixels, so a piece that swings away leaves no hole. The stage reads that
|
|
79
|
+
* image only there and never invents a pixel.
|
|
80
|
+
*
|
|
81
|
+
* Pure: no clock, no randomness, no file access. The CLI reads and writes.
|
|
82
|
+
*/
|
|
83
|
+
import type { CharacterConfig, Cut, EarlyConfig, Extend, Patch, PlanEntry, Run, SeeThrough } from './config.ts';
|
|
84
|
+
import { type Problem, refuseIfAny } from './errors.ts';
|
|
85
|
+
import { figuresPhrase, implausibleRules, type Layer, type LayerFigures, layerFigures, type LayerSet, NEAR_WHITE_MIN, OPAQUE_ALPHA_ABOVE, ruleSummary } from './layers.ts';
|
|
86
|
+
import { squarePad } from './inputs.ts';
|
|
87
|
+
import { PAINTING_RUN, type PartRecord, type PartsFile, type RecompositeRecord } from './parts.ts';
|
|
88
|
+
import {
|
|
89
|
+
alphaComposite,
|
|
90
|
+
connectedComponents,
|
|
91
|
+
crop,
|
|
92
|
+
dilate,
|
|
93
|
+
erode,
|
|
94
|
+
fillHoles,
|
|
95
|
+
gaussianBlur,
|
|
96
|
+
type Mask,
|
|
97
|
+
morphClose,
|
|
98
|
+
newFloatImage,
|
|
99
|
+
newMask,
|
|
100
|
+
newRaster,
|
|
101
|
+
type Raster,
|
|
102
|
+
resize,
|
|
103
|
+
warpAffine,
|
|
104
|
+
} from './raster/index.ts';
|
|
105
|
+
import { pyRound } from './round.ts';
|
|
106
|
+
|
|
107
|
+
const f32 = Math.fround;
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// the reference's constants, each named once
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/** Alpha at or above this is "opaque" for ownership, projection cores and the seam override. `>= 250`, not `== 255`: See-through leaves alpha-254 speckle. */
|
|
114
|
+
export const CORE_ALPHA = 250;
|
|
115
|
+
/** A ghost component is kept when its area is at least this many pixels … */
|
|
116
|
+
export const GHOST_MIN_AREA = 40;
|
|
117
|
+
/** … and at least this fraction of the layer's largest component. */
|
|
118
|
+
export const GHOST_MIN_FRACTION = 0.01;
|
|
119
|
+
/** The side of the square erosion that makes a projection core, and of the closing that fills speckle refusals. */
|
|
120
|
+
export const CORE_KERNEL = 5;
|
|
121
|
+
/** Projection is refused where See-through's pixel and the painting's differ by more than this, max channel. */
|
|
122
|
+
export const DRIFT_LIMIT = 90;
|
|
123
|
+
/** The Gaussian sigma that feathers the projection weight. */
|
|
124
|
+
export const FEATHER_SIGMA = 1.0;
|
|
125
|
+
/** Head-run projection stays this many rig pixels inside the head crop. */
|
|
126
|
+
export const HEAD_WINDOW_INSET = 3;
|
|
127
|
+
/** The head crop's bottom line in rig pixels is `trunc(y1 * s) - HEAD_BOTTOM_INSET`. */
|
|
128
|
+
export const HEAD_BOTTOM_INSET = 4;
|
|
129
|
+
/** `belowCrop` probes the head part this many rows above the crop line … */
|
|
130
|
+
export const CROP_PROBE_ROWS = 2;
|
|
131
|
+
/** … seeds components in this many rows from the crop line down … */
|
|
132
|
+
export const CROP_SEED_ROWS = 6;
|
|
133
|
+
/** … within this many columns of a column where the head part reaches the line. */
|
|
134
|
+
export const CROP_SEED_COLUMNS = 12;
|
|
135
|
+
/** Alpha above this counts as "the head part reaches the crop line". */
|
|
136
|
+
export const CROP_PROBE_ALPHA = 128;
|
|
137
|
+
/** Alpha at or above this in a later plan part keeps `growRim`'s ring off the pixel. */
|
|
138
|
+
export const FRONT_ALPHA = 128;
|
|
139
|
+
/** A painting pixel whose min channel is above this is "near-white" (the faithful rim and seam guard). Defined beside the layer reader, which applies the same test to a layer's colour (`layerFigures`). */
|
|
140
|
+
export { NEAR_WHITE_MIN };
|
|
141
|
+
/** The seam override acts where the flat composite differs from the painting by more than this, max channel. */
|
|
142
|
+
export const SEAM_LIMIT = 60;
|
|
143
|
+
/** `--propose-plan` drops a tag with fewer run-space opaque pixels than this after ghost clean-up. */
|
|
144
|
+
export const PROPOSE_MIN_PX = 150;
|
|
145
|
+
/** The recomposite's "error pixel": max-channel difference from the painting above this. */
|
|
146
|
+
export const ERROR_LIMIT = 40;
|
|
147
|
+
/** The recomposite's "within" figure: mean-channel difference at or below this. */
|
|
148
|
+
export const WITHIN_LIMIT = 8;
|
|
149
|
+
/**
|
|
150
|
+
* An error pixel is "uncovered" when no part has alpha above this there. The
|
|
151
|
+
* definition is the one the reference's own demo report measured with, and it
|
|
152
|
+
* reproduces that report's figure exactly (1,564 on the reference's demo
|
|
153
|
+
* output; with "no part has alpha above 0" the same output reads 105).
|
|
154
|
+
*/
|
|
155
|
+
export const COVERED_ALPHA = 128;
|
|
156
|
+
/** `figureSilhouette`: the border ring whose median colour is the background, in rig pixels. */
|
|
157
|
+
export const SILHOUETTE_BORDER = 8;
|
|
158
|
+
/** `figureSilhouette`: a pixel is figure where it differs from that median by more than this, max channel. */
|
|
159
|
+
export const SILHOUETTE_LIMIT = 40;
|
|
160
|
+
|
|
161
|
+
export type SeamRule = 'near-white' | 'silhouette';
|
|
162
|
+
export const SEAM_RULES: readonly SeamRule[] = ['near-white', 'silhouette'];
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The seam rule a run uses when none is named. Set by measurement, not taste:
|
|
166
|
+
* the silhouette rule ships as the default only if it lowers the error pixel
|
|
167
|
+
* count on every oracle character without raising mean |d| on any
|
|
168
|
+
* (ORACLE_assemble.md, *faithful vs silhouette*).
|
|
169
|
+
*/
|
|
170
|
+
export const DEFAULT_SEAM_RULE: SeamRule = 'near-white';
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Where a layer may take the painting's pixel (`projectSource`). `core` is the
|
|
174
|
+
* reference's rule: the layer's top-most `alpha >= 250` pixels eroded by a 5x5
|
|
175
|
+
* square, so a part a few pixels wide has no core and none of its visible
|
|
176
|
+
* pixels is projected. `visible` keeps the erosion only where it guards
|
|
177
|
+
* something — along a rim where a later layer of the run is in front — and
|
|
178
|
+
* projects every other top-most `alpha >= 250` pixel of the layer.
|
|
179
|
+
*/
|
|
180
|
+
export type ProjectRule = 'core' | 'visible';
|
|
181
|
+
export const PROJECT_RULES: readonly ProjectRule[] = ['core', 'visible'];
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The projection rule a run uses when none is named: the reference's, so the
|
|
185
|
+
* examples stay comparable with it. `visible` was measured on both public
|
|
186
|
+
* examples against it (the change that added the flag); it is opt-in.
|
|
187
|
+
*/
|
|
188
|
+
export const DEFAULT_PROJECT_RULE: ProjectRule = 'core';
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// inputs
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
export interface Geometry {
|
|
195
|
+
/** The painting's size in source pixels. */
|
|
196
|
+
sourceW: number;
|
|
197
|
+
sourceH: number;
|
|
198
|
+
/** `seethrough.resolution`: both runs' canvas side. */
|
|
199
|
+
resolution: number;
|
|
200
|
+
/** `seethrough.head_box`, source pixels. */
|
|
201
|
+
headBox: [number, number, number, number];
|
|
202
|
+
/** `assemble.rig_scale`: rig pixels per source pixel. */
|
|
203
|
+
rigScale: number;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface AssembleInput {
|
|
207
|
+
/** The painting. Its alpha is ignored, as the reference's `convert("RGB")` drops it. */
|
|
208
|
+
source: Raster;
|
|
209
|
+
full: LayerSet;
|
|
210
|
+
head: LayerSet;
|
|
211
|
+
resolution: number;
|
|
212
|
+
headBox: [number, number, number, number];
|
|
213
|
+
rigScale: number;
|
|
214
|
+
plan: PlanEntry[];
|
|
215
|
+
extend: Extend[];
|
|
216
|
+
/** `assemble.patches`: extra parts cut from the painting, placed per their `draw` (`placePatches`). */
|
|
217
|
+
patches: Patch[];
|
|
218
|
+
/** `assemble.cuts`: plan parts' pixels taken into new parts by polygon (`cutPart`), placed per their `draw`. */
|
|
219
|
+
cuts: Cut[];
|
|
220
|
+
/**
|
|
221
|
+
* `cuts[i].under`'s image, decoded by the caller (the stage reads no file),
|
|
222
|
+
* at the cut's index: null for a cut without `under`. Absent = every cut
|
|
223
|
+
* without one. A cut that names `under` with no image here is refused
|
|
224
|
+
* (`ASSEMBLE_UNDER_PRESENT`), never assembled as if it named none.
|
|
225
|
+
*/
|
|
226
|
+
under?: ReadonlyArray<Raster | null>;
|
|
227
|
+
seamRule: SeamRule;
|
|
228
|
+
projectRule: ProjectRule;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The derived numbers of the two runs' geometry. */
|
|
232
|
+
interface Frame {
|
|
233
|
+
W: number;
|
|
234
|
+
H: number;
|
|
235
|
+
S: number;
|
|
236
|
+
/** Full run: the painting was centred on a white `side x side` square (`squarePad`), padded this much on the left… */
|
|
237
|
+
fullPad: number;
|
|
238
|
+
/** …and this much on top, in source px. At most one of the two is non-zero. */
|
|
239
|
+
fullPadTop: number;
|
|
240
|
+
fullK: number;
|
|
241
|
+
headBox: [number, number, number, number];
|
|
242
|
+
headK: number;
|
|
243
|
+
/** The crop line in rig pixels. */
|
|
244
|
+
headBottom: number;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Check the geometry and derive the frame, refusing by name what the
|
|
249
|
+
* reference would have mis-read. Every problem is collected.
|
|
250
|
+
*/
|
|
251
|
+
export function checkGeometry(g: Geometry, runs?: { full: LayerSet; head: LayerSet }): Frame {
|
|
252
|
+
const problems: Problem[] = [];
|
|
253
|
+
const fail = (code: string, object: string, detail: string): void => {
|
|
254
|
+
problems.push({ code, object, detail });
|
|
255
|
+
};
|
|
256
|
+
const { sourceW: w, sourceH: h, resolution, headBox, rigScale: S } = g;
|
|
257
|
+
const pad = squarePad(w, h);
|
|
258
|
+
const W = Math.trunc(w * S);
|
|
259
|
+
const H = Math.trunc(h * S);
|
|
260
|
+
if (W < 1 || H < 1) fail('ASSEMBLE_RIG_SIZE', 'config.assemble.rig_scale', `${S} makes a ${W}x${H} rig from a ${w}x${h} painting; at least 1x1 is required`);
|
|
261
|
+
const [x0, y0, x1, y1] = headBox;
|
|
262
|
+
if (!(x0 >= 0 && y0 >= 0 && x1 <= w && y1 <= h && x1 > x0 && y1 > y0)) {
|
|
263
|
+
fail('ASSEMBLE_HEAD_BOX_INSIDE', 'config.seethrough.head_box', `is [${headBox.join(', ')}]; a non-empty box inside the ${w}x${h} painting is required`);
|
|
264
|
+
}
|
|
265
|
+
const headBottom = Math.trunc(y1 * S) - HEAD_BOTTOM_INSET;
|
|
266
|
+
if (headBottom - CROP_PROBE_ROWS < 0 && y1 > y0) {
|
|
267
|
+
fail(
|
|
268
|
+
'ASSEMBLE_HEAD_BOX_INSIDE',
|
|
269
|
+
'config.seethrough.head_box',
|
|
270
|
+
`bottom ${y1} is rig row ${Math.trunc(y1 * S)}; the crop line (that row - ${HEAD_BOTTOM_INSET}) probes ${CROP_PROBE_ROWS} rows above itself, so a bottom at rig row ${HEAD_BOTTOM_INSET + CROP_PROBE_ROWS} or further down is required`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (runs !== undefined) {
|
|
274
|
+
for (const run of ['full', 'head'] as const) {
|
|
275
|
+
const c = runs[run].canvas;
|
|
276
|
+
if (c.w !== resolution || c.h !== resolution) {
|
|
277
|
+
fail('ASSEMBLE_RUN_CANVAS', `the ${run} run (${runs[run].source})`, `has a ${c.w}x${c.h} canvas; config.seethrough.resolution ${resolution} makes ${resolution}x${resolution} required`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
refuseIfAny(problems);
|
|
282
|
+
return {
|
|
283
|
+
W,
|
|
284
|
+
H,
|
|
285
|
+
S,
|
|
286
|
+
fullPad: pad.left,
|
|
287
|
+
fullPadTop: pad.top,
|
|
288
|
+
fullK: pad.side / resolution,
|
|
289
|
+
headBox,
|
|
290
|
+
headK: (x1 - x0) / resolution,
|
|
291
|
+
headBottom,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// step 1 — ghost clean-up
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Place a layer's pixels on its run's `resolution x resolution` canvas.
|
|
301
|
+
* `readWrapperLayers`/`readPsdLayers` already refused a box outside it.
|
|
302
|
+
*/
|
|
303
|
+
export function placeLayer(layer: Layer, resolution: number): Raster {
|
|
304
|
+
const out = newRaster(resolution, resolution);
|
|
305
|
+
const p = layer.pixels;
|
|
306
|
+
for (let y = 0; y < p.height; y++) {
|
|
307
|
+
out.data.set(p.data.subarray(y * p.width * 4, (y + 1) * p.width * 4), ((layer.top + y) * resolution + layer.left) * 4);
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Step 1. See-through writes every tag, and an empty tag still carries a few
|
|
314
|
+
* dozen sub-threshold pixels scattered at the canvas edge.
|
|
315
|
+
*
|
|
316
|
+
* ⚖️ Invariant: afterwards the layer's alpha is non-zero only on the 8-connected
|
|
317
|
+
* components of `alpha > 8` whose area is at least `GHOST_MIN_AREA` and at
|
|
318
|
+
* least `GHOST_MIN_FRACTION` of the largest component's; every other pixel —
|
|
319
|
+
* including every pixel of alpha 8 or less — has alpha 0. Colour channels are
|
|
320
|
+
* untouched. `ghostPx` counts the `alpha > 8` pixels removed. A layer with no
|
|
321
|
+
* `alpha > 8` pixel comes back fully transparent with `ghostPx` 0.
|
|
322
|
+
* (`assemble_parts.py` `clean`.)
|
|
323
|
+
*/
|
|
324
|
+
export function cleanGhosts(canvas: Raster): { canvas: Raster; ghostPx: number } {
|
|
325
|
+
const { width: w, height: h } = canvas;
|
|
326
|
+
const out: Raster = { width: w, height: h, data: new Uint8ClampedArray(canvas.data) };
|
|
327
|
+
const m = newMask(w, h);
|
|
328
|
+
let any = false;
|
|
329
|
+
for (let i = 0; i < w * h; i++) {
|
|
330
|
+
if (canvas.data[i * 4 + 3] > OPAQUE_ALPHA_ABOVE) {
|
|
331
|
+
m.data[i] = 1;
|
|
332
|
+
any = true;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (!any) {
|
|
336
|
+
for (let i = 0; i < w * h; i++) out.data[i * 4 + 3] = 0;
|
|
337
|
+
return { canvas: out, ghostPx: 0 };
|
|
338
|
+
}
|
|
339
|
+
const cc = connectedComponents(m, 8);
|
|
340
|
+
let big = 0;
|
|
341
|
+
for (let l = 1; l < cc.count; l++) big = Math.max(big, cc.stats[l].area);
|
|
342
|
+
const keep = new Uint8Array(cc.count);
|
|
343
|
+
for (let l = 1; l < cc.count; l++) keep[l] = cc.stats[l].area >= Math.max(GHOST_MIN_AREA, GHOST_MIN_FRACTION * big) ? 1 : 0;
|
|
344
|
+
let ghostPx = 0;
|
|
345
|
+
for (let i = 0; i < w * h; i++) {
|
|
346
|
+
const l = cc.labels[i];
|
|
347
|
+
if (keep[l] === 0) {
|
|
348
|
+
if (m.data[i] === 1) ghostPx++;
|
|
349
|
+
out.data[i * 4 + 3] = 0;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return { canvas: out, ghostPx };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export interface RunLayer {
|
|
356
|
+
tag: string;
|
|
357
|
+
/** The cleaned run-space canvas. */
|
|
358
|
+
canvas: Raster;
|
|
359
|
+
drawOrder: number;
|
|
360
|
+
ghostPx: number;
|
|
361
|
+
/** `alpha > 8` pixels after clean-up, in run pixels. */
|
|
362
|
+
opaqueRunPx: number;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Every layer of a run, placed and cleaned, in draw order (back to front). */
|
|
366
|
+
export function runLayers(set: LayerSet, resolution: number): RunLayer[] {
|
|
367
|
+
return set.layers.map((layer) => {
|
|
368
|
+
const { canvas, ghostPx } = cleanGhosts(placeLayer(layer, resolution));
|
|
369
|
+
let opaqueRunPx = 0;
|
|
370
|
+
for (let i = 3; i < canvas.data.length; i += 4) if (canvas.data[i] > OPAQUE_ALPHA_ABOVE) opaqueRunPx++;
|
|
371
|
+
return { tag: layer.name, canvas, drawOrder: layer.drawOrder, ghostPx, opaqueRunPx };
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// step 2 — resample into rig space
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
/** The run-to-rig map: `rig = k * run + (tx, ty)`, in float32 as the reference's `np.float32` matrix holds it. */
|
|
380
|
+
export function runMap(frame: Frame, run: Run): { k: number; sx: number; tx: number; ty: number } {
|
|
381
|
+
if (run === 'full') {
|
|
382
|
+
const k = frame.fullK * frame.S;
|
|
383
|
+
// A portrait painting's top pad is 0, and its ty is the reference's literal 0 rather than f32(-0).
|
|
384
|
+
return { k, sx: f32(k), tx: f32(-frame.fullPad * frame.S), ty: frame.fullPadTop === 0 ? 0 : f32(-frame.fullPadTop * frame.S) };
|
|
385
|
+
}
|
|
386
|
+
const k = frame.headK * frame.S;
|
|
387
|
+
return { k, sx: f32(k), tx: f32(frame.headBox[0] * frame.S), ty: f32(frame.headBox[1] * frame.S) };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Step 2. Warp a run-space layer into the `W x H` rig, premultiplied.
|
|
392
|
+
*
|
|
393
|
+
* ⚖️ Invariant: the result is `cv2.warpAffine` of the premultiplied layer
|
|
394
|
+
* through `runMap` — bicubic when the map enlarges (`k >= 1`), and the
|
|
395
|
+
* reference's `INTER_AREA` otherwise, which `warpAffine` answers with its
|
|
396
|
+
* bilinear filter (`src/raster/warp.ts` measured that) — clipped to 0..255,
|
|
397
|
+
* un-premultiplied, and truncated to 8 bits. Premultiplying and
|
|
398
|
+
* un-premultiplying are done in float32, as numpy does them on a float32
|
|
399
|
+
* array. A layer with no non-zero alpha maps to a fully transparent rig layer
|
|
400
|
+
* without being warped: every tap would read 0.
|
|
401
|
+
* (`assemble_parts.py` `to_rig`.)
|
|
402
|
+
*/
|
|
403
|
+
export function layerToRig(canvas: Raster, frame: Frame, run: Run): Raster {
|
|
404
|
+
const { W, H } = frame;
|
|
405
|
+
const n = canvas.width * canvas.height;
|
|
406
|
+
let any = false;
|
|
407
|
+
for (let i = 0; i < n && !any; i++) if (canvas.data[i * 4 + 3] !== 0) any = true;
|
|
408
|
+
if (!any) return newRaster(W, H);
|
|
409
|
+
const pre = newFloatImage(canvas.width, canvas.height, 4);
|
|
410
|
+
for (let i = 0; i < n; i++) {
|
|
411
|
+
const a = canvas.data[i * 4 + 3];
|
|
412
|
+
const af = f32(a / 255);
|
|
413
|
+
for (let c = 0; c < 3; c++) pre.data[i * 4 + c] = f32(canvas.data[i * 4 + c] * af);
|
|
414
|
+
pre.data[i * 4 + 3] = a;
|
|
415
|
+
}
|
|
416
|
+
const m = runMap(frame, run);
|
|
417
|
+
const wr = warpAffine(pre, { sx: m.sx, sy: m.sx, tx: m.tx, ty: m.ty }, W, H, m.k < 1 ? 'bilinear' : 'bicubic');
|
|
418
|
+
const out = newRaster(W, H);
|
|
419
|
+
const eps = f32(1e-3);
|
|
420
|
+
const clip = (v: number): number => (v < 0 ? 0 : v > 255 ? 255 : v);
|
|
421
|
+
for (let i = 0; i < W * H; i++) {
|
|
422
|
+
const a = f32(clip(wr.data[i * 4 + 3]));
|
|
423
|
+
for (let c = 0; c < 3; c++) {
|
|
424
|
+
const v = f32(clip(wr.data[i * 4 + c]));
|
|
425
|
+
out.data[i * 4 + c] = a > 0 ? Math.trunc(clip(f32(f32(v * 255) / Math.max(a, eps)))) : 0;
|
|
426
|
+
}
|
|
427
|
+
out.data[i * 4 + 3] = Math.trunc(a);
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The painting in rig space: `PIL.Image.resize(..., LANCZOS)` of its RGB, as
|
|
434
|
+
* an opaque raster. (`src/raster/resize.ts` `lanczos3` is PIL's, bit-exact;
|
|
435
|
+
* with alpha 255 its premultiply round trip is the identity.)
|
|
436
|
+
*/
|
|
437
|
+
export function sourceInRig(source: Raster, W: number, H: number): Raster {
|
|
438
|
+
const rgb: Raster = { width: source.width, height: source.height, data: new Uint8ClampedArray(source.data) };
|
|
439
|
+
for (let i = 3; i < rgb.data.length; i += 4) rgb.data[i] = 255;
|
|
440
|
+
return resize(rgb, W, H, 'lanczos3');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// step 3 — source projection
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
export interface ProjectionStats {
|
|
448
|
+
core: number;
|
|
449
|
+
taken: number;
|
|
450
|
+
refused: number;
|
|
451
|
+
/**
|
|
452
|
+
* Where no later layer of the run is opaque (`alpha >= 250`) in front of
|
|
453
|
+
* this one: the run's owner of the pixel is this layer or one behind it. A
|
|
454
|
+
* pixel of this layer with alpha above 8 is VISIBLE exactly where this is
|
|
455
|
+
* set, and OCCLUDED everywhere else.
|
|
456
|
+
*/
|
|
457
|
+
clear: Mask;
|
|
458
|
+
/** The pixels counted in `taken`: the accepted set, cut to the core. */
|
|
459
|
+
takenMask: Mask;
|
|
460
|
+
/** The pixels counted in `core`, and in `refused`; null when the layer has no top-most pixel (both counts 0). A cut splits the counts by these. */
|
|
461
|
+
coreMask: Mask | null;
|
|
462
|
+
refusedMask: Mask | null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function maxDiff(a: Uint8ClampedArray, i: number, b: Uint8ClampedArray, j: number): number {
|
|
466
|
+
return Math.max(Math.abs(a[i] - b[j]), Math.abs(a[i + 1] - b[j + 1]), Math.abs(a[i + 2] - b[j + 2]));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Step 3, over one run's layers in draw order. Modifies each rig layer's
|
|
471
|
+
* colour in place and returns its counts.
|
|
472
|
+
*
|
|
473
|
+
* ⚖️ Invariant: a layer's CORE is where it is the top-most `alpha >= 250`
|
|
474
|
+
* layer of its own run, eroded by a 5x5 square. Inside the core, the pixels
|
|
475
|
+
* that agree with the painting (max channel difference <= `DRIFT_LIMIT`),
|
|
476
|
+
* closed by 5x5 and cut back to the core — and, for the head run, kept
|
|
477
|
+
* `HEAD_WINDOW_INSET` pixels inside the head crop — are the ACCEPTED set. The
|
|
478
|
+
* colour becomes `w * painting + (1 - w) * layer`, truncated, with `w` the
|
|
479
|
+
* accepted set blurred by a Gaussian of sigma 1 and zeroed outside the core;
|
|
480
|
+
* outside the core nothing changes, alpha never changes, and occluded pixels
|
|
481
|
+
* keep See-through's synthesis. `core`, `taken` (accepted) and `refused` (core
|
|
482
|
+
* pixels over the drift limit, before closing) are counted.
|
|
483
|
+
* (`assemble_parts.py` `main`, the per-run loop.)
|
|
484
|
+
*
|
|
485
|
+
* With `rule: 'visible'` (NOT in the reference) the core is the top-most
|
|
486
|
+
* `alpha >= 250` set less every pixel within the 5x5 square of a pixel where a
|
|
487
|
+
* LATER layer of the run is the owner — the erosion kept only along a rim with
|
|
488
|
+
* a neighbour in front, where the painting's pixel may be that neighbour's
|
|
489
|
+
* edge. Every other step is the same. Fringe pixels (alpha below 250) are not
|
|
490
|
+
* projected under either rule: the painting there is this layer blended with
|
|
491
|
+
* what is behind it.
|
|
492
|
+
*
|
|
493
|
+
* Both rules return `clear` (no later layer is the owner) and `takenMask`, the
|
|
494
|
+
* masks the part counts in `parts.json` are derived from; `takenMask` is a
|
|
495
|
+
* subset of the top-most set, and so of `clear`.
|
|
496
|
+
*/
|
|
497
|
+
export function projectSource(rigLayers: Raster[], srcr: Raster, frame: Frame, run: Run, rule: ProjectRule = DEFAULT_PROJECT_RULE): ProjectionStats[] {
|
|
498
|
+
const { W, H } = frame;
|
|
499
|
+
const owner = new Int32Array(W * H).fill(-1);
|
|
500
|
+
rigLayers.forEach((r, i) => {
|
|
501
|
+
for (let p = 0; p < W * H; p++) if (r.data[p * 4 + 3] >= CORE_ALPHA) owner[p] = i;
|
|
502
|
+
});
|
|
503
|
+
let win: Mask | null = null;
|
|
504
|
+
if (run === 'head') {
|
|
505
|
+
win = newMask(W, H);
|
|
506
|
+
const [bx0, by0, bx1, by1] = frame.headBox;
|
|
507
|
+
const x0 = Math.max(0, Math.trunc(bx0 * frame.S) + HEAD_WINDOW_INSET);
|
|
508
|
+
const y0 = Math.max(0, Math.trunc(by0 * frame.S) + HEAD_WINDOW_INSET);
|
|
509
|
+
const x1 = Math.min(W, Math.trunc(bx1 * frame.S) - HEAD_WINDOW_INSET);
|
|
510
|
+
const y1 = Math.min(H, Math.trunc(by1 * frame.S) - HEAD_WINDOW_INSET);
|
|
511
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) win.data[y * W + x] = 1;
|
|
512
|
+
}
|
|
513
|
+
return rigLayers.map((r, i) => {
|
|
514
|
+
const top = newMask(W, H);
|
|
515
|
+
const clear = newMask(W, H);
|
|
516
|
+
const takenMask = newMask(W, H);
|
|
517
|
+
let anyTop = false;
|
|
518
|
+
for (let p = 0; p < W * H; p++) {
|
|
519
|
+
if (owner[p] <= i) clear.data[p] = 1;
|
|
520
|
+
if (owner[p] === i && r.data[p * 4 + 3] >= CORE_ALPHA) {
|
|
521
|
+
top.data[p] = 1;
|
|
522
|
+
anyTop = true;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (!anyTop) return { core: 0, taken: 0, refused: 0, clear, takenMask, coreMask: null, refusedMask: null };
|
|
526
|
+
let core: Mask;
|
|
527
|
+
if (rule === 'core') core = erode(top, CORE_KERNEL);
|
|
528
|
+
else {
|
|
529
|
+
const front = newMask(W, H);
|
|
530
|
+
for (let p = 0; p < W * H; p++) if (owner[p] > i) front.data[p] = 1;
|
|
531
|
+
const rim = dilate(front, CORE_KERNEL);
|
|
532
|
+
core = newMask(W, H);
|
|
533
|
+
for (let p = 0; p < W * H; p++) if (top.data[p] === 1 && rim.data[p] === 0) core.data[p] = 1;
|
|
534
|
+
}
|
|
535
|
+
const ok = newMask(W, H);
|
|
536
|
+
const refusedMask = newMask(W, H);
|
|
537
|
+
let coreN = 0;
|
|
538
|
+
let refused = 0;
|
|
539
|
+
for (let p = 0; p < W * H; p++) {
|
|
540
|
+
if (core.data[p] === 0) continue;
|
|
541
|
+
coreN++;
|
|
542
|
+
if (maxDiff(r.data, p * 4, srcr.data, p * 4) <= DRIFT_LIMIT) ok.data[p] = 1;
|
|
543
|
+
else {
|
|
544
|
+
refused++;
|
|
545
|
+
refusedMask.data[p] = 1;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const closed = morphClose(ok, CORE_KERNEL);
|
|
549
|
+
const okf = newFloatImage(W, H, 1);
|
|
550
|
+
let taken = 0;
|
|
551
|
+
for (let p = 0; p < W * H; p++) {
|
|
552
|
+
const v = closed.data[p] === 1 && core.data[p] === 1 && (win === null || win.data[p] === 1) ? 1 : 0;
|
|
553
|
+
okf.data[p] = v;
|
|
554
|
+
takenMask.data[p] = v;
|
|
555
|
+
taken += v;
|
|
556
|
+
}
|
|
557
|
+
const blurred = gaussianBlur(okf, FEATHER_SIGMA);
|
|
558
|
+
for (let p = 0; p < W * H; p++) {
|
|
559
|
+
if (core.data[p] === 0) continue;
|
|
560
|
+
const w = blurred.data[p];
|
|
561
|
+
const rest = f32(1 - w);
|
|
562
|
+
for (let c = 0; c < 3; c++) {
|
|
563
|
+
const v = f32(f32(w * srcr.data[p * 4 + c]) + f32(rest * r.data[p * 4 + c]));
|
|
564
|
+
r.data[p * 4 + c] = Math.trunc(v < 0 ? 0 : v > 255 ? 255 : v);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return { core: coreN, taken, refused, clear, takenMask, coreMask: core, refusedMask };
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
// step 4 — merge below the head crop
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* The pixels of `extra` (a full-run layer in rig space) that continue `part`
|
|
577
|
+
* below the crop line.
|
|
578
|
+
*
|
|
579
|
+
* ⚖️ Invariant: the result is every 8-connected component of `extra`'s
|
|
580
|
+
* `alpha > 8` pixels at or below row `headBottom` that has a pixel in rows
|
|
581
|
+
* `headBottom .. headBottom + 5` within `CROP_SEED_COLUMNS` columns of a column
|
|
582
|
+
* where `part` has alpha above 128 on row `headBottom - 2`. Whole components,
|
|
583
|
+
* so a long lock keeps its own outline to the tip. Empty when `part` does not
|
|
584
|
+
* reach the line or `extra` has nothing below it.
|
|
585
|
+
* (`assemble_parts.py` `below_crop`.)
|
|
586
|
+
*/
|
|
587
|
+
export function belowCrop(part: Raster, extra: Raster, headBottom: number): Mask {
|
|
588
|
+
const { width: W, height: H } = part;
|
|
589
|
+
const sel = newMask(W, H);
|
|
590
|
+
const below = newMask(W, H);
|
|
591
|
+
let anyBelow = false;
|
|
592
|
+
for (let y = Math.max(0, headBottom); y < H; y++) {
|
|
593
|
+
for (let x = 0; x < W; x++) {
|
|
594
|
+
if (extra.data[(y * W + x) * 4 + 3] > OPAQUE_ALPHA_ABOVE) {
|
|
595
|
+
below.data[y * W + x] = 1;
|
|
596
|
+
anyBelow = true;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const probe = headBottom - CROP_PROBE_ROWS;
|
|
601
|
+
const near = new Uint8Array(W);
|
|
602
|
+
let anyCol = false;
|
|
603
|
+
for (let x = 0; x < W; x++) {
|
|
604
|
+
if (part.data[(probe * W + x) * 4 + 3] > CROP_PROBE_ALPHA) {
|
|
605
|
+
anyCol = true;
|
|
606
|
+
for (let c = Math.max(0, x - CROP_SEED_COLUMNS); c <= Math.min(W - 1, x + CROP_SEED_COLUMNS); c++) near[c] = 1;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (!anyCol || !anyBelow) return sel;
|
|
610
|
+
const cc = connectedComponents(below, 8);
|
|
611
|
+
const chosen = new Uint8Array(cc.count);
|
|
612
|
+
for (let y = Math.max(0, headBottom); y < Math.min(H, headBottom + CROP_SEED_ROWS); y++) {
|
|
613
|
+
for (let x = 0; x < W; x++) {
|
|
614
|
+
const l = cc.labels[y * W + x];
|
|
615
|
+
if (l > 0 && near[x] === 1) chosen[l] = 1;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
for (let p = 0; p < W * H; p++) if (chosen[cc.labels[p]] === 1 && extra.data[p * 4 + 3] > 0) sel.data[p] = 1;
|
|
619
|
+
return sel;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Close the half-pixel seam round a merged-in piece: the full run's silhouette
|
|
624
|
+
* is a few pixels narrower than the painting, and a renderer's sub-pixel
|
|
625
|
+
* resample shows the layer beneath along that edge.
|
|
626
|
+
*
|
|
627
|
+
* ⚖️ Invariant: the RING is the 3x3 dilation of `sel`'s `alpha >= 250` pixels,
|
|
628
|
+
* restricted to pixels where `part` has alpha below 250, the painting is not
|
|
629
|
+
* near-white (min channel <= 235) and no later plan part covers
|
|
630
|
+
* (`front`, alpha >= 128). Every ring pixel takes the painting's colour at
|
|
631
|
+
* alpha 255, so the flat composite there equals the painting; the count is
|
|
632
|
+
* returned. Nothing changes when `sel` is empty. When `touched` is given,
|
|
633
|
+
* every ring pixel written is set in it.
|
|
634
|
+
* (`assemble_parts.py` `grow_rim`.)
|
|
635
|
+
*/
|
|
636
|
+
export function growRim(part: Raster, sel: Mask, srcr: Raster, front: Mask | null, touched?: Mask): number {
|
|
637
|
+
const { width: W, height: H } = part;
|
|
638
|
+
const core = newMask(W, H);
|
|
639
|
+
let anySel = false;
|
|
640
|
+
for (let p = 0; p < W * H; p++) {
|
|
641
|
+
if (sel.data[p] === 1) {
|
|
642
|
+
anySel = true;
|
|
643
|
+
if (part.data[p * 4 + 3] >= CORE_ALPHA) core.data[p] = 1;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (!anySel) return 0;
|
|
647
|
+
const ring = dilate(core, 3);
|
|
648
|
+
let grown = 0;
|
|
649
|
+
for (let p = 0; p < W * H; p++) {
|
|
650
|
+
if (ring.data[p] === 0 || part.data[p * 4 + 3] >= CORE_ALPHA) continue;
|
|
651
|
+
const s = p * 4;
|
|
652
|
+
if (Math.min(srcr.data[s], srcr.data[s + 1], srcr.data[s + 2]) > NEAR_WHITE_MIN) continue;
|
|
653
|
+
if (front !== null && front.data[p] === 1) continue;
|
|
654
|
+
part.data[s] = srcr.data[s];
|
|
655
|
+
part.data[s + 1] = srcr.data[s + 1];
|
|
656
|
+
part.data[s + 2] = srcr.data[s + 2];
|
|
657
|
+
part.data[s + 3] = 255;
|
|
658
|
+
if (touched !== undefined) touched.data[p] = 1;
|
|
659
|
+
grown++;
|
|
660
|
+
}
|
|
661
|
+
return grown;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Step 4 for one part. Modifies `part` in place and returns `merged_px`.
|
|
666
|
+
*
|
|
667
|
+
* ⚖️ Invariant: for each `extend_below_crop` entry naming this part, the
|
|
668
|
+
* `belowCrop` pixels are copied from the extend layer (colour and alpha), then
|
|
669
|
+
* `growRim` closes their seam against the later plan parts. `merged_px` is the
|
|
670
|
+
* copied pixels plus the ring of the LAST such entry — the reference assigns
|
|
671
|
+
* rather than adds, and that is kept (a part with one entry, the only case the
|
|
672
|
+
* reference's corpus has, is unaffected).
|
|
673
|
+
*
|
|
674
|
+
* When `trace` is given, every pixel an entry wrote (copied or ring) has
|
|
675
|
+
* `trace.from` set to that entry's index in `extras`, a later entry
|
|
676
|
+
* overwriting an earlier one — the layer whose run says whether the pixel is
|
|
677
|
+
* visible — and `trace.ring` set when the last write was the ring's.
|
|
678
|
+
*/
|
|
679
|
+
export function mergeBelowCrop(part: Raster, extras: Raster[], front: Mask, srcr: Raster, headBottom: number, trace?: MergeTrace): number {
|
|
680
|
+
let merged = 0;
|
|
681
|
+
extras.forEach((extra, e) => {
|
|
682
|
+
const sel = belowCrop(part, extra, headBottom);
|
|
683
|
+
let copied = 0;
|
|
684
|
+
for (let p = 0; p < sel.data.length; p++) {
|
|
685
|
+
if (sel.data[p] === 0) continue;
|
|
686
|
+
copied++;
|
|
687
|
+
part.data.set(extra.data.subarray(p * 4, p * 4 + 4), p * 4);
|
|
688
|
+
if (trace !== undefined) {
|
|
689
|
+
trace.from[p] = e;
|
|
690
|
+
trace.ring.data[p] = 0;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const ring = newMask(part.width, part.height);
|
|
694
|
+
merged = copied + growRim(part, sel, srcr, front, ring);
|
|
695
|
+
if (trace !== undefined) {
|
|
696
|
+
trace.lastSel = sel;
|
|
697
|
+
trace.lastRing = ring;
|
|
698
|
+
for (let p = 0; p < ring.data.length; p++) {
|
|
699
|
+
if (ring.data[p] === 1) {
|
|
700
|
+
trace.from[p] = e;
|
|
701
|
+
trace.ring.data[p] = 1;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
return merged;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** Which extend entry last wrote each pixel (-1: none), and whether that write was the ring's. */
|
|
710
|
+
export interface MergeTrace {
|
|
711
|
+
from: Int32Array;
|
|
712
|
+
ring: Mask;
|
|
713
|
+
/**
|
|
714
|
+
* The last entry's copied pixels and its ring: `merged_px` is their two
|
|
715
|
+
* counts added (the reference assigns per entry), so a cut splits it by
|
|
716
|
+
* where these lie. Absent when no entry ran.
|
|
717
|
+
*/
|
|
718
|
+
lastSel?: Mask;
|
|
719
|
+
lastRing?: Mask;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export interface VisibilityCounts {
|
|
723
|
+
visible_px: number;
|
|
724
|
+
occluded_px: number;
|
|
725
|
+
visible_not_projected_px: number;
|
|
726
|
+
/** The part's projected pixels, the part layer's and any a merge copied in. */
|
|
727
|
+
projected: number;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* The visibility counts of one part, from the masks its pixels were made
|
|
732
|
+
* with, refusing — as a bug, not a report — when they do not add up.
|
|
733
|
+
*
|
|
734
|
+
* ⚖️ Invariant: `opaque` is the part's `alpha > 8` pixels; `visible` the pixels
|
|
735
|
+
* set in `visible`; `occluded` the opaque pixels NOT set in it; `projected` the
|
|
736
|
+
* pixels set in `projected`. Required: `visible + occluded = opaque` (so every
|
|
737
|
+
* visible pixel is opaque) and every projected pixel is visible;
|
|
738
|
+
* `visible_not_projected = visible - projected`. The assembler builds
|
|
739
|
+
* `visible` as the opaque pixels no later layer of their run is in front of,
|
|
740
|
+
* and `projected` as the accepted set of `projectSource`, so both hold by
|
|
741
|
+
* construction and a refusal here names a mask that escaped its definition.
|
|
742
|
+
*/
|
|
743
|
+
export function visibilityCounts(object: string, part: Raster, visible: Mask, projected: Mask): VisibilityCounts {
|
|
744
|
+
const n = part.width * part.height;
|
|
745
|
+
let opaque = 0;
|
|
746
|
+
let vis = 0;
|
|
747
|
+
let occ = 0;
|
|
748
|
+
let proj = 0;
|
|
749
|
+
let projHidden = 0;
|
|
750
|
+
for (let p = 0; p < n; p++) {
|
|
751
|
+
const o = part.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE;
|
|
752
|
+
if (o) opaque++;
|
|
753
|
+
if (visible.data[p] === 1) vis++;
|
|
754
|
+
else if (o) occ++;
|
|
755
|
+
if (projected.data[p] === 1) {
|
|
756
|
+
proj++;
|
|
757
|
+
if (visible.data[p] !== 1) projHidden++;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
const problems: Problem[] = [];
|
|
761
|
+
if (vis + occ !== opaque) {
|
|
762
|
+
problems.push({
|
|
763
|
+
code: 'ASSEMBLE_COUNTS_ADD_UP',
|
|
764
|
+
object,
|
|
765
|
+
detail: `visible ${vis} + occluded ${occ} = ${vis + occ}, opaque (alpha above ${OPAQUE_ALPHA_ABOVE}) ${opaque}; equal is required — the visible mask holds ${vis + occ - opaque} pixel(s) that are not opaque (a bug in the assembler, not in the inputs)`,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
if (projHidden > 0) {
|
|
769
|
+
problems.push({
|
|
770
|
+
code: 'ASSEMBLE_COUNTS_ADD_UP',
|
|
771
|
+
object,
|
|
772
|
+
detail: `${projHidden} of ${proj} projected pixel(s) are not visible; projected <= visible, pixel by pixel, is required — a pixel a later layer covers was taken from the painting (a bug in the assembler, not in the inputs)`,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
refuseIfAny(problems);
|
|
776
|
+
return { visible_px: vis, occluded_px: occ, visible_not_projected_px: vis - proj, projected: proj };
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ---------------------------------------------------------------------------
|
|
780
|
+
// step 5 — seam override
|
|
781
|
+
// ---------------------------------------------------------------------------
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* The figure, as a mask over the rig: the pixels whose colour differs from
|
|
785
|
+
* the median colour of the painting's outer `SILHOUETTE_BORDER`-pixel ring by
|
|
786
|
+
* more than `SILHOUETTE_LIMIT` (max channel), opened twice by a 3x3 square
|
|
787
|
+
* (erode twice, dilate twice) to drop edge noise, with every enclosed hole
|
|
788
|
+
* filled — so a white blouse inside the figure's outline is figure, and the
|
|
789
|
+
* white page round it is not. Computed once, from the painting alone.
|
|
790
|
+
*
|
|
791
|
+
* This is NOT in the reference. It is the input of `seamRule: 'silhouette'`.
|
|
792
|
+
*/
|
|
793
|
+
export function figureSilhouette(srcr: Raster): Mask {
|
|
794
|
+
const { width: W, height: H } = srcr;
|
|
795
|
+
const ring: number[][] = [[], [], []];
|
|
796
|
+
for (let y = 0; y < H; y++) {
|
|
797
|
+
for (let x = 0; x < W; x++) {
|
|
798
|
+
if (y >= SILHOUETTE_BORDER && y < H - SILHOUETTE_BORDER && x >= SILHOUETTE_BORDER && x < W - SILHOUETTE_BORDER) continue;
|
|
799
|
+
for (let c = 0; c < 3; c++) ring[c].push(srcr.data[(y * W + x) * 4 + c]);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
const median = ring.map((v) => {
|
|
803
|
+
const s = [...v].sort((a, b) => a - b);
|
|
804
|
+
const n = s.length;
|
|
805
|
+
return n % 2 === 1 ? s[(n - 1) / 2] : (s[n / 2 - 1] + s[n / 2]) / 2;
|
|
806
|
+
});
|
|
807
|
+
const fig = newMask(W, H);
|
|
808
|
+
for (let p = 0; p < W * H; p++) {
|
|
809
|
+
const d = Math.max(...[0, 1, 2].map((c) => Math.abs(srcr.data[p * 4 + c] - median[c])));
|
|
810
|
+
if (d > SILHOUETTE_LIMIT) fig.data[p] = 1;
|
|
811
|
+
}
|
|
812
|
+
const opened = dilate(dilate(erode(erode(fig, 3), 3), 3), 3);
|
|
813
|
+
return fillHoles(opened);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
export interface PlacedPart {
|
|
817
|
+
record: PartRecord;
|
|
818
|
+
image: Raster;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** The seam pass's view of a stack of parts, before anything is recoloured (`seamCandidates`). */
|
|
822
|
+
export interface SeamCandidates {
|
|
823
|
+
/** Per rig pixel: the index of the top-most part with alpha above 0 there, or -1. */
|
|
824
|
+
top: Int32Array;
|
|
825
|
+
/** Per rig pixel: the index of the top-most part with alpha >= `CORE_ALPHA` there, or -1. */
|
|
826
|
+
holder: Int32Array;
|
|
827
|
+
/** Per rig pixel: 1 where the seam rule admits the pixel as a candidate (`seamOverride`'s invariant). */
|
|
828
|
+
cand: Uint8Array;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* The candidates of the seam pass over `parts` in draw order, exactly as
|
|
833
|
+
* `seamOverride` defines them (its invariant): the flat composite on white,
|
|
834
|
+
* in float as the reference composites; a candidate differs from the
|
|
835
|
+
* painting by more than `SEAM_LIMIT` (max channel), is covered by some part
|
|
836
|
+
* (alpha > 0) and is admitted by the rule. Also returned: which part is the
|
|
837
|
+
* top-most cover of each pixel, and which is the top-most at alpha >=
|
|
838
|
+
* `CORE_ALPHA` — the part that holds the pixel.
|
|
839
|
+
*/
|
|
840
|
+
export function seamCandidates(parts: PlacedPart[], srcr: Raster, rule: SeamRule, silhouette: Mask | null): SeamCandidates {
|
|
841
|
+
const { width: W, height: H } = srcr;
|
|
842
|
+
const top = new Int32Array(W * H).fill(-1);
|
|
843
|
+
const holder = new Int32Array(W * H).fill(-1);
|
|
844
|
+
const can = new Float64Array(W * H * 3).fill(255);
|
|
845
|
+
parts.forEach(({ record: p, image }, i) => {
|
|
846
|
+
for (let y = 0; y < p.h; y++) {
|
|
847
|
+
for (let x = 0; x < p.w; x++) {
|
|
848
|
+
const s = (y * p.w + x) * 4;
|
|
849
|
+
const d = (p.y + y) * W + p.x + x;
|
|
850
|
+
const alpha = image.data[s + 3];
|
|
851
|
+
if (alpha > 0) top[d] = i;
|
|
852
|
+
if (alpha >= CORE_ALPHA) holder[d] = i;
|
|
853
|
+
const a = f32(alpha / 255);
|
|
854
|
+
const one = f32(1 - a);
|
|
855
|
+
for (let c = 0; c < 3; c++) can[d * 3 + c] = can[d * 3 + c] * one + f32(image.data[s + c] * a);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
const cand = new Uint8Array(W * H);
|
|
860
|
+
for (let p = 0; p < W * H; p++) {
|
|
861
|
+
if (top[p] < 0) continue;
|
|
862
|
+
const s = p * 4;
|
|
863
|
+
let d = 0;
|
|
864
|
+
for (let c = 0; c < 3; c++) d = Math.max(d, Math.abs(can[p * 3 + c] - srcr.data[s + c]));
|
|
865
|
+
if (!(d > SEAM_LIMIT)) continue;
|
|
866
|
+
const painted = Math.min(srcr.data[s], srcr.data[s + 1], srcr.data[s + 2]) <= NEAR_WHITE_MIN;
|
|
867
|
+
const admitted = rule === 'near-white' ? painted : painted || (silhouette !== null && silhouette.data[p] === 1);
|
|
868
|
+
if (admitted) cand[p] = 1;
|
|
869
|
+
}
|
|
870
|
+
return { top, holder, cand };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** Pixels of one part's fringe that `pushBackFringe` cleared, counted by the part that holds them. */
|
|
874
|
+
export interface FringePushed {
|
|
875
|
+
/** The part drawn beneath, at alpha >= `CORE_ALPHA`, that the painting shows there. */
|
|
876
|
+
part: string;
|
|
877
|
+
px: number;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** The art mask the contour mesher reads (alpha above 8), and its 4-connected islands. */
|
|
881
|
+
function artIslands(image: Raster): { labels: Int32Array; count: number; core: Uint8Array; area: Int32Array } {
|
|
882
|
+
const n = image.width * image.height;
|
|
883
|
+
const art = newMask(image.width, image.height);
|
|
884
|
+
for (let q = 0; q < n; q++) if (image.data[q * 4 + 3] > OPAQUE_ALPHA_ABOVE) art.data[q] = 1;
|
|
885
|
+
const cc = connectedComponents(art, 4);
|
|
886
|
+
const core = new Uint8Array(cc.count);
|
|
887
|
+
const area = new Int32Array(cc.count);
|
|
888
|
+
for (let q = 0; q < n; q++) {
|
|
889
|
+
const l = cc.labels[q];
|
|
890
|
+
if (l === 0) continue;
|
|
891
|
+
area[l]++;
|
|
892
|
+
if (image.data[q * 4 + 3] >= CORE_ALPHA) core[l] = 1;
|
|
893
|
+
}
|
|
894
|
+
return { labels: cc.labels, count: cc.count, core, area };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** One pixel `pushBackFringe` cleared: its index in the part's box, its RGBA before, its holder's index in the stack, and whether it is still clear. */
|
|
898
|
+
interface Cleared {
|
|
899
|
+
q: number;
|
|
900
|
+
rgba: number[];
|
|
901
|
+
h: number;
|
|
902
|
+
on: boolean;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* `pushBackFringe`'s after-step on one part: clearing a fringe never splits
|
|
907
|
+
* an island of the part's art. The art is the contour mesher's reading —
|
|
908
|
+
* alpha above 8, 4-connected (`src/contour.ts`, `CONTOUR_ONE_ISLAND`) — and
|
|
909
|
+
* the islands before the clearing are the part's islands with every cleared
|
|
910
|
+
* pixel put back.
|
|
911
|
+
*
|
|
912
|
+
* ⚖️ Invariant: afterwards every island the part had before is at most one
|
|
913
|
+
* island. Where the clearing left one in several pieces, the largest piece
|
|
914
|
+
* (by area; the first in scan order on a tie) stays, and each other piece is
|
|
915
|
+
* either
|
|
916
|
+
* - a CRUMB — every pixel below `CORE_ALPHA`, and every pixel either held
|
|
917
|
+
* by some part at alpha >= `CORE_ALPHA` (`holderAt` >= 0) or not covering
|
|
918
|
+
* (alpha <= `COVERED_ALPHA`), so clearing it uncovers nothing — which is
|
|
919
|
+
* cleared too: a piece of the same fringe, cut off from its part. Its
|
|
920
|
+
* pixels are counted against the holder of the first cleared pixel beside
|
|
921
|
+
* it, the clearing that cut it off; or
|
|
922
|
+
* - anything else (it holds a pixel at alpha >= `CORE_ALPHA`, or a covering
|
|
923
|
+
* pixel nothing holds), in which case every cleared pixel 4-adjacent to it
|
|
924
|
+
* is put back as it was.
|
|
925
|
+
* Repeated until no island is split: the pieces were joined only through
|
|
926
|
+
* cleared pixels, and each round clears a crumb or puts a pixel back, so the
|
|
927
|
+
* loop ends.
|
|
928
|
+
* So the part's island count never rises, and `CONTOUR_ONE_ISLAND` reads
|
|
929
|
+
* the islands it read before. Without the bridge put back, the clearing cut a
|
|
930
|
+
* 7 px piece of opaque art off demo's `sleeves` (the pull request that added
|
|
931
|
+
* this step); a fringe cleared along a long edge leaves a crumb wherever the
|
|
932
|
+
* edge was two pixels wide, and selftest `AS47` plants both.
|
|
933
|
+
*/
|
|
934
|
+
function keepArtWhole(part: PlacedPart, cleared: Cleared[], holderAt: (q: number) => number): void {
|
|
935
|
+
if (cleared.length === 0) return;
|
|
936
|
+
const { image } = part;
|
|
937
|
+
const { width: w, height: h } = image;
|
|
938
|
+
const n = w * h;
|
|
939
|
+
const whole: Raster = { width: w, height: h, data: new Uint8ClampedArray(image.data) };
|
|
940
|
+
for (const c of cleared) whole.data.set(c.rgba, c.q * 4);
|
|
941
|
+
const before = artIslands(whole);
|
|
942
|
+
for (;;) {
|
|
943
|
+
const after = artIslands(image);
|
|
944
|
+
// Per piece: its island before, and whether it is a crumb; per island before: its pieces and the largest.
|
|
945
|
+
const islandOf = new Int32Array(after.count).fill(-1);
|
|
946
|
+
const crumb = new Uint8Array(after.count).fill(1);
|
|
947
|
+
const pieces = new Int32Array(before.count);
|
|
948
|
+
const largest = new Int32Array(before.count).fill(-1);
|
|
949
|
+
for (let q = 0; q < n; q++) {
|
|
950
|
+
const l = after.labels[q];
|
|
951
|
+
if (l === 0) continue;
|
|
952
|
+
if (islandOf[l] < 0) {
|
|
953
|
+
const b = before.labels[q];
|
|
954
|
+
islandOf[l] = b;
|
|
955
|
+
pieces[b]++;
|
|
956
|
+
if (largest[b] < 0 || after.area[l] > after.area[largest[b]]) largest[b] = l;
|
|
957
|
+
}
|
|
958
|
+
const a = image.data[q * 4 + 3];
|
|
959
|
+
if (a >= CORE_ALPHA || (a > COVERED_ALPHA && holderAt(q) < 0)) crumb[l] = 0;
|
|
960
|
+
}
|
|
961
|
+
const split = (l: number): boolean => l > 0 && pieces[islandOf[l]] > 1 && largest[islandOf[l]] !== l;
|
|
962
|
+
const beside = (c: Cleared): number[] => {
|
|
963
|
+
const x = c.q % w;
|
|
964
|
+
const y = (c.q - x) / w;
|
|
965
|
+
return [x > 0 ? c.q - 1 : -1, x < w - 1 ? c.q + 1 : -1, y > 0 ? c.q - w : -1, y < h - 1 ? c.q + w : -1].filter((m) => m >= 0 && split(after.labels[m])).map((m) => after.labels[m]);
|
|
966
|
+
};
|
|
967
|
+
// A crumb is counted against the holder of the first cleared pixel beside it: the clearing that cut it off.
|
|
968
|
+
const cutBy = new Int32Array(after.count).fill(-1);
|
|
969
|
+
for (const c of cleared) if (c.on) for (const l of beside(c)) if (crumb[l] === 1 && cutBy[l] < 0) cutBy[l] = c.h;
|
|
970
|
+
for (let l = 1; l < after.count; l++) if (cutBy[l] < 0) crumb[l] = 0;
|
|
971
|
+
const restore = cleared.filter((c) => c.on && beside(c).some((l) => crumb[l] === 0));
|
|
972
|
+
let changed = 0;
|
|
973
|
+
for (let q = 0; q < n; q++) {
|
|
974
|
+
const l = after.labels[q];
|
|
975
|
+
if (!split(l) || crumb[l] === 0) continue;
|
|
976
|
+
cleared.push({ q, rgba: Array.from(image.data.subarray(q * 4, q * 4 + 4)), h: cutBy[l], on: true });
|
|
977
|
+
image.data.fill(0, q * 4, q * 4 + 4);
|
|
978
|
+
changed++;
|
|
979
|
+
}
|
|
980
|
+
for (const c of restore) {
|
|
981
|
+
image.data.set(c.rgba, c.q * 4);
|
|
982
|
+
c.on = false;
|
|
983
|
+
changed++;
|
|
984
|
+
}
|
|
985
|
+
if (changed === 0) break;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Step 4b (issue #119), over every part in draw order, before the seam
|
|
991
|
+
* override. Modifies the part images in place and returns, per part, the
|
|
992
|
+
* pixels it cleared, by holder.
|
|
993
|
+
*
|
|
994
|
+
* ⚖️ Invariant: a pixel is PUSHED BACK in a part when, over the stack as it
|
|
995
|
+
* stands (`seamCandidates`): (1) the pixel is a seam candidate — the flat
|
|
996
|
+
* composite differs from the painting by more than `SEAM_LIMIT` (max
|
|
997
|
+
* channel) and the seam rule admits it; (2) this part is drawn after the
|
|
998
|
+
* pixel's HOLDER — the top-most part at alpha >= `CORE_ALPHA` there — with
|
|
999
|
+
* alpha above 0, which makes it a fringe (below `CORE_ALPHA`, or it would be
|
|
1000
|
+
* the holder) that the seam override, recolouring only at alpha >=
|
|
1001
|
+
* `CORE_ALPHA`, cannot reach; and (3) the holder is in the painting's colour
|
|
1002
|
+
* — its own RGB within `SEAM_LIMIT` of the painting's (max channel): the
|
|
1003
|
+
* painting shows the holder there. Every such pixel becomes transparent (all
|
|
1004
|
+
* four channels 0). Then `keepArtWhole` holds each part's art to the islands
|
|
1005
|
+
* it had: a crumb the clearing cut off is cleared too, and a cleared bridge
|
|
1006
|
+
* to opaque art is put back. No constant is new: (1) and (2) are the seam
|
|
1007
|
+
* override's own candidate set and alpha, (3) is the seam override's own
|
|
1008
|
+
* limit, applied to the part the pixel falls back to, and the islands are the
|
|
1009
|
+
* contour mesher's reading of the art. Where the holder is itself the top
|
|
1010
|
+
* cover, nothing is drawn after it and nothing is cleared: a pixel at alpha
|
|
1011
|
+
* >= `CORE_ALPHA` is the seam override's, as before.
|
|
1012
|
+
*
|
|
1013
|
+
* Why (3) asks for the holder's colour: without it a fringe that is too
|
|
1014
|
+
* faint rather than the wrong colour — an earring's edge over hair, where the
|
|
1015
|
+
* painting shows the earring — is cleared too, and the seam override then
|
|
1016
|
+
* paints the earring's colour onto the hair beneath, which moves with the
|
|
1017
|
+
* hair: the sliver this rule exists to remove, moved to the other part.
|
|
1018
|
+
* Measured on the public examples (the pull request that closed issue #119):
|
|
1019
|
+
* without (3) the holders' `seam_override_px` rose from 47 to 617 on demo's
|
|
1020
|
+
* `hair_back` and from 20 to 222 on sample's `bottomwear`; with it, and with
|
|
1021
|
+
* the crumbs `keepArtWhole` clears, no part's `seam_override_px` moved by
|
|
1022
|
+
* more than 1 on either example under either seam rule.
|
|
1023
|
+
*
|
|
1024
|
+
* Why it is right to clear rather than to recolour: the holder is opaque
|
|
1025
|
+
* there, so no pixel loses cover (`COVERED_ALPHA` is below `CORE_ALPHA`) and
|
|
1026
|
+
* no uncovered error pixel appears; and the colour the painting shows there
|
|
1027
|
+
* belongs to the holder, which moves with the holder's bones. A fringe
|
|
1028
|
+
* recoloured to the painting would carry the holder's colour on the part in
|
|
1029
|
+
* front, as a translucent rim that leaves the holder when the two move apart.
|
|
1030
|
+
*
|
|
1031
|
+
* Left alone: a pixel whose stack composites to within `SEAM_LIMIT` of the
|
|
1032
|
+
* painting (an anti-aliased edge that is the painting's edge); a fringe no
|
|
1033
|
+
* part holds beneath (the figure's outline over the page — nothing would
|
|
1034
|
+
* hold the pixel after it); and every pixel at alpha >= `CORE_ALPHA`, which
|
|
1035
|
+
* is the seam override's.
|
|
1036
|
+
*/
|
|
1037
|
+
export function pushBackFringe(parts: PlacedPart[], srcr: Raster, rule: SeamRule, silhouette: Mask | null): FringePushed[][] {
|
|
1038
|
+
const { width: W } = srcr;
|
|
1039
|
+
const { holder, cand } = seamCandidates(parts, srcr, rule, silhouette);
|
|
1040
|
+
const at = (i: number, d: number): number => {
|
|
1041
|
+
const p = parts[i].record;
|
|
1042
|
+
const x = (d % W) - p.x;
|
|
1043
|
+
const y = Math.floor(d / W) - p.y;
|
|
1044
|
+
return x >= 0 && x < p.w && y >= 0 && y < p.h ? (y * p.w + x) * 4 : -1;
|
|
1045
|
+
};
|
|
1046
|
+
/** The holder shows the painting at rig pixel `d`: it exists and its own colour is within `SEAM_LIMIT` of the painting's. */
|
|
1047
|
+
const shows = (d: number): boolean => holder[d] >= 0 && maxDiff(parts[holder[d]].image.data, at(holder[d], d), srcr.data, d * 4) <= SEAM_LIMIT;
|
|
1048
|
+
/** Per part, every pixel cleared: its index in the part's box, its colour before, its holder, and whether it is still clear. */
|
|
1049
|
+
const cleared: Cleared[][] = parts.map(() => []);
|
|
1050
|
+
for (let d = 0; d < cand.length; d++) {
|
|
1051
|
+
// Every part drawn after the holder is below CORE_ALPHA there, by the holder's definition; when the holder is the top
|
|
1052
|
+
// cover itself the loop below has nothing to clear, and the seam override acts on the pixel as before.
|
|
1053
|
+
if (cand[d] === 0 || !shows(d)) continue;
|
|
1054
|
+
for (let i = holder[d] + 1; i < parts.length; i++) {
|
|
1055
|
+
const s = at(i, d);
|
|
1056
|
+
if (s < 0) continue;
|
|
1057
|
+
const img = parts[i].image.data;
|
|
1058
|
+
if (img[s + 3] === 0) continue;
|
|
1059
|
+
cleared[i].push({ q: s / 4, rgba: Array.from(img.subarray(s, s + 4)), h: holder[d], on: true });
|
|
1060
|
+
img.fill(0, s, s + 4);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
parts.forEach((part, i) => keepArtWhole(part, cleared[i], (q) => {
|
|
1064
|
+
const d = (part.record.y + Math.floor(q / part.record.w)) * W + part.record.x + (q % part.record.w);
|
|
1065
|
+
return holder[d];
|
|
1066
|
+
}));
|
|
1067
|
+
const counts: Array<Map<number, number>> = parts.map(() => new Map());
|
|
1068
|
+
cleared.forEach((list, i) => {
|
|
1069
|
+
for (const c of list) if (c.on) counts[i].set(c.h, (counts[i].get(c.h) ?? 0) + 1);
|
|
1070
|
+
});
|
|
1071
|
+
return counts.map((m) =>
|
|
1072
|
+
[...m.entries()]
|
|
1073
|
+
.sort((a, b) => b[1] - a[1] || a[0] - b[0])
|
|
1074
|
+
.map(([h, px]) => ({ part: parts[h].record.name, px })),
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Step 5, over every part in plan order. Modifies the part images in place and
|
|
1080
|
+
* sets each record's `seam_override_px`.
|
|
1081
|
+
*
|
|
1082
|
+
* ⚖️ Invariant: over the flat composite of the parts on white (float, as the
|
|
1083
|
+
* reference composites), a CANDIDATE is a pixel differing from the painting by
|
|
1084
|
+
* more than `SEAM_LIMIT` (max channel) that some part covers (alpha > 0) and
|
|
1085
|
+
* that the rule admits — `near-white` (the reference's): the painting's min
|
|
1086
|
+
* channel is <= 235; `silhouette`: that, OR the pixel is inside
|
|
1087
|
+
* `figureSilhouette` — so a near-white painting pixel is protected only
|
|
1088
|
+
* outside the figure, which is the background the reference's guard exists
|
|
1089
|
+
* to protect. (Replacing the whiteness test by the silhouette outright was
|
|
1090
|
+
* measured and rejected: the opened mask loses figure-edge pixels the
|
|
1091
|
+
* whiteness test admits, and error px rose on eight of ten oracle
|
|
1092
|
+
* characters — ORACLE_assemble.md.)
|
|
1093
|
+
* Each candidate is recoloured to the painting's colour in the TOP-MOST part
|
|
1094
|
+
* covering it, and only where that part's alpha is >= 250; alpha never
|
|
1095
|
+
* changes. Candidates are chosen once, before any recolouring.
|
|
1096
|
+
* (`assemble_parts.py` `main`, "5. seam override".)
|
|
1097
|
+
*/
|
|
1098
|
+
export function seamOverride(parts: PlacedPart[], srcr: Raster, rule: SeamRule, silhouette: Mask | null): void {
|
|
1099
|
+
const { width: W } = srcr;
|
|
1100
|
+
const { top, cand } = seamCandidates(parts, srcr, rule, silhouette);
|
|
1101
|
+
parts.forEach(({ record: p, image }, i) => {
|
|
1102
|
+
let n = 0;
|
|
1103
|
+
for (let y = 0; y < p.h; y++) {
|
|
1104
|
+
for (let x = 0; x < p.w; x++) {
|
|
1105
|
+
const d = (p.y + y) * W + p.x + x;
|
|
1106
|
+
const s = (y * p.w + x) * 4;
|
|
1107
|
+
if (cand[d] === 0 || top[d] !== i || image.data[s + 3] < CORE_ALPHA) continue;
|
|
1108
|
+
image.data[s] = srcr.data[d * 4];
|
|
1109
|
+
image.data[s + 1] = srcr.data[d * 4 + 1];
|
|
1110
|
+
image.data[s + 2] = srcr.data[d * 4 + 2];
|
|
1111
|
+
n++;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
p.seam_override_px = n;
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// ---------------------------------------------------------------------------
|
|
1119
|
+
// the recomposite
|
|
1120
|
+
// ---------------------------------------------------------------------------
|
|
1121
|
+
|
|
1122
|
+
export interface RecompositeFigures {
|
|
1123
|
+
/** Mean over pixels of the mean-channel |recomposite - painting|. */
|
|
1124
|
+
meanAbs: number;
|
|
1125
|
+
/** Fraction of pixels whose mean-channel difference is <= `WITHIN_LIMIT`. */
|
|
1126
|
+
within: number;
|
|
1127
|
+
/** Pixels whose max-channel difference is > `ERROR_LIMIT`. */
|
|
1128
|
+
errorPx: number;
|
|
1129
|
+
/** Of those, the ones no part covers (no part has alpha above `COVERED_ALPHA`). */
|
|
1130
|
+
uncoveredErrorPx: number;
|
|
1131
|
+
/** The 8-connected components of the uncovered error pixels: how many there are … */
|
|
1132
|
+
holeCount: number;
|
|
1133
|
+
/** … and the largest `HOLES_LISTED` of them, largest first (`uncoveredHoles`). */
|
|
1134
|
+
holes: RecompositeHole[];
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** How many uncovered holes the assemble summary and `parts.json` list, largest first. */
|
|
1138
|
+
export const HOLES_LISTED = 5;
|
|
1139
|
+
|
|
1140
|
+
/** A part beside an uncovered hole: how many of its covered pixels touch the hole. */
|
|
1141
|
+
export interface HoleBorder {
|
|
1142
|
+
part: string;
|
|
1143
|
+
px: number;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** One 8-connected component of the uncovered error pixels, in rig pixels (y down, origin top-left). */
|
|
1147
|
+
export interface RecompositeHole {
|
|
1148
|
+
px: number;
|
|
1149
|
+
x: number;
|
|
1150
|
+
y: number;
|
|
1151
|
+
w: number;
|
|
1152
|
+
h: number;
|
|
1153
|
+
/** Every part bordering the hole, most border pixels first, then plan order; empty when it borders none. */
|
|
1154
|
+
borders: HoleBorder[];
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/** The parts composited in plan order onto opaque white, as `PIL.Image.alpha_composite` does it. */
|
|
1158
|
+
export function recomposite(parts: PlacedPart[], W: number, H: number): Raster {
|
|
1159
|
+
let can = newRaster(W, H);
|
|
1160
|
+
can.data.fill(255);
|
|
1161
|
+
for (const { record: p, image } of parts) can = alphaComposite(can, image, p.x, p.y);
|
|
1162
|
+
return can;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/** Per rig pixel: 1 where some part has alpha above `COVERED_ALPHA`. */
|
|
1166
|
+
function coveredMask(parts: PlacedPart[], W: number, H: number): Uint8Array {
|
|
1167
|
+
const covered = new Uint8Array(W * H);
|
|
1168
|
+
for (const { record: p, image } of parts) {
|
|
1169
|
+
for (let y = 0; y < p.h; y++) for (let x = 0; x < p.w; x++) if (image.data[(y * p.w + x) * 4 + 3] > COVERED_ALPHA) covered[(p.y + y) * W + p.x + x] = 1;
|
|
1170
|
+
}
|
|
1171
|
+
return covered;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** Per rig pixel: 1 where the recomposite's max-channel difference from the painting is above `ERROR_LIMIT`. */
|
|
1175
|
+
function errorMask(can: Raster, srcr: Raster): Uint8Array {
|
|
1176
|
+
const n = srcr.width * srcr.height;
|
|
1177
|
+
const err = new Uint8Array(n);
|
|
1178
|
+
for (let p = 0; p < n; p++) {
|
|
1179
|
+
let m = 0;
|
|
1180
|
+
for (let c = 0; c < 3; c++) m = Math.max(m, Math.abs(can.data[p * 4 + c] - srcr.data[p * 4 + c]));
|
|
1181
|
+
if (m > ERROR_LIMIT) err[p] = 1;
|
|
1182
|
+
}
|
|
1183
|
+
return err;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* The uncovered error pixels as holes: every 8-connected component of the
|
|
1188
|
+
* pixels whose recomposite differs from the painting by more than
|
|
1189
|
+
* `ERROR_LIMIT` (max channel) and that no part covers (alpha above
|
|
1190
|
+
* `COVERED_ALPHA`) — the pixels `uncoveredErrorPx` counts, so the components'
|
|
1191
|
+
* areas sum to it.
|
|
1192
|
+
*
|
|
1193
|
+
* ⚖️ Invariant: a hole's box is its pixels' bounding box; its `borders` are
|
|
1194
|
+
* the parts with a covered pixel 8-adjacent to one of its pixels, each with
|
|
1195
|
+
* the number of such pixels it covers (a pixel two parts cover counts for
|
|
1196
|
+
* both), most first and then in plan order. Holes are sorted by area, largest
|
|
1197
|
+
* first, then by the box's top row and left column, so the order is a
|
|
1198
|
+
* function of the pixels and not of a labelling scan. 8-connectivity is the
|
|
1199
|
+
* reference's own for blobs (`cv2.connectedComponentsWithStats(…, 8)` in
|
|
1200
|
+
* ghost clean-up): a hole joined only at a corner is one hole.
|
|
1201
|
+
*
|
|
1202
|
+
* This is not in the reference, whose report stopped at the count. The count
|
|
1203
|
+
* cannot say where to look; a box and the parts round it can.
|
|
1204
|
+
*/
|
|
1205
|
+
export function uncoveredHoles(can: Raster, srcr: Raster, parts: PlacedPart[]): { count: number; holes: RecompositeHole[] } {
|
|
1206
|
+
const { width: W, height: H } = srcr;
|
|
1207
|
+
const covered = coveredMask(parts, W, H);
|
|
1208
|
+
const err = errorMask(can, srcr);
|
|
1209
|
+
const mask = newMask(W, H);
|
|
1210
|
+
for (let p = 0; p < W * H; p++) if (err[p] === 1 && covered[p] === 0) mask.data[p] = 1;
|
|
1211
|
+
const cc = connectedComponents(mask, 8);
|
|
1212
|
+
const all = cc.stats.slice(1).filter((s) => s.area > 0);
|
|
1213
|
+
all.sort((a, b) => b.area - a.area || a.top - b.top || a.left - b.left);
|
|
1214
|
+
const listed = all.slice(0, HOLES_LISTED);
|
|
1215
|
+
const holes = listed.map((s): RecompositeHole => {
|
|
1216
|
+
const borders: HoleBorder[] = [];
|
|
1217
|
+
parts.forEach(({ record: p, image }) => {
|
|
1218
|
+
let n = 0;
|
|
1219
|
+
// A part pixel can touch the hole only inside the hole's box grown by one.
|
|
1220
|
+
const x0 = Math.max(p.x, s.left - 1);
|
|
1221
|
+
const x1 = Math.min(p.x + p.w - 1, s.left + s.width);
|
|
1222
|
+
const y0 = Math.max(p.y, s.top - 1);
|
|
1223
|
+
const y1 = Math.min(p.y + p.h - 1, s.top + s.height);
|
|
1224
|
+
for (let y = y0; y <= y1; y++) {
|
|
1225
|
+
for (let x = x0; x <= x1; x++) {
|
|
1226
|
+
if (image.data[((y - p.y) * p.w + (x - p.x)) * 4 + 3] <= COVERED_ALPHA) continue;
|
|
1227
|
+
let touches = false;
|
|
1228
|
+
for (let dy = -1; dy <= 1 && !touches; dy++) {
|
|
1229
|
+
for (let dx = -1; dx <= 1 && !touches; dx++) {
|
|
1230
|
+
const u = x + dx;
|
|
1231
|
+
const v = y + dy;
|
|
1232
|
+
if (u >= 0 && u < W && v >= 0 && v < H && cc.labels[v * W + u] === s.label) touches = true;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
if (touches) n++;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
if (n > 0) borders.push({ part: p.name, px: n });
|
|
1239
|
+
});
|
|
1240
|
+
// Array.prototype.sort is stable, so equal counts keep plan order.
|
|
1241
|
+
borders.sort((a, b) => b.px - a.px);
|
|
1242
|
+
return { px: s.area, x: s.left, y: s.top, w: s.width, h: s.height, borders };
|
|
1243
|
+
});
|
|
1244
|
+
return { count: all.length, holes };
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
export function measureRecomposite(can: Raster, srcr: Raster, parts: PlacedPart[]): RecompositeFigures {
|
|
1248
|
+
const { width: W, height: H } = srcr;
|
|
1249
|
+
const covered = coveredMask(parts, W, H);
|
|
1250
|
+
let sum = 0;
|
|
1251
|
+
let within = 0;
|
|
1252
|
+
let errorPx = 0;
|
|
1253
|
+
let uncoveredErrorPx = 0;
|
|
1254
|
+
for (let p = 0; p < W * H; p++) {
|
|
1255
|
+
let s = 0;
|
|
1256
|
+
let m = 0;
|
|
1257
|
+
for (let c = 0; c < 3; c++) {
|
|
1258
|
+
const d = Math.abs(can.data[p * 4 + c] - srcr.data[p * 4 + c]);
|
|
1259
|
+
s += d;
|
|
1260
|
+
m = Math.max(m, d);
|
|
1261
|
+
}
|
|
1262
|
+
const mean = s / 3;
|
|
1263
|
+
sum += mean;
|
|
1264
|
+
if (mean <= WITHIN_LIMIT) within++;
|
|
1265
|
+
if (m > ERROR_LIMIT) {
|
|
1266
|
+
errorPx++;
|
|
1267
|
+
if (covered[p] === 0) uncoveredErrorPx++;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
const { count, holes } = uncoveredHoles(can, srcr, parts);
|
|
1271
|
+
return { meanAbs: sum / (W * H), within: within / (W * H), errorPx, uncoveredErrorPx, holeCount: count, holes };
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/** The error map's colour for an uncovered error pixel: no part is there, so the page shows through. */
|
|
1275
|
+
export const MAP_UNCOVERED: readonly [number, number, number] = [255, 0, 0];
|
|
1276
|
+
/** … for a covered error pixel: a part is there, in a colour more than `ERROR_LIMIT` off the painting's. */
|
|
1277
|
+
export const MAP_MISMATCHED: readonly [number, number, number] = [0, 0, 255];
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* The error map, `recomposite_error_rig.png`: the rig canvas, opaque, with
|
|
1281
|
+
* every uncovered error pixel `MAP_UNCOVERED` (red), every covered error pixel
|
|
1282
|
+
* `MAP_MISMATCHED` (blue), and every other pixel the painting in grey, dimmed
|
|
1283
|
+
* to the top quarter of the range — `192 + floor(luma / 4)`, luma the integer
|
|
1284
|
+
* BT.601 `(299 R + 587 G + 114 B + 500) / 1000` truncated.
|
|
1285
|
+
*
|
|
1286
|
+
* Why the painting dimmed rather than transparent: the map is read at contact-
|
|
1287
|
+
* sheet size, and a red blob on nothing says a hole exists but not where on
|
|
1288
|
+
* the figure it is; on a faint grey figure it sits between the legs it falls
|
|
1289
|
+
* between. Why grey and light: no pixel of the dimmed painting can be either
|
|
1290
|
+
* flag colour (a grey has equal channels, and at 192..255 it is lighter than
|
|
1291
|
+
* both), so the two colours mean exactly the two masks. Integer arithmetic
|
|
1292
|
+
* only, so the bytes are deterministic.
|
|
1293
|
+
*/
|
|
1294
|
+
export function recompositeErrorMap(can: Raster, srcr: Raster, parts: PlacedPart[]): Raster {
|
|
1295
|
+
const { width: W, height: H } = srcr;
|
|
1296
|
+
const covered = coveredMask(parts, W, H);
|
|
1297
|
+
const err = errorMask(can, srcr);
|
|
1298
|
+
const map = newRaster(W, H);
|
|
1299
|
+
for (let p = 0; p < W * H; p++) {
|
|
1300
|
+
const s = p * 4;
|
|
1301
|
+
let rgb: readonly [number, number, number];
|
|
1302
|
+
if (err[p] === 1) rgb = covered[p] === 0 ? MAP_UNCOVERED : MAP_MISMATCHED;
|
|
1303
|
+
else {
|
|
1304
|
+
const luma = Math.floor((299 * srcr.data[s] + 587 * srcr.data[s + 1] + 114 * srcr.data[s + 2] + 500) / 1000);
|
|
1305
|
+
const g = 192 + Math.floor(luma / 4);
|
|
1306
|
+
rgb = [g, g, g];
|
|
1307
|
+
}
|
|
1308
|
+
map.data[s] = rgb[0];
|
|
1309
|
+
map.data[s + 1] = rgb[1];
|
|
1310
|
+
map.data[s + 2] = rgb[2];
|
|
1311
|
+
map.data[s + 3] = 255;
|
|
1312
|
+
}
|
|
1313
|
+
return map;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
export function figuresLine(f: RecompositeFigures): string {
|
|
1317
|
+
return (
|
|
1318
|
+
`recomposite vs source: mean |d|=${f.meanAbs.toFixed(2)}, within ${WITHIN_LIMIT}: ${(100 * f.within).toFixed(1)}%, ` +
|
|
1319
|
+
`error px > ${ERROR_LIMIT}: ${f.errorPx}, uncovered error px: ${f.uncoveredErrorPx}`
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/** A hole's box as the part lines and refusals print a box: `x,y wxh`. */
|
|
1324
|
+
export function holeBox(h: RecompositeHole): string {
|
|
1325
|
+
return `${h.x},${h.y} ${h.w}x${h.h}`;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* The hole lines under `figuresLine`: the count, then one line per listed
|
|
1330
|
+
* hole — `uncovered hole 1: 2340 px at 412,1088 30x78 (between "legwear_r"
|
|
1331
|
+
* 120 px, "legwear_l" 96 px)`, or `(borders no part)`.
|
|
1332
|
+
*/
|
|
1333
|
+
export function holeLines(f: RecompositeFigures): string[] {
|
|
1334
|
+
const head = `uncovered holes (8-connected): ${f.holeCount}${f.holeCount > f.holes.length ? `, the largest ${f.holes.length} listed` : ''}`;
|
|
1335
|
+
return [
|
|
1336
|
+
head,
|
|
1337
|
+
...f.holes.map(
|
|
1338
|
+
(h, i) =>
|
|
1339
|
+
` uncovered hole ${i + 1}: ${h.px} px at ${holeBox(h)} (${h.borders.length === 0 ? 'borders no part' : `between ${h.borders.map((b) => `"${b.part}" ${b.px} px`).join(', ')}`})`,
|
|
1340
|
+
),
|
|
1341
|
+
];
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/** The `parts.json` `recomposite` block: the four figures, the limits they were measured at, and the listed holes. */
|
|
1345
|
+
export function recompositeRecord(f: RecompositeFigures): RecompositeRecord {
|
|
1346
|
+
return {
|
|
1347
|
+
mean_abs: pyRound(f.meanAbs, 3),
|
|
1348
|
+
within_limit: WITHIN_LIMIT,
|
|
1349
|
+
within_share: pyRound(f.within, 4),
|
|
1350
|
+
error_limit: ERROR_LIMIT,
|
|
1351
|
+
error_px: f.errorPx,
|
|
1352
|
+
covered_alpha: COVERED_ALPHA,
|
|
1353
|
+
uncovered_error_px: f.uncoveredErrorPx,
|
|
1354
|
+
hole_count: f.holeCount,
|
|
1355
|
+
holes_listed: HOLES_LISTED,
|
|
1356
|
+
holes: f.holes.map((h) => ({ px: h.px, x: h.x, y: h.y, w: h.w, h: h.h, borders: h.borders.map((b) => ({ part: b.part, px: b.px })) })),
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// ---------------------------------------------------------------------------
|
|
1361
|
+
// patches — extra parts cut from the painting
|
|
1362
|
+
// ---------------------------------------------------------------------------
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* One `assemble.patches` entry as a part: the painting's own pixels over its
|
|
1366
|
+
* box, taken where its alpha rule says.
|
|
1367
|
+
*
|
|
1368
|
+
* ⚖️ Invariant: a pixel of the rig is in the patch exactly when it lies in
|
|
1369
|
+
* `box` (`[x0, y0, x1, y1]`, rig pixels, `x1`/`y1` exclusive) and, for
|
|
1370
|
+
* `alpha: "silhouette"`, `figureSilhouette(srcr)` holds it; there its colour
|
|
1371
|
+
* is the painting's (`srcr`) and its alpha 255, everywhere else the pixel is
|
|
1372
|
+
* transparent. The silhouette is the painting's, not the union of the
|
|
1373
|
+
* layers' alpha: the pixels a patch exists for are exactly the ones no layer
|
|
1374
|
+
* holds, so the layers' union is empty over them by definition. The record is
|
|
1375
|
+
* the patch's alpha box and its counts, all of them `opaque_px` or 0 — a
|
|
1376
|
+
* patch is 100 % source (`src/parts.ts`). `seam_override_px` is set later by
|
|
1377
|
+
* `seamOverride`, as for every part.
|
|
1378
|
+
*/
|
|
1379
|
+
export function cutPatch(patch: Patch, srcr: Raster, silhouette: Mask | null): { record: PartRecord; image: Raster } | null {
|
|
1380
|
+
const { width: W, height: H } = srcr;
|
|
1381
|
+
const [bx0, by0, bx1, by1] = patch.box;
|
|
1382
|
+
const r = newRaster(W, H);
|
|
1383
|
+
let x0 = W;
|
|
1384
|
+
let y0 = H;
|
|
1385
|
+
let x1 = -1;
|
|
1386
|
+
let y1 = -1;
|
|
1387
|
+
let n = 0;
|
|
1388
|
+
for (let y = by0; y < by1; y++) {
|
|
1389
|
+
for (let x = bx0; x < bx1; x++) {
|
|
1390
|
+
const p = y * W + x;
|
|
1391
|
+
if (patch.alpha === 'silhouette' && (silhouette === null || silhouette.data[p] === 0)) continue;
|
|
1392
|
+
r.data.set(srcr.data.subarray(p * 4, p * 4 + 3), p * 4);
|
|
1393
|
+
r.data[p * 4 + 3] = 255;
|
|
1394
|
+
n++;
|
|
1395
|
+
if (x < x0) x0 = x;
|
|
1396
|
+
if (x > x1) x1 = x;
|
|
1397
|
+
if (y < y0) y0 = y;
|
|
1398
|
+
if (y > y1) y1 = y;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
if (n === 0) return null;
|
|
1402
|
+
const record: PartRecord = {
|
|
1403
|
+
name: patch.name,
|
|
1404
|
+
from: `${PAINTING_RUN}:${patch.name}`,
|
|
1405
|
+
x: x0,
|
|
1406
|
+
y: y0,
|
|
1407
|
+
w: x1 - x0 + 1,
|
|
1408
|
+
h: y1 - y0 + 1,
|
|
1409
|
+
opaque_px: n,
|
|
1410
|
+
visible_px: n,
|
|
1411
|
+
occluded_px: 0,
|
|
1412
|
+
projected_core_px: n,
|
|
1413
|
+
source_px_taken: n,
|
|
1414
|
+
visible_not_projected_px: 0,
|
|
1415
|
+
refused_drift_px: 0,
|
|
1416
|
+
merged_px: 0,
|
|
1417
|
+
seam_override_px: 0,
|
|
1418
|
+
};
|
|
1419
|
+
return { record, image: crop(r, x0, y0, record.w, record.h) };
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/** One place in the draw order: a plan part, a patch or a cut's new part, by its index in its list. */
|
|
1423
|
+
export type DrawSlot = { plan: number } | { patch: number } | { cut: number };
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* The draw order of plan parts, patches and cuts together, back to front:
|
|
1427
|
+
* every `"back"` patch in `patches` order, then every `"back"` cut in `cuts`
|
|
1428
|
+
* order; then each plan part preceded by the patches drawn `{before: <that
|
|
1429
|
+
* part>}` in `patches` order and then the cuts drawn so in `cuts` order; then
|
|
1430
|
+
* every `"front"` patch, then every `"front"` cut. With no cuts it is the
|
|
1431
|
+
* order of plan parts and patches it always was. The loader has already
|
|
1432
|
+
* refused a `before` that names no plan part.
|
|
1433
|
+
*/
|
|
1434
|
+
export function drawOrder(plan: readonly string[], patches: readonly Patch[], cuts: readonly Cut[] = []): DrawSlot[] {
|
|
1435
|
+
const out: DrawSlot[] = [];
|
|
1436
|
+
const place = (at: (draw: Patch['draw']) => boolean): void => {
|
|
1437
|
+
patches.forEach((q, i) => {
|
|
1438
|
+
if (at(q.draw)) out.push({ patch: i });
|
|
1439
|
+
});
|
|
1440
|
+
cuts.forEach((q, i) => {
|
|
1441
|
+
if (at(q.draw)) out.push({ cut: i });
|
|
1442
|
+
});
|
|
1443
|
+
};
|
|
1444
|
+
place((d) => d === 'back');
|
|
1445
|
+
plan.forEach((name, pi) => {
|
|
1446
|
+
place((d) => typeof d === 'object' && d.before === name);
|
|
1447
|
+
out.push({ plan: pi });
|
|
1448
|
+
});
|
|
1449
|
+
place((d) => d === 'front');
|
|
1450
|
+
return out;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// ---------------------------------------------------------------------------
|
|
1454
|
+
// cuts — a plan part's pixels taken into a new part by a declared polygon
|
|
1455
|
+
// ---------------------------------------------------------------------------
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* The rig pixels a polygon holds: pixel `(x, y)` is inside when its centre
|
|
1459
|
+
* `(x + 0.5, y + 0.5)` is inside the polygon by the even-odd rule: on each
|
|
1460
|
+
* row, the polygon's edges cross the centre line at sorted x, and the centres
|
|
1461
|
+
* strictly between the first and second crossing, the third and fourth, …
|
|
1462
|
+
* are inside. An edge crosses when exactly one of its ends lies below the
|
|
1463
|
+
* line; the vertices are integers and the line is at a half, so no vertex
|
|
1464
|
+
* lies on it and a horizontal edge never crosses. A centre exactly on a
|
|
1465
|
+
* crossing is outside. Pure arithmetic on the declared integers: the same
|
|
1466
|
+
* polygon holds the same pixels on every machine.
|
|
1467
|
+
*/
|
|
1468
|
+
export function pixelsInPolygon(polygon: ReadonlyArray<readonly [number, number]>, W: number, H: number): Mask {
|
|
1469
|
+
const m = newMask(W, H);
|
|
1470
|
+
const n = polygon.length;
|
|
1471
|
+
for (let y = 0; y < H; y++) {
|
|
1472
|
+
const cy = y + 0.5;
|
|
1473
|
+
// The crossings of this row's centre line, sorted; pixels between pairs are inside.
|
|
1474
|
+
const xs: number[] = [];
|
|
1475
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
1476
|
+
const [xi, yi] = polygon[i];
|
|
1477
|
+
const [xj, yj] = polygon[j];
|
|
1478
|
+
if (yi > cy !== yj > cy) xs.push(xi + ((cy - yi) * (xj - xi)) / (yj - yi));
|
|
1479
|
+
}
|
|
1480
|
+
if (xs.length === 0) continue;
|
|
1481
|
+
xs.sort((a, b) => a - b);
|
|
1482
|
+
for (let k = 0; k + 1 < xs.length; k += 2) {
|
|
1483
|
+
// Centres x + 0.5 with xs[k] < x + 0.5 < xs[k + 1]; a centre exactly on a crossing is outside.
|
|
1484
|
+
const from = Math.max(0, Math.floor(xs[k] - 0.5) + 1);
|
|
1485
|
+
const to = Math.min(W - 1, Math.ceil(xs[k + 1] - 0.5) - 1);
|
|
1486
|
+
for (let x = from; x <= to; x++) m.data[y * W + x] = 1;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
return m;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
/** What one cut did, counted when it was made: the from part's opaque pixels before, what it kept, what the cut took. */
|
|
1493
|
+
export interface CutCount {
|
|
1494
|
+
from: string;
|
|
1495
|
+
into: string;
|
|
1496
|
+
/** The from part's `alpha > 8` pixels before any of its cuts. */
|
|
1497
|
+
before: number;
|
|
1498
|
+
/** What the from part kept after all its cuts; the same on every cut of one part. */
|
|
1499
|
+
kept: number;
|
|
1500
|
+
/** What this cut took. */
|
|
1501
|
+
taken: number;
|
|
1502
|
+
/** Of `taken`, the band the from part keeps as well (`cutBand`); counted in neither `kept` nor `before` twice. */
|
|
1503
|
+
band: number;
|
|
1504
|
+
/** With `under` only (issue #193): the base pixels filled from the under image (`fillUnder`); absent without it. */
|
|
1505
|
+
under?: number;
|
|
1506
|
+
/** With `under` only: the removed pixels the image left empty (its alpha below the alpha removed); `under + underEmpty` is the removed count. */
|
|
1507
|
+
underEmpty?: number;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/**
|
|
1511
|
+
* The cut lines the assemble stage prints, one per plan part with cuts, in
|
|
1512
|
+
* the `pixels:` line's shape: ` cut: "<from>" opaque <before> = "<from>"
|
|
1513
|
+
* <kept> + "<into>" <taken> …`. Empty without cuts, so a config with none
|
|
1514
|
+
* prints what it printed before.
|
|
1515
|
+
*/
|
|
1516
|
+
export function cutLines(cuts: readonly CutCount[]): string[] {
|
|
1517
|
+
const byFrom = new Map<string, CutCount[]>();
|
|
1518
|
+
for (const c of cuts) byFrom.set(c.from, [...(byFrom.get(c.from) ?? []), c]);
|
|
1519
|
+
return [...byFrom.entries()].map(
|
|
1520
|
+
([from, cs]) =>
|
|
1521
|
+
` cut: "${from}" opaque ${cs[0].before} = "${from}" ${cs[0].kept} + ${cs.map((c) => `"${c.into}" ${c.taken}`).join(' + ')}; band ${cs.reduce((a, c) => a + c.band, 0)} px held by both` +
|
|
1522
|
+
(cs.some((c) => c.under !== undefined) ? `; under ${cs.reduce((a, c) => a + (c.under ?? 0), 0)} px filled, ${cs.reduce((a, c) => a + (c.underEmpty ?? 0), 0)} px left empty` : ''),
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* The box a cut's `under` image covers (issue #193): the polygon's bounding
|
|
1528
|
+
* box in rig pixels, its corners the polygon's least and greatest x and y —
|
|
1529
|
+
* the image is `x1 - x0` by `y1 - y0` and its pixel `(i, j)` is rig pixel
|
|
1530
|
+
* `(x0 + i, y0 + j)`. Every pixel the polygon holds lies in it: a pixel is
|
|
1531
|
+
* held by its centre, and the centres inside lie strictly between the
|
|
1532
|
+
* extreme vertices.
|
|
1533
|
+
*/
|
|
1534
|
+
export function underBox(polygon: ReadonlyArray<readonly [number, number]>): { x0: number; y0: number; w: number; h: number } {
|
|
1535
|
+
const xs = polygon.map((q) => q[0]);
|
|
1536
|
+
const ys = polygon.map((q) => q[1]);
|
|
1537
|
+
const x0 = Math.min(...xs);
|
|
1538
|
+
const y0 = Math.min(...ys);
|
|
1539
|
+
return { x0, y0, w: Math.max(...xs) - x0, h: Math.max(...ys) - y0 };
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* Fill the base where a cut removed its pixels (issue #193). The removed
|
|
1544
|
+
* pixels are those the piece holds (alpha above 0) and the band does not —
|
|
1545
|
+
* the pixels the cut cleared in the base. At each, when the under image's
|
|
1546
|
+
* alpha is at least the alpha the cut removed there, the base takes the
|
|
1547
|
+
* image's pixel, colour and alpha (filled); otherwise the base stays cleared
|
|
1548
|
+
* there (left empty): a transparent pixel is the author's "nothing lies under
|
|
1549
|
+
* here", and a pixel less opaque than the layer was is not half-filled. That
|
|
1550
|
+
* is the only place the image is read: its pixels over the band, over pixels
|
|
1551
|
+
* the layer never had, and over the box outside the polygon change nothing.
|
|
1552
|
+
* `filled + empty` is the removed count.
|
|
1553
|
+
*/
|
|
1554
|
+
export function fillUnder(base: Raster, piece: Raster, band: Mask, under: Raster, box: { x0: number; y0: number }): { filled: number; empty: number } {
|
|
1555
|
+
const W = base.width;
|
|
1556
|
+
let filled = 0;
|
|
1557
|
+
let empty = 0;
|
|
1558
|
+
for (let p = 0; p < W * base.height; p++) {
|
|
1559
|
+
const a = piece.data[p * 4 + 3];
|
|
1560
|
+
if (a === 0 || band.data[p] === 1) continue;
|
|
1561
|
+
const u = ((Math.floor(p / W) - box.y0) * under.width + (p % W) - box.x0) * 4;
|
|
1562
|
+
if (under.data[u + 3] < a) {
|
|
1563
|
+
empty++;
|
|
1564
|
+
continue;
|
|
1565
|
+
}
|
|
1566
|
+
base.data.set(under.data.subarray(u, u + 4), p * 4);
|
|
1567
|
+
filled++;
|
|
1568
|
+
}
|
|
1569
|
+
return { filled, empty };
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* A cut's band (issue #170, `overlap`): the polygon's pixels within `overlap`
|
|
1574
|
+
* rig px of its edge, distance read as Chebyshev distance — a pixel of the
|
|
1575
|
+
* polygon is in the band when the `(2 * overlap + 1)`-square centred on it
|
|
1576
|
+
* holds a pixel the polygon does not, i.e. the polygon's mask less its
|
|
1577
|
+
* `erode` by that square (`cv2.erode`'s border: the rig's edge is not the
|
|
1578
|
+
* polygon's edge). Empty at `overlap` 0. The base part keeps its pixels there
|
|
1579
|
+
* as well as the piece, so the piece's resampled edge has art under it.
|
|
1580
|
+
*/
|
|
1581
|
+
export function cutBand(polygonMask: Mask, overlap: number): Mask {
|
|
1582
|
+
const band = newMask(polygonMask.width, polygonMask.height);
|
|
1583
|
+
if (overlap === 0) return band;
|
|
1584
|
+
const inner = erode(polygonMask, 2 * overlap + 1);
|
|
1585
|
+
for (let p = 0; p < band.data.length; p++) band.data[p] = polygonMask.data[p] === 1 && inner.data[p] === 0 ? 1 : 0;
|
|
1586
|
+
return band;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// ---------------------------------------------------------------------------
|
|
1590
|
+
// the stage
|
|
1591
|
+
// ---------------------------------------------------------------------------
|
|
1592
|
+
|
|
1593
|
+
export interface AssembleResult {
|
|
1594
|
+
parts: PartsFile;
|
|
1595
|
+
/** In plan order, each cropped to its record's box. */
|
|
1596
|
+
images: PlacedPart[];
|
|
1597
|
+
recomposite: Raster;
|
|
1598
|
+
/** `recompositeErrorMap`: uncovered error pixels red, covered ones blue, the painting dimmed grey. */
|
|
1599
|
+
errorMap: Raster;
|
|
1600
|
+
figures: RecompositeFigures;
|
|
1601
|
+
/** What each `assemble.cuts` entry took, counted when it was made (`cutLines`); empty without cuts. */
|
|
1602
|
+
cuts: CutCount[];
|
|
1603
|
+
seamRule: SeamRule;
|
|
1604
|
+
projectRule: ProjectRule;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function tagsOf(set: LayerSet): Set<string> {
|
|
1608
|
+
return new Set(set.layers.map((l) => l.name));
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/**
|
|
1612
|
+
* Refuse, by name, every plan and extend entry whose tag its run does not
|
|
1613
|
+
* hold. Collected before any pixel work.
|
|
1614
|
+
*/
|
|
1615
|
+
export function checkPlanAgainstRuns(plan: PlanEntry[], extend: Extend[], full: LayerSet, head: LayerSet): void {
|
|
1616
|
+
const tags = { full: tagsOf(full), head: tagsOf(head) };
|
|
1617
|
+
const problems: Problem[] = [];
|
|
1618
|
+
const known = (run: Run): string => [...tags[run]].sort().join(', ');
|
|
1619
|
+
plan.forEach(([name, run, tag], i) => {
|
|
1620
|
+
if (!tags[run].has(tag)) {
|
|
1621
|
+
problems.push({
|
|
1622
|
+
code: 'ASSEMBLE_PLAN_TAG_IN_RUN',
|
|
1623
|
+
object: `config.assemble.plan[${i}] (part "${name}")`,
|
|
1624
|
+
detail: `takes ${run}:${tag}; the ${run} run has no layer "${tag}" — it holds ${known(run)}`,
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
});
|
|
1628
|
+
extend.forEach((e, i) => {
|
|
1629
|
+
if (!tags[e.run].has(e.tag)) {
|
|
1630
|
+
problems.push({
|
|
1631
|
+
code: 'ASSEMBLE_EXTEND_TAG_IN_RUN',
|
|
1632
|
+
object: `config.assemble.extend_below_crop[${i}] (part "${e.part}")`,
|
|
1633
|
+
detail: `takes ${e.run}:${e.tag}; the ${e.run} run has no layer "${e.tag}" — it holds ${known(e.run)}`,
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
});
|
|
1637
|
+
refuseIfAny(problems);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/**
|
|
1641
|
+
* Run the stage in memory. Everything that can be refused is refused before
|
|
1642
|
+
* this returns; nothing here writes a file, so a caller that writes only
|
|
1643
|
+
* after this returned writes only after green.
|
|
1644
|
+
*/
|
|
1645
|
+
export function assemble(input: AssembleInput): AssembleResult {
|
|
1646
|
+
const { source, full, head, plan, extend, patches, cuts, seamRule, projectRule } = input;
|
|
1647
|
+
const unders = cuts.map((q, i) => input.under?.[i] ?? null);
|
|
1648
|
+
const frame = checkGeometry(
|
|
1649
|
+
{ sourceW: source.width, sourceH: source.height, resolution: input.resolution, headBox: input.headBox, rigScale: input.rigScale },
|
|
1650
|
+
{ full, head },
|
|
1651
|
+
);
|
|
1652
|
+
checkPlanAgainstRuns(plan, extend, full, head);
|
|
1653
|
+
const { W, H } = frame;
|
|
1654
|
+
const srcr = sourceInRig(source, W, H);
|
|
1655
|
+
|
|
1656
|
+
const rig = new Map<string, Raster>();
|
|
1657
|
+
const stats = new Map<string, ProjectionStats>();
|
|
1658
|
+
const ghost: Record<string, number> = {};
|
|
1659
|
+
for (const [run, set] of [['full', full], ['head', head]] as const) {
|
|
1660
|
+
const layers = runLayers(set, input.resolution);
|
|
1661
|
+
const rigLayers = layers.map((l) => layerToRig(l.canvas, frame, run));
|
|
1662
|
+
const st = projectSource(rigLayers, srcr, frame, run, projectRule);
|
|
1663
|
+
layers.forEach((l, i) => {
|
|
1664
|
+
rig.set(`${run}:${l.tag}`, rigLayers[i]);
|
|
1665
|
+
stats.set(`${run}:${l.tag}`, st[i]);
|
|
1666
|
+
ghost[`${run}:${l.tag}`] = l.ghostPx;
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// Patches are checked against the rig before any pixel work, like the plan.
|
|
1671
|
+
const patchProblems: Problem[] = [];
|
|
1672
|
+
patches.forEach((q, i) => {
|
|
1673
|
+
const [, , bx1, by1] = q.box;
|
|
1674
|
+
if (bx1 > W || by1 > H) {
|
|
1675
|
+
patchProblems.push({
|
|
1676
|
+
code: 'ASSEMBLE_PATCH_BOX_INSIDE',
|
|
1677
|
+
object: `config.assemble.patches[${i}] (patch "${q.name}")`,
|
|
1678
|
+
detail: `box is [${q.box.join(', ')}]; the rig is ${W}x${H} (the painting times rig_scale ${frame.S}), so x1 <= ${W} and y1 <= ${H} are required — the box is in rig pixels, x1 and y1 exclusive`,
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
});
|
|
1682
|
+
// Cuts too: a polygon past the rig is in some other space (source pixels,
|
|
1683
|
+
// most likely), and two cuts of one part that share a pixel would give it
|
|
1684
|
+
// to both. Every pair is named.
|
|
1685
|
+
const cutMasks = cuts.map((q) => pixelsInPolygon(q.polygon, W, H));
|
|
1686
|
+
cuts.forEach((q, i) => {
|
|
1687
|
+
const out = q.polygon.filter(([x, y]) => x > W || y > H);
|
|
1688
|
+
if (out.length > 0) {
|
|
1689
|
+
patchProblems.push({
|
|
1690
|
+
code: 'ASSEMBLE_CUT_INSIDE',
|
|
1691
|
+
object: `config.assemble.cuts[${i}] (part "${q.into}")`,
|
|
1692
|
+
detail: `polygon point(s) ${out.map(([x, y]) => `[${x}, ${y}]`).join(', ')} lie past the ${W}x${H} rig (the painting times rig_scale ${frame.S}); x <= ${W} and y <= ${H} are required — the polygon is in rig pixels, the space of parts.json`,
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
for (let j = 0; j < i; j++) {
|
|
1696
|
+
if (cuts[j].from !== q.from) continue;
|
|
1697
|
+
let shared = 0;
|
|
1698
|
+
let first = -1;
|
|
1699
|
+
for (let p = 0; p < W * H; p++) {
|
|
1700
|
+
if (cutMasks[i].data[p] === 1 && cutMasks[j].data[p] === 1) {
|
|
1701
|
+
shared++;
|
|
1702
|
+
if (first < 0) first = p;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
if (shared > 0) {
|
|
1706
|
+
patchProblems.push({
|
|
1707
|
+
code: 'ASSEMBLE_CUT_OVERLAP',
|
|
1708
|
+
object: `config.assemble.cuts[${i}] (part "${q.into}")`,
|
|
1709
|
+
detail: `shares ${shared} rig pixel(s) with cuts[${j}] (part "${cuts[j].into}"), the first at ${first % W},${Math.floor(first / W)}; both cut "${q.from}", and a pixel goes to one part — polygons of one part that do not overlap are required`,
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
});
|
|
1714
|
+
// A cut's under image (issue #193), before any pixel work too: it must have
|
|
1715
|
+
// been read, be exactly the polygon's bounding box, and the piece must be
|
|
1716
|
+
// drawn in front of the part it fills — drawn behind, the fill would hide
|
|
1717
|
+
// the piece where it was painted.
|
|
1718
|
+
const order0 = drawOrder(
|
|
1719
|
+
plan.map((e) => e[0]),
|
|
1720
|
+
patches,
|
|
1721
|
+
cuts,
|
|
1722
|
+
);
|
|
1723
|
+
cuts.forEach((q, i) => {
|
|
1724
|
+
if (q.under === undefined) return;
|
|
1725
|
+
const object = `config.assemble.cuts[${i}].under (part "${q.into}")`;
|
|
1726
|
+
const u = unders[i];
|
|
1727
|
+
if (u === null) {
|
|
1728
|
+
patchProblems.push({ code: 'ASSEMBLE_UNDER_PRESENT', object, detail: `names ${q.under}, and no image was handed to the stage for it; the decoded PNG is required (a caller of assemble() reads the file)` });
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
const box = underBox(q.polygon);
|
|
1732
|
+
if (u.width !== box.w || u.height !== box.h) {
|
|
1733
|
+
patchProblems.push({
|
|
1734
|
+
code: 'ASSEMBLE_UNDER_SIZE',
|
|
1735
|
+
object,
|
|
1736
|
+
detail: `${q.under} is ${u.width}x${u.height}; the polygon's bounding box is ${box.w}x${box.h} at ${box.x0},${box.y0} (least and greatest polygon x and y, in rig pixels), and an image of exactly that size is required — its pixel (i, j) is rig pixel (${box.x0} + i, ${box.y0} + j)`,
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
const at = order0.findIndex((d) => 'cut' in d && d.cut === i);
|
|
1740
|
+
const base = order0.findIndex((d) => 'plan' in d && plan[d.plan][0] === q.from);
|
|
1741
|
+
if (at < base) {
|
|
1742
|
+
patchProblems.push({
|
|
1743
|
+
code: 'ASSEMBLE_UNDER_DRAW',
|
|
1744
|
+
object: `config.assemble.cuts[${i}].draw (part "${q.into}")`,
|
|
1745
|
+
detail: `is ${JSON.stringify(q.draw)}, which draws "${q.into}" behind "${q.from}"; with under, "${q.from}" is filled where the piece was painted and would hide it, so a draw in front of "${q.from}" is required — "front", or {"before": "<the plan part after ${q.from}>"} to keep the setup pose`,
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
});
|
|
1749
|
+
refuseIfAny(patchProblems);
|
|
1750
|
+
|
|
1751
|
+
interface Built {
|
|
1752
|
+
key: string;
|
|
1753
|
+
r: Raster;
|
|
1754
|
+
st: ProjectionStats;
|
|
1755
|
+
extendKeys: string[];
|
|
1756
|
+
trace: MergeTrace;
|
|
1757
|
+
merged: number;
|
|
1758
|
+
/** The rig pixels this piece of the layer owns when the layer is cut, null when it is not: the counts are split by it. */
|
|
1759
|
+
region: Mask | null;
|
|
1760
|
+
}
|
|
1761
|
+
const built: Array<Built | null> = plan.map(() => null);
|
|
1762
|
+
const empty: Problem[] = [];
|
|
1763
|
+
/** The part's alpha box and its `alpha > 8` count, over the whole rig. */
|
|
1764
|
+
const measureBox = (r: Raster): { x0: number; y0: number; x1: number; y1: number; opaque: number } => {
|
|
1765
|
+
let x0 = W;
|
|
1766
|
+
let y0 = H;
|
|
1767
|
+
let x1 = -1;
|
|
1768
|
+
let y1 = -1;
|
|
1769
|
+
let opaque = 0;
|
|
1770
|
+
for (let y = 0; y < H; y++) {
|
|
1771
|
+
for (let x = 0; x < W; x++) {
|
|
1772
|
+
const a = r.data[(y * W + x) * 4 + 3];
|
|
1773
|
+
if (a > OPAQUE_ALPHA_ABOVE) opaque++;
|
|
1774
|
+
if (a > 0) {
|
|
1775
|
+
if (x < x0) x0 = x;
|
|
1776
|
+
if (x > x1) x1 = x;
|
|
1777
|
+
if (y < y0) y0 = y;
|
|
1778
|
+
if (y > y1) y1 = y;
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
return { x0, y0, x1, y1, opaque };
|
|
1783
|
+
};
|
|
1784
|
+
plan.forEach(([name, run, tag], pi) => {
|
|
1785
|
+
const key = `${run}:${tag}`;
|
|
1786
|
+
const src = rig.get(key) as Raster;
|
|
1787
|
+
const r: Raster = { width: W, height: H, data: new Uint8ClampedArray(src.data) };
|
|
1788
|
+
const st = stats.get(key) as ProjectionStats;
|
|
1789
|
+
const extendKeys = extend.filter((e) => e.part === name).map((e) => `${e.run}:${e.tag}`);
|
|
1790
|
+
const extras = extendKeys.map((k) => rig.get(k) as Raster);
|
|
1791
|
+
let merged = 0;
|
|
1792
|
+
const trace: MergeTrace = { from: new Int32Array(W * H).fill(-1), ring: newMask(W, H) };
|
|
1793
|
+
if (extras.length > 0) {
|
|
1794
|
+
const front = newMask(W, H);
|
|
1795
|
+
for (const [, run2, tag2] of plan.slice(pi + 1)) {
|
|
1796
|
+
const f = rig.get(`${run2}:${tag2}`) as Raster;
|
|
1797
|
+
for (let p = 0; p < W * H; p++) if (f.data[p * 4 + 3] >= FRONT_ALPHA) front.data[p] = 1;
|
|
1798
|
+
}
|
|
1799
|
+
merged = mergeBelowCrop(r, extras, front, srcr, frame.headBottom, trace);
|
|
1800
|
+
}
|
|
1801
|
+
const { x1, opaque } = measureBox(r);
|
|
1802
|
+
if (opaque === 0) {
|
|
1803
|
+
empty.push({
|
|
1804
|
+
code: 'ASSEMBLE_PART_OPAQUE',
|
|
1805
|
+
object: `part "${name}" (${key}, config.assemble.plan[${pi}])`,
|
|
1806
|
+
detail: `has 0 pixels with alpha above ${OPAQUE_ALPHA_ABOVE} in the rig (${x1 < 0 ? 'no pixel at all' : 'only fringe below it'}; ${ghost[key]} ghost px removed in the run); a part with at least one opaque pixel is required — drop it from the plan or take the tag from the other run`,
|
|
1807
|
+
});
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
built[pi] = { key, r, st, extendKeys, trace, merged, region: null };
|
|
1811
|
+
});
|
|
1812
|
+
const silhouette = seamRule === 'silhouette' || patches.some((q) => q.alpha === 'silhouette') ? figureSilhouette(srcr) : null;
|
|
1813
|
+
const cut = patches.map((q, i) => {
|
|
1814
|
+
const c = cutPatch(q, srcr, silhouette);
|
|
1815
|
+
if (c === null) {
|
|
1816
|
+
empty.push({
|
|
1817
|
+
code: 'ASSEMBLE_PATCH_OPAQUE',
|
|
1818
|
+
object: `patch "${q.name}" (config.assemble.patches[${i}])`,
|
|
1819
|
+
detail: `takes 0 pixels: ${q.alpha === 'silhouette' ? `the painting's figure silhouette does not reach its box [${q.box.join(', ')}]` : 'its box is empty'}; a patch with at least one pixel is required — move the box onto the figure, or take "alpha": "box"`,
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
return c;
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
// The cuts (issue #170), after step 4 so a piece takes the merged pixels
|
|
1826
|
+
// too: every pixel of the part with alpha above 0 whose centre a cut's
|
|
1827
|
+
// polygon holds moves to that cut's piece, colour and alpha, and is cleared
|
|
1828
|
+
// in the part. The polygons of one part are disjoint (refused above), so
|
|
1829
|
+
// each pixel goes to exactly one piece, and the counts printed are taken
|
|
1830
|
+
// here, before steps 4b and 5.
|
|
1831
|
+
const count = (m: Mask | null, region: Mask): number => {
|
|
1832
|
+
if (m === null) return 0;
|
|
1833
|
+
let n = 0;
|
|
1834
|
+
for (let p = 0; p < W * H; p++) if (m.data[p] === 1 && region.data[p] === 1) n++;
|
|
1835
|
+
return n;
|
|
1836
|
+
};
|
|
1837
|
+
const opaqueOf = (r: Raster): number => {
|
|
1838
|
+
let n = 0;
|
|
1839
|
+
for (let p = 0; p < W * H; p++) if (r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE) n++;
|
|
1840
|
+
return n;
|
|
1841
|
+
};
|
|
1842
|
+
const mergedIn = (b: Built, region: Mask): number => count(b.trace.lastSel ?? null, region) + count(b.trace.lastRing ?? null, region);
|
|
1843
|
+
const pieces: Array<Built | null> = cuts.map(() => null);
|
|
1844
|
+
const cutCounts: CutCount[] = [];
|
|
1845
|
+
plan.forEach(([name], pi) => {
|
|
1846
|
+
const mine = cuts.map((q, i) => i).filter((i) => cuts[i].from === name);
|
|
1847
|
+
const b = built[pi];
|
|
1848
|
+
if (mine.length === 0 || b === null) return;
|
|
1849
|
+
const before = opaqueOf(b.r);
|
|
1850
|
+
const rest = newMask(W, H);
|
|
1851
|
+
rest.data.fill(1);
|
|
1852
|
+
// The base's own pixels: `rest`, and every cut's band, which it keeps as well as the piece.
|
|
1853
|
+
const own = newMask(W, H);
|
|
1854
|
+
const bands: number[] = [];
|
|
1855
|
+
const taken = mine.map((i) => {
|
|
1856
|
+
const m = cutMasks[i];
|
|
1857
|
+
const band = cutBand(m, cuts[i].overlap);
|
|
1858
|
+
const r = newRaster(W, H);
|
|
1859
|
+
let n = 0;
|
|
1860
|
+
let nb = 0;
|
|
1861
|
+
for (let p = 0; p < W * H; p++) {
|
|
1862
|
+
rest.data[p] &= 1 - m.data[p];
|
|
1863
|
+
if (band.data[p] === 1) own.data[p] = 1;
|
|
1864
|
+
const a = b.r.data[p * 4 + 3];
|
|
1865
|
+
if (m.data[p] === 0 || a === 0) continue;
|
|
1866
|
+
r.data.set(b.r.data.subarray(p * 4, p * 4 + 4), p * 4);
|
|
1867
|
+
if (band.data[p] === 0) b.r.data.fill(0, p * 4, p * 4 + 4);
|
|
1868
|
+
if (a > OPAQUE_ALPHA_ABOVE) {
|
|
1869
|
+
n++;
|
|
1870
|
+
if (band.data[p] === 1) nb++;
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
pieces[i] = { ...b, r, region: m, merged: mergedIn(b, m) };
|
|
1874
|
+
bands.push(nb);
|
|
1875
|
+
return n;
|
|
1876
|
+
});
|
|
1877
|
+
for (let p = 0; p < W * H; p++) if (rest.data[p] === 1) own.data[p] = 1;
|
|
1878
|
+
const bandSum = bands.reduce((a, n) => a + n, 0);
|
|
1879
|
+
// What the base kept of the strict partition; its image also holds the bands.
|
|
1880
|
+
const kept = opaqueOf(b.r) - bandSum;
|
|
1881
|
+
const sum = taken.reduce((a, n) => a + n, 0);
|
|
1882
|
+
const object = `part "${name}" (${b.key}, config.assemble.plan[${pi}])`;
|
|
1883
|
+
// By construction: the pieces partition the layer, so their counts add up to the layer's. A refusal here is a bug.
|
|
1884
|
+
const split = [
|
|
1885
|
+
['opaque px', before, kept + sum],
|
|
1886
|
+
['merged_px', b.merged, mergedIn(b, rest) + mine.reduce((a, i) => a + (pieces[i] as Built).merged, 0)],
|
|
1887
|
+
['projected_core_px', b.st.core, count(b.st.coreMask, rest) + mine.reduce((a, i) => a + count(b.st.coreMask, cutMasks[i]), 0)],
|
|
1888
|
+
['source_px_taken', b.st.taken, count(b.st.takenMask, rest) + mine.reduce((a, i) => a + count(b.st.takenMask, cutMasks[i]), 0)],
|
|
1889
|
+
['refused_drift_px', b.st.refused, count(b.st.refusedMask, rest) + mine.reduce((a, i) => a + count(b.st.refusedMask, cutMasks[i]), 0)],
|
|
1890
|
+
] as const;
|
|
1891
|
+
for (const [what, whole, parts] of split) {
|
|
1892
|
+
if (whole !== parts) empty.push({ code: 'ASSEMBLE_COUNTS_ADD_UP', object, detail: `${what}: ${whole} before its cuts, ${parts} over the pieces; equal is required (a bug in the assembler, not in the inputs)` });
|
|
1893
|
+
}
|
|
1894
|
+
mine.forEach((i, k) => {
|
|
1895
|
+
if (taken[k] === 0) {
|
|
1896
|
+
empty.push({
|
|
1897
|
+
code: 'ASSEMBLE_CUT_PIXELS',
|
|
1898
|
+
object: `config.assemble.cuts[${i}] (part "${cuts[i].into}")`,
|
|
1899
|
+
detail: `takes 0 of "${name}"'s ${before} opaque pixel(s) (alpha above ${OPAQUE_ALPHA_ABOVE}); its polygon holds ${count(cutMasks[i], cutMasks[i])} rig pixel(s) and none is that layer's art — a cut that takes at least one is required: draw the polygon over the art (parts/${name}.png, placed at its parts.json x, y, is the layer in rig pixels)`,
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
});
|
|
1903
|
+
if (kept === 0) {
|
|
1904
|
+
empty.push({
|
|
1905
|
+
code: 'ASSEMBLE_CUT_PIXELS',
|
|
1906
|
+
object,
|
|
1907
|
+
detail: `keeps 0 of its ${before} opaque pixel(s): ${mine.map((i, k) => `cuts[${i}] ("${cuts[i].into}") takes ${taken[k]}`).join(', ')}; a part that keeps at least one is required — to move the whole layer, rename the plan part instead`,
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
b.region = own;
|
|
1911
|
+
b.merged = mergedIn(b, own);
|
|
1912
|
+
// Issue #193: the base takes the under image where the cut removed its
|
|
1913
|
+
// pixels, after the counts above were taken from the cut alone. A filled
|
|
1914
|
+
// pixel lies outside the base's region, so it is counted occluded (the
|
|
1915
|
+
// piece is drawn over it) and never projected.
|
|
1916
|
+
const filled = mine.map((i) => {
|
|
1917
|
+
const u = unders[i];
|
|
1918
|
+
if (u === null) return undefined;
|
|
1919
|
+
// Filled where the image is at least as opaque as the layer was; left empty elsewhere (`fillUnder`).
|
|
1920
|
+
const f = fillUnder(b.r, (pieces[i] as Built).r, cutBand(cutMasks[i], cuts[i].overlap), u, underBox(cuts[i].polygon));
|
|
1921
|
+
if (f.filled === 0) {
|
|
1922
|
+
empty.push({
|
|
1923
|
+
code: 'ASSEMBLE_UNDER_EMPTY',
|
|
1924
|
+
object: `config.assemble.cuts[${i}].under (part "${cuts[i].into}")`,
|
|
1925
|
+
detail: `${cuts[i].under} fills 0 of the ${f.empty} pixel(s) the cut removed from "${name}": at every one its alpha is below the alpha removed (an all-transparent image, or another file); an image that fills at least one is required — to leave the whole hole, drop under`,
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
return f;
|
|
1929
|
+
});
|
|
1930
|
+
mine.forEach((i, k) => {
|
|
1931
|
+
const c: CutCount = { from: name, into: cuts[i].into, before, kept, taken: taken[k], band: bands[k] };
|
|
1932
|
+
const f = filled[k];
|
|
1933
|
+
if (f !== undefined) {
|
|
1934
|
+
c.under = f.filled;
|
|
1935
|
+
c.underEmpty = f.empty;
|
|
1936
|
+
}
|
|
1937
|
+
cutCounts.push(c);
|
|
1938
|
+
});
|
|
1939
|
+
});
|
|
1940
|
+
refuseIfAny(empty);
|
|
1941
|
+
// In cut order, as the config lists them.
|
|
1942
|
+
cutCounts.sort((a, b) => cuts.findIndex((q) => q.into === a.into) - cuts.findIndex((q) => q.into === b.into));
|
|
1943
|
+
const order = order0;
|
|
1944
|
+
const pieceAt = (d: { plan: number } | { cut: number }): { name: string; b: Built } =>
|
|
1945
|
+
'plan' in d ? { name: plan[d.plan][0], b: built[d.plan] as Built } : { name: cuts[d.cut].into, b: pieces[d.cut] as Built };
|
|
1946
|
+
|
|
1947
|
+
// Step 4b: push back a fringe the painting shows another part through, over
|
|
1948
|
+
// the plan parts still uncropped and the patches, in draw order.
|
|
1949
|
+
const stack: PlacedPart[] = order.map((d) => {
|
|
1950
|
+
if ('patch' in d) return cut[d.patch] as PlacedPart;
|
|
1951
|
+
const { name, b } = pieceAt(d);
|
|
1952
|
+
const whole: PartRecord = { name, from: b.key, x: 0, y: 0, w: W, h: H, opaque_px: 0, projected_core_px: 0, source_px_taken: 0, refused_drift_px: 0, merged_px: 0, seam_override_px: 0 };
|
|
1953
|
+
return { record: whole, image: b.r };
|
|
1954
|
+
});
|
|
1955
|
+
const pushedBy = pushBackFringe(stack, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null);
|
|
1956
|
+
const pushedOf: FringePushed[][] = plan.map(() => []);
|
|
1957
|
+
const pushedOfCut: FringePushed[][] = cuts.map(() => []);
|
|
1958
|
+
order.forEach((d, i) => {
|
|
1959
|
+
if ('plan' in d) pushedOf[d.plan] = pushedBy[i];
|
|
1960
|
+
if ('cut' in d) pushedOfCut[d.cut] = pushedBy[i];
|
|
1961
|
+
});
|
|
1962
|
+
|
|
1963
|
+
const planned: Array<PlacedPart | null> = plan.map(() => null);
|
|
1964
|
+
const cutPlaced: Array<PlacedPart | null> = cuts.map(() => null);
|
|
1965
|
+
const pieceList: Array<{ name: string; b: Built; pushed: FringePushed[]; at: string; set: (p: PlacedPart) => void }> = [
|
|
1966
|
+
...plan.map(([name], pi) => ({ name, b: built[pi] as Built, pushed: pushedOf[pi], at: `config.assemble.plan[${pi}]`, set: (q: PlacedPart) => (planned[pi] = q) })),
|
|
1967
|
+
...cuts.map((q, i) => ({ name: q.into, b: pieces[i] as Built, pushed: pushedOfCut[i], at: `config.assemble.cuts[${i}]`, set: (pp: PlacedPart) => (cutPlaced[i] = pp) })),
|
|
1968
|
+
];
|
|
1969
|
+
pieceList.forEach(({ name, b, pushed: pushedHere, at, set }) => {
|
|
1970
|
+
const { key, r, st, extendKeys, trace, merged, region } = b;
|
|
1971
|
+
// A pixel the merge wrote is judged in the extend layer's run, every other
|
|
1972
|
+
// in the part's own. Projected = the colour came from projectSource's
|
|
1973
|
+
// accepted set: the part layer's, or for a copied pixel the extend
|
|
1974
|
+
// layer's (its rig layer was projected before it was copied). A ring pixel
|
|
1975
|
+
// is growRim's, counted in merged_px, and is not projection.
|
|
1976
|
+
const visible = newMask(W, H);
|
|
1977
|
+
const projected = newMask(W, H);
|
|
1978
|
+
for (let p = 0; p < W * H; p++) {
|
|
1979
|
+
const e = trace.from[p];
|
|
1980
|
+
const own = e < 0 ? st : (stats.get(extendKeys[e]) as ProjectionStats);
|
|
1981
|
+
// A pixel outside the piece's region is a base pixel filled from a cut's under image (issue #193): occluded, never visible.
|
|
1982
|
+
if (r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE && own.clear.data[p] === 1 && (region === null || region.data[p] === 1)) visible.data[p] = 1;
|
|
1983
|
+
if (trace.ring.data[p] === 0 && own.takenMask.data[p] === 1 && (region === null || region.data[p] === 1)) projected.data[p] = 1;
|
|
1984
|
+
}
|
|
1985
|
+
const { x0, y0, x1, y1, opaque } = measureBox(r);
|
|
1986
|
+
if (opaque === 0) {
|
|
1987
|
+
const pushed = pushedHere.reduce((a, q) => a + q.px, 0);
|
|
1988
|
+
empty.push({
|
|
1989
|
+
code: 'ASSEMBLE_PART_OPAQUE',
|
|
1990
|
+
object: `part "${name}" (${key}, ${at})`,
|
|
1991
|
+
detail: `has 0 pixels with alpha above ${OPAQUE_ALPHA_ABOVE} in the rig once its fringe is pushed back: every one of its pixels was below alpha ${CORE_ALPHA} where the painting shows another part (${pushed} px pushed back, held by ${pushedHere.map((q) => `"${q.part}" ${q.px} px`).join(', ')}); a part with at least one opaque pixel is required — drop it from the plan or take the tag from the other run`,
|
|
1992
|
+
});
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
const vc = visibilityCounts(`part "${name}" (${key})`, r, visible, projected);
|
|
1996
|
+
const record: PartRecord = {
|
|
1997
|
+
name,
|
|
1998
|
+
from: key,
|
|
1999
|
+
x: x0,
|
|
2000
|
+
y: y0,
|
|
2001
|
+
w: x1 - x0 + 1,
|
|
2002
|
+
h: y1 - y0 + 1,
|
|
2003
|
+
opaque_px: opaque,
|
|
2004
|
+
visible_px: vc.visible_px,
|
|
2005
|
+
occluded_px: vc.occluded_px,
|
|
2006
|
+
projected_core_px: region === null ? st.core : count(st.coreMask, region),
|
|
2007
|
+
source_px_taken: region === null ? st.taken : count(st.takenMask, region),
|
|
2008
|
+
visible_not_projected_px: vc.visible_not_projected_px,
|
|
2009
|
+
refused_drift_px: region === null ? st.refused : count(st.refusedMask, region),
|
|
2010
|
+
merged_px: merged,
|
|
2011
|
+
seam_override_px: 0,
|
|
2012
|
+
};
|
|
2013
|
+
if (pushedHere.length > 0) record.fringe_pushed_back = pushedHere;
|
|
2014
|
+
set({ record, image: crop(r, x0, y0, record.w, record.h) });
|
|
2015
|
+
});
|
|
2016
|
+
refuseIfAny(empty);
|
|
2017
|
+
const placed: PlacedPart[] = order.map((d) => ('plan' in d ? planned[d.plan] : 'cut' in d ? cutPlaced[d.cut] : cut[d.patch]) as PlacedPart);
|
|
2018
|
+
|
|
2019
|
+
seamOverride(placed, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null);
|
|
2020
|
+
const can = recomposite(placed, W, H);
|
|
2021
|
+
const figures = measureRecomposite(can, srcr, placed);
|
|
2022
|
+
return {
|
|
2023
|
+
parts: { rig_size: [W, H], scale_rig_per_source: frame.S, parts: placed.map((p) => p.record), ghost_px: ghost, recomposite: recompositeRecord(figures) },
|
|
2024
|
+
images: placed,
|
|
2025
|
+
recomposite: can,
|
|
2026
|
+
errorMap: recompositeErrorMap(can, srcr, placed),
|
|
2027
|
+
figures,
|
|
2028
|
+
cuts: cutCounts,
|
|
2029
|
+
seamRule,
|
|
2030
|
+
projectRule,
|
|
2031
|
+
};
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// ---------------------------------------------------------------------------
|
|
2035
|
+
// --propose-plan
|
|
2036
|
+
// ---------------------------------------------------------------------------
|
|
2037
|
+
|
|
2038
|
+
/**
|
|
2039
|
+
* The tags the reference proposes from the HEAD run. Its own set, kept
|
|
2040
|
+
* verbatim — it is not `src/tags.ts`'s head group: it adds the hair and the
|
|
2041
|
+
* neck, and it holds only the split forms of the eye, brow and ear tags, and
|
|
2042
|
+
* neither `eyewear` nor `nose`. So an unsplit `eyebrow`, or `eyewear`, is
|
|
2043
|
+
* proposed from the full run.
|
|
2044
|
+
*/
|
|
2045
|
+
export const PROPOSE_HEAD_TAGS: readonly string[] = [
|
|
2046
|
+
'back hair',
|
|
2047
|
+
'front hair',
|
|
2048
|
+
'face',
|
|
2049
|
+
'ears-r',
|
|
2050
|
+
'ears-l',
|
|
2051
|
+
'headwear',
|
|
2052
|
+
'earwear',
|
|
2053
|
+
'mouth',
|
|
2054
|
+
'neck',
|
|
2055
|
+
'eyewhite-r',
|
|
2056
|
+
'eyewhite-l',
|
|
2057
|
+
'irides-r',
|
|
2058
|
+
'irides-l',
|
|
2059
|
+
'eyelash-r',
|
|
2060
|
+
'eyelash-l',
|
|
2061
|
+
'eyebrow-r',
|
|
2062
|
+
'eyebrow-l',
|
|
2063
|
+
];
|
|
2064
|
+
|
|
2065
|
+
/** The part name the reference proposes for a tag; any other tag becomes its own name with spaces and hyphens as underscores. */
|
|
2066
|
+
export const PROPOSE_ALIAS: Readonly<Record<string, string>> = {
|
|
2067
|
+
'back hair': 'hair_back',
|
|
2068
|
+
'front hair': 'hair_front',
|
|
2069
|
+
footwear: 'shoes',
|
|
2070
|
+
'irides-r': 'iris_r',
|
|
2071
|
+
'irides-l': 'iris_l',
|
|
2072
|
+
'eyelash-r': 'lash_r',
|
|
2073
|
+
'eyelash-l': 'lash_l',
|
|
2074
|
+
'eyebrow-r': 'brow_r',
|
|
2075
|
+
'eyebrow-l': 'brow_l',
|
|
2076
|
+
'ears-r': 'ear_r',
|
|
2077
|
+
'ears-l': 'ear_l',
|
|
2078
|
+
headwear: 'hairpin',
|
|
2079
|
+
earwear: 'earring',
|
|
2080
|
+
};
|
|
2081
|
+
|
|
2082
|
+
function aliasOf(tag: string): string {
|
|
2083
|
+
return PROPOSE_ALIAS[tag] ?? tag.replaceAll(' ', '_').replaceAll('-', '_');
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
export interface PlanProposal {
|
|
2087
|
+
plan: PlanEntry[];
|
|
2088
|
+
extend_below_crop: Extend[];
|
|
2089
|
+
notes: string[];
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
/** Python's `%.0f`: round half to even on the binary value. */
|
|
2093
|
+
function fmt0(v: number): string {
|
|
2094
|
+
const f = Math.floor(v);
|
|
2095
|
+
const d = v - f;
|
|
2096
|
+
const r = d > 0.5 ? f + 1 : d < 0.5 ? f : f % 2 === 0 ? f : f + 1;
|
|
2097
|
+
return String(r);
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
/**
|
|
2101
|
+
* A default `assemble.plan` and `extend_below_crop` from the two runs, with
|
|
2102
|
+
* notes on every choice that was not the default.
|
|
2103
|
+
*
|
|
2104
|
+
* ⚖️ Invariant (`assemble_parts.py` `propose_plan`): a tag is proposable when
|
|
2105
|
+
* it is not `nose` and holds at least `PROPOSE_MIN_PX` run pixels of alpha
|
|
2106
|
+
* above 8 after ghost clean-up. Head tags (`PROPOSE_HEAD_TAGS`) come from the
|
|
2107
|
+
* head run, the rest from the full run. Order, back to front: `back hair`;
|
|
2108
|
+
* the body layers in the full run's draw order, with `neck` placed just before
|
|
2109
|
+
* the first of topwear, bottomwear or neckwear; then the remaining head layers
|
|
2110
|
+
* in the head run's draw order. A `headwear`/`earwear` the head run drops or
|
|
2111
|
+
* shrinks (its rig-pixel area below 0.8 of the full run's) is taken from the
|
|
2112
|
+
* full run instead, drawn right after `back hair` — noted. `front hair` and
|
|
2113
|
+
* `back hair` taken from the head run are extended below the crop from the
|
|
2114
|
+
* full run when the full run's layer reaches more than 8 rig pixels below the
|
|
2115
|
+
* crop line. A lone `handwear` layer is named `sleeves`.
|
|
2116
|
+
*
|
|
2117
|
+
* ⚖️ This port's, not the reference's: a layer that crosses a plausibility
|
|
2118
|
+
* rule (`implausibleRules` in `src/layers.ts` — mostly translucent, a pale
|
|
2119
|
+
* background-coloured haze, or out of proportion with the rest of its run's
|
|
2120
|
+
* figure) is neither proposed, nor taken as the head-run fallback, nor extended
|
|
2121
|
+
* below the crop, and a note names the layer, its three figures and every rule
|
|
2122
|
+
* it crossed with the bar. Those notes follow the reference's. [observed] No
|
|
2123
|
+
* layer of either public example crosses a rule, so their proposals are the
|
|
2124
|
+
* reference's byte for byte.
|
|
2125
|
+
*/
|
|
2126
|
+
export function proposePlan(full: LayerSet, head: LayerSet, g: Geometry, minPx = PROPOSE_MIN_PX): PlanProposal {
|
|
2127
|
+
const frame = checkGeometry(g, { full, head });
|
|
2128
|
+
const runs = { full: runLayers(full, g.resolution), head: runLayers(head, g.resolution) };
|
|
2129
|
+
const find = (run: Run, tag: string): RunLayer | undefined => runs[run].find((l) => l.tag === tag);
|
|
2130
|
+
const figures: Record<Run, LayerFigures[]> = { full: layerFigures(full), head: layerFigures(head) };
|
|
2131
|
+
const dropNotes: string[] = [];
|
|
2132
|
+
const noted = new Set<string>();
|
|
2133
|
+
/** The plausibility rules `run:tag` crosses; the first time a consulted layer crosses any, a note says so. */
|
|
2134
|
+
const implausible = (run: Run, tag: string, what: string): boolean => {
|
|
2135
|
+
const f = figures[run].find((x) => x.name === tag);
|
|
2136
|
+
const rules = f === undefined ? [] : implausibleRules(f);
|
|
2137
|
+
if (f === undefined || rules.length === 0) return false;
|
|
2138
|
+
if (!noted.has(`${run}:${tag}`)) {
|
|
2139
|
+
noted.add(`${run}:${tag}`);
|
|
2140
|
+
dropNotes.push(`${tag}: ${run} run layer ${figuresPhrase(f)} -> ${what} by ${rules.map((r) => `${r} (${ruleSummary(r)})`).join(', ')}`);
|
|
2141
|
+
}
|
|
2142
|
+
return true;
|
|
2143
|
+
};
|
|
2144
|
+
const ok = (run: Run, tag: string): boolean => {
|
|
2145
|
+
const l = find(run, tag);
|
|
2146
|
+
return tag !== 'nose' && l !== undefined && l.opaqueRunPx >= minPx && !implausible(run, tag, 'not proposed');
|
|
2147
|
+
};
|
|
2148
|
+
const body = runs.full.filter((l) => !PROPOSE_HEAD_TAGS.includes(l.tag) && ok('full', l.tag)).map((l) => l.tag);
|
|
2149
|
+
const heads = runs.head.filter((l) => PROPOSE_HEAD_TAGS.includes(l.tag) && ok('head', l.tag)).map((l) => l.tag);
|
|
2150
|
+
const hw = body.filter((t) => t.startsWith('handwear'));
|
|
2151
|
+
const plan: PlanEntry[] = [];
|
|
2152
|
+
const drop = (t: string): void => {
|
|
2153
|
+
const i = heads.indexOf(t);
|
|
2154
|
+
if (i >= 0) heads.splice(i, 1);
|
|
2155
|
+
};
|
|
2156
|
+
if (heads.includes('back hair')) {
|
|
2157
|
+
plan.push([PROPOSE_ALIAS['back hair'], 'head', 'back hair']);
|
|
2158
|
+
drop('back hair');
|
|
2159
|
+
}
|
|
2160
|
+
let neckDone = !heads.includes('neck');
|
|
2161
|
+
for (const t of body) {
|
|
2162
|
+
if (!neckDone && (t === 'topwear' || t === 'bottomwear' || t === 'neckwear')) {
|
|
2163
|
+
plan.push(['neck', 'head', 'neck']);
|
|
2164
|
+
drop('neck');
|
|
2165
|
+
neckDone = true;
|
|
2166
|
+
}
|
|
2167
|
+
plan.push([t.startsWith('handwear') && hw.length === 1 ? 'sleeves' : aliasOf(t), 'full', t]);
|
|
2168
|
+
}
|
|
2169
|
+
const notes: string[] = [];
|
|
2170
|
+
const headArea = (frame.headK * frame.S) ** 2;
|
|
2171
|
+
const fullArea = (frame.fullK * frame.S) ** 2;
|
|
2172
|
+
for (const t of ['headwear', 'earwear']) {
|
|
2173
|
+
const hl = find('head', t);
|
|
2174
|
+
const fl = find('full', t);
|
|
2175
|
+
const hp = hl !== undefined ? hl.opaqueRunPx * headArea : 0;
|
|
2176
|
+
const fp = fl !== undefined ? fl.opaqueRunPx * fullArea : 0;
|
|
2177
|
+
if (ok('full', t) && hp < 0.8 * fp) {
|
|
2178
|
+
drop(t);
|
|
2179
|
+
const at = plan.length > 0 && plan[0][2] === 'back hair' ? 1 : 0;
|
|
2180
|
+
plan.splice(at, 0, [PROPOSE_ALIAS[t], 'full', t]);
|
|
2181
|
+
notes.push(`${t}: head run ${fmt0(hp)} rig px < 0.8 x full run ${fmt0(fp)} -> full-run layer, drawn behind the face`);
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
for (const t of heads) plan.push([aliasOf(t), 'head', t]);
|
|
2185
|
+
const extend: Extend[] = [];
|
|
2186
|
+
for (const t of ['front hair', 'back hair']) {
|
|
2187
|
+
const fl = find('full', t);
|
|
2188
|
+
if (fl === undefined || !plan.some((p) => p[1] === 'head' && p[2] === t)) continue;
|
|
2189
|
+
if (implausible('full', t, 'not extended below the crop')) continue;
|
|
2190
|
+
let last = -1;
|
|
2191
|
+
const n = g.resolution;
|
|
2192
|
+
for (let y = 0; y < n; y++) {
|
|
2193
|
+
for (let x = 0; x < n; x++) {
|
|
2194
|
+
if (fl.canvas.data[(y * n + x) * 4 + 3] > OPAQUE_ALPHA_ABOVE) {
|
|
2195
|
+
last = y;
|
|
2196
|
+
break;
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
// The run row maps to the rig as the warp maps it: k * row, less the top pad in rig px.
|
|
2201
|
+
if (last >= 0 && (last + 1) * frame.fullK * frame.S - frame.fullPadTop * frame.S > frame.headBottom + 8) extend.push({ part: PROPOSE_ALIAS[t], run: 'full', tag: t });
|
|
2202
|
+
}
|
|
2203
|
+
return { plan, extend_below_crop: extend, notes: [...notes, ...dropNotes] };
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
// ---------------------------------------------------------------------------
|
|
2207
|
+
// what the stage reads from a config
|
|
2208
|
+
// ---------------------------------------------------------------------------
|
|
2209
|
+
|
|
2210
|
+
/**
|
|
2211
|
+
* The config fields the stage reads: `seethrough.resolution` and `.head_box`,
|
|
2212
|
+
* `assemble.rig_scale`, `.plan`, `.extend_below_crop`, `.patches` and `.cuts` — and
|
|
2213
|
+
* nothing of the rig. The CLI hands it a config from
|
|
2214
|
+
* `parseEarlyConfig(raw, 'assemble')`; a config the full loader accepted is
|
|
2215
|
+
* the same shape with more in it. The head box is refused here rather than by
|
|
2216
|
+
* the loader, because `inputs` runs before it exists; `seethrough` too, for a
|
|
2217
|
+
* full-loader config, where it is optional.
|
|
2218
|
+
*/
|
|
2219
|
+
export function stageFields(cfg: Pick<CharacterConfig, 'seethrough' | 'assemble'>): {
|
|
2220
|
+
resolution: number;
|
|
2221
|
+
headBox: [number, number, number, number];
|
|
2222
|
+
rigScale: number;
|
|
2223
|
+
plan: PlanEntry[];
|
|
2224
|
+
extend: Extend[];
|
|
2225
|
+
patches: Patch[];
|
|
2226
|
+
cuts: Cut[];
|
|
2227
|
+
} {
|
|
2228
|
+
const problems: Problem[] = [];
|
|
2229
|
+
if (cfg.seethrough === undefined) {
|
|
2230
|
+
problems.push({ code: 'ASSEMBLE_FIELD_PRESENT', object: 'config.seethrough', detail: 'is absent; assemble reads seethrough.resolution and seethrough.head_box, which place the two runs on the painting' });
|
|
2231
|
+
} else if (cfg.seethrough.head_box === undefined) {
|
|
2232
|
+
problems.push({ code: 'ASSEMBLE_FIELD_PRESENT', object: 'config.seethrough.head_box', detail: 'is absent; the head run cannot be placed on the painting without the box it was cropped from' });
|
|
2233
|
+
}
|
|
2234
|
+
refuseIfAny(problems);
|
|
2235
|
+
const st = cfg.seethrough as SeeThrough;
|
|
2236
|
+
return {
|
|
2237
|
+
resolution: st.resolution,
|
|
2238
|
+
headBox: st.head_box as [number, number, number, number],
|
|
2239
|
+
rigScale: cfg.assemble.rig_scale,
|
|
2240
|
+
plan: cfg.assemble.plan,
|
|
2241
|
+
extend: cfg.assemble.extend_below_crop ?? [],
|
|
2242
|
+
patches: cfg.assemble.patches ?? [],
|
|
2243
|
+
cuts: cfg.assemble.cuts ?? [],
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
/**
|
|
2248
|
+
* The three numbers `--propose-plan` reads, from a config that does not have a
|
|
2249
|
+
* plan yet. The config comes through `parseEarlyConfig` (`src/config.ts`), the
|
|
2250
|
+
* one partial entry point, which has already checked the fields with the full
|
|
2251
|
+
* loader's rules; what is left here is that this stage needs the head box,
|
|
2252
|
+
* which that loader leaves optional.
|
|
2253
|
+
*/
|
|
2254
|
+
export function proposeFields(cfg: EarlyConfig): { resolution: number; headBox: [number, number, number, number]; rigScale: number } {
|
|
2255
|
+
if (cfg.seethrough.head_box === undefined) {
|
|
2256
|
+
refuseIfAny([{ code: 'ASSEMBLE_FIELD_PRESENT', object: 'config.seethrough.head_box', detail: 'is absent; the plan is proposed from both runs, and the head run cannot be placed on the painting without the box it was cropped from — run `propose --head-box` first' }]);
|
|
2257
|
+
}
|
|
2258
|
+
return { resolution: cfg.seethrough.resolution, headBox: cfg.seethrough.head_box as [number, number, number, number], rigScale: cfg.assemble.rig_scale };
|
|
2259
|
+
}
|