Files
ausboss-ComfyUI-AusBoss/tests/touch_math.test.mjs
T
ausboss 33a576d786 Mask by Touch: the pack's own loaders show their picture by themselves
With a plain Load Image wired straight in, the picture shows on the node
as soon as a file is chosen. With Image Crop + Rotate + Pad or Load
Image + Pad it did not: the node showed Show picture and waited for a
press, because those loaders may crop, turn or pad the file, so the file
alone is not the picture.

The node now asks for that run itself. A moment after a file is chosen on
one of those loaders, a setting on it is changed or the wire is made, the
loader's picture is on the node, cropped and all.

- Changes are waited out for 0.7 seconds, so a crop being dragged sends
  one run when it rests, not one per step.
- Only in answer to something the person did: opening a workflow sends
  nothing, and picks saved with a workflow still wait for their own
  press.
- Nothing is sent without SAM 3 wired, or with looks set to "only on a
  click or the Look button".
2026-10-11 18:14:31 -05:00

815 lines
40 KiB
JavaScript

// The math behind Mask by Touch's face (js/shared/touch_math.mjs): the
// touches as text, the pieces a pointer lights up, and the edge steps the
// face works out itself so the picture answers without a run.
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
BRUSH_RADIUS,
OUTLINE,
OWN_LOADERS,
PIECES_PER_PLANE,
STROKE_MAX_POINTS,
TINT,
TOUCH_MAX_ITEMS,
TOUCH_MAX_POINTS,
candidatesAt,
chipGroups,
chipLabel,
cleanWord,
composeEdge,
decodeTouches,
encodeTouches,
extendStroke,
fillHoles,
fineChoice,
fineEdgeLabel,
fineMade,
fineMask,
growMask,
loaderSignature,
maskShare,
nameShown,
packPlanes,
paintCover,
paintStroke,
paintTint,
pieceAt,
pieceMask,
pieceOverlaps,
pieceRanks,
pieceWholes,
pieceSizes,
piecesInside,
pointCount,
prefixStamps,
previewPixels,
readoutText,
sameTouches,
shrinkMask,
smoothMask,
softenMask,
stampOf,
strokePieces,
thinStroke,
touchFits,
} from "../js/shared/touch_math.mjs";
const total = (mask) => mask.reduce((sum, value) => sum + value, 0);
function blank(width, height) {
return new Uint8Array(width * height);
}
function box(width, height, x0, y0, x1, y1) {
const mask = blank(width, height);
for (let y = y0; y < y1; y += 1) mask.fill(1, y * width + x0, y * width + x1);
return mask;
}
const TAP = { t: "tap", op: 1, x: 160, y: 1450 };
const FIND = { t: "find", op: 1, r: 30, pts: [[150, 1240], [230, 1010], [330, 1150]] };
const PAINT = { t: "paint", op: 0, r: 45, pts: [[640, 120], [760, 170]] };
test("touches survive the trip to text and back", () => {
const text = encodeTouches("abc", [TAP, FIND, PAINT]);
assert.equal(text, JSON.stringify({ v: 1, key: "abc", items: [TAP, FIND, PAINT] }));
assert.deepEqual(decodeTouches(text), { key: "abc", items: [TAP, FIND, PAINT] });
});
test("no touches is the empty text, and the empty text is no touches", () => {
assert.equal(encodeTouches("abc", []), "");
assert.deepEqual(decodeTouches(""), { key: "", items: [] });
assert.deepEqual(decodeTouches(" "), { key: "", items: [] });
});
test("text that is not touches gives none, and never throws", () => {
for (const text of ["{", "[]", "null", "42", '{"items": 3}', '{"v":1,"items":[{"t":"lasso"}]}', undefined, null, 7]) {
assert.deepEqual(decodeTouches(text).items, []);
}
});
test("a touch that is not whole is left out, the rest stay", () => {
const text = JSON.stringify({ v: 1, key: "k", items: [
TAP,
{ t: "tap", op: 1, x: "12", y: 3 },
{ t: "find", op: 1, r: 30, pts: [] },
{ t: "paint", op: 1, pts: [[1, 2]] },
{ t: "paint", op: 1, r: 9, pts: [[1, 2], ["a", 2]] },
] });
assert.deepEqual(decodeTouches(text).items, [TAP, { t: "paint", op: 1, r: 9, pts: [[1, 2]] }]);
});
test("touches are written as whole pixels and the brush is kept in range", () => {
const text = encodeTouches("k", [
{ t: "tap", op: 0, x: 10.6, y: 3.2 },
{ t: "paint", op: 1, r: 0.2, pts: [[1.4, 2.6]] },
{ t: "paint", op: 1, r: 99999, pts: [[1, 2]] },
]);
assert.deepEqual(decodeTouches(text).items, [
{ t: "tap", op: 0, x: 11, y: 3 },
{ t: "paint", op: 1, r: BRUSH_RADIUS[0], pts: [[1, 3]] },
{ t: "paint", op: 1, r: BRUSH_RADIUS[1], pts: [[1, 2]] },
]);
});
test("reading stops at the server's caps", () => {
const many = Array.from({ length: TOUCH_MAX_ITEMS + 30 }, (_, index) => ({ t: "tap", op: 1, x: index, y: 1 }));
assert.equal(decodeTouches(JSON.stringify({ v: 1, key: "k", items: many })).items.length, TOUCH_MAX_ITEMS);
const long = { t: "paint", op: 1, r: 5, pts: Array.from({ length: TOUCH_MAX_POINTS }, (_, index) => [index, 1]) };
const two = decodeTouches(JSON.stringify({ v: 1, key: "k", items: [long, TAP] })).items;
assert.equal(two.length, 1, "the tap after 20,000 points no longer fits");
assert.equal(pointCount(two), TOUCH_MAX_POINTS);
});
test("touchFits says whether one more touch stays under the caps", () => {
const full = Array.from({ length: TOUCH_MAX_ITEMS }, () => TAP);
assert.equal(touchFits(full.slice(1), TAP), true);
assert.equal(touchFits(full, TAP), false);
const long = { t: "paint", op: 1, r: 5, pts: Array.from({ length: TOUCH_MAX_POINTS }, () => [1, 1]) };
assert.equal(touchFits([long], TAP), false);
});
test("the stamp is 32-bit FNV-1a, the same the server writes", () => {
assert.equal(stampOf(""), "811c9dc5");
assert.equal(stampOf("a"), "e40c292c");
assert.equal(stampOf("foobar"), "bf9cf968");
assert.equal(stampOf(undefined), "811c9dc5");
});
test("prefixStamps gives the stamp of every shorter list of touches", () => {
const items = [TAP, FIND, PAINT];
const stamps = prefixStamps("abc", items);
assert.equal(stamps.length, 3);
for (let count = 1; count <= items.length; count += 1) {
assert.equal(stamps[count - 1], stampOf(encodeTouches("abc", items.slice(0, count))));
}
assert.notEqual(prefixStamps("abd", items)[2], stamps[2], "another picture, another stamp");
});
test("a stroke takes a point only once the pointer has moved on", () => {
const points = [];
assert.equal(extendStroke(points, 0, 0, 5), true);
assert.equal(extendStroke(points, 3, 0, 5), false);
assert.equal(extendStroke(points, 5, 0, 5), true);
assert.deepEqual(points, [[0, 0], [5, 0]]);
});
test("thinning a stroke keeps its ends and its corners", () => {
const straight = Array.from({ length: 50 }, (_, index) => [index * 4, 10]);
assert.deepEqual(thinStroke(straight, 1), [[0, 10], [196, 10]]);
const corner = [[0, 0], [10, 0], [20, 0], [20, 10], [20, 20]];
assert.deepEqual(thinStroke(corner, 1), [[0, 0], [20, 0], [20, 20]]);
assert.deepEqual(thinStroke([[4, 4]], 1), [[4, 4]]);
});
test("a very long stroke is thinned to the most one stroke may hold", () => {
const wiggle = Array.from({ length: 4000 }, (_, index) => [index, index % 2 ? 40 : 0]);
const thin = thinStroke(wiggle, 1);
assert.equal(thin.length, STROKE_MAX_POINTS);
assert.deepEqual(thin[0], [0, 0]);
assert.deepEqual(thin[thin.length - 1], [3999, 40]);
});
test("grow is a square brush, and shrink takes it back", () => {
const dot = box(21, 21, 10, 10, 11, 11);
const grown = growMask(dot, 21, 21, 3);
assert.equal(total(grown), 49);
assert.equal(grown[7 * 21 + 7], 1);
assert.equal(grown[6 * 21 + 7], 0);
assert.deepEqual(shrinkMask(grown, 21, 21, 3), dot);
assert.equal(growMask(dot, 21, 21, 0), dot, "no growth hands the mask back as it is");
});
test("the picture's own edge does not eat into a shrunk mask", () => {
const left = box(10, 10, 0, 0, 5, 10);
const shrunk = shrinkMask(left, 10, 10, 2);
assert.equal(shrunk[0], 1, "the corner on the picture's edge stays");
assert.equal(shrunk[4], 0, "the side facing the rest of the picture moves in");
assert.equal(total(shrunk), 30);
});
test("fill holes closes a pinhole and leaves a real opening", () => {
const solid = box(40, 40, 5, 5, 35, 35);
solid[20 * 40 + 20] = 0;
assert.equal(total(fillHoles(solid, 40, 40)), 900);
const ring = blank(40, 40);
for (let y = 0; y < 40; y += 1) {
for (let x = 0; x < 40; x += 1) {
const distance = Math.hypot(x - 20, y - 20);
if (distance < 15 && distance > 8) ring[y * 40 + x] = 1;
}
}
const before = total(ring);
assert.equal(total(fillHoles(ring, 40, 40)), before, "the ring's middle is far over 1% of the ring");
assert.ok(total(fillHoles(ring, 40, 40, Infinity)) > before, "with no cap the middle is filled");
});
test("fill holes never fills what is open to the picture's edge, and copies", () => {
const cup = blank(20, 20);
for (let y = 5; y < 15; y += 1) { cup[y * 20 + 5] = 1; cup[y * 20 + 14] = 1; }
for (let x = 5; x < 15; x += 1) cup[14 * 20 + x] = 1;
const filled = fillHoles(cup, 20, 20, Infinity);
assert.deepEqual(filled, cup);
assert.notEqual(filled, cup, "a new array, so the picked area itself is never changed");
assert.equal(total(fillHoles(blank(8, 8), 8, 8)), 0);
});
test("soften keeps the mask between 0 and 1 and spreads its edge", () => {
const half = box(40, 40, 0, 0, 20, 40);
const soft = softenMask(half, 40, 40, 3);
assert.ok(soft instanceof Float32Array);
assert.ok(soft.every((value) => value >= 0 && value <= 1.000001));
assert.ok(soft[20 * 40 + 2] > 0.99, "far inside stays full");
assert.ok(soft[20 * 40 + 37] < 0.01, "far outside stays empty");
const edge = soft[20 * 40 + 20];
assert.ok(edge > 0.2 && edge < 0.5, `just outside the edge is partly on (${edge})`);
assert.deepEqual([...softenMask(half, 40, 40, 0)], [...half]);
});
test("smooth drops a speck and keeps the edge hard", () => {
const mask = box(60, 60, 10, 10, 50, 50);
mask[2 * 60 + 2] = 1;
const smooth = smoothMask(mask, 60, 60, 2);
assert.ok(smooth instanceof Uint8Array);
assert.equal(smooth[2 * 60 + 2], 0, "the lone pixel is gone");
assert.equal(smooth[30 * 60 + 30], 1);
assert.ok(smooth.every((value) => value === 0 || value === 1));
assert.equal(smoothMask(mask, 60, 60, 0), mask);
});
test("the edge steps run in the server's order: fill, smooth, grow, soften", () => {
const raw = box(60, 60, 20, 20, 40, 40);
raw[30 * 60 + 30] = 0;
const plain = composeEdge({ raw, width: 60, height: 60 });
assert.equal(total(plain), 399);
const filled = composeEdge({ raw, width: 60, height: 60, fill: true });
assert.equal(total(filled), 400);
// Grown first, the pinhole would be closed by the growth and never count
// as a hole; filled first, growing adds exactly the border.
const grown = composeEdge({ raw, width: 60, height: 60, fill: true, grow: 2 });
assert.equal(total(grown), 24 * 24);
const shrunk = composeEdge({ raw, width: 60, height: 60, fill: true, grow: -2 });
assert.equal(total(shrunk), 16 * 16);
const soft = composeEdge({ raw, width: 60, height: 60, fill: true, soften: 2 });
assert.ok(soft[20 * 60 + 18] > 0 && soft[20 * 60 + 18] < 1);
});
test("a setting that is on never rounds away on the small copy", () => {
assert.equal(previewPixels(0, 0.5), 0);
assert.equal(previewPixels(1, 0.2), 1);
assert.equal(previewPixels(-1, 0.2), -1);
assert.equal(previewPixels(24, 0.627), 15);
assert.equal(previewPixels(-24, 0.627), -15);
assert.equal(previewPixels(8, 1), 8);
});
test("maskShare is the part of the picture a mask covers", () => {
assert.equal(maskShare(box(10, 10, 0, 0, 5, 10)), 0.5);
assert.equal(maskShare(new Float32Array([0.5, 0.5, 0, 1])), 0.5);
assert.equal(maskShare(new Uint8Array(0)), 0);
});
test("a dab of paint is a disc, a stroke is the brush dragged along", () => {
const dab = paintStroke(blank(41, 41), 41, 41, [[20, 20]], 10, 1);
const area = total(dab);
assert.ok(Math.abs(area - Math.PI * 100) < 20, `a disc of radius 10 (${area})`);
const line = paintStroke(blank(100, 41), 100, 41, [[20, 20], [80, 20]], 10, 1);
assert.ok(Math.abs(total(line) - (Math.PI * 100 + 60 * 20)) < 60, `a capsule (${total(line)})`);
assert.equal(line[20 * 100 + 50], 1);
assert.equal(line[5 * 100 + 50], 0);
});
test("paint that runs off the picture is cut at its edge, and 0 takes away", () => {
const corner = paintStroke(blank(20, 20), 20, 20, [[-5, -5], [40, 40]], 3, 1);
assert.equal(corner[0], 1);
assert.equal(corner[19 * 20 + 19], 1);
const cleared = paintStroke(box(20, 20, 0, 0, 20, 20), 20, 20, [[10, 10]], 4, 0);
assert.equal(cleared[10 * 20 + 10], 0);
assert.equal(cleared[0], 1);
assert.deepEqual(paintStroke(blank(4, 4), 4, 4, [], 3, 1), blank(4, 4));
});
// Three pieces, one inside the other, and a fourth on the second plane.
function nested() {
const width = 30;
const height = 30;
const small = box(width, height, 12, 12, 16, 16);
const middle = box(width, height, 8, 8, 20, 20);
const large = box(width, height, 2, 2, 28, 28);
const far = box(width, height, 24, 0, 30, 2);
const planes = [new Uint8ClampedArray(width * height * 4), new Uint8ClampedArray(width * height * 4)];
const put = (mask, piece) => {
const rgba = planes[Math.floor(piece / PIECES_PER_PLANE)];
const bit = piece % PIECES_PER_PLANE;
for (let index = 0; index < mask.length; index += 1) {
if (mask[index]) rgba[index * 4 + Math.floor(bit / 8)] |= 1 << (bit % 8);
}
};
put(small, 0);
put(middle, 9);
put(large, 17);
put(far, 25);
for (const rgba of planes) for (let at = 3; at < rgba.length; at += 4) rgba[at] = 255;
return { width, height, small, middle, large, far, packed: packPlanes(planes, width, height) };
}
test("pieces come back out of the planes bit for bit", () => {
const { width, height, small, middle, large, far, packed } = nested();
assert.deepEqual(pieceMask(packed, 0), small);
assert.deepEqual(pieceMask(packed, 9), middle);
assert.deepEqual(pieceMask(packed, 17), large);
assert.deepEqual(pieceMask(packed, 25), far);
assert.equal(pieceMask(packed, 60), null);
const sizes = pieceSizes(packed, 26);
assert.equal(sizes[0], 16);
assert.equal(sizes[9], 144);
assert.equal(sizes[17], 26 * 26);
assert.equal(sizes[25], 12);
assert.equal(total(sizes), 16 + 144 + 676 + 12);
assert.equal(width * height, small.length);
});
test("the pointer lights the smallest piece that would still add something", () => {
const { width, small, middle, packed } = nested();
const sizes = pieceSizes(packed, 26);
const centre = 14 * width + 14;
const nothing = piecesInside(packed, 26, null);
assert.equal(pieceAt(packed, centre, { sizes, inside: nothing }), 0);
// The small one is picked: the same spot now lights the one around it.
assert.equal(pieceAt(packed, centre, { sizes, inside: piecesInside(packed, 26, small) }), 9);
assert.equal(pieceAt(packed, centre, { sizes, inside: piecesInside(packed, 26, middle) }), 17);
assert.equal(pieceAt(packed, 0, { sizes, inside: nothing }), -1, "nothing there");
assert.equal(pieceAt(packed, 1 * width + 25, { sizes, inside: nothing }), 25, "a piece on the second plane");
});
test("taking away, the pointer lights the smallest piece with picked area in it", () => {
const { width, small, large, packed } = nested();
const sizes = pieceSizes(packed, 26);
const centre = 14 * width + 14;
assert.equal(pieceAt(packed, centre, { sizes, inside: piecesInside(packed, 26, null), adding: false }), -1);
assert.equal(pieceAt(packed, centre, { sizes, inside: piecesInside(packed, 26, small), adding: false }), 0);
// Only the outer band is picked: the two inside it have nothing to give up.
const band = Uint8Array.from(large, (value, index) => (value && !pieceMask(packed, 9)[index] ? 1 : 0));
assert.equal(pieceAt(packed, centre, { sizes, inside: piecesInside(packed, 26, band), adding: false }), 17);
});
test("the tint has a line on the mask's edge and a dark hairline outside it", () => {
const width = 20;
const height = 20;
const mask = Float32Array.from(box(width, height, 5, 5, 15, 15));
const pixels = new Uint8ClampedArray(width * height * 4).fill(9);
paintTint(pixels, mask, width, height);
const at = (x, y) => [...pixels.slice((y * width + x) * 4, (y * width + x) * 4 + 4)];
assert.deepEqual(at(5, 5), [...OUTLINE, 255], "the mask's own edge is the line");
assert.deepEqual(at(10, 10).slice(0, 3), TINT, "inside is the tint");
assert.ok(at(10, 10)[3] > 100 && at(10, 10)[3] < 160);
assert.deepEqual(at(4, 10).slice(0, 3), [0, 0, 0], "one pixel out is the hairline");
assert.ok(at(4, 10)[3] > 0);
assert.deepEqual(at(1, 1), [0, 0, 0, 0], "the rest is clear");
});
test("nothing picked paints nothing", () => {
const pixels = new Uint8ClampedArray(16 * 4).fill(200);
paintTint(pixels, new Float32Array(16), 4, 4);
assert.ok(pixels.every((value) => value === 0));
});
test("the mask view is black and white, the cut-out covers what is not picked", () => {
const mask = new Float32Array([1, 0.5, 0, 0]);
const pixels = new Uint8ClampedArray(16);
paintCover(pixels, mask, 2, 2, "mask");
assert.deepEqual([...pixels.slice(0, 8)], [255, 255, 255, 255, 128, 128, 128, 255]);
assert.deepEqual([...pixels.slice(8, 12)], [0, 0, 0, 255]);
paintCover(pixels, mask, 2, 2, "cut");
assert.equal(pixels[3], 0, "the picked pixel shows the picture");
assert.equal(pixels[11], 255, "an unpicked pixel is covered");
assert.ok(pixels[7] > 100 && pixels[7] < 150, "a half-picked pixel is half covered");
});
test("the readout says what is picked in plain words", () => {
assert.equal(readoutText({}), "nothing picked");
assert.equal(readoutText({ count: 2, share: 0 }), "nothing picked");
assert.equal(readoutText({ count: 1, share: 0.043, only: "thing" }), "1 thing · 4% of the picture");
assert.equal(readoutText({ count: 1, share: 0.24, only: "under" }), "the thing under your paint · 24%");
assert.equal(readoutText({ count: 1, share: 0.03, only: "paint" }), "your paint, as it is · 3%");
assert.equal(readoutText({ count: 3, share: 0.27 }), "3 picks · 27% of the picture");
assert.equal(readoutText({ count: 1, share: 0.001, only: "thing" }), "1 thing · under 1% of the picture");
assert.equal(readoutText({ count: 4, share: 0.2, waiting: true }), "finding it");
});
// ---- named things ----------------------------------------------------------
const NAMED = { t: "name", op: 1, w: "jacket", x: 412, y: 900 };
test("a named touch survives the trip, and its word is cleaned", () => {
const text = encodeTouches("k", [NAMED, TAP]);
assert.deepEqual(decodeTouches(text).items, [NAMED, TAP]);
assert.equal(cleanWord(" fur collar "), "fur collar");
assert.equal(cleanWord("pants:16, (shoes)"), "pants 16 shoes", "the text reader's own signs are taken out");
assert.equal(cleanWord("x".repeat(200)).length, 60);
assert.deepEqual(decodeTouches(JSON.stringify({ v: 1, key: "k", items: [
{ t: "name", op: 0, w: " teddy bear ", x: 3.6, y: 9 },
{ t: "name", op: 1, w: ",:", x: 1, y: 1 },
{ t: "name", op: 1, w: "hat" },
] })).items, [{ t: "name", op: 0, w: "teddy bear", x: 4, y: 9 }]);
assert.equal(pointCount([NAMED, TAP, FIND]), 5, "a named touch is one point");
});
// A person (large), the jacket they wear (inside it), a button on the jacket
// found at a point, a wall behind everything, and a "dress" SAM 3 is not
// sure of that covers the jacket too.
function scene() {
const width = 40;
const height = 40;
const masks = {
button: box(width, height, 18, 18, 20, 20),
jacket: box(width, height, 12, 12, 26, 28),
dress: box(width, height, 12, 12, 26, 30),
person: box(width, height, 10, 4, 28, 36),
wall: box(width, height, 0, 0, 40, 34),
};
// In the order a server sends them: smallest first.
const order = ["button", "jacket", "dress", "person", "wall"];
const list = [
{ kind: "point", name: "", sure: 0 },
{ kind: "name", name: "jacket", sure: 0.96 },
{ kind: "name", name: "dress", sure: 0.7 },
{ kind: "name", name: "person", sure: 0.98 },
{ kind: "name", name: "wall", sure: 0.9 },
];
const rgba = new Uint8ClampedArray(width * height * 4);
order.forEach((name, piece) => {
masks[name].forEach((value, index) => { if (value) rgba[index * 4 + Math.floor(piece / 8)] |= 1 << (piece % 8); });
});
for (let at = 3; at < rgba.length; at += 4) rgba[at] = 255;
const planes = packPlanes([rgba], width, height);
const sizes = pieceSizes(planes, list.length);
return { width, height, masks, list, planes, sizes, ranks: pieceRanks(list, sizes, width * height) };
}
test("pieces are offered sure names first, then points, then unsure names, backdrops last", () => {
const { width, planes, ranks } = scene();
// On the button: all five cover it.
assert.deepEqual(candidatesAt(planes, 19 * width + 19, ranks), [1, 3, 0, 2, 4], "jacket, person, the point piece, dress, wall");
// On the jacket beside the button.
assert.deepEqual(candidatesAt(planes, 14 * width + 14, ranks), [1, 3, 2, 4]);
// On the person's legs: no jacket there.
assert.deepEqual(candidatesAt(planes, 32 * width + 14, ranks), [3, 4]);
assert.deepEqual(candidatesAt(planes, 39 * width + 39, ranks), []);
assert.deepEqual(candidatesAt(planes, 19 * width + 19, null), [0, 1, 2, 3, 4], "without an order: smallest first");
});
test("the pointer lights the jacket, not the person, and goes on when it is picked", () => {
const { width, masks, planes, sizes, ranks } = scene();
const spot = 14 * width + 14;
const none = piecesInside(planes, 5, null);
assert.equal(pieceAt(planes, spot, { sizes, inside: none, ranks }), 1, "the jacket");
assert.equal(pieceAt(planes, spot, { sizes, inside: piecesInside(planes, 5, masks.jacket), ranks }), 3, "then the person");
assert.equal(pieceAt(planes, spot, { sizes, inside: piecesInside(planes, 5, masks.person), ranks }), 4, "then the wall");
assert.equal(pieceAt(planes, spot, { sizes, inside: piecesInside(planes, 5, masks.person), ranks, adding: false }), 1, "taking away starts with the jacket again");
});
test("a stroke means the piece most of it lies in", () => {
const { width, height, list, planes, sizes, ranks } = scene();
const pixels = width * height;
// Across the jacket, inside the person.
const across = paintStroke(blank(width, height), width, height, [[14, 20], [24, 20]], 1.5, 1);
assert.deepEqual(strokePieces(planes, list, sizes, pixels, across, ranks), { candidates: [1, 3, 2, 4], pair: null, pairAt: 0 });
// Down the legs: the person, then the wall.
const legs = paintStroke(blank(width, height), width, height, [[19, 30], [19, 33]], 1.5, 1);
assert.deepEqual(strokePieces(planes, list, sizes, pixels, legs, ranks).candidates, [3, 4]);
// In the empty corner: nothing.
const corner = paintStroke(blank(width, height), width, height, [[37, 37], [39, 39]], 1, 1);
assert.deepEqual(strokePieces(planes, list, sizes, pixels, corner, ranks), { candidates: [], pair: null, pairAt: 0 });
assert.deepEqual(strokePieces(planes, list, sizes, pixels, blank(width, height), ranks), { candidates: [], pair: null, pairAt: 0 });
});
test("a stroke across a pair takes both", () => {
// Two shoes side by side with a gap, each a sure name; a stroke crosses
// both and lies mostly in neither.
const width = 60;
const height = 20;
const left = box(width, height, 5, 5, 25, 15);
const right = box(width, height, 35, 5, 55, 15);
const list = [{ kind: "name", name: "shoe", sure: 0.92 }, { kind: "name", name: "shoe", sure: 0.9 }];
const rgba = new Uint8ClampedArray(width * height * 4);
left.forEach((value, index) => { if (value) rgba[index * 4] |= 1; });
right.forEach((value, index) => { if (value) rgba[index * 4] |= 2; });
for (let at = 3; at < rgba.length; at += 4) rgba[at] = 255;
const planes = packPlanes([rgba], width, height);
const sizes = pieceSizes(planes, 2);
const ranks = pieceRanks(list, sizes, width * height);
const stroke = paintStroke(blank(width, height), width, height, [[8, 10], [52, 10]], 1.5, 1);
assert.deepEqual(strokePieces(planes, list, sizes, width * height, stroke, ranks), { candidates: [], pair: [0, 1], pairAt: 0 });
// Not a pair when SAM 3 is unsure of one of them.
const unsure = [{ kind: "name", name: "shoe", sure: 0.92 }, { kind: "name", name: "shoe", sure: 0.6 }];
assert.equal(strokePieces(planes, unsure, sizes, width * height, stroke, pieceRanks(unsure, sizes, width * height)).pair, null);
// A stroke on one shoe is that shoe alone.
const one = paintStroke(blank(width, height), width, height, [[8, 10], [22, 10]], 1.5, 1);
assert.deepEqual(strokePieces(planes, list, sizes, width * height, one, ranks), { candidates: [0], pair: null, pairAt: 0 });
});
test("both shoes come before the person standing in them", () => {
// A person with two shoes at the bottom; the stroke crosses both shoes
// and lies wholly in the person.
const width = 60;
const height = 60;
const masks = [
box(width, height, 10, 44, 26, 54), // left shoe
box(width, height, 34, 44, 50, 54), // right shoe
box(width, height, 8, 4, 52, 56), // person
box(width, height, 0, 40, 60, 60), // floor: under the shoes and between them
];
const list = [
{ kind: "name", name: "shoe", sure: 0.95 },
{ kind: "name", name: "shoe", sure: 0.92 },
{ kind: "name", name: "person", sure: 0.98 },
{ kind: "name", name: "floor", sure: 0.9 },
];
const rgba = new Uint8ClampedArray(width * height * 4);
masks.forEach((mask, piece) => mask.forEach((value, index) => { if (value) rgba[index * 4] |= 1 << piece; }));
for (let at = 3; at < rgba.length; at += 4) rgba[at] = 255;
const planes = packPlanes([rgba], width, height);
const sizes = pieceSizes(planes, 4);
const overlaps = pieceOverlaps(planes, 4);
const ranks = pieceRanks(list, sizes, width * height, overlaps);
const wholes = pieceWholes(list, sizes, width * height, overlaps);
const stroke = paintStroke(blank(width, height), width, height, [[13, 49], [47, 49]], 1.5, 1);
const found = strokePieces(planes, list, sizes, width * height, stroke, ranks, wholes);
// The person and the floor each hold the whole stroke; the two shoes
// together hold most of it and are smaller than either.
assert.deepEqual(found.candidates, [3, 2], "the floor is smaller than the person here");
assert.deepEqual(found.pair, [1, 0].sort((a, b) => ranks[a] - ranks[b]));
assert.equal(found.pairAt, 0, "the pair comes first");
});
test("a stroke over a jacket that crosses its sleeve means the jacket", () => {
const width = 50;
const height = 50;
const masks = [
box(width, height, 10, 14, 18, 34), // sleeve
box(width, height, 20, 20, 30, 30), // a pocket SAM 3 calls a bag
box(width, height, 10, 14, 30, 34), // jacket
];
const list = [
{ kind: "name", name: "sleeve", sure: 0.93 },
{ kind: "name", name: "bag", sure: 0.9 },
{ kind: "name", name: "jacket", sure: 0.96 },
];
const rgba = new Uint8ClampedArray(width * height * 4);
masks.forEach((mask, piece) => mask.forEach((value, index) => { if (value) rgba[index * 4] |= 1 << piece; }));
for (let at = 3; at < rgba.length; at += 4) rgba[at] = 255;
const planes = packPlanes([rgba], width, height);
const sizes = pieceSizes(planes, 3);
const overlaps = pieceOverlaps(planes, 3);
const ranks = pieceRanks(list, sizes, width * height, overlaps);
const wholes = pieceWholes(list, sizes, width * height, overlaps);
assert.deepEqual(Array.from(wholes), [2, -1, -1], "the sleeve is part of the jacket; a bag is no part word");
// Half on the sleeve, half on the pocket: without the part rule the two
// would be a pair that comes before the jacket.
const stroke = paintStroke(blank(width, height), width, height, [[11, 24], [28, 24]], 1.5, 1);
assert.deepEqual(strokePieces(planes, list, sizes, width * height, stroke, ranks, wholes), { candidates: [2], pair: null, pairAt: 0 });
assert.notEqual(strokePieces(planes, list, sizes, width * height, stroke, ranks).pair, null, "without the table of wholes they pair up");
});
test("a skirt comes before the outfit SAM 3 also calls a dress", () => {
const width = 40;
const height = 60;
const masks = [
box(width, height, 10, 30, 30, 50), // skirt: 400 of the dress's 700
box(width, height, 10, 15, 30, 50), // "dress": the top and the skirt
box(width, height, 8, 2, 32, 58), // person
];
const list = [
{ kind: "name", name: "skirt", sure: 0.98 },
{ kind: "name", name: "dress", sure: 0.89 },
{ kind: "name", name: "person", sure: 0.98 },
];
const rgba = new Uint8ClampedArray(width * height * 4);
masks.forEach((mask, piece) => mask.forEach((value, index) => { if (value) rgba[index * 4] |= 1 << piece; }));
for (let at = 3; at < rgba.length; at += 4) rgba[at] = 255;
const planes = packPlanes([rgba], width, height);
const sizes = pieceSizes(planes, 3);
const ranks = pieceRanks(list, sizes, width * height, pieceOverlaps(planes, 3));
assert.deepEqual(candidatesAt(planes, 40 * width + 20, ranks), [0, 1, 2], "skirt, dress, person");
});
test("one named pick says its name on the node's edge", () => {
assert.equal(readoutText({ count: 1, share: 0.18, only: "thing", name: "jacket" }), "jacket \u00b7 18% of the picture");
assert.equal(readoutText({ count: 2, share: 0.3, name: "jacket" }), "2 picks \u00b7 30% of the picture");
});
test("a big part goes behind the thing it is part of, but never behind the person", () => {
// A person, the jacket on them, a sleeve that is a good third of the
// jacket, and a small badge on the jacket. All sure.
const width = 50;
const height = 50;
const masks = [
box(width, height, 22, 22, 24, 24), // badge: tiny
box(width, height, 10, 14, 18, 34), // sleeve: 160 of the jacket's 400
box(width, height, 10, 14, 30, 34), // jacket
box(width, height, 8, 4, 32, 46), // person
];
const list = [
{ kind: "name", name: "badge", sure: 0.9 },
{ kind: "name", name: "sleeve", sure: 0.93 },
{ kind: "name", name: "jacket", sure: 0.96 },
{ kind: "name", name: "person", sure: 0.98 },
];
const rgba = new Uint8ClampedArray(width * height * 4);
masks.forEach((mask, piece) => mask.forEach((value, index) => { if (value) rgba[index * 4] |= 1 << piece; }));
for (let at = 3; at < rgba.length; at += 4) rgba[at] = 255;
const planes = packPlanes([rgba], width, height);
const sizes = pieceSizes(planes, 4);
const overlaps = pieceOverlaps(planes, 4);
assert.equal(overlaps[1 * 4 + 2], 160, "the sleeve lies in the jacket");
assert.equal(overlaps[2 * 4 + 1], 160);
assert.equal(overlaps[0 * 4 + 1], 0, "the badge is not on the sleeve");
const ranks = pieceRanks(list, sizes, width * height, overlaps);
// On the sleeve: the jacket first, then the sleeve, then the person.
assert.deepEqual(candidatesAt(planes, 20 * width + 12, ranks), [2, 1, 3]);
// On the badge: a small part is meant as itself.
assert.deepEqual(candidatesAt(planes, 23 * width + 23, ranks), [0, 2, 3]);
// The jacket is nearly half the person and still comes before it.
assert.deepEqual(candidatesAt(planes, 30 * width + 26, ranks), [2, 3]);
// Without the table of overlaps nothing is moved: smallest first.
assert.deepEqual(candidatesAt(planes, 20 * width + 12, pieceRanks(list, sizes, width * height)), [1, 2, 3]);
});
test("a pick of every thing of a name survives the trip", () => {
const text = encodeTouches("k", [{ t: "all", op: 1, w: " neon, sign " }, { t: "all", op: 0, w: "bird" }, { t: "all", op: 1, w: " ,: " }]);
assert.deepEqual(decodeTouches(text).items, [{ t: "all", op: 1, w: "neon sign" }, { t: "all", op: 0, w: "bird" }]);
assert.equal(touchFits([], { t: "all", op: 1, w: "bird" }), true);
});
test("each pick has a short name for its chip", () => {
assert.equal(chipLabel({ t: "name", op: 1, w: "jacket", x: 1, y: 2 }), "jacket");
assert.equal(chipLabel({ t: "name", op: 0, w: "cup", x: 1, y: 2 }), "\u2212 cup");
assert.equal(chipLabel({ t: "all", op: 1, w: "bird" }), "every bird");
assert.equal(chipLabel({ t: "all", op: 1, w: "bird" }, 5), "5 \u00d7 bird");
assert.equal(chipLabel({ t: "tap", op: 1, x: 1, y: 2 }), "thing");
assert.equal(chipLabel({ t: "find", op: 1, r: 4, pts: [[1, 2]] }), "stroke");
assert.equal(chipLabel({ t: "paint", op: 0, r: 4, pts: [[1, 2]] }), "\u2212 paint");
});
test("two touches are the same when they are written the same", () => {
const cup = { t: "name", op: 1, w: "cup", x: 10, y: 20 };
// A touch fresh from a click and the same one read back from the widget: the key order differs.
assert.equal(sameTouches([cup], decodeTouches(encodeTouches("k", [{ y: 20, x: 10, w: "cup", op: 1, t: "name" }])).items), true);
assert.equal(sameTouches([cup], [{ ...cup, op: 0 }]), false);
assert.equal(sameTouches([cup], [{ ...cup, w: "mug" }]), false);
assert.equal(sameTouches([cup], [cup, cup]), false);
assert.equal(sameTouches([], []), true);
const stroke = { t: "find", op: 1, r: 12, pts: [[1, 2], [3, 4]] };
assert.equal(sameTouches([stroke], [{ ...stroke, pts: [[1, 2], [3, 4]] }]), true);
assert.equal(sameTouches([stroke], [{ ...stroke, t: "paint" }]), false);
});
test("marks made with Draw that follow each other share one chip", () => {
const paint = (op) => ({ t: "paint", op, r: 4, pts: [[1, 2]] });
const cup = { t: "name", op: 1, w: "cup", x: 1, y: 2 };
assert.deepEqual(chipGroups([]), []);
assert.deepEqual(chipGroups([paint(1), paint(1), paint(0), paint(1)]), [{ from: 0, to: 3, count: 4, paint: true, adds: 3 }]);
// A pick between two runs of paint keeps them apart: touches count in order.
assert.deepEqual(chipGroups([paint(1), paint(1), cup, paint(0), paint(0)]), [
{ from: 0, to: 1, count: 2, paint: true, adds: 2 },
{ from: 2, to: 2, count: 1, paint: false, adds: 1 },
{ from: 3, to: 4, count: 2, paint: true, adds: 0 },
]);
// Picks never share a chip, not even two of the same name.
assert.equal(chipGroups([cup, cup, { t: "find", op: 1, r: 4, pts: [[1, 2]] }, { t: "tap", op: 0, x: 1, y: 2 }]).length, 4);
});
// ---- fine edges ---------------------------------------------------------------
test("Fine edges is on only while the input holds a model file", () => {
for (const value of [undefined, null, "", " ", "off", " off "]) assert.equal(fineChoice(value), "", JSON.stringify(value));
assert.equal(fineChoice("model.safetensors"), "model.safetensors");
assert.equal(fineChoice(" sub/model.safetensors "), "sub/model.safetensors");
});
test("the Fine edges choices read off and on, and by name only when there are several files", () => {
assert.equal(fineEdgeLabel("off", ["off", "model.safetensors"]), "off");
assert.equal(fineEdgeLabel("model.safetensors", ["off", "model.safetensors"]), "on");
// A workflow that names a file this machine does not have: still "on", so it reads as what was chosen.
assert.equal(fineEdgeLabel("model.safetensors", ["off"]), "on");
const several = ["off", "small/model.safetensors", "vitmatte_base.safetensors"];
assert.equal(fineEdgeLabel("small/model.safetensors", several), "model");
assert.equal(fineEdgeLabel("vitmatte_base.safetensors", several), "vitmatte_base");
assert.equal(fineEdgeLabel("off", several), "off");
});
test("the line of settings a fine edge was made with is the one the node writes", () => {
// The same strings as fine_made in nodes/node_mask_by_touch.py (tests/test_mask_by_touch.py holds that side).
assert.equal(fineMade({ grow: 0, soften: 0, smooth: 0, fill: true, invert: false, fine: "model.safetensors" }), "0|0.000|0|1|0|model.safetensors");
assert.equal(fineMade({ grow: 12, soften: 0.5, smooth: 3, fill: false, invert: true, fine: "a/b.safetensors" }), "12|0.500|3|0|1|a/b.safetensors");
assert.notEqual(fineMade({ grow: 1, fine: "m" }), fineMade({ grow: 2, fine: "m" }), "any setting that changes the mask changes the line");
assert.notEqual(fineMade({ soften: 1, fine: "m" }), fineMade({ soften: 1.5, fine: "m" }));
assert.notEqual(fineMade({ invert: true, fine: "m" }), fineMade({ invert: false, fine: "m" }));
});
test("a fine edge's mask is its picture's see-through channel", () => {
const rgba = new Uint8ClampedArray([10, 20, 30, 0, 40, 50, 60, 128, 70, 80, 90, 255]);
assert.deepEqual([...fineMask(rgba)].map((value) => Math.round(value * 1000) / 1000), [0, 0.502, 1]);
});
test("with clean colours the cut-out is drawn whole, and nothing of the picture shows through", () => {
const mask = new Float32Array([1, 0.5, 0, 1]);
const colours = new Uint8ClampedArray([200, 0, 0, 255, 200, 0, 0, 128, 0, 0, 0, 0, 0, 100, 0, 255]);
const pixels = paintCover(new Uint8ClampedArray(16), mask, 2, 2, "cut", colours);
assert.deepEqual([...pixels.slice(0, 4)], [200, 0, 0, 255], "all the thing: its clean colour, solid");
assert.equal(pixels[7], 255, "half see-through: still solid, the chequer mixed in under it");
assert.ok(pixels[4] > 100 && pixels[4] < 200 && pixels[5] > 0, "half the clean colour, half the chequer");
assert.equal(pixels[11], 255, "not the thing: the chequer, solid");
assert.ok(pixels[8] < 60 && pixels[9] < 60 && pixels[10] < 60);
// Without clean colours it is a cover with a hole, as before: the picture shows through where the mask is.
const plain = paintCover(new Uint8ClampedArray(16), mask, 2, 2, "cut");
assert.deepEqual([plain[3], plain[7], plain[11]], [0, 128, 255]);
// The mask view does not care about colours.
assert.deepEqual([...paintCover(new Uint8ClampedArray(16), mask, 2, 2, "mask", colours).slice(4, 8)], [128, 128, 128, 255]);
});
// ---- the subject, the background, and names SAM 3 is not sure of ---------------
test("the subject and the background are picks without a point or a word", () => {
const text = encodeTouches("k", [{ t: "subject", op: 1, x: 5, w: "junk" }, { t: "background", op: 0 }]);
assert.deepEqual(decodeTouches(text).items, [{ t: "subject", op: 1 }, { t: "background", op: 0 }]);
assert.equal(chipLabel({ t: "subject", op: 1 }), "subject");
assert.equal(chipLabel({ t: "background", op: 0 }), "\u2212 background");
assert.equal(touchFits([], { t: "subject", op: 1 }), true);
});
test("a word that was not shown is kept quiet: picked, saved, and called a thing", () => {
const quiet = { t: "name", op: 1, w: "foot", x: 10, y: 20, q: 1 };
const loud = { t: "name", op: 1, w: "hair", x: 10, y: 20 };
const round = decodeTouches(encodeTouches("k", [quiet, loud])).items;
assert.deepEqual(round, [quiet, loud], "the mark is saved, and only where it is set");
assert.equal(chipLabel(quiet), "thing");
assert.equal(chipLabel({ ...quiet, op: 0 }), "\u2212 thing");
assert.equal(chipLabel(loud), "hair");
assert.deepEqual(decodeTouches(encodeTouches("k", [{ ...loud, q: 0 }, { ...loud, q: "yes" }])).items, [loud, loud], "anything but 1 is not the mark");
assert.equal(sameTouches([quiet], [loud]), false);
});
test("a name is shown only when SAM 3 is sure of it, and surer still for a big thing", () => {
// The numbers come from 1137 names on 22 pictures: under 0.8 one name in three was wrong.
assert.equal(nameShown(0.79, 0.05), false);
assert.equal(nameShown(0.8, 0.05), true);
assert.equal(nameShown(0.99, 0.05), true);
// A big thing: "mirror" at 0.81 and "poster" at 0.898 were both wrong, the right big ones all 0.958 or more.
assert.equal(nameShown(0.898, 0.95), false);
assert.equal(nameShown(0.89, 0.3), false);
assert.equal(nameShown(0.9, 0.3), true);
assert.equal(nameShown(0.85, 0.29), true);
assert.equal(nameShown(undefined, undefined), false);
});
test("the pack's own picture loaders are the ones the node runs by itself", () => {
assert.deepEqual([...OWN_LOADERS].sort(), ["AUSBOSS_NODES_ImageCropRotatePad", "AUSBOSS_NODES_LoadImagePad"]);
// A core Load Image is not in the list: its file is the picture, and the node shows it without a run.
assert.equal(OWN_LOADERS.has("LoadImage"), false);
});
test("a loader's file and settings make one text, and another text means another picture", () => {
const widgets = (over = {}) => [
{ name: "image", value: "photo.png" },
{ name: "rotation_degrees", value: 0 },
{ name: "crop_width", value: 0 },
{ name: "resize_to_megapixels", value: false },
// What is only drawn carries no setting: a panel, a card, a button.
{ name: "ausboss_panel", value: "", serialize: false },
{ name: "upload", value: {} },
{ value: 3 },
null,
].map((widget) => (widget && over[widget.name] !== undefined ? { ...widget, value: over[widget.name] } : widget));
const base = loaderSignature(widgets());
assert.equal(base, '[["image","photo.png"],["rotation_degrees",0],["crop_width",0],["resize_to_megapixels",false]]');
assert.equal(loaderSignature(widgets()), base);
assert.notEqual(loaderSignature(widgets({ image: "other.png" })), base);
assert.notEqual(loaderSignature(widgets({ crop_width: 512 })), base);
assert.notEqual(loaderSignature(widgets({ rotation_degrees: 90 })), base);
assert.equal(loaderSignature(undefined), "[]");
});
test("the node asks for its own loader's picture only in answer to the person, never while a workflow opens", () => {
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const stage = readFileSync(join(root, "js", "shared", "touch_stage.mjs"), "utf-8");
const show = stage.slice(stage.indexOf("function showOwnPicture"), stage.indexOf("function followOwnLoader"));
// the same run the Show picture button asks for
assert.match(show, /requestRun\(face, "scope"\)/);
// never with the look-on-button setting, never over picks that wait for their own press
assert.match(show, /face\.host\.lookOnButton\?\.\(\)/);
assert.match(show, /face\.pic && face\.held > 0/);
const follow = stage.slice(stage.indexOf("function followOwnLoader"), stage.indexOf("const OTHER_PICTURE"));
assert.match(follow, /!face\.host\.loading\?\.\(\)/);
assert.match(stage, /if \(!face\.host\.loading\?\.\(\)\) followOwnLoader\(face, true\)/);
const host = readFileSync(join(root, "js", "mask_by_touch", "index.js"), "utf-8");
assert.match(host, /loading: \(\) => Boolean\(app\.configuringGraph\)/);
});