Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a444478
fix(inference): settle an equal-area pointed-piece tie by reading order
JArmandoAnaya Sep 16, 2026
90e89e6
feat(annotator): hold a binary mask and round the way Python does
JArmandoAnaya Sep 16, 2026
96e47ac
feat(annotator): read a mask as runs, and take its extent off them
JArmandoAnaya Sep 16, 2026
6937767
fix(annotator): runs/spans must match Python contract on out-of-contr…
JArmandoAnaya Sep 16, 2026
22630af
feat(annotator): pick a mask's pieces the way the kernel does
JArmandoAnaya Sep 16, 2026
4eac33e
feat(annotator): close a piece's narrow gaps over row bitsets
JArmandoAnaya Sep 16, 2026
9ce3aae
feat(annotator): trace a piece's boundary along its pixels' edges
JArmandoAnaya Sep 16, 2026
db670db
feat(annotator): turn a binary mask into a box or an outline
JArmandoAnaya Sep 16, 2026
6747da1
test(inference): export mask-to-geometry golden cases for the port
JArmandoAnaya Sep 16, 2026
54dd30b
test(annotator): hold the mask pipeline to the kernel's own answers
JArmandoAnaya Sep 16, 2026
623dffb
test(build): resolve the mask geometry API from the packed annotator
JArmandoAnaya Sep 16, 2026
e60455e
test(inference): add a case that reaches the closing-radius cap
JArmandoAnaya Sep 16, 2026
3fab8c0
test(inference): write the mask-geometry fixture compact to fit the s…
JArmandoAnaya Sep 16, 2026
02fd507
docs: say what the two languages' square roots actually do
JArmandoAnaya Sep 16, 2026
6446816
fix(annotator): refuse a mask whose buffer disagrees with its dimensions
JArmandoAnaya Sep 16, 2026
1fe6613
fix(inference): stabilize nearest mask selection
JArmandoAnaya Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/content/inference.md
Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,8 @@ before anything else looks at the mask.
**A polygon is the piece you pointed at, not the biggest one on the frame.** Which of the
survivors you meant is a question only the points can answer, so the choice is made from the
prompt: a point inside a piece picks that piece; several points inside several pieces pick the
largest of *those*, because two positives describe one object rather than propose two; and a
largest of *those*, because two positives describe one object rather than propose two, and two
pieces of exactly the same size answer with the one whose earliest run comes first; and a
point inside none of them picks the piece nearest to it, since a mask need not cover the exact
pixel you clicked. Negative points never select - they say what the shape is not, and a piece is
chosen before its shape is known.
Expand Down
19 changes: 14 additions & 5 deletions frontend/annotator/src/core/geometry/simplify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,20 @@
* keeps it current, and neither half shares a toolchain with the other.
*
* **Exact equality is achievable and is what the gate asserts.** Both languages
* hold IEEE-754 doubles, `Math.sqrt` and Python's `** 0.5` are the same
* correctly-rounded operation, and every expression below is in the same order
* as the Python it mirrors. Keeping that order is not style: the algorithm's
* output is decided by comparisons, so a re-association that moves a value by
* one unit in the last place can move a vertex.
* hold IEEE-754 doubles, and every expression below is in the same order as the
* Python it mirrors — that ordering is what the parity actually depends on.
* Keeping it is not style: the algorithm's output is decided by comparisons, so
* a re-association that moves a value by one unit in the last place can move a
* vertex.
*
* `Math.sqrt` and Python's `** 0.5` are *not* quite the same operation, though:
* CPython routes `** 0.5` through libm `pow`, which is not correctly rounded,
* and it disagrees with a correctly-rounded square root by one ulp on roughly a
* tenth of a percent of inputs. The mask pipeline avoids carrying that
* difference into nearest-piece selection by comparing squared distances there;
* `closingRadius` absorbs it through truncation. The existing simplification
* fixture continues to assert exact output for its covered contours. Making
* Python's side call `math.sqrt` would be a separate semantic change.
*
* ## Why a contour is the input rather than a mask
*
Expand Down
33 changes: 33 additions & 0 deletions frontend/annotator/src/core/mask/binaryMask.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";

import { pythonRound } from "./binaryMask";

