rig-parts 2.4.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.ts +11 -9
- package/docs/AUTHORING.md +87 -6
- package/docs/STABILITY.md +8 -1
- package/package.json +2 -2
- package/src/assemble.ts +70 -19
- package/src/build.ts +17 -6
- package/src/check.ts +176 -20
- package/src/config.ts +12 -6
- package/src/paintingedges.ts +543 -0
- package/src/projectrule.ts +22 -0
- package/src/provenance.ts +1 -1
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { OPAQUE_ALPHA_ABOVE } from './layers.ts';
|
|
2
|
+
import { connectedComponents } from './raster/components.ts';
|
|
3
|
+
import { type Mask, newMask, type Raster, RasterError } from './raster/types.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Issue #953, under `projectRule: 'visible'` only: the parts' visible edges are
|
|
7
|
+
* resolved from the painting, at the rig's own density, and See-through keeps
|
|
8
|
+
* what lies under another part (its hidden surface) and the partition into
|
|
9
|
+
* parts. Not in the reference.
|
|
10
|
+
*
|
|
11
|
+
* Why: a See-through run is capped at its resolution (1024 on the public
|
|
12
|
+
* examples, over the padded square of the painting), so every run layer is
|
|
13
|
+
* enlarged into the rig and its alpha edge is a soft ramp the painting does not
|
|
14
|
+
* have; where two parts meet along a painted line each layer repaints the line
|
|
15
|
+
* on its own side of its own ramp, which reads as a doubled line, a stair-step
|
|
16
|
+
* and, where neither layer is opaque, the background through a gap. Projection
|
|
17
|
+
* (`projectSource`) recolours only a layer's opaque area, so it cannot reach
|
|
18
|
+
* either.
|
|
19
|
+
*
|
|
20
|
+
* The step reads the final stack (every part on the full rig canvas, in draw
|
|
21
|
+
* order) and the painting resampled into the rig, and writes alpha and colour
|
|
22
|
+
* at the pixels near a visible outline. Everything else is unchanged.
|
|
23
|
+
*
|
|
24
|
+
* ⚖️ Invariant. Let OWNER(p) be the top-most part with alpha above
|
|
25
|
+
* {@link EDGE_OWNER_ALPHA} at p, or the background (-1): See-through's own
|
|
26
|
+
* half-coverage line, which says which parts meet and roughly where. A pixel
|
|
27
|
+
* is an EDGE pixel when another owner lies within {@link EDGE_RADIUS}
|
|
28
|
+
* (Chebyshev) — the nearest one, ties to the part drawn in front. Of the pair
|
|
29
|
+
* (p's owner, that nearest other), FRONT is the one drawn in front and BEHIND
|
|
30
|
+
* the other (the background is behind every part). Inside the
|
|
31
|
+
* `(2 * EDGE_RADIUS + 1)²` window around p:
|
|
32
|
+
*
|
|
33
|
+
* - the behind colour `Cb` is the per-channel median of the painting over the
|
|
34
|
+
* window's BEHIND-owned pixels farthest from FRONT (the window pixels least
|
|
35
|
+
* likely to be the edge), so a painted line between the two never sets it;
|
|
36
|
+
* - the front reference `Cf` is the painting at the window's FRONT-owned pixel
|
|
37
|
+
* farthest from `Cb` (max channel, the distance every other assemble limit
|
|
38
|
+
* is in; first in scan order on a tie) — a painted line along FRONT's
|
|
39
|
+
* outline counts as FRONT, which gives the line to the part drawn in front,
|
|
40
|
+
* the part whose outline it is in the rig;
|
|
41
|
+
* - FRONT's coverage at p is P's position along `Cb -> Cf` (integer dot
|
|
42
|
+
* products), normalised by a trimap: 0 at the highest position among the
|
|
43
|
+
* BEHIND-side pixels above, 1 at the lowest among FRONT's window pixels
|
|
44
|
+
* farthest from BEHIND. Where those two sides do not separate along the axis
|
|
45
|
+
* (a side is all edge, or the textures overlap) — counted as `unseparated` —
|
|
46
|
+
* the ends are `Cb` and `Cf` themselves. On a mix of the two colours it is
|
|
47
|
+
* the mixing weight, and a texture of BEHIND across that axis reads as 0
|
|
48
|
+
* rather than as FRONT (measured: a distance-only weight left a 10–27 % halo
|
|
49
|
+
* of the face over the hair at 3–4 rig px from the face's edge on
|
|
50
|
+
* reference-sample);
|
|
51
|
+
* - on FRONT's side of See-through's line, a pixel whose colour FRONT itself
|
|
52
|
+
* holds deeper in (within half of {@link EDGE_CONTRAST}, and nearer that
|
|
53
|
+
* than any BEHIND-side pixel) is FRONT's interior — a lining, a highlight —
|
|
54
|
+
* and reads 1, however it falls along the axis.
|
|
55
|
+
*
|
|
56
|
+
* A painted pixel no part covers above the owner floor — the gap between two
|
|
57
|
+
* soft rims — is owned by the nearest part (within `2 * EDGE_RADIUS + 2`), so
|
|
58
|
+
* the gap is never read as the background's colour.
|
|
59
|
+
*
|
|
60
|
+
* The ring rule. Rings are counted in Chebyshev distance from the other owner.
|
|
61
|
+
* FRONT's coverage never rises going away from FRONT and never falls going
|
|
62
|
+
* into it: on the inner ring (FRONT-owned, next to the line) it is at least
|
|
63
|
+
* that of its resolved outer-ring neighbours (a painted line just outside
|
|
64
|
+
* See-through's line is FRONT's outline, so what lies between is FRONT's too);
|
|
65
|
+
* deeper inside it is the greater of See-through's own alpha and the
|
|
66
|
+
* painting's (the ramp past the half line is filled; a light interior is never
|
|
67
|
+
* holed); farther out it is no more than its neighbour one ring nearer.
|
|
68
|
+
* Resolved ring by ring, outer before inner.
|
|
69
|
+
*
|
|
70
|
+
* FRONT's alpha becomes that coverage (as 0..255, rounded; within
|
|
71
|
+
* {@link OPAQUE_ALPHA_ABOVE} of 0 or 255 it is snapped there), and its colour
|
|
72
|
+
* the painting unmixed from `Cb` (`Cb + (P - Cb) / a`, clamped); at `a = 1` that
|
|
73
|
+
* is the painting's pixel. Where that colour clamps so the composite would
|
|
74
|
+
* miss the painting by more than half of {@link EDGE_CONTRAST} (a painted line
|
|
75
|
+
* darker or lighter than both colours), FRONT's alpha rises to the least that
|
|
76
|
+
* brings it within. Where FRONT does not cover p fully and BEHIND is a
|
|
77
|
+
* part, BEHIND is made opaque there in `Cb` (in the painting's own colour
|
|
78
|
+
* where FRONT's alpha is 0), so the flat composite at p is the painting, the
|
|
79
|
+
* rim stays FRONT's, and BEHIND's hidden surface past the window is untouched.
|
|
80
|
+
*
|
|
81
|
+
* A pixel is left as it was — and counted, by reason — when a part drawn in
|
|
82
|
+
* front of FRONT, or between BEHIND and FRONT, has alpha above
|
|
83
|
+
* {@link OPAQUE_ALPHA_ABOVE} there (its coverage is not modelled: a
|
|
84
|
+
* translucent part is never erased; at or below that floor a part is not
|
|
85
|
+
* opaque anywhere in this package, and it is left as it was); when
|
|
86
|
+
* `|Cf - Cb|` (max channel) is below {@link EDGE_CONTRAST} (the painting draws
|
|
87
|
+
* no edge between the two: a pair under the recomposite's error limit cannot
|
|
88
|
+
* hold an error pixel); when
|
|
89
|
+
* either part may not be edited (`editable`: a painting patch is the painting
|
|
90
|
+
* already); when either part keeps the pixel (`keep`, issue #637), feathers it
|
|
91
|
+
* (`feathered`, a limb's authored soft edge) or the two are a turn's moving
|
|
92
|
+
* pair; or when the window holds no BEHIND-owned pixel.
|
|
93
|
+
*
|
|
94
|
+
* A part's art (alpha above {@link OPAQUE_ALPHA_ABOVE}) keeps its pieces,
|
|
95
|
+
* read 4-connected as `CONTOUR_ONE_ISLAND` reads them: where the step would
|
|
96
|
+
* split a piece, each cut-off part is joined back to the piece's largest part
|
|
97
|
+
* along the path that restores the fewest lowered pixels, and those are not
|
|
98
|
+
* written; a new piece
|
|
99
|
+
* holding none of the part's art is not written (`pieces`, counted per part
|
|
100
|
+
* and pixel). Where that leaves out BEHIND's fill under FRONT, FRONT takes the
|
|
101
|
+
* painting's pixel at full alpha instead, under the same rule.
|
|
102
|
+
*
|
|
103
|
+
* Every decision is read off the stack as it stood before the step, then
|
|
104
|
+
* applied, so the result does not depend on the order pixels are visited.
|
|
105
|
+
* Deterministic: integer rig arithmetic, medians by sort, no float reduction
|
|
106
|
+
* whose order varies.
|
|
107
|
+
*/
|
|
108
|
+
export const EDGE_RADIUS = 3;
|
|
109
|
+
/** The owner's alpha floor: above it a part covers a pixel. Equal to `COVERED_ALPHA` in `src/assemble.ts` (held by the selftest). */
|
|
110
|
+
export const EDGE_OWNER_ALPHA = 128;
|
|
111
|
+
/** The least front contrast resolved, max channel. Equal to `ERROR_LIMIT` in `src/assemble.ts` (held by the selftest). */
|
|
112
|
+
export const EDGE_CONTRAST = 40;
|
|
113
|
+
|
|
114
|
+
/** Why an edge pixel was left as it was; see {@link paintingEdges}. */
|
|
115
|
+
export interface PaintingEdgeSkips {
|
|
116
|
+
cover: number;
|
|
117
|
+
contrast: number;
|
|
118
|
+
locked: number;
|
|
119
|
+
noBehind: number;
|
|
120
|
+
/** Part writes left out so a part's art keeps its 4-connected pieces (counted per part and pixel, not per edge pixel). */
|
|
121
|
+
pieces: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface PaintingEdgeResult {
|
|
125
|
+
/** Per part (stack index), the pixels whose RGBA the step changed. */
|
|
126
|
+
changed: Mask[];
|
|
127
|
+
/** Edge pixels resolved (whether or not a value changed), and the ones left. */
|
|
128
|
+
resolved: number;
|
|
129
|
+
/** Of `resolved`, the pixels whose two far sides did not separate along the axis, read against `Cb` and `Cf` themselves. */
|
|
130
|
+
unseparated: number;
|
|
131
|
+
skipped: PaintingEdgeSkips;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface PaintingEdgeOptions {
|
|
135
|
+
/**
|
|
136
|
+
* Per rig pixel, 1 where the painting shows figure (`paintedFigure` in
|
|
137
|
+
* `src/assemble.ts`). A figure pixel no part covers above
|
|
138
|
+
* {@link EDGE_OWNER_ALPHA} — a gap between two parts' soft rims — is owned by
|
|
139
|
+
* the nearest part within the cap, not the background: the painting holds
|
|
140
|
+
* figure there, and taking the behind colour from it would take a painted line.
|
|
141
|
+
*/
|
|
142
|
+
painted: Uint8Array;
|
|
143
|
+
/** Per part: whether the step may write it (false for a painting patch). */
|
|
144
|
+
editable: readonly boolean[];
|
|
145
|
+
keep?: ReadonlyArray<Mask | null>;
|
|
146
|
+
feathered?: ReadonlyArray<Mask | null>;
|
|
147
|
+
/** Two parts a turn moves against each other; their shared edge is left alone. */
|
|
148
|
+
movingPair?: ((a: number, b: number) => boolean) | null;
|
|
149
|
+
/** Called once per part and pixel whose RGBA changed. */
|
|
150
|
+
written?: (part: number, pixel: number) => void;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Chebyshev distance to the nearest set pixel, capped at `cap` (two-pass, exact for the 8-neighbour metric). */
|
|
154
|
+
function chebyshev(set: Uint8Array, W: number, H: number, cap: number): Uint8Array {
|
|
155
|
+
const d = new Uint8Array(W * H).fill(cap);
|
|
156
|
+
for (let p = 0; p < W * H; p++) if (set[p] === 1) d[p] = 0;
|
|
157
|
+
for (let y = 0; y < H; y++) {
|
|
158
|
+
for (let x = 0; x < W; x++) {
|
|
159
|
+
const p = y * W + x;
|
|
160
|
+
let v = d[p];
|
|
161
|
+
if (v === 0) continue;
|
|
162
|
+
if (x > 0) v = Math.min(v, d[p - 1] + 1);
|
|
163
|
+
if (y > 0) {
|
|
164
|
+
v = Math.min(v, d[p - W] + 1);
|
|
165
|
+
if (x > 0) v = Math.min(v, d[p - W - 1] + 1);
|
|
166
|
+
if (x < W - 1) v = Math.min(v, d[p - W + 1] + 1);
|
|
167
|
+
}
|
|
168
|
+
d[p] = Math.min(v, cap);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (let y = H - 1; y >= 0; y--) {
|
|
172
|
+
for (let x = W - 1; x >= 0; x--) {
|
|
173
|
+
const p = y * W + x;
|
|
174
|
+
let v = d[p];
|
|
175
|
+
if (v === 0) continue;
|
|
176
|
+
if (x < W - 1) v = Math.min(v, d[p + 1] + 1);
|
|
177
|
+
if (y < H - 1) {
|
|
178
|
+
v = Math.min(v, d[p + W] + 1);
|
|
179
|
+
if (x < W - 1) v = Math.min(v, d[p + W + 1] + 1);
|
|
180
|
+
if (x > 0) v = Math.min(v, d[p + W - 1] + 1);
|
|
181
|
+
}
|
|
182
|
+
d[p] = Math.min(v, cap);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return d;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function maxChannel(a: Uint8ClampedArray, i: number, c: readonly number[]): number {
|
|
189
|
+
return Math.max(Math.abs(a[i] - c[0]), Math.abs(a[i + 1] - c[1]), Math.abs(a[i + 2] - c[2]));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Lower median of a list of integers (sorted copy; deterministic). */
|
|
193
|
+
function median(v: number[]): number {
|
|
194
|
+
const s = v.slice().sort((a, b) => a - b);
|
|
195
|
+
return s[(s.length - 1) >> 1];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
interface Decision {
|
|
199
|
+
p: number;
|
|
200
|
+
front: number;
|
|
201
|
+
behind: number;
|
|
202
|
+
/** FRONT's coverage read off the painting (0..255, before the ring rule), and FRONT's own alpha there before the step. */
|
|
203
|
+
matte: number;
|
|
204
|
+
own: number;
|
|
205
|
+
/** Which side of See-through's half-coverage line p is on (FRONT-owned or not), and its distance to the other owner. */
|
|
206
|
+
inside: boolean;
|
|
207
|
+
ring: number;
|
|
208
|
+
cb: [number, number, number];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** See the invariant above {@link EDGE_RADIUS}. Modifies `parts` (full rig canvases, draw order) in place. */
|
|
212
|
+
export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEdgeOptions): PaintingEdgeResult {
|
|
213
|
+
const W = srcr.width;
|
|
214
|
+
const H = srcr.height;
|
|
215
|
+
const n = parts.length;
|
|
216
|
+
// Every part is read as the whole rig canvas; a cropped image would be read at the wrong place.
|
|
217
|
+
parts.forEach((r, i) => {
|
|
218
|
+
if (r.width !== W || r.height !== H) throw new RasterError(`paintingEdges: part ${i} is ${r.width}x${r.height}; the rig canvas ${W}x${H} is required (place a cropped part on it first)`);
|
|
219
|
+
});
|
|
220
|
+
const R = EDGE_RADIUS;
|
|
221
|
+
const cap = 2 * R + 2;
|
|
222
|
+
const owner = new Int16Array(W * H).fill(-1);
|
|
223
|
+
for (let p = 0; p < W * H; p++) {
|
|
224
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
225
|
+
if (parts[i].data[p * 4 + 3] > EDGE_OWNER_ALPHA) {
|
|
226
|
+
owner[p] = i;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// A painted pixel no part covers goes to the nearest part (ties to the one drawn in front); farther than the cap, it stays background.
|
|
232
|
+
{
|
|
233
|
+
const near: Uint8Array[] = [];
|
|
234
|
+
for (let k = 0; k < n; k++) {
|
|
235
|
+
const set = new Uint8Array(W * H);
|
|
236
|
+
for (let p = 0; p < W * H; p++) if (owner[p] === k) set[p] = 1;
|
|
237
|
+
near.push(chebyshev(set, W, H, cap));
|
|
238
|
+
}
|
|
239
|
+
const fill: Array<[number, number]> = [];
|
|
240
|
+
for (let p = 0; p < W * H; p++) {
|
|
241
|
+
if (owner[p] !== -1 || options.painted[p] !== 1) continue;
|
|
242
|
+
let best = -1;
|
|
243
|
+
let bd = cap;
|
|
244
|
+
for (let k = n - 1; k >= 0; k--) if (near[k][p] < bd) { best = k; bd = near[k][p]; }
|
|
245
|
+
if (best >= 0) fill.push([p, best]);
|
|
246
|
+
}
|
|
247
|
+
for (const [p, k] of fill) owner[p] = k;
|
|
248
|
+
}
|
|
249
|
+
// Distance to each owner, the background at index n.
|
|
250
|
+
const dist: Uint8Array[] = [];
|
|
251
|
+
for (let k = 0; k <= n; k++) {
|
|
252
|
+
const set = new Uint8Array(W * H);
|
|
253
|
+
const label = k === n ? -1 : k;
|
|
254
|
+
let any = false;
|
|
255
|
+
for (let p = 0; p < W * H; p++) {
|
|
256
|
+
if (owner[p] === label) {
|
|
257
|
+
set[p] = 1;
|
|
258
|
+
any = true;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
dist.push(any ? chebyshev(set, W, H, cap) : new Uint8Array(W * H).fill(cap));
|
|
262
|
+
}
|
|
263
|
+
const distOf = (label: number): Uint8Array => dist[label < 0 ? n : label];
|
|
264
|
+
const skipped: PaintingEdgeSkips = { cover: 0, contrast: 0, locked: 0, noBehind: 0, pieces: 0 };
|
|
265
|
+
const decisions: Decision[] = [];
|
|
266
|
+
let resolved = 0;
|
|
267
|
+
let unseparated = 0;
|
|
268
|
+
for (let y = 0; y < H; y++) {
|
|
269
|
+
for (let x = 0; x < W; x++) {
|
|
270
|
+
const p = y * W + x;
|
|
271
|
+
const a = owner[p];
|
|
272
|
+
// The nearest other owner within R; ties to the one drawn in front (the background is behind every part).
|
|
273
|
+
let b = -2;
|
|
274
|
+
let bd = R + 1;
|
|
275
|
+
for (let k = n; k >= 0; k--) {
|
|
276
|
+
const label = k === n ? -1 : k;
|
|
277
|
+
if (label === a) continue;
|
|
278
|
+
const d = dist[k][p];
|
|
279
|
+
if (d < bd || (d === bd && b !== -2 && label > b)) {
|
|
280
|
+
if (d <= R) {
|
|
281
|
+
b = label;
|
|
282
|
+
bd = d;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (b === -2) continue;
|
|
287
|
+
const front = Math.max(a, b);
|
|
288
|
+
const behind = Math.min(a, b);
|
|
289
|
+
// Coverage the two-layer model cannot carry: anything in front of FRONT, or between BEHIND and FRONT.
|
|
290
|
+
let covered = false;
|
|
291
|
+
for (let j = behind + 1; j < n && !covered; j++) if (j !== front && parts[j].data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE) covered = true;
|
|
292
|
+
if (covered) {
|
|
293
|
+
skipped.cover++;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (
|
|
297
|
+
!options.editable[front] ||
|
|
298
|
+
(behind >= 0 && !options.editable[behind]) ||
|
|
299
|
+
options.keep?.[front]?.data[p] === 1 ||
|
|
300
|
+
(behind >= 0 && options.keep?.[behind]?.data[p] === 1) ||
|
|
301
|
+
options.feathered?.[front]?.data[p] === 1 ||
|
|
302
|
+
(behind >= 0 && options.feathered?.[behind]?.data[p] === 1) ||
|
|
303
|
+
(behind >= 0 && options.movingPair?.(front, behind) === true)
|
|
304
|
+
) {
|
|
305
|
+
skipped.locked++;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const df = distOf(front);
|
|
309
|
+
const db = distOf(behind);
|
|
310
|
+
// BEHIND's and FRONT's window pixels farthest from the other: the two sides least likely to be the edge itself.
|
|
311
|
+
let farB = -1;
|
|
312
|
+
let farF = -1;
|
|
313
|
+
const sideB: number[] = [];
|
|
314
|
+
const sideF: number[] = [];
|
|
315
|
+
for (let qy = Math.max(0, y - R); qy <= Math.min(H - 1, y + R); qy++) {
|
|
316
|
+
for (let qx = Math.max(0, x - R); qx <= Math.min(W - 1, x + R); qx++) {
|
|
317
|
+
const q = qy * W + qx;
|
|
318
|
+
if (owner[q] === behind) {
|
|
319
|
+
if (df[q] > farB) {
|
|
320
|
+
farB = df[q];
|
|
321
|
+
sideB.length = 0;
|
|
322
|
+
}
|
|
323
|
+
if (df[q] === farB) sideB.push(q);
|
|
324
|
+
} else if (owner[q] === front) {
|
|
325
|
+
if (db[q] > farF) {
|
|
326
|
+
farF = db[q];
|
|
327
|
+
sideF.length = 0;
|
|
328
|
+
}
|
|
329
|
+
if (db[q] === farF) sideF.push(q);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (sideB.length === 0) {
|
|
334
|
+
skipped.noBehind++;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const channel = (c: number): number[] => sideB.map((q) => srcr.data[q * 4 + c]);
|
|
338
|
+
const cb = [median(channel(0)), median(channel(1)), median(channel(2))];
|
|
339
|
+
// The front reference: the window's FRONT-owned pixel farthest from Cb (max channel), first in scan order on a tie.
|
|
340
|
+
let contrast = 0;
|
|
341
|
+
let ref = -1;
|
|
342
|
+
for (let qy = Math.max(0, y - R); qy <= Math.min(H - 1, y + R); qy++) {
|
|
343
|
+
for (let qx = Math.max(0, x - R); qx <= Math.min(W - 1, x + R); qx++) {
|
|
344
|
+
const q = qy * W + qx;
|
|
345
|
+
if (owner[q] !== front) continue;
|
|
346
|
+
const c = maxChannel(srcr.data, q * 4, cb);
|
|
347
|
+
if (c > contrast) {
|
|
348
|
+
contrast = c;
|
|
349
|
+
ref = q;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// Every pixel's position along Cb -> Cf, as an integer dot product.
|
|
354
|
+
const axis = ref < 0 ? [0, 0, 0] : [0, 1, 2].map((c) => srcr.data[ref * 4 + c] - cb[c]);
|
|
355
|
+
const along = (q: number): number => (srcr.data[q * 4] - cb[0]) * axis[0] + (srcr.data[q * 4 + 1] - cb[1]) * axis[1] + (srcr.data[q * 4 + 2] - cb[2]) * axis[2];
|
|
356
|
+
if (ref < 0 || contrast < EDGE_CONTRAST) {
|
|
357
|
+
skipped.contrast++;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
// The trimap: BEHIND's far side reads 0 up to its highest position, FRONT's far side 1 from its lowest.
|
|
361
|
+
// Where the two far sides do not separate along the axis (a side is all edge, or the texture overlaps), Cb and Cf themselves.
|
|
362
|
+
const sideLo = Math.max(...sideB.map(along));
|
|
363
|
+
const sideHi = sideF.length === 0 ? -Infinity : Math.min(...sideF.map(along));
|
|
364
|
+
const separate = sideHi > sideLo;
|
|
365
|
+
if (!separate) unseparated++;
|
|
366
|
+
const lo = separate ? sideLo : 0;
|
|
367
|
+
const hi = separate ? sideHi : along(ref);
|
|
368
|
+
resolved++;
|
|
369
|
+
let matte = Math.round((255 * Math.min(hi - lo, Math.max(0, along(p) - lo))) / (hi - lo));
|
|
370
|
+
// On FRONT's side of the line, a colour FRONT itself holds deeper in (within half the contrast floor, and nearer FRONT than
|
|
371
|
+
// BEHIND) is FRONT's interior — a light lining, a highlight — not a mix with BEHIND, however it falls along the axis.
|
|
372
|
+
if (a === front && matte < 255) {
|
|
373
|
+
let dF = Infinity;
|
|
374
|
+
let dFdeep = Infinity;
|
|
375
|
+
for (let qy = Math.max(0, y - R); qy <= Math.min(H - 1, y + R); qy++) {
|
|
376
|
+
for (let qx = Math.max(0, x - R); qx <= Math.min(W - 1, x + R); qx++) {
|
|
377
|
+
const q = qy * W + qx;
|
|
378
|
+
if (q === p || owner[q] !== front) continue;
|
|
379
|
+
const d = Math.max(Math.abs(srcr.data[q * 4] - srcr.data[p * 4]), Math.abs(srcr.data[q * 4 + 1] - srcr.data[p * 4 + 1]), Math.abs(srcr.data[q * 4 + 2] - srcr.data[p * 4 + 2]));
|
|
380
|
+
dF = Math.min(dF, d);
|
|
381
|
+
if (db[q] >= 2) dFdeep = Math.min(dFdeep, d);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const dOwn = dFdeep === Infinity ? dF : dFdeep;
|
|
385
|
+
let dB = Infinity;
|
|
386
|
+
for (const q of sideB) dB = Math.min(dB, Math.max(Math.abs(srcr.data[q * 4] - srcr.data[p * 4]), Math.abs(srcr.data[q * 4 + 1] - srcr.data[p * 4 + 1]), Math.abs(srcr.data[q * 4 + 2] - srcr.data[p * 4 + 2])));
|
|
387
|
+
if (2 * dOwn <= EDGE_CONTRAST && dOwn < dB) matte = 255;
|
|
388
|
+
}
|
|
389
|
+
const own = parts[front].data[p * 4 + 3];
|
|
390
|
+
decisions.push({ p, front, behind, matte, own, inside: a === front, ring: bd, cb: [cb[0], cb[1], cb[2]] });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// The ring rule (see the invariant above), resolved ring by ring, the outer ring before the inner one.
|
|
394
|
+
const at = new Int32Array(W * H).fill(-1);
|
|
395
|
+
decisions.forEach((q, k) => { at[q.p] = k; });
|
|
396
|
+
const alphaOf = new Int16Array(decisions.length);
|
|
397
|
+
const stage = (q: Decision): number => (q.inside ? 2 * q.ring : 2 * q.ring - 1);
|
|
398
|
+
const order = decisions.map((_, k) => k).sort((u, v) => stage(decisions[u]) - stage(decisions[v]) || u - v);
|
|
399
|
+
/** The greatest coverage already resolved for the same FRONT among p's 8 neighbours on the given side and ring. */
|
|
400
|
+
const neighbours = (q: Decision, inside: boolean, ring: number): number => {
|
|
401
|
+
let best = 0;
|
|
402
|
+
const x = q.p % W;
|
|
403
|
+
const y = (q.p - x) / W;
|
|
404
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
405
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
406
|
+
const nx = x + dx;
|
|
407
|
+
const ny = y + dy;
|
|
408
|
+
if ((dx === 0 && dy === 0) || nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
|
|
409
|
+
const j = at[ny * W + nx];
|
|
410
|
+
if (j >= 0 && decisions[j].front === q.front && decisions[j].inside === inside && decisions[j].ring === ring) best = Math.max(best, alphaOf[j]);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return best;
|
|
414
|
+
};
|
|
415
|
+
for (const k of order) {
|
|
416
|
+
const q = decisions[k];
|
|
417
|
+
let alpha = q.matte;
|
|
418
|
+
if (q.inside && q.ring > 1) alpha = Math.max(q.own, q.matte);
|
|
419
|
+
else if (q.inside) alpha = Math.max(alpha, neighbours(q, false, 1));
|
|
420
|
+
else if (q.ring > 1) alpha = Math.min(alpha, neighbours(q, false, q.ring - 1));
|
|
421
|
+
if (alpha <= OPAQUE_ALPHA_ABOVE) alpha = 0;
|
|
422
|
+
else if (alpha >= 255 - OPAQUE_ALPHA_ABOVE) alpha = 255;
|
|
423
|
+
alphaOf[k] = alpha;
|
|
424
|
+
}
|
|
425
|
+
// Every write is planned first, per part, so a part's pieces can be held before anything lands.
|
|
426
|
+
const planned: Array<Map<number, readonly [number, number, number, number]>> = parts.map(() => new Map());
|
|
427
|
+
const plan = (i: number, p: number, r: number, g: number, bl: number, al: number): void => {
|
|
428
|
+
const d = parts[i].data;
|
|
429
|
+
const s = p * 4;
|
|
430
|
+
if (d[s] === r && d[s + 1] === g && d[s + 2] === bl && d[s + 3] === al) return;
|
|
431
|
+
planned[i].set(p, [r, g, bl, al]);
|
|
432
|
+
};
|
|
433
|
+
/** Per part, the pixels it fills as BEHIND, with the FRONT part over them. */
|
|
434
|
+
const fills: Array<Map<number, number>> = parts.map(() => new Map());
|
|
435
|
+
decisions.forEach((q, k) => {
|
|
436
|
+
const s = q.p * 4;
|
|
437
|
+
const P = [srcr.data[s], srcr.data[s + 1], srcr.data[s + 2]];
|
|
438
|
+
const unmixAt = (c: number, a: number): number => Math.min(255, Math.max(0, Math.round(q.cb[c] + ((P[c] - q.cb[c]) * 255) / a)));
|
|
439
|
+
// FRONT's colour is clamped to 0..255, so a pixel the two colours cannot mix at that coverage (a painted line darker or
|
|
440
|
+
// lighter than both) would miss the painting: FRONT's coverage rises to the least that brings the composite within half
|
|
441
|
+
// the contrast floor of it (at full coverage it is the painting).
|
|
442
|
+
const miss = (a: number): number => Math.max(...[0, 1, 2].map((c) => Math.abs(Math.round((unmixAt(c, a) * a + q.cb[c] * (255 - a)) / 255) - P[c])));
|
|
443
|
+
let alpha = alphaOf[k];
|
|
444
|
+
if (alpha > 0 && alpha < 255) while (alpha < 255 && 2 * miss(alpha) > EDGE_CONTRAST) alpha++;
|
|
445
|
+
if (alpha >= 255 - OPAQUE_ALPHA_ABOVE) alpha = 255;
|
|
446
|
+
const unmix = (c: number): number => unmixAt(c, alpha);
|
|
447
|
+
if (alpha === 0) plan(q.front, q.p, 0, 0, 0, 0);
|
|
448
|
+
else if (alpha === 255) plan(q.front, q.p, P[0], P[1], P[2], 255);
|
|
449
|
+
else plan(q.front, q.p, unmix(0), unmix(1), unmix(2), alpha);
|
|
450
|
+
if (q.behind >= 0 && alpha < 255) {
|
|
451
|
+
if (alpha === 0) plan(q.behind, q.p, P[0], P[1], P[2], 255);
|
|
452
|
+
else plan(q.behind, q.p, q.cb[0], q.cb[1], q.cb[2], 255);
|
|
453
|
+
fills[q.behind].set(q.p, q.front);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
// A part's art (alpha above the floor) keeps its pieces: rigc takes one closed outline per mesh, read 4-connected
|
|
457
|
+
// (`CONTOUR_ONE_ISLAND`). A new piece holding none of the part's art is not written.
|
|
458
|
+
planned.forEach((writes, i) => {
|
|
459
|
+
if (writes.size === 0) return;
|
|
460
|
+
const before = newMask(W, H);
|
|
461
|
+
for (let p = 0; p < W * H; p++) before.data[p] = parts[i].data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE ? 1 : 0;
|
|
462
|
+
const after = (): Mask => {
|
|
463
|
+
const m = newMask(W, H);
|
|
464
|
+
m.data.set(before.data);
|
|
465
|
+
for (const [p, v] of writes) m.data[p] = v[3] > OPAQUE_ALPHA_ABOVE ? 1 : 0;
|
|
466
|
+
return m;
|
|
467
|
+
};
|
|
468
|
+
const was = connectedComponents(before, 4);
|
|
469
|
+
let now = connectedComponents(after(), 4);
|
|
470
|
+
const pieces = new Map<number, Set<number>>();
|
|
471
|
+
for (let p = 0; p < W * H; p++) {
|
|
472
|
+
const o = was.labels[p];
|
|
473
|
+
const l = now.labels[p];
|
|
474
|
+
if (o === 0 || l === 0) continue;
|
|
475
|
+
const set = pieces.get(o) ?? new Set<number>();
|
|
476
|
+
set.add(l);
|
|
477
|
+
pieces.set(o, set);
|
|
478
|
+
}
|
|
479
|
+
// A piece of art the step would split: each cut-off part of it is joined back to its largest part along the path that
|
|
480
|
+
// restores the fewest lowered pixels (a 0-1 search inside the piece as it was), and only those are not written.
|
|
481
|
+
for (const [o, set] of pieces) {
|
|
482
|
+
if (set.size < 2) continue;
|
|
483
|
+
const labels = [...set].sort((u, v) => now.stats[v].area - now.stats[u].area || u - v);
|
|
484
|
+
const main = labels[0];
|
|
485
|
+
const cost = new Int32Array(W * H).fill(-1);
|
|
486
|
+
const from = new Int32Array(W * H).fill(-1);
|
|
487
|
+
const deque = new Int32Array(2 * W * H + 2);
|
|
488
|
+
let head = W * H + 1;
|
|
489
|
+
let tail = head;
|
|
490
|
+
const nowOver = after();
|
|
491
|
+
for (let p = 0; p < W * H; p++) {
|
|
492
|
+
if (now.labels[p] === main && was.labels[p] === o) {
|
|
493
|
+
cost[p] = 0;
|
|
494
|
+
deque[tail++] = p;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
while (head < tail) {
|
|
498
|
+
const p = deque[head++];
|
|
499
|
+
const x = p % W;
|
|
500
|
+
for (const q of [x > 0 ? p - 1 : -1, x < W - 1 ? p + 1 : -1, p - W, p + W]) {
|
|
501
|
+
if (q < 0 || q >= W * H || was.labels[q] !== o) continue;
|
|
502
|
+
const step = nowOver.data[q] === 1 ? 0 : 1;
|
|
503
|
+
if (cost[q] !== -1 && cost[q] <= cost[p] + step) continue;
|
|
504
|
+
cost[q] = cost[p] + step;
|
|
505
|
+
from[q] = p;
|
|
506
|
+
if (step === 0) deque[--head] = q;
|
|
507
|
+
else deque[tail++] = q;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
for (const l of labels.slice(1)) {
|
|
511
|
+
let best = -1;
|
|
512
|
+
for (let p = 0; p < W * H; p++) if (now.labels[p] === l && cost[p] >= 0 && (best < 0 || cost[p] < cost[best])) best = p;
|
|
513
|
+
for (let p = best; p >= 0 && cost[p] > 0; p = from[p]) {
|
|
514
|
+
if (nowOver.data[p] === 0 && writes.delete(p)) skipped.pieces++;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
now = connectedComponents(after(), 4);
|
|
519
|
+
const holdsArt = new Uint8Array(now.count);
|
|
520
|
+
for (let p = 0; p < W * H; p++) if (before.data[p] === 1) holdsArt[now.labels[p]] = 1;
|
|
521
|
+
for (const [p] of [...writes]) {
|
|
522
|
+
const l = now.labels[p];
|
|
523
|
+
if (l !== 0 && holdsArt[l] === 0) {
|
|
524
|
+
writes.delete(p);
|
|
525
|
+
skipped.pieces++;
|
|
526
|
+
// A fill BEHIND cannot take: FRONT takes the painting's pixel whole, so the composite there is still the painting
|
|
527
|
+
// (FRONT is later in draw order, so its own pieces are held after this).
|
|
528
|
+
const front = fills[i].get(p);
|
|
529
|
+
if (front !== undefined) planned[front].set(p, [srcr.data[p * 4], srcr.data[p * 4 + 1], srcr.data[p * 4 + 2], 255]);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
const changed = parts.map(() => newMask(W, H));
|
|
534
|
+
planned.forEach((writes, i) => {
|
|
535
|
+
const d = parts[i].data;
|
|
536
|
+
for (const [p, v] of writes) {
|
|
537
|
+
d.set(v, p * 4);
|
|
538
|
+
changed[i].data[p] = 1;
|
|
539
|
+
options.written?.(i, p);
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
return { changed, resolved, unseparated, skipped };
|
|
543
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Shared projection choices for the config reader and assembly. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a layer may take the painting's pixel (`projectSource` in
|
|
5
|
+
* `src/assemble.ts`). `core` is the reference's rule: the layer's top-most
|
|
6
|
+
* `alpha >= 250` pixels eroded by a 5x5 square, so a part a few pixels wide has
|
|
7
|
+
* no core and none of its visible pixels is projected. `visible` keeps the
|
|
8
|
+
* erosion only where it guards something — along a rim where a later layer of
|
|
9
|
+
* the run is in front — and projects every other top-most `alpha >= 250` pixel
|
|
10
|
+
* of the layer; the parts' visible edges are then resolved from the painting
|
|
11
|
+
* (`paintingEdges`).
|
|
12
|
+
*/
|
|
13
|
+
export type ProjectRule = 'core' | 'visible';
|
|
14
|
+
export const PROJECT_RULES: readonly ProjectRule[] = ['core', 'visible'];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The projection rule a run uses when none is named: the reference's, so the
|
|
18
|
+
* examples stay comparable with it. `visible` was measured on the public
|
|
19
|
+
* examples against it (the changes that added the flag and `paintingEdges`);
|
|
20
|
+
* it is opt-in.
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_PROJECT_RULE: ProjectRule = 'core';
|
package/src/provenance.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { crop, type Raster } from './raster/index.ts';
|
|
|
8
8
|
export const AUTOMATIC_WRITERS = {
|
|
9
9
|
sourceProjection: -1, extend: -2, growRim: -3, limbSplit: -4,
|
|
10
10
|
limbCap: -5, limbHybrid: -6, limbFeather: -7, fringeRestore: -8,
|
|
11
|
-
seamOverride: -9, armSplit: -10, headFaceUnderfill: -11, ghostCleanup: -12, unattributed: -13, paintingExtension: -14,
|
|
11
|
+
seamOverride: -9, armSplit: -10, headFaceUnderfill: -11, ghostCleanup: -12, unattributed: -13, paintingExtension: -14, paintingEdge: -15,
|
|
12
12
|
} as const;
|
|
13
13
|
export type AutomaticWriter = keyof typeof AUTOMATIC_WRITERS;
|
|
14
14
|
export interface PixelProvenance {
|