describe("pythonRound", () => {
it("rounds a half to the even neighbour, as Python does", () => {
expect(pythonRound(0.5)).toBe(0);
expect(pythonRound(1.5)).toBe(2);
expect(pythonRound(2.5)).toBe(2);
expect(pythonRound(3.5)).toBe(4);
});

it("disagrees with Math.round on exactly the halves that decide a component", () => {
for (const half of [0.5, 2.5, 4.5, 6.5]) {
expect(pythonRound(half)).not.toBe(Math.round(half));
}
});

it("rounds a negative half to even too, and never to -0", () => {
expect(pythonRound(-0.5)).toBe(0);
expect(Object.is(pythonRound(-0.5), -0)).toBe(false);
expect(pythonRound(-1.5)).toBe(-2);
expect(pythonRound(-2.5)).toBe(-2);
});

it("rounds anything that is not a half to the nearest whole number", () => {
expect(pythonRound(0.4)).toBe(0);
expect(pythonRound(0.6)).toBe(1);
expect(pythonRound(-0.4)).toBe(0);
expect(pythonRound(-0.6)).toBe(-1);
expect(pythonRound(7)).toBe(7);
});
});
59 changes: 59 additions & 0 deletions frontend/annotator/src/core/mask/binaryMask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* What a segmenter answers with, in the asset's own pixels.
*
* ## The seam, and why it is structural rather than a dependency
*
* `@visionset/browser-inference` stops at a mask: model execution, an image
* embedding, a grid of bytes and a confidence. Turning that grid into a box or
* a polygon is a product decision — which pieces count as the object, how big a
* gap is an artefact, where the boundary of a pixel is — and it belongs here,
* beside the rest of the asset-pixel arithmetic, for the reason
* `visionset.inference.masks` lives above the segmentation adapter rather than
* inside it: it runs once for every model there will ever be.
*
* So `RawSegmentation` is *structurally* assignable to this type and neither
* package imports the other. A host composes them.
*/
export interface BinaryMask {
readonly width: number;
readonly height: number;
/**
* Row-major, `width * height` bytes, each **exactly 0 or 1**.
*
* Not "any non-zero is lit": the authoritative Python reads a row through
* `index(True)` and `index(False)`, which match the bytes 1 and 0 and nothing
* else, so a 255 already breaks the scan there. The contract is the contract
* rather than a convention this side is free to widen.
*/
readonly mask: Uint8Array;
}

/**
* One connected piece of a mask, cropped to its own extent.
*
* `x` and `y` are where the crop sits in the asset, and every coordinate finally
* emitted has them added back. Cropped rather than carried at full size, which
* is what keeps a plural answer affordable: a 4K mask is eight million bytes,
* and one of those per piece would cost more than the forward pass.
*/
export interface Piece {
readonly x: number;
readonly y: number;
readonly mask: BinaryMask;
}

/**
* Python's `round()`: halves go to the even neighbour.
*
* `Math.round` takes them upward, and answers `-0` for `-0.5`. The difference
* decides which component a click at a half-pixel coordinate selects, and every
* test written at integer coordinates passes under either — which is exactly
* what makes it worth a function with a name.
*/
export function pythonRound(value: number): number {
const low = Math.floor(value);
const rest = value - low;
if (rest > 0.5) return low + 1;
if (rest < 0.5) return low;
return low % 2 === 0 ? low : low + 1;
}
149 changes: 149 additions & 0 deletions frontend/annotator/src/core/mask/closing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";

import { closingRadius, filled } from "./closing";
import { bboxFrom, maskOf, runs } from "./runs";
import type { Run } from "./runs";

const lit = (mask: { width: number; mask: Uint8Array }): number =>
mask.mask.reduce((total: number, byte) => total + (byte === 0 ? 0 : 1), 0);

/** A solid square with a one-row bite `depth` pixels deep, cut in from the right. */
const notched = (depth: number, size = 64) => {
const runsOf: Run[] = [];
for (let y = 0; y < size; y += 1) {
if (y === Math.floor(size / 2) && depth > 0) runsOf.push([y, 0, size - depth - 1]);
else runsOf.push([y, 0, size - 1]);
}
return maskOf(size, size, runsOf);
};

/**
* A 64x64 square with an 8x8 bite out of its right edge.
*
* Wide in *both* directions, which is what makes it a bay rather than a notch:
* a one-row bite closes at radius 1 however deep it is, because the dimension
* the close has to bridge is its height.
*/
const bayed = () => {
const runsOf: Run[] = [];
for (let y = 0; y < 64; y += 1) {
if (y >= 28 && y < 36) runsOf.push([y, 0, 55]);
else runsOf.push([y, 0, 63]);
}
return maskOf(64, 64, runsOf);
};

/**
* A 64x64 square with a 2-px notch, inside an 80x80 canvas.
*
* Framed so background survives the close: `notched(2)` is the whole canvas but
* for two pixels, and closing it leaves nothing unlit at all.
*/
const framedNotch = () => {
const runsOf: Run[] = [];
for (let y = 8; y < 72; y += 1) {
if (y === 40) runsOf.push([y, 8, 69]);
else runsOf.push([y, 8, 71]);
}
return maskOf(80, 80, runsOf);
};

/** A 64x64 square with a centred square hole `hole` pixels on a side. */
const holed = (hole: number, size = 64) => {
const low = Math.floor(size / 2) - Math.floor(hole / 2);
const runsOf: Run[] = [];
for (let y = 0; y < size; y += 1) {
if (y >= low && y < low + hole) {
runsOf.push([y, 0, low - 1], [y, low + hole, size - 1]);
} else {
runsOf.push([y, 0, size - 1]);
}
}
return maskOf(size, size, runsOf);
};

/** A `size`x`size` solid square, placed at the origin of a `canvas`x`canvas` frame. */
const solidSquareIn = (canvas: number, size: number) =>
maskOf(canvas, canvas, Array.from({ length: size }, (_, y): Run => [y, 0, size - 1]));

describe("closingRadius", () => {
it("scales with the piece's own area, not the frame", () => {
expect(closingRadius(solidSquareIn(64, 64))).toBe(closingRadius(solidSquareIn(200, 64)));
expect(closingRadius(notched(0, 200))).toBeGreaterThan(closingRadius(notched(0, 64)));
});

it("reaches nothing at all on a piece too small to have artefacts", () => {
expect(closingRadius(maskOf(8, 8, [[3, 3, 4]]))).toBe(0);
});

it("stops at the cap however large the piece", () => {
expect(
closingRadius(maskOf(800, 800, Array.from({ length: 800 }, (_, y): Run => [y, 0, 799]))),
).toBe(6);
});

it("truncates rather than rounding, as Python's int() does", () => {
// 14400 lit pixels -> sqrt(28.8) / 2 = 2.6832..., which truncates to 2 and
// would round to 3. This is the assertion that catches a Math.round port.
expect(
closingRadius(maskOf(120, 120, Array.from({ length: 120 }, (_, y): Run => [y, 0, 119]))),
).toBe(2);
});
});

describe("filled", () => {
it("hands back the very same mask when the reach works out at nothing", () => {
const small = maskOf(8, 8, [[3, 3, 4]]);
expect(filled(small)).toBe(small);
});

it("hands back the very same mask when the close changes nothing", () => {
const clean = notched(0);
expect(filled(clean)).toBe(clean);
});

it("closes a notch narrower than the reach", () => {
const narrow = notched(2);
const after = filled(narrow);
expect(after).not.toBe(narrow);
expect(lit(after)).toBeGreaterThan(lit(narrow));
});

it("leaves a bay wider than the reach alone", () => {
const wide = bayed();
expect(filled(wide)).toBe(wide);
});

it("closes a one-row bite however deep it is, because its height is the gap", () => {
const deep = notched(40);
expect(filled(deep)).not.toBe(deep);
});

it("fills a small enclosed hole", () => {
const withHole = holed(2);
expect(lit(withHole)).toBe(64 * 64 - 4);
expect(lit(filled(withHole))).toBe(64 * 64);
});

it("never moves the extent", () => {
for (const mask of [notched(2), notched(5), holed(2)]) {
expect(bboxFrom(filled(mask))).toEqual(bboxFrom(mask));
}
});

it("keeps the mask's own dimensions", () => {
const after = filled(notched(2));
expect(after.width).toBe(64);
expect(after.height).toBe(64);
expect(after.mask.length).toBe(64 * 64);
});

it("answers only 0 and 1", () => {
expect(new Set(filled(framedNotch()).mask)).toEqual(new Set([0, 1]));
});

it("leaves runs it did not need to touch exactly where they were", () => {
const after = filled(notched(2));
expect(runs(after)[0]).toEqual([0, 0, 63]);
});
});
Loading
Loading