{session.parameters.includes("detail") && onDetail !== undefined && (
-
+
);
}
+
+/** Sentence case, from the wire's own lowercase vocabulary. */
+function labelFor(detail: Detail): string {
+ return detail.charAt(0).toUpperCase() + detail.slice(1);
+}
diff --git a/frontend/ui-core/src/annotator/suggestFlow.test.tsx b/frontend/ui-core/src/annotator/suggestFlow.test.tsx
index ebd40a5..28fb8e4 100644
--- a/frontend/ui-core/src/annotator/suggestFlow.test.tsx
+++ b/frontend/ui-core/src/annotator/suggestFlow.test.tsx
@@ -215,10 +215,10 @@ beforeEach(() => {
contour: [],
},
],
- applied: { detail: "balanced", fill_holes: 0.002, fragments: "one" },
- // Declared as the server declares it for a box class: `fragments` alone.
- // The panel renders what this names and works none of it out itself.
- parameters: ["fragments"],
+ applied: { detail: "balanced" },
+ // Declared as the server declares it for a box class: nothing at all, so the
+ // panel renders no adjustments. It works none of that out for itself (#557).
+ parameters: [],
};
suggestRefusal = null;
suggestHold = null;
@@ -625,176 +625,76 @@ describe("a press outside the asset is not a prompt", () => {
});
});
-describe("what the wait looks like while it is happening", () => {
+describe("what the wait looks like, and where it is not reported", () => {
/*
- These are about the *wiring* — that the host's one clock reaches both the card
- and the canvas — and not about the thresholds themselves, which are unit-tested
- on fake time in `adapters/react/pending.test.ts`. Real timers here, because the
- request is real: the route is held open, so the wait is genuine rather than
- simulated, and `findBy*` does the waiting that a fixed sleep would guess at.
-
- jsdom's `matchMedia` stub answers `matches: true` to every query, so the halo
- renders in its reduced-motion form throughout. That is asserted rather than
- worked around: a stub that lies in one direction should be visible in what the
- test claims.
+ The panel is the only surface that says a request is out (#557). A ring at the
+ click point and a busy cursor were both removed: sitting on the picture next
+ to the pointer, they read as the machine having hung rather than as work in
+ progress, and the card already says so in words.
+
+ Real timers, because the request is real: the route is held open, so the wait
+ is genuine rather than simulated, and `findBy*` does the waiting that a fixed
+ sleep would guess at.
*/
- it("puts a halo at the click and a busy cursor on the canvas once the wait is real", async () => {
+ it("says so on the card and puts nothing at all on the canvas", async () => {
const gate = held();
await open();
await arm();
clickCanvas();
- const halo = await screen.findByTestId("suggest-halo");
- expect(halo.getAttribute("data-motion")).toBe("still");
- expect(screen.getByTestId("annotator-pane").style.cursor).toBe("progress");
-
- gate.release();
-
- await waitFor(() => expect(screen.queryByTestId("suggest-halo")).toBeNull());
- expect(screen.getByTestId("annotator-pane").style.cursor).not.toBe("progress");
- });
-
- it("is up on the first render after the click, with no clock advanced", async () => {
- // The load-bearing case for the delay's removal, and it is deliberately
- // synchronous — no `findBy`, no `waitFor`, nothing that would let a timer or
- // a second render slip in underneath. A show delay of any length, including
- // one frame's worth, turns this red.
- const gate = held();
- await open();
- await arm();
-
- clickCanvas();
-
- expect(screen.getByTestId("suggest-halo")).toBeTruthy();
- expect(screen.getByTestId("suggest-asking")).toBeTruthy();
- expect(screen.getByTestId("annotator-pane").style.cursor).toBe("progress");
-
- gate.release();
- await waitFor(() => expect(screen.queryByTestId("suggest-halo")).toBeNull());
- });
-
- it("comes up on a fast answer too, and goes away on its own", async () => {
- // Nothing held, so the stub answers about as fast as anything can, and the
- // halo is still up on the click — which is the delay's removal seen from the
- // case the delay existed for.
- //
- // What is deliberately **not** asserted here is the floor holding the halo
- // past the answer. That is real behaviour and it is pinned in the machine's
- // own unit tests, but its rendered consequence is not observable from this
- // harness: a jsdom round trip through react-query routinely runs past 250ms
- // — an attempt at exactly this assertion measured 273ms and failed on a
- // floor that had honestly expired — and the alternative, driving the hook
- // under fake timers, does not deliver the effect-driven state update at all.
- // A racy assertion here would be worse than the gap it papers over.
- await open();
- await arm();
- clickCanvas();
-
- expect(screen.getByTestId("suggest-halo")).toBeTruthy();
-
- await screen.findByTestId("suggestion-shape");
- await waitFor(() => expect(screen.queryByTestId("suggest-halo")).toBeNull());
+ await screen.findByTestId("suggest-asking");
+ // The mutation test for the removal: restore either affordance and one of
+ // these turns red.
+ expect(screen.queryByTestId("suggest-halo")).toBeNull();
expect(screen.getByTestId("annotator-pane").style.cursor).not.toBe("progress");
- });
-
- it("reports the wait in the card and on the canvas off the same clock", async () => {
- const gate = held();
- await open();
- await arm();
- clickCanvas();
-
- await screen.findByTestId("suggest-halo");
- expect(screen.getByTestId("suggest-asking")).toBeTruthy();
- // The cold-start sentence is a second threshold away and has not been earned.
- expect(screen.queryByTestId("suggest-cold-start")).toBeNull();
gate.release();
- await waitFor(() => expect(screen.queryByTestId("suggest-asking")).toBeNull());
+ await screen.findByTestId("suggest-shown");
+ expect(screen.queryByTestId("suggest-halo")).toBeNull();
});
- it("takes the halo back on Escape without waiting out the visibility floor", async () => {
+ it("is up on the first render after the click, with no clock advanced", async () => {
+ // The load-bearing case for the delay's removal: the card is the report, so
+ // it has to be there on the frame the request leaves.
const gate = held();
await open();
await arm();
clickCanvas();
- await screen.findByTestId("suggest-halo");
-
- await userEvent.keyboard("{Escape}");
-
- // Synchronous: `discardSuggestion` cancels the indicator itself rather than
- // letting the status drop do it, because the ordinary path honours the floor
- // and a take-back that lingers a quarter second is not one.
- expect(screen.queryByTestId("suggest-halo")).toBeNull();
- expect(screen.getByTestId("annotator-pane").style.cursor).not.toBe("progress");
+ await screen.findByTestId("suggest-asking");
gate.release();
+ await screen.findByTestId("suggest-shown");
});
it("keeps the shape drawn through a refine, while the card reports the new ask", async () => {
- // The canvas and the panel deliberately say different things here, and both
- // are true: the shape is the best answer anyone has until a better one
- // arrives, and the card is about the request that is out. What must not
- // happen is the shape blanking — that is `paintSuggestion` testing what the
- // session holds rather than what its status is.
await open();
await arm();
clickCanvas();
await screen.findByTestId("suggestion-shape");
- const gate = held();
- clickCanvas();
-
- await waitFor(() => expect(screen.getByTestId("suggest-asking")).toBeTruthy());
- expect(screen.getByTestId("suggestion-shape")).toBeTruthy();
- expect(screen.queryByTestId("suggest-accept")).toBeNull();
-
- gate.release();
- await waitFor(() =>
- expect((screen.getByTestId("suggest-accept") as HTMLButtonElement).disabled).toBe(false),
- );
- });
-
- it("anchors the halo on the newest click, which is the one being waited for", async () => {
- await open();
- await arm();
- clickCanvasAt(100, 100);
- await screen.findByTestId("suggestion-shape");
-
const gate = held();
clickCanvasAt(140, 160);
-
- const halo = await screen.findByTestId("suggest-halo");
- const dots = screen.getByTestId("prompt-points").children;
- const newest = dots[dots.length - 1]!;
- expect(halo.getAttribute("cx")).toBe(newest.getAttribute("cx"));
- expect(halo.getAttribute("cy")).toBe(newest.getAttribute("cy"));
+ await screen.findByTestId("suggest-asking");
+ // The best answer anyone has stays on screen while the next one is fetched.
+ expect(screen.getByTestId("suggestion-shape")).toBeTruthy();
gate.release();
+ await screen.findByTestId("suggest-shown");
});
- it("clears the indicator when the answer is a refusal", async () => {
- // A refusal is an answer. The one thing this must not do is leave a halo
- // pulsing over a request that is over — the panel carries the sentence.
+ it("clears the report when the answer is a refusal", async () => {
const gate = held();
- suggestRefusal = {
- status: 409,
- code: "INFERENCE_CONNECTION_NOT_RUNNABLE",
- message: "Install the local-inference extra to run this model.",
- };
await open();
await arm();
clickCanvas();
- await screen.findByTestId("suggest-halo");
+ await screen.findByTestId("suggest-asking");
+ suggestRefusal = { status: 422, code: "UNSUPPORTED_PROMPT", message: "not a segmenter" };
gate.release();
await screen.findByTestId("suggest-refusal");
- // `waitFor`, not an immediate read: a refusal *is* an answer, so it goes out
- // through the ordinary path and pays the visibility floor like any other. The
- // claim is that it goes, not that it goes on the same tick.
- await waitFor(() => expect(screen.queryByTestId("suggest-halo")).toBeNull());
- expect(screen.getByTestId("annotator-pane").style.cursor).not.toBe("progress");
+ expect(screen.queryByTestId("suggest-asking")).toBeNull();
});
});
diff --git a/frontend/ui-core/src/annotator/suggestPanel.test.tsx b/frontend/ui-core/src/annotator/suggestPanel.test.tsx
index b592d9c..1c75687 100644
--- a/frontend/ui-core/src/annotator/suggestPanel.test.tsx
+++ b/frontend/ui-core/src/annotator/suggestPanel.test.tsx
@@ -9,7 +9,7 @@
* never a dead button.
*/
-import { cleanup, render, screen } from "@testing-library/react";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { JSX } from "react";
@@ -35,7 +35,7 @@ function answerOf(...suggestions: readonly Suggestion[]): Answer {
modelRef: MODEL_REF,
confidence: suggestions[0]?.confidence ?? null,
suggestions,
- parameters: ["detail", "fill_holes", "fragments"],
+ parameters: ["detail"],
};
}
@@ -453,7 +453,7 @@ describe("the adjustments, which are a section and never a popup", () => {
contour: [[0, 0], [10, 0], [10, 10], [0, 10]],
},
],
- parameters: ["detail", "fill_holes", "fragments"],
+ parameters: ["detail"],
};
}
@@ -468,28 +468,26 @@ describe("the adjustments, which are a section and never a popup", () => {
expect(screen.queryByTestId("suggest-adjustments")).toBeNull();
});
- it("renders exactly the parameters the server declared, in its order", () => {
+ it("renders exactly the parameters the server declared, and nothing else", () => {
render(mount({ session: showingPolygon(), adjusting: true, onAdjusting: vi.fn(),
- onDetail: vi.fn(), onMaskAdjustment: vi.fn() }));
- expect(screen.getByTestId("suggest-detail-balanced")).toBeTruthy();
- expect(screen.getByTestId("suggest-fill-holes")).toBeTruthy();
- expect(screen.getByTestId("suggest-fragments")).toBeTruthy();
+ onDetail: vi.fn() }));
+ expect(screen.getByTestId("suggest-detail")).toBeTruthy();
+ // The two that were here and are not (#557). A control wired to nothing on
+ // the ordinary mask is worse than no control.
+ expect(screen.queryByTestId("suggest-fill-holes")).toBeNull();
+ expect(screen.queryByTestId("suggest-fragments")).toBeNull();
});
- it("offers a box class only what moves a box, because the wire says only that", () => {
+ it("offers a box class no section at all, because the wire declared nothing", () => {
// The whole of the rule: no condition in this file mentions a box. Declare
- // `fill_holes` for a box in the kernel's table and this test goes red there.
+ // `detail` for a box in the kernel's table and this test goes red there.
const session = asked();
- const boxy = answered(session, session.serial, {
- ...polygonAnswer(),
- parameters: ["fragments"],
- });
- render(mount({ session: boxy, adjusting: true, onAdjusting: vi.fn(),
- onDetail: vi.fn(), onMaskAdjustment: vi.fn() }));
+ const boxy = answered(session, session.serial, { ...polygonAnswer(), parameters: [] });
+ render(mount({ session: boxy, adjusting: true, onAdjusting: vi.fn(), onDetail: vi.fn() }));
- expect(screen.getByTestId("suggest-fragments")).toBeTruthy();
- expect(screen.queryByTestId("suggest-detail-balanced")).toBeNull();
- expect(screen.queryByTestId("suggest-fill-holes")).toBeNull();
+ expect(screen.queryByTestId("suggest-adjustments")).toBeNull();
+ expect(screen.queryByTestId("suggest-adjust-open")).toBeNull();
+ expect(screen.queryByTestId("suggest-detail")).toBeNull();
});
it("renders nothing at all where the server declared no parameters", () => {
@@ -501,28 +499,39 @@ describe("the adjustments, which are a section and never a popup", () => {
expect(screen.queryByTestId("suggest-adjust-open")).toBeNull();
});
- it("says what the current setting costs, beside the control that sets it", () => {
+ it("names the step and what it costs in one label, beside the control", () => {
render(mount({ session: showingPolygon(), adjusting: true, onAdjusting: vi.fn(),
- onDetail: vi.fn(), onMaskAdjustment: vi.fn() }));
- expect(screen.getByTestId("suggest-vertex-count").textContent).toBe("4 pts");
+ onDetail: vi.fn() }));
+ expect(screen.getByTestId("suggest-detail-label").textContent).toBe("Balanced · 4 pts");
});
- it("reports a step through the door that needs no request", async () => {
- const onDetail = vi.fn();
+ it("puts the slider on the step the session is holding", () => {
render(mount({ session: showingPolygon(), adjusting: true, onAdjusting: vi.fn(),
- onDetail, onMaskAdjustment: vi.fn() }));
+ onDetail: vi.fn() }));
+ const slider = screen.getByTestId("suggest-detail") as HTMLInputElement;
+ expect(slider.value).toBe("1");
+ expect(slider.min).toBe("0");
+ expect(slider.max).toBe("2");
+ });
- await userEvent.click(screen.getByTestId("suggest-detail-coarse"));
+ it("reports a step through the door that needs no request", () => {
+ const onDetail = vi.fn();
+ render(mount({ session: showingPolygon(), adjusting: true, onAdjusting: vi.fn(), onDetail }));
+
+ fireEvent.change(screen.getByTestId("suggest-detail"), { target: { value: "0" } });
expect(onDetail).toHaveBeenCalledWith("coarse");
+ fireEvent.change(screen.getByTestId("suggest-detail"), { target: { value: "2" } });
+ expect(onDetail).toHaveBeenCalledWith("fine");
});
- it("reports a mask setting through the other door, which asks again", async () => {
- const onMaskAdjustment = vi.fn();
+ it("does not let a press on the slider take focus off the canvas", () => {
+ // Every chord in the editor is a keydown on the annotator's own root, so a
+ // control that took focus would switch `[`, `]`, Esc and Enter off with
+ // nothing on screen to say why (#557).
render(mount({ session: showingPolygon(), adjusting: true, onAdjusting: vi.fn(),
- onDetail: vi.fn(), onMaskAdjustment }));
-
- await userEvent.click(screen.getByTestId("suggest-fragments"));
- expect(onMaskAdjustment).toHaveBeenCalledWith({ fragments: "all" });
+ onDetail: vi.fn() }));
+ const press = fireEvent.mouseDown(screen.getByTestId("suggest-detail"));
+ expect(press).toBe(false);
});
it("keeps the controls operable on an answer with nothing in it", () => {
@@ -534,11 +543,10 @@ describe("the adjustments, which are a section and never a popup", () => {
...polygonAnswer(),
suggestions: [],
});
- render(mount({ session: empty, adjusting: true, onAdjusting: vi.fn(),
- onDetail: vi.fn(), onMaskAdjustment: vi.fn() }));
+ render(mount({ session: empty, adjusting: true, onAdjusting: vi.fn(), onDetail: vi.fn() }));
expect(screen.getByTestId("suggest-none")).toBeTruthy();
expect(screen.getByTestId("suggest-adjustments")).toBeTruthy();
- expect(screen.getByTestId("suggest-detail-balanced")).toBeTruthy();
+ expect(screen.getByTestId("suggest-detail")).toBeTruthy();
});
});
diff --git a/frontend/ui-core/src/data/inferenceQueries.ts b/frontend/ui-core/src/data/inferenceQueries.ts
index 4b266f9..a66879a 100644
--- a/frontend/ui-core/src/data/inferenceQueries.ts
+++ b/frontend/ui-core/src/data/inferenceQueries.ts
@@ -46,7 +46,6 @@
import type {
Adjustments,
Detail,
- Fragments,
GeometryType,
SuggestParameter,
} from "@visionset/annotator";
@@ -106,8 +105,6 @@ export interface SuggestionOut {
readonly regions: readonly SuggestedRegion[];
readonly applied: {
readonly detail: Detail;
- readonly fill_holes: number;
- readonly fragments: Fragments;
};
/**
* Which settings have any effect on the kind of shape this class holds.
@@ -447,8 +444,6 @@ export function useSuggestRegion() {
negative: input.negative.map(([x, y]) => ({ x, y })),
allowed_geometries: [...input.allowedGeometries],
detail: input.adjustments.detail,
- fill_holes: input.adjustments.fillHoles,
- fragments: input.adjustments.fragments,
} as never,
}),
checkSuggestRegion,
diff --git a/frontend/ui-core/src/generated/api.ts b/frontend/ui-core/src/generated/api.ts
index 5c28267..ddd0bf5 100644
--- a/frontend/ui-core/src/generated/api.ts
+++ b/frontend/ui-core/src/generated/api.ts
@@ -977,28 +977,29 @@ export interface paths {
*
* **`allowed_geometries` is the caller's schema, not a preference.** The answer
* is produced in one of the kinds named or not at all: a class that admits
- * polygons gets outlines, a class that admits only boxes gets their extents,
- * and a class that admits neither gets no regions. Answering in a kind the
- * schema would refuse would produce a suggestion that cannot be accepted.
- *
- * **The three parameters shape the mask, and none of them reaches the model.**
- * `detail` decides how much of an outline survives simplification, `fill_holes`
- * how wide a gap in the mask is closed before it is traced, and `fragments`
- * whether the piece under the click is the answer or every piece worth
- * proposing. Each is optional and each has a default, so a caller that sends
- * none of them gets what this route always gave.
- *
- * **`parameters` says which of them apply here**, for the kind of shape this
- * request will come back in — `detail` and `fill_holes` change an outline and a
- * box has none — and it is present even when there is nothing to propose, so
+ * polygons gets the outline of the piece under the click, a class that admits
+ * only boxes gets one box over every piece the mask kept, and a class that
+ * admits neither gets no regions. Answering in a kind the schema would refuse
+ * would produce a suggestion that cannot be accepted.
+ *
+ * **`detail` is the one setting, and it does not reach the model.** It decides
+ * how much of an outline survives simplification. It is optional and defaults
+ * to `balanced`, which is what every suggestion used before there was a choice.
+ * Closing the small gaps in a mask and dropping its noise specks still happen,
+ * at fixed defaults nobody asks for.
+ *
+ * **`parameters` says which settings apply here**, for the kind of shape this
+ * request will come back in. It is empty for a box class — `detail` changes an
+ * outline and a box has none — which is how a client is told to render no
+ * adjustments at all. It is present even when there is nothing to propose, so
* somebody who adjusted their way into an empty answer can adjust their way
* back out. A client renders what this names and works none of it out itself.
*
- * **`contour` on each region is the unsimplified outline.** It is what lets a
- * client re-run `detail` locally rather than asking again, and it is the *same*
- * points this route reduced — simplification is not nested, so a client
- * starting from anything else could not be held to the same answer. A box
- * carries none, because there is nothing it was reduced from.
+ * **`contour` on each region is the outline the shape was reduced from.** It is
+ * what lets a client re-run `detail` locally rather than asking again, and it
+ * is the *same* points this route reduced — simplification is not nested, so a
+ * client starting from anything else could not be held to the same answer. A
+ * box carries none, because there is nothing it was reduced from.
*
* **Every point must be on the asset**, positive and negative alike — `x` in
* `[0, width]` and `y` in `[0, height]`, both ends included, in the asset's own
@@ -2264,9 +2265,6 @@ export interface components {
*/
AppliedParameters: {
detail: components["schemas"]["Detail"];
- /** Fill Holes */
- fill_holes: number;
- fragments: components["schemas"]["Fragments"];
};
/**
* AssetAction
@@ -3104,16 +3102,6 @@ export interface components {
/** Total */
total: number;
};
- /**
- * Fragments
- * @description How many of a mask's separate pieces become shapes.
- *
- * `one` is the piece your points are on, not the biggest piece on the frame:
- * which of them you meant is a question only the prompt can answer. `all`
- * proposes every piece big enough to be worth proposing.
- * @enum {string}
- */
- Fragments: "one" | "all";
/**
* GeometryType
* @description Every geometry the domain can address.
@@ -3870,7 +3858,7 @@ export interface components {
* @description A setting that shapes a suggestion. Order is display order.
* @enum {string}
*/
- SuggestParameter: "detail" | "fill_holes" | "fragments";
+ SuggestParameter: "detail";
/**
* SuggestPoint
* @description One click, in the asset's own pixel coordinates.
@@ -3916,13 +3904,6 @@ export interface components {
connection_id: string;
/** @default balanced */
detail: components["schemas"]["Detail"];
- /**
- * Fill Holes
- * @default 0.002
- */
- fill_holes: number;
- /** @default one */
- fragments: components["schemas"]["Fragments"];
/** Negative */
negative?: components["schemas"]["SuggestPoint"][];
/** Positive */
@@ -3953,7 +3934,7 @@ export interface components {
* `regions` is empty when there is no suggestion, and that is an ordinary
* answer rather than an error: a click can land on sky, the model can be less
* sure than the caller asked for, the shape found can be one this class cannot
- * hold, and the parameters as set can leave nothing. A 404 or a 409 for any of
+ * hold, and the detail as set can leave nothing. A 404 or a 409 for any of
* those would be telling the caller they did something wrong when they did not.
*
* `model_ref` is echoed on every answer, including the empty one, because it
@@ -3965,8 +3946,10 @@ export interface components {
*
* `parameters` names which settings have any effect on the kind of shape this
* request will come back in, so a client renders exactly those and works none
- * of it out for itself. It is present on an empty answer too, which is what
- * lets somebody who adjusted their way into nothing adjust their way back out.
+ * of it out for itself. It is empty for a box class, which is how a client is
+ * told to render no adjustments at all. It is present on an empty answer too,
+ * which is what lets somebody who adjusted their way into nothing adjust their
+ * way back out.
*/
SuggestionOut: {
applied: components["schemas"]["AppliedParameters"];
diff --git a/frontend/ui-core/src/generated/checks.ts b/frontend/ui-core/src/generated/checks.ts
index 3b0acf5..fa6c338 100644
--- a/frontend/ui-core/src/generated/checks.ts
+++ b/frontend/ui-core/src/generated/checks.ts
@@ -290,14 +290,11 @@ export const checkSplitAssignmentOut: Check
=
export const checkDetail: Check =
/*#__PURE__*/ oneOf(["coarse", "balanced", "fine"] as const);
-export const checkFragments: Check =
- /*#__PURE__*/ oneOf(["one", "all"] as const);
-
export const checkAppliedParameters: Check =
- /*#__PURE__*/ object({ "detail": [true, checkDetail], "fill_holes": [true, isNumber], "fragments": [true, checkFragments] } as const);
+ /*#__PURE__*/ object({ "detail": [true, checkDetail] } as const);
export const checkSuggestParameter: Check =
- /*#__PURE__*/ oneOf(["detail", "fill_holes", "fragments"] as const);
+ /*#__PURE__*/ oneOf(["detail"] as const);
export const checkBboxGeometry: Check =
/*#__PURE__*/ object({ "height": [true, isNumber], "type": [true, lit("bbox")], "width": [true, isNumber], "x": [true, isNumber], "y": [true, isNumber] } as const);
diff --git a/openapi.json b/openapi.json
index 85c6202..bee8dc9 100644
--- a/openapi.json
+++ b/openapi.json
@@ -428,19 +428,10 @@
"properties": {
"detail": {
"$ref": "#/components/schemas/Detail"
- },
- "fill_holes": {
- "title": "Fill Holes",
- "type": "number"
- },
- "fragments": {
- "$ref": "#/components/schemas/Fragments"
}
},
"required": [
- "detail",
- "fill_holes",
- "fragments"
+ "detail"
],
"title": "AppliedParameters",
"type": "object"
@@ -2347,15 +2338,6 @@
"title": "FormatPage",
"type": "object"
},
- "Fragments": {
- "description": "How many of a mask's separate pieces become shapes.\n\n`one` is the piece your points are on, not the biggest piece on the frame:\nwhich of them you meant is a question only the prompt can answer. `all`\nproposes every piece big enough to be worth proposing.",
- "enum": [
- "one",
- "all"
- ],
- "title": "Fragments",
- "type": "string"
- },
"GeometryType": {
"description": "Every geometry the domain can address.\n\n3D values exist today even though unimplemented: the domain never assumes\n\"image\" anywhere \u2014 that is the Physical AI roadmap encoded as a type.",
"enum": [
@@ -3815,9 +3797,7 @@
"SuggestParameter": {
"description": "A setting that shapes a suggestion. Order is display order.",
"enum": [
- "detail",
- "fill_holes",
- "fragments"
+ "detail"
],
"title": "SuggestParameter",
"type": "string"
@@ -3868,17 +3848,6 @@
"$ref": "#/components/schemas/Detail",
"default": "balanced"
},
- "fill_holes": {
- "default": 0.002,
- "maximum": 1.0,
- "minimum": 0.0,
- "title": "Fill Holes",
- "type": "number"
- },
- "fragments": {
- "$ref": "#/components/schemas/Fragments",
- "default": "one"
- },
"negative": {
"items": {
"$ref": "#/components/schemas/SuggestPoint"
@@ -3965,7 +3934,7 @@
"type": "object"
},
"SuggestionOut": {
- "description": "What the model proposes, or an honest nothing.\n\n`regions` is empty when there is no suggestion, and that is an ordinary\nanswer rather than an error: a click can land on sky, the model can be less\nsure than the caller asked for, the shape found can be one this class cannot\nhold, and the parameters as set can leave nothing. A 404 or a 409 for any of\nthose would be telling the caller they did something wrong when they did not.\n\n`model_ref` is echoed on every answer, including the empty one, because it\nis what an accepted suggestion has to carry into its annotation \u2014 and a\ncaller that had to remember which connection it asked would be keeping a\nsecond copy of something the response can simply state. `confidence` is the\nsame: one number for the answer, because the model scored one mask and the\npieces cut out of it are that same claim seen in parts.\n\n`parameters` names which settings have any effect on the kind of shape this\nrequest will come back in, so a client renders exactly those and works none\nof it out for itself. It is present on an empty answer too, which is what\nlets somebody who adjusted their way into nothing adjust their way back out.",
+ "description": "What the model proposes, or an honest nothing.\n\n`regions` is empty when there is no suggestion, and that is an ordinary\nanswer rather than an error: a click can land on sky, the model can be less\nsure than the caller asked for, the shape found can be one this class cannot\nhold, and the detail as set can leave nothing. A 404 or a 409 for any of\nthose would be telling the caller they did something wrong when they did not.\n\n`model_ref` is echoed on every answer, including the empty one, because it\nis what an accepted suggestion has to carry into its annotation \u2014 and a\ncaller that had to remember which connection it asked would be keeping a\nsecond copy of something the response can simply state. `confidence` is the\nsame: one number for the answer, because the model scored one mask and the\npieces cut out of it are that same claim seen in parts.\n\n`parameters` names which settings have any effect on the kind of shape this\nrequest will come back in, so a client renders exactly those and works none\nof it out for itself. It is empty for a box class, which is how a client is\ntold to render no adjustments at all. It is present on an empty answer too,\nwhich is what lets somebody who adjusted their way into nothing adjust their\nway back out.",
"properties": {
"applied": {
"$ref": "#/components/schemas/AppliedParameters"
@@ -7335,7 +7304,7 @@
},
"/inference/suggest": {
"post": {
- "description": "Propose a shape for the thing under those points.\n\nThe server side of the editor's suggest gesture. One asset, one prompt set,\none answer \u2014 batch prediction is a separate path and is not this one.\n\n**Nothing is written and nothing is remembered.** A suggestion is a proposal:\naccepting it is a later, ordinary annotation write carrying `provenance:\nmodel`, this response's `model_ref`, and its `confidence`. Discarding it\ncosts a request that already finished. The only thing that outlives the call\nis a cached image embedding, which is an optimisation rather than a record \u2014\nso the same points sent twice answer the same way, and a restart changes\nnothing but the latency of the first click.\n\n**The first click on an asset is the slow one.** A segmenter reads the whole\nimage once and then answers any number of clicks from that reading almost for\nfree, which is what makes refining by adding points practical. Sending the\naccumulated points \u2014 rather than a diff \u2014 is what keeps this stateless.\n\n**`allowed_geometries` is the caller's schema, not a preference.** The answer\nis produced in one of the kinds named or not at all: a class that admits\npolygons gets outlines, a class that admits only boxes gets their extents,\nand a class that admits neither gets no regions. Answering in a kind the\nschema would refuse would produce a suggestion that cannot be accepted.\n\n**The three parameters shape the mask, and none of them reaches the model.**\n`detail` decides how much of an outline survives simplification, `fill_holes`\nhow wide a gap in the mask is closed before it is traced, and `fragments`\nwhether the piece under the click is the answer or every piece worth\nproposing. Each is optional and each has a default, so a caller that sends\nnone of them gets what this route always gave.\n\n**`parameters` says which of them apply here**, for the kind of shape this\nrequest will come back in \u2014 `detail` and `fill_holes` change an outline and a\nbox has none \u2014 and it is present even when there is nothing to propose, so\nsomebody who adjusted their way into an empty answer can adjust their way\nback out. A client renders what this names and works none of it out itself.\n\n**`contour` on each region is the unsimplified outline.** It is what lets a\nclient re-run `detail` locally rather than asking again, and it is the *same*\npoints this route reduced \u2014 simplification is not nested, so a client\nstarting from anything else could not be held to the same answer. A box\ncarries none, because there is nothing it was reduced from.\n\n**Every point must be on the asset**, positive and negative alike \u2014 `x` in\n`[0, width]` and `y` in `[0, height]`, both ends included, in the asset's own\npixel frame. One point off the picture refuses the whole request with 422\n`PROMPT_POINT_OUT_OF_BOUNDS` rather than being dropped, because a gesture\nwith a point removed is a different gesture. Nothing is clamped: a\ncoordinate outside the frame is not a place on the image, and answering\nabout the nearest edge instead would return a mask, and a confidence, for a\nquestion nobody asked.\n\nAn empty `regions` is a successful answer with nothing to propose. Refusals\nare reserved for things the caller can act on: an unknown project, asset or\nconnection is 404; a connection whose weights are not here yet, or whose kind\nthis build cannot run, is 409 and names what to do; a connection whose model\nanswers words rather than places is 422, as is a prompt point off the asset.",
+ "description": "Propose a shape for the thing under those points.\n\nThe server side of the editor's suggest gesture. One asset, one prompt set,\none answer \u2014 batch prediction is a separate path and is not this one.\n\n**Nothing is written and nothing is remembered.** A suggestion is a proposal:\naccepting it is a later, ordinary annotation write carrying `provenance:\nmodel`, this response's `model_ref`, and its `confidence`. Discarding it\ncosts a request that already finished. The only thing that outlives the call\nis a cached image embedding, which is an optimisation rather than a record \u2014\nso the same points sent twice answer the same way, and a restart changes\nnothing but the latency of the first click.\n\n**The first click on an asset is the slow one.** A segmenter reads the whole\nimage once and then answers any number of clicks from that reading almost for\nfree, which is what makes refining by adding points practical. Sending the\naccumulated points \u2014 rather than a diff \u2014 is what keeps this stateless.\n\n**`allowed_geometries` is the caller's schema, not a preference.** The answer\nis produced in one of the kinds named or not at all: a class that admits\npolygons gets the outline of the piece under the click, a class that admits\nonly boxes gets one box over every piece the mask kept, and a class that\nadmits neither gets no regions. Answering in a kind the schema would refuse\nwould produce a suggestion that cannot be accepted.\n\n**`detail` is the one setting, and it does not reach the model.** It decides\nhow much of an outline survives simplification. It is optional and defaults\nto `balanced`, which is what every suggestion used before there was a choice.\nClosing the small gaps in a mask and dropping its noise specks still happen,\nat fixed defaults nobody asks for.\n\n**`parameters` says which settings apply here**, for the kind of shape this\nrequest will come back in. It is empty for a box class \u2014 `detail` changes an\noutline and a box has none \u2014 which is how a client is told to render no\nadjustments at all. It is present even when there is nothing to propose, so\nsomebody who adjusted their way into an empty answer can adjust their way\nback out. A client renders what this names and works none of it out itself.\n\n**`contour` on each region is the outline the shape was reduced from.** It is\nwhat lets a client re-run `detail` locally rather than asking again, and it\nis the *same* points this route reduced \u2014 simplification is not nested, so a\nclient starting from anything else could not be held to the same answer. A\nbox carries none, because there is nothing it was reduced from.\n\n**Every point must be on the asset**, positive and negative alike \u2014 `x` in\n`[0, width]` and `y` in `[0, height]`, both ends included, in the asset's own\npixel frame. One point off the picture refuses the whole request with 422\n`PROMPT_POINT_OUT_OF_BOUNDS` rather than being dropped, because a gesture\nwith a point removed is a different gesture. Nothing is clamped: a\ncoordinate outside the frame is not a place on the image, and answering\nabout the nearest edge instead would return a mask, and a confidence, for a\nquestion nobody asked.\n\nAn empty `regions` is a successful answer with nothing to propose. Refusals\nare reserved for things the caller can act on: an unknown project, asset or\nconnection is 404; a connection whose weights are not here yet, or whose kind\nthis build cannot run, is 409 and names what to do; a connection whose model\nanswers words rather than places is 422, as is a prompt point off the asset.",
"operationId": "suggest_region",
"requestBody": {
"content": {
diff --git a/src/visionset/inference/masks.py b/src/visionset/inference/masks.py
index 7abeb8b..1e096cd 100644
--- a/src/visionset/inference/masks.py
+++ b/src/visionset/inference/masks.py
@@ -17,15 +17,20 @@
**The pipeline is fixed and its order is not configurable.**
-1. :func:`components` — which pieces of the mask survive.
-2. :func:`filled` — the gaps in them narrower than a reach, closed or kept.
+1. :func:`components` — which pieces of the mask survive the noise filter.
+2. :func:`filled` — the gaps in them narrower than a reach, closed.
3. :func:`contour` — the boundary of what is left.
4. :func:`polygon_at` — that boundary, reduced to a vertex count somebody can edit.
-The geometry branch happens after step 2: a polygon class takes steps 3 and 4, a
-box class takes the filled piece's extent. **A box therefore does not depend on
-``detail``**, which is what "applies to polygon only" means once it is code
-rather than a table.
+The geometry branch happens after step 2: a polygon class takes steps 3 and 4 on
+the piece the prompt points at, a box class takes one extent over *every*
+surviving piece. **A box therefore does not depend on ``detail``**, which is what
+"applies to polygon only" means once it is code rather than a table.
+
+**Only one of these is a question anybody is asked.** The reach of the close and
+the noise floor are fixed here, because on the ordinary single clean piece every
+setting of either produced the same shape — controls wired to nothing (#557).
+``detail`` is the one that moves something a person can see.
**Which shape is produced is the caller's schema decision, not this module's
guess.** :func:`shapes_from` takes the geometry kinds the active class actually
@@ -61,11 +66,8 @@ class allowing only boxes gets extents, and a class allowing neither is not
from visionset.kernel.domain import (
DEFAULT_DETAIL,
- DEFAULT_FILL_HOLES,
- DEFAULT_FRAGMENTS,
BboxGeometry,
Detail,
- Fragments,
Geometry,
GeometryType,
Mask,
@@ -102,14 +104,43 @@ class allowing only boxes gets extents, and a class allowing neither is not
"""
MINIMUM_FRAGMENT_SHARE: Final = 0.05
-"""How big a piece has to be, against the biggest one, to be worth proposing.
-
-Only consulted when the caller asked for every piece. A segmenter's mask
-routinely carries specks a twentieth the size of the thing that was clicked —
-antialiasing along an edge, a reflection, a scrap of the same colour across the
-frame — and proposing each of them as its own annotation turns one click into a
-cleanup job. Relative to the largest piece rather than to the frame, so it means
-the same thing on a mask covering everything and a mask covering a corner.
+"""How big a piece has to be, against the biggest one, to survive the noise filter.
+
+A segmenter's mask routinely carries specks a twentieth the size of the thing
+that was clicked — antialiasing along an edge, a reflection, a scrap of the same
+colour across the frame. None of them is ever the answer to a click, so they are
+dropped before anything else looks at the mask. Relative to the largest piece
+rather than to the frame, so it means the same thing on a mask covering
+everything and a mask covering a corner.
+
+Applied unconditionally, and that is a decision rather than a simplification
+(#557): as a setting it did nothing on the ordinary single clean piece and could
+only be got wrong on the unusual one.
+"""
+
+CLOSING_REACH: Final = 0.002
+"""The largest gap closed, as a share of the piece's own lit area.
+
+A share rather than a pixel count, so it means the same thing on a thing fifty
+pixels across and a thing five hundred across. Two parts in a thousand puts the
+reach at about one pixel on an object fifty across, two on one a hundred across
+and four on one two hundred across — the scale of the notches and bays a
+segmenter leaves along an edge, and well under anything somebody would call a
+feature of the shape.
+
+Fixed rather than asked for (#557). It lives here rather than in the domain
+because it is a number about *this* pipeline, the way :data:`EPSILON` is.
+"""
+
+MAXIMUM_CLOSING_RADIUS: Final = 6
+"""However large the piece, the reach stops here.
+
+Two reasons that agree. A gap wider than a few pixels is a feature of the shape
+rather than an artefact of tracing it, so a reach that keeps growing with the
+object eventually closes a real bay somebody wanted; and the close costs a pass
+per unit of radius, on the path somebody is waiting on after a click — an
+unbounded reach worked out at 22 on a 4K frame, which is 44 passes to bridge
+gaps that were never there.
"""
@@ -334,16 +365,20 @@ def _cropped(label: int, found: Sequence[tuple[int, int, int]], labels: Sequence
return Piece(x=left, y=top, mask=grid)
-def components(
- mask: Mask, *, fragments: Fragments = DEFAULT_FRAGMENTS, at: Sequence[Point] = ()
-) -> list[Piece]:
+def components(mask: Mask, *, at: Sequence[Point] = ()) -> list[Piece]:
"""Step 1 — the pieces of the mask worth turning into shapes.
- ``ONE`` is the piece the prompt points at, per :func:`_pointed_at`. ``ALL``
- is every piece at or above :data:`MINIMUM_FRAGMENT_SHARE` of the largest,
- ordered biggest first so that a panel showing several proposals leads with
- the one most likely to be the thing that was clicked; ties keep reading
- order, so the result is stable for a given mask.
+ Everything below :data:`MINIMUM_FRAGMENT_SHARE` of the largest piece is
+ dropped as noise, first and unconditionally. What survives is ordered with
+ the piece the prompt points at (:func:`_pointed_at`) at the head and the rest
+ biggest-first behind it, ties in reading order, so the answer is stable for a
+ given mask.
+
+ **That one ordering is the whole difference between the two geometries.** A
+ polygon takes the head of the list, because a click asks about one object; a
+ box takes the union of all of it, because a mask arriving in several pieces
+ is nearly always one object seen around an occlusion. Neither branch needs a
+ setting to say which it wants.
An empty mask answers with no pieces, which is an ordinary answer and not an
error — the click landed on sky.
@@ -354,15 +389,12 @@ def components(
labels = _components(found)
size = _areas(found, labels)
- if fragments is Fragments.ONE:
- chosen = [_pointed_at(found, labels, size, at)]
- else:
- floor = max(size.values()) * MINIMUM_FRAGMENT_SHARE
- chosen = sorted(
- (label for label, area in size.items() if area >= floor),
- key=lambda label: (-size[label], label),
- )
- return [_cropped(label, found, labels) for label in chosen]
+ floor = max(size.values()) * MINIMUM_FRAGMENT_SHARE
+ survived = {label for label, area in size.items() if area >= floor}
+ kept = [(run, label) for run, label in zip(found, labels, strict=True) if label in survived]
+ first = _pointed_at([run for run, _ in kept], [label for _, label in kept], size, at)
+ rest = sorted(survived - {first}, key=lambda label: (-size[label], label))
+ return [_cropped(label, found, labels) for label in (first, *rest)]
def _bits(mask: Mask, *, pad: int) -> tuple[list[int], int]:
@@ -433,23 +465,22 @@ def _met(rows: Sequence[int]) -> int:
return met
-def closing_radius(mask: Mask, *, fill_holes: float) -> int:
- """How far to reach, for a piece of this size and that share.
+def closing_radius(mask: Mask) -> int:
+ """How far to reach, for a piece of this size.
- The share names the largest *hole* to close, and a hole of area ``a`` needs a
- reach of about ``sqrt(a) / 2`` to be bridged — so the radius comes from the
- piece's own lit area rather than from a pixel count, and the setting means
- the same thing on a thing fifty pixels across and a thing five hundred
+ :data:`CLOSING_REACH` names the largest *hole* to close, and a hole of area
+ ``a`` needs a reach of about ``sqrt(a) / 2`` to be bridged — so the radius
+ comes from the piece's own lit area rather than from a pixel count, and it
+ means the same thing on a thing fifty pixels across and a thing five hundred
across. Rounded down, so the smallest shapes get no closing at all rather
- than one that would swallow a feature.
+ than one that would swallow a feature, and capped at
+ :data:`MAXIMUM_CLOSING_RADIUS` at the other end.
"""
- if fill_holes <= 0.0:
- return 0
lit = sum(last - first + 1 for _, first, last in runs(mask))
- return int((lit * fill_holes) ** 0.5 / 2)
+ return min(int((lit * CLOSING_REACH) ** 0.5 / 2), MAXIMUM_CLOSING_RADIUS)
-def filled(mask: Mask, *, fill_holes: float = DEFAULT_FILL_HOLES) -> Mask:
+def filled(mask: Mask) -> Mask:
"""Step 2 — close the small gaps in a piece, wherever they are.
A morphological close: grow the shape, then shrink it back by the same
@@ -466,14 +497,11 @@ def filled(mask: Mask, *, fill_holes: float = DEFAULT_FILL_HOLES) -> Mask:
bites out of an edge, the notch where two strokes almost meet, the pinhole at
a corner.
- ``0.0`` closes nothing and is a legitimate request rather than a disabled
- feature: a mask of foliage is mostly gaps and every one of them is real.
-
Returns the mask unchanged — the same object — when the reach works out at
nothing or the shape has no gap that narrow, which is the common case and
saves rebuilding a grid to say so.
"""
- radius = closing_radius(mask, fill_holes=fill_holes)
+ radius = closing_radius(mask)
if radius < 1:
return mask
before, width = _bits(mask, pad=radius)
@@ -689,23 +717,52 @@ def target_kind(allowed: Sequence[GeometryType]) -> GeometryType | None:
return None
+def _union_of(pieces: Sequence[Piece]) -> BboxGeometry | None:
+ """One box over every piece, or ``None`` if none of them holds anything.
+
+ The answer to a point prompt is *this object*, and a mask that arrives in
+ several pieces is nearly always one object seen around an occlusion — a
+ railing across an animal, a post in front of a car. Both of the alternatives
+ are wrong in exactly that case: the largest piece alone cuts the object off
+ at the occlusion, and a box per piece annotates one thing twice (#557).
+ """
+ boxes = [box for box in (_boxed(piece) for piece in pieces) if box is not None]
+ if not boxes:
+ return None
+ left = min(box.x for box in boxes)
+ top = min(box.y for box in boxes)
+ right = max(box.x + box.width for box in boxes)
+ bottom = max(box.y + box.height for box in boxes)
+ return BboxGeometry(x=left, y=top, width=right - left, height=bottom - top)
+
+
def shapes_from(
mask: Mask,
*,
allowed: Sequence[GeometryType],
detail: Detail = DEFAULT_DETAIL,
- fill_holes: float = DEFAULT_FILL_HOLES,
- fragments: Fragments = DEFAULT_FRAGMENTS,
at: Sequence[Point] = (),
) -> list[Shaped]:
"""The whole pipeline: a mask and a class's geometries in, proposals out.
The four steps in their fixed order, with the geometry branch after the
- second. A class that admits polygons gets outlines; one that admits only
- boxes gets extents, measured off the filled piece rather than off a
- simplified outline's corners — which is what keeps ``detail`` from quietly
+ first. A class that admits polygons gets the outline of the piece the prompt
+ points at; one that admits only boxes gets a single box over every piece that
+ survived the noise filter, measured off the mask's own extent rather than off
+ a simplified outline's corners — which is what keeps ``detail`` from quietly
moving a box.
+ **The branch is before the close, and the close is paid for once.** A close
+ only ever adds pixels whose whole neighbourhood was already reachable, so it
+ cannot push an edge outward and a box is the same box either way
+ (``test_closing_never_moves_the_extent``). So a box skips it entirely, and a
+ polygon runs it on the one piece it is about to trace — rather than on every
+ piece that survived, which is work thrown away for all but one of them.
+
+ **A list, though today it holds at most one.** The plural shape is kept
+ because accepting part of a plural proposal is tracked work (#548) and
+ because an empty list is how "nothing to propose" is already said.
+
**Nothing is ever widened.** A class admitting neither kind gets an empty
list, and a piece too thin to be a polygon is dropped rather than demoted to
a box: answering in a kind the caller did not ask for is how a suggestion
@@ -715,16 +772,16 @@ def shapes_from(
if kind is None:
return []
- shaped: list[Shaped] = []
- for piece in components(mask, fragments=fragments, at=at):
- whole = Piece(x=piece.x, y=piece.y, mask=filled(piece.mask, fill_holes=fill_holes))
- if kind is GeometryType.POLYGON:
- traced = _shifted(contour(whole.mask), piece=whole)
- polygon = polygon_at(traced, detail=detail)
- if polygon is not None:
- shaped.append(Shaped(geometry=polygon, contour=tuple(traced)))
- continue
- box = _boxed(whole)
- if box is not None:
- shaped.append(Shaped(geometry=box))
- return shaped
+ pieces = components(mask, at=at)
+ if not pieces:
+ return []
+
+ if kind is GeometryType.BBOX:
+ box = _union_of(pieces)
+ return [] if box is None else [Shaped(geometry=box)]
+
+ pointed = pieces[0]
+ whole = Piece(x=pointed.x, y=pointed.y, mask=filled(pointed.mask))
+ traced = _shifted(contour(whole.mask), piece=whole)
+ polygon = polygon_at(traced, detail=detail)
+ return [] if polygon is None else [Shaped(geometry=polygon, contour=tuple(traced))]
diff --git a/src/visionset/inference/suggestions.py b/src/visionset/inference/suggestions.py
index 6a2372c..9fa977b 100644
--- a/src/visionset/inference/suggestions.py
+++ b/src/visionset/inference/suggestions.py
@@ -33,10 +33,7 @@
from visionset.inference.providers import ProviderPool, resident
from visionset.kernel.domain import (
DEFAULT_DETAIL,
- DEFAULT_FILL_HOLES,
- DEFAULT_FRAGMENTS,
Detail,
- Fragments,
GeometryType,
PointPrompt,
PredictionRequest,
@@ -91,8 +88,6 @@ def suggest(
prompt: PointPrompt,
allowed: tuple[GeometryType, ...],
detail: Detail = DEFAULT_DETAIL,
- fill_holes: float = DEFAULT_FILL_HOLES,
- fragments: Fragments = DEFAULT_FRAGMENTS,
minimum_confidence: float = 0.0,
pool: ProviderPool | None = None,
) -> Suggestion:
@@ -106,7 +101,7 @@ def suggest(
An empty ``shapes`` is a real answer and not a failure: the model was asked
about a patch of sky, or was not sure enough, or the shape it found cannot be
- expressed in the kinds this class admits, or the parameters as set leave
+ expressed in the kinds this class admits, or the detail as set leaves
nothing. Every one of those is "no suggestion", and none of them is an error
somebody made.
@@ -167,14 +162,7 @@ def suggest(
segment = answer.segments[0]
at: tuple[Point, ...] = tuple(prompt.positive)
- shapes = shapes_from(
- segment.mask,
- allowed=allowed,
- detail=detail,
- fill_holes=fill_holes,
- fragments=fragments,
- at=at,
- )
+ shapes = shapes_from(segment.mask, allowed=allowed, detail=detail, at=at)
return Suggestion(
model_ref=answer.model_ref,
shapes=tuple(shapes),
diff --git a/src/visionset/kernel/domain/__init__.py b/src/visionset/kernel/domain/__init__.py
index a815dba..eed0e31 100644
--- a/src/visionset/kernel/domain/__init__.py
+++ b/src/visionset/kernel/domain/__init__.py
@@ -182,11 +182,8 @@
)
from visionset.kernel.domain.suggestion import (
DEFAULT_DETAIL,
- DEFAULT_FILL_HOLES,
- DEFAULT_FRAGMENTS,
PARAMETER_APPLIES_TO,
Detail,
- Fragments,
SuggestParameter,
suggest_parameters,
)
@@ -367,12 +364,9 @@
"Mask",
"SegmentedMask",
"Detail",
- "Fragments",
"SuggestParameter",
"PARAMETER_APPLIES_TO",
"DEFAULT_DETAIL",
- "DEFAULT_FILL_HOLES",
- "DEFAULT_FRAGMENTS",
"suggest_parameters",
"ActivityEntry",
"ActivityKind",
diff --git a/src/visionset/kernel/domain/suggestion.py b/src/visionset/kernel/domain/suggestion.py
index b630f7e..751102c 100644
--- a/src/visionset/kernel/domain/suggestion.py
+++ b/src/visionset/kernel/domain/suggestion.py
@@ -1,11 +1,12 @@
-# usage: from visionset.kernel.domain import Detail, Fragments, suggest_parameters
+# usage: from visionset.kernel.domain import Detail, suggest_parameters
"""How a model's mask becomes a shape, said out loud so a client can offer it.
A segmenter answers a click with a grid of booleans, and turning that grid into a
polygon or a box is a chain of choices: which blobs to keep, whether to close the
-holes inside them, how much of the traced outline survives. Those choices used to
-be made once, invisibly, by whatever ran the model. This module is the vocabulary
-for making them askable.
+holes inside them, how much of the traced outline survives. Most of those choices
+are made once, with a fixed default, by the pipeline — and one of them is worth
+putting in front of the person looking at the proposal. This module is the
+vocabulary for the one.
**The names are this domain's, not an imaging library's.** ``detail`` is a
question about the shape somebody is going to edit; ``epsilon`` is a parameter of
@@ -13,12 +14,20 @@
would publish an implementation as a contract and make a second implementation a
breaking change.
-**Applicability is declared, never derived.** ``detail`` and ``fill_holes`` are
-about an outline, so they mean nothing for a class that stores a box; a client
-that worked that out for itself would be the hand-mirrored table
-``capabilities.py`` exists to prevent, and it would drift the first time a
-parameter changed hands. So :data:`PARAMETER_APPLIES_TO` states it once and
-:func:`suggest_parameters` is the only reader.
+**Applicability is declared, never derived.** ``detail`` is about an outline, so
+it means nothing for a class that stores a box; a client that worked that out for
+itself would be the hand-mirrored table ``capabilities.py`` exists to prevent, and
+it would drift the first time a parameter changed hands. So
+:data:`PARAMETER_APPLIES_TO` states it once and :func:`suggest_parameters` is the
+only reader.
+
+**Two settings were here and are not.** Closing the gaps in a mask and dropping
+its noise specks are still done, at fixed defaults that live beside the pipeline
+in ``visionset.inference.masks``. They stopped being askable because on an
+ordinary single clean piece every position of either gave the same shape, so they
+read as controls wired to nothing (#557). Their value is in the default rather
+than in the choice; they come back as parameters if a real need for the choice
+appears.
Pure, and in the domain rather than beside the code that computes the pipeline,
on ``capabilities.py``'s terms: a question about domain values, answered from a
@@ -58,77 +67,31 @@ class Detail(StrEnum):
FINE = "fine"
-# ``ONE`` rather than ``LARGEST``, because the blob it selects is the one the
-# prompt points at rather than the biggest one on the frame. That distinction is
-# the whole of the gesture: a point-prompted segmenter is asked about a *place*,
-# and the blob owning the topmost-leftmost lit pixel is a fact about where
-# speckle fell. Naming this ``largest`` would describe a tie-break as though it
-# were the rule.
-class Fragments(StrEnum):
- """How many of a mask's separate pieces become shapes.
-
- `one` is the piece your points are on, not the biggest piece on the frame:
- which of them you meant is a question only the prompt can answer. `all`
- proposes every piece big enough to be worth proposing.
- """
-
- #: The piece the prompt points at, and nothing else.
- ONE = "one"
- #: Every piece big enough to be worth proposing.
- ALL = "all"
-
-
# One member per parameter, and the table below owes every one of them a row.
class SuggestParameter(StrEnum):
"""A setting that shapes a suggestion. Order is display order."""
DETAIL = "detail"
- FILL_HOLES = "fill_holes"
- FRAGMENTS = "fragments"
DEFAULT_DETAIL: Final = Detail.BALANCED
"""What a caller that says nothing gets, and what every suggestion got before."""
-DEFAULT_FRAGMENTS: Final = Fragments.ONE
-"""One shape per click, which is what a click asks for."""
-
-
-DEFAULT_FILL_HOLES: Final = 0.002
-"""The largest gap closed by default, as a share of the piece's area.
-
-A share rather than a pixel count, so it means the same thing on a thing fifty
-pixels across and a thing five hundred across. Two parts in a thousand puts the
-reach at about one pixel on an object fifty across, two on one a hundred across
-and four on one two hundred across — the scale of the notches and bays a
-segmenter leaves along an edge, and well under anything somebody would call a
-feature of the shape.
-
-Zero closes nothing, which is a legitimate thing to ask for and not a disabled
-feature: a mask of foliage is mostly gaps, and every one of them is real.
-"""
-
-
PARAMETER_APPLIES_TO: Final[Mapping[SuggestParameter, frozenset[GeometryType]]] = {
SuggestParameter.DETAIL: frozenset({GeometryType.POLYGON}),
- SuggestParameter.FILL_HOLES: frozenset({GeometryType.POLYGON}),
- SuggestParameter.FRAGMENTS: frozenset({GeometryType.POLYGON, GeometryType.BBOX}),
}
"""Which geometries each parameter has any effect on.
-``detail`` and ``fill_holes`` are about an *outline*: one decides how many
-vertices it keeps and the other decides whether it detours into every notch
-along the way. A box has no outline to spend either on — closing a gap cannot
-move an extent outward, and a vertex budget has nothing to buy — so offering
-them for a box class would be offering controls that do nothing.
-
-``fragments`` applies to both, because it decides how many shapes there are
-before any of them has a kind.
+``detail`` is about an *outline*: it decides how many vertices that outline keeps.
+A box has no outline to spend a vertex budget on, so offering it for a box class
+would be offering a control that does nothing. A box class therefore declares no
+parameters at all, and a client shows it no adjustments — which is a rendering
+rule the client reads rather than one it works out (#557).
**A parameter missing from this mapping is a test failure, not a default.**
``test_every_parameter_declares_the_geometries_it_applies_to`` sweeps
-``SuggestParameter`` against these keys, so a fourth parameter arrives with its
+``SuggestParameter`` against these keys, so a second parameter arrives with its
applicability stated or it does not arrive. The alternative — treating an absent
row as "applies to everything" — is the one that ships a control nobody can use
and nobody notices.
@@ -140,8 +103,9 @@ def suggest_parameters(geometry: GeometryType) -> tuple[SuggestParameter, ...]:
The only reader of :data:`PARAMETER_APPLIES_TO`, so that a surface deciding
what to render never re-derives the rule. A geometry no parameter applies to
- answers empty, which is honest rather than exceptional: a class that holds no
- shape gets no suggestion to adjust in the first place.
+ answers empty, which is honest rather than exceptional: it is how a box class
+ is told there is nothing here to adjust, and a class that holds no shape gets
+ no suggestion to adjust in the first place.
"""
return tuple(
parameter for parameter in SuggestParameter if geometry in PARAMETER_APPLIES_TO[parameter]
diff --git a/src/visionset/server/models.py b/src/visionset/server/models.py
index 054f37c..b4b6a71 100644
--- a/src/visionset/server/models.py
+++ b/src/visionset/server/models.py
@@ -48,8 +48,6 @@
from visionset.inference import capabilities_of
from visionset.kernel.domain import (
DEFAULT_DETAIL,
- DEFAULT_FILL_HOLES,
- DEFAULT_FRAGMENTS,
ActivityEntry,
ActivityKind,
Annotation,
@@ -84,7 +82,6 @@
Detail,
DownloadSize,
ExportCompatibility,
- Fragments,
Geometry,
GeometryType,
ImageFormat,
@@ -1938,21 +1935,19 @@ class SuggestRequest(BaseModel):
#: How much of an outline survives simplification. Omitted means `balanced`,
#: which is what every suggestion used before there was a choice.
detail: Detail = DEFAULT_DETAIL
- #: The largest gap closed in the model's mask, as a share of the piece's own
- #: area. Zero closes nothing.
- fill_holes: float = Field(DEFAULT_FILL_HOLES, ge=0.0, le=1.0)
- #: Whether the piece under the click is the answer, or every piece big enough
- #: to be worth proposing.
- fragments: Fragments = DEFAULT_FRAGMENTS
class SuggestedRegion(BaseModel):
"""One proposed shape, and the contour it was reduced from."""
geometry: Geometry
- #: The unsimplified outline, in the asset's own pixels — what lets a client
- #: re-run `detail` locally instead of asking again. Empty for a box, which is
- #: an extent rather than something reduced from anything.
+ #: The outline the shape was reduced from, in the asset's own pixels — what
+ #: lets a client re-run `detail` locally instead of asking again. Already
+ #: reduced once at the half-pixel floor, which is what makes the client's
+ #: answer and the server's provably the same: simplification is not nested,
+ #: so both have to start from identical points.
+ #: Empty for a box, which is an extent rather than something reduced from
+ #: anything.
#:
#: Required rather than defaulted: it is on every answer, and a field a
#: client has to check for is one it will eventually forget to check for.
@@ -1963,8 +1958,6 @@ class AppliedParameters(BaseModel):
"""The parameter values this answer was actually produced with."""
detail: Detail
- fill_holes: float
- fragments: Fragments
class SuggestionOut(BaseModel):
@@ -1973,7 +1966,7 @@ class SuggestionOut(BaseModel):
`regions` is empty when there is no suggestion, and that is an ordinary
answer rather than an error: a click can land on sky, the model can be less
sure than the caller asked for, the shape found can be one this class cannot
- hold, and the parameters as set can leave nothing. A 404 or a 409 for any of
+ hold, and the detail as set can leave nothing. A 404 or a 409 for any of
those would be telling the caller they did something wrong when they did not.
`model_ref` is echoed on every answer, including the empty one, because it
@@ -1985,8 +1978,10 @@ class SuggestionOut(BaseModel):
`parameters` names which settings have any effect on the kind of shape this
request will come back in, so a client renders exactly those and works none
- of it out for itself. It is present on an empty answer too, which is what
- lets somebody who adjusted their way into nothing adjust their way back out.
+ of it out for itself. It is empty for a box class, which is how a client is
+ told to render no adjustments at all. It is present on an empty answer too,
+ which is what lets somebody who adjusted their way into nothing adjust their
+ way back out.
"""
model_ref: str
diff --git a/src/visionset/server/routes/inference.py b/src/visionset/server/routes/inference.py
index 8c47d0f..7785f46 100644
--- a/src/visionset/server/routes/inference.py
+++ b/src/visionset/server/routes/inference.py
@@ -318,28 +318,29 @@ def suggest_region(workspace: WorkspaceDep, body: SuggestRequest) -> SuggestionO
**`allowed_geometries` is the caller's schema, not a preference.** The answer
is produced in one of the kinds named or not at all: a class that admits
- polygons gets outlines, a class that admits only boxes gets their extents,
- and a class that admits neither gets no regions. Answering in a kind the
- schema would refuse would produce a suggestion that cannot be accepted.
-
- **The three parameters shape the mask, and none of them reaches the model.**
- `detail` decides how much of an outline survives simplification, `fill_holes`
- how wide a gap in the mask is closed before it is traced, and `fragments`
- whether the piece under the click is the answer or every piece worth
- proposing. Each is optional and each has a default, so a caller that sends
- none of them gets what this route always gave.
-
- **`parameters` says which of them apply here**, for the kind of shape this
- request will come back in — `detail` and `fill_holes` change an outline and a
- box has none — and it is present even when there is nothing to propose, so
+ polygons gets the outline of the piece under the click, a class that admits
+ only boxes gets one box over every piece the mask kept, and a class that
+ admits neither gets no regions. Answering in a kind the schema would refuse
+ would produce a suggestion that cannot be accepted.
+
+ **`detail` is the one setting, and it does not reach the model.** It decides
+ how much of an outline survives simplification. It is optional and defaults
+ to `balanced`, which is what every suggestion used before there was a choice.
+ Closing the small gaps in a mask and dropping its noise specks still happen,
+ at fixed defaults nobody asks for.
+
+ **`parameters` says which settings apply here**, for the kind of shape this
+ request will come back in. It is empty for a box class — `detail` changes an
+ outline and a box has none — which is how a client is told to render no
+ adjustments at all. It is present even when there is nothing to propose, so
somebody who adjusted their way into an empty answer can adjust their way
back out. A client renders what this names and works none of it out itself.
- **`contour` on each region is the unsimplified outline.** It is what lets a
- client re-run `detail` locally rather than asking again, and it is the *same*
- points this route reduced — simplification is not nested, so a client
- starting from anything else could not be held to the same answer. A box
- carries none, because there is nothing it was reduced from.
+ **`contour` on each region is the outline the shape was reduced from.** It is
+ what lets a client re-run `detail` locally rather than asking again, and it
+ is the *same* points this route reduced — simplification is not nested, so a
+ client starting from anything else could not be held to the same answer. A
+ box carries none, because there is nothing it was reduced from.
**Every point must be on the asset**, positive and negative alike — `x` in
`[0, width]` and `y` in `[0, height]`, both ends included, in the asset's own
@@ -368,8 +369,6 @@ def suggest_region(workspace: WorkspaceDep, body: SuggestRequest) -> SuggestionO
prompt=prompt,
allowed=tuple(body.allowed_geometries),
detail=body.detail,
- fill_holes=body.fill_holes,
- fragments=body.fragments,
)
return SuggestionOut(
model_ref=answer.model_ref,
@@ -378,9 +377,7 @@ def suggest_region(workspace: WorkspaceDep, body: SuggestRequest) -> SuggestionO
SuggestedRegion(geometry=shape.geometry, contour=list(shape.contour))
for shape in answer.shapes
],
- applied=AppliedParameters(
- detail=body.detail, fill_holes=body.fill_holes, fragments=body.fragments
- ),
+ applied=AppliedParameters(detail=body.detail),
parameters=list(answer.parameters),
)
diff --git a/tests/inference/test_masks.py b/tests/inference/test_masks.py
index b36888e..414b1bf 100644
--- a/tests/inference/test_masks.py
+++ b/tests/inference/test_masks.py
@@ -15,7 +15,9 @@
import pytest
+from visionset.inference import masks
from visionset.inference.masks import (
+ MAXIMUM_CLOSING_RADIUS,
MINIMUM_FRAGMENT_SHARE,
MINIMUM_TOLERANCE,
bbox_from,
@@ -32,7 +34,6 @@
from visionset.kernel.domain import (
BboxGeometry,
Detail,
- Fragments,
GeometryType,
PolygonGeometry,
)
@@ -118,7 +119,7 @@ def islands() -> list[list[int]]:
def test_one_piece_is_the_piece_under_the_click() -> None:
"""The first symptom this rule was written for: a shape from somewhere nobody clicked."""
- pieces = components(speckled(), fragments=Fragments.ONE, at=[(3.0, 5.0)])
+ pieces = components(speckled(), at=[(3.0, 5.0)])
assert len(pieces) == 1
assert (pieces[0].x, pieces[0].y) == (1, 3)
assert lit(list(pieces[0].mask)) == 36, "the 36-px object, not the 1-px speck"
@@ -140,25 +141,37 @@ def test_several_points_spanning_pieces_prefer_the_largest_of_them() -> None:
assert (pieces[0].x, pieces[0].y) == (1, 3), "the 36-px object beats the 1-px speck"
-def test_without_a_point_the_topmost_piece_still_wins() -> None:
- """The no-point call is unchanged — nothing outside a point prompt has an opinion."""
+def test_without_a_point_the_noise_filter_still_answers_with_the_object() -> None:
+ """Nothing outside a point prompt has an opinion — but the speck is gone first.
+
+ The topmost-leftmost rule only ever ran over pieces that survived, and a 1-px
+ speck against a 36-px object does not. What used to need a click to beat the
+ speck no longer needs one (#557).
+ """
pieces = components(speckled())
- assert (pieces[0].x, pieces[0].y) == (9, 0)
+ assert len(pieces) == 1
+ assert (pieces[0].x, pieces[0].y) == (1, 3)
-def test_every_piece_is_offered_when_every_piece_was_asked_for() -> None:
- pieces = components(islands(), fragments=Fragments.ALL)
+def test_every_surviving_piece_comes_back_biggest_first_behind_the_pointed_at_one() -> None:
+ pieces = components(islands())
assert [lit(list(piece.mask)) for piece in pieces] == [25, 16], "biggest first"
assert [(piece.x, piece.y) for piece in pieces] == [(1, 2), (14, 3)]
-def test_a_speck_is_dropped_even_when_every_piece_was_asked_for() -> None:
+def test_the_piece_under_the_click_leads_even_when_it_is_not_the_biggest() -> None:
+ """The ordering the two geometries both read: head for a polygon, all of it for a box."""
+ pieces = components(islands(), at=[(15.0, 4.0)])
+ assert [lit(list(piece.mask)) for piece in pieces] == [16, 25]
+
+
+def test_a_speck_is_dropped_before_anything_else_looks_at_the_mask() -> None:
"""One click must not become a cleanup job.
- The speck is 1 px against a 36-px object — far under the share — so asking
- for everything still answers with the thing that was clicked.
+ The speck is 1 px against a 36-px object — far under the share — so it never
+ reaches a shape, whichever geometry asked.
"""
- pieces = components(speckled(), fragments=Fragments.ALL)
+ pieces = components(speckled())
assert len(pieces) == 1
assert lit(list(pieces[0].mask)) == 36
@@ -166,19 +179,23 @@ def test_a_speck_is_dropped_even_when_every_piece_was_asked_for() -> None:
def test_a_piece_is_kept_when_it_clears_the_share() -> None:
"""The other side of the same rule, so the threshold is not merely "drop small things"."""
assert 25 * MINIMUM_FRAGMENT_SHARE <= 16
- assert len(components(islands(), fragments=Fragments.ALL)) == 2
+ assert len(components(islands())) == 2
def test_an_empty_mask_has_no_pieces() -> None:
assert components(empty(), at=[(5.0, 5.0)]) == []
- assert components(empty(), fragments=Fragments.ALL) == []
+ assert components(empty()) == []
# --- step 2: closing the gaps --------------------------------------------------
-def notched(depth: int, size: int = 13) -> list[list[bool]]:
- """A solid square with a one-row bite ``depth`` pixels deep, cut in from the right."""
+def notched(depth: int, size: int = 64) -> list[list[bool]]:
+ """A solid square with a one-row bite ``depth`` pixels deep, cut in from the right.
+
+ 64 on a side by default, because the reach is a share of the piece's own area
+ and a 13-px square is under the floor where it works out at nothing.
+ """
mask = [[True] * size for _ in range(size)]
for x in range(size - depth, size):
mask[size // 2][x] = False
@@ -193,43 +210,53 @@ def test_a_narrow_notch_is_closed_and_the_outline_gets_simpler() -> None:
comes back the shape somebody meant.
"""
mask = notched(2)
- after = filled(mask, fill_holes=0.05)
+ after = filled(mask)
assert lit([list(row) for row in after]) > lit(mask)
assert len(contour(after)) < len(contour(mask))
def test_a_bay_wider_than_the_reach_is_kept() -> None:
"""Concavity at that scale is shape rather than noise, and it survives untouched."""
- mask = notched(2, size=13)
- for y in (5, 6, 7):
- for x in (10, 11, 12):
+ mask = [[True] * 64 for _ in range(64)]
+ for y in range(28, 36):
+ for x in range(56, 64):
mask[y][x] = False
- assert filled(mask, fill_holes=0.05) is mask
+ assert filled(mask) is mask
def test_closing_never_moves_the_extent() -> None:
- """Why `fill_holes` is declared for a polygon and not for a box.
+ """Why the close is declared for a polygon and does nothing to a box.
A close only ever adds pixels whose whole neighbourhood was already reachable,
so it cannot push an edge outward. The applicability table says the same
thing; this is the behaviour under it.
"""
mask = notched(2)
- assert bbox_from(filled(mask, fill_holes=0.05)) == bbox_from(mask)
+ assert bbox_from(filled(mask)) == bbox_from(mask)
def test_the_reach_scales_with_the_piece_rather_than_the_frame() -> None:
- """One setting, every size — the property an absolute pixel count cannot have."""
- small = closing_radius(rect(0, 0, 39, 39, width=40, height=40), fill_holes=0.002)
- large = closing_radius(rect(0, 0, 199, 199, width=200, height=200), fill_holes=0.002)
+ """One default, every size — the property an absolute pixel count cannot have."""
+ small = closing_radius(rect(0, 0, 63, 63, width=64, height=64))
+ large = closing_radius(rect(0, 0, 199, 199, width=200, height=200))
assert small < large
-def test_closing_nothing_is_a_request_the_pipeline_honours() -> None:
- """A mask of foliage is mostly gaps and every one of them is real."""
- mask = notched(2)
- assert filled(mask, fill_holes=0.0) is mask
- assert closing_radius(mask, fill_holes=0.0) == 0
+def test_the_reach_stops_at_the_cap_however_large_the_piece() -> None:
+ """A gap wider than a few pixels is shape, and the pass costs a step per unit.
+
+ Uncapped this worked out at 22 on a 4K frame — 44 bitset passes on the path
+ somebody is waiting on after a click, to bridge gaps that were never there.
+ """
+ huge = closing_radius(rect(0, 0, 799, 799, width=800, height=800))
+ assert huge == MAXIMUM_CLOSING_RADIUS
+
+
+def test_a_piece_too_small_to_have_artefacts_is_closed_not_at_all() -> None:
+ """Rounded down, so the smallest shapes keep every feature they have."""
+ tiny = rect(0, 0, 11, 11, width=12, height=12)
+ assert closing_radius(tiny) == 0
+ assert filled(tiny) is tiny
def test_an_enclosed_hole_is_closed_in_the_mask_and_invisible_in_the_shape() -> None:
@@ -242,23 +269,27 @@ def test_an_enclosed_hole_is_closed_in_the_mask_and_invisible_in_the_shape() ->
pixels to 400 and left the traced outline byte-identical.
"""
piece = components(holed(2))[0]
- after = filled(piece.mask, fill_holes=0.02)
- assert lit(list(piece.mask)) == 400 - 4
- assert lit([list(row) for row in after]) == 400
+ after = filled(piece.mask)
+ assert lit(list(piece.mask)) == 64 * 64 - 4
+ assert lit([list(row) for row in after]) == 64 * 64
assert outline(piece.mask) == outline(after), "the hole was never on the outer ring"
def holed(hole: int) -> list[list[bool]]:
- """A 20x20 square with a centred square hole ``hole`` pixels on a side."""
- low = 10 - hole // 2
+ """A 64x64 square with a centred square hole ``hole`` pixels on a side.
+
+ 64 rather than 20 for :func:`notched`'s reason: the reach is a share of the
+ piece's area, so a small square is under the floor where it reaches nothing.
+ """
+ low = 32 - hole // 2
return [
[
- 5 <= x <= 24
- and 5 <= y <= 24
+ 5 <= x <= 68
+ and 5 <= y <= 68
and not (low <= x - 5 < low + hole and low <= y - 5 < low + hole)
- for x in range(40)
+ for x in range(80)
]
- for y in range(40)
+ for y in range(80)
]
@@ -407,25 +438,65 @@ def test_a_piece_too_thin_to_be_a_polygon_is_dropped_rather_than_demoted() -> No
assert shapes_from(rect(10, 10, 11, 10), allowed=BOTH) == []
-def test_every_island_becomes_its_own_shape() -> None:
- shaped = shapes_from(islands(), allowed=POLYGON_ONLY, fragments=Fragments.ALL)
- assert len(shaped) == 2
- assert [s.geometry.type for s in shaped] == [GeometryType.POLYGON, GeometryType.POLYGON]
+def test_a_polygon_is_the_piece_that_was_clicked_and_only_that_piece() -> None:
+ """A click asks about one object, so a polygon class gets one outline."""
+ shaped = shapes_from(islands(), allowed=POLYGON_ONLY, at=[(3.0, 4.0)])
+ assert len(shaped) == 1
+ assert shaped[0].geometry.type is GeometryType.POLYGON
+
+
+def test_a_box_is_one_union_over_every_surviving_piece() -> None:
+ """Decision 4: an occluded object is one thing, however many pieces it arrives in.
+
+ `islands()` is 25 px at (1,2) and 16 px at (14,3) — a railing's worth apart.
+ Largest-only would cut the object off at the occlusion; a box per piece would
+ annotate it twice. One box covers both.
+ """
+ shaped = shapes_from(islands(), allowed=BOX_ONLY, at=[(3.0, 4.0)])
+ assert len(shaped) == 1
+ assert shaped[0].geometry == BboxGeometry(x=1.0, y=2.0, width=17.0, height=5.0)
-def test_one_fragment_is_still_the_default() -> None:
- assert len(shapes_from(islands(), allowed=POLYGON_ONLY, at=[(3.0, 4.0)])) == 1
+def test_the_union_leaves_out_the_specks_the_noise_filter_dropped() -> None:
+ """The other half of the same rule: a union of everything would follow speckle.
+
+ `speckled()` is a 36-px object at (1,3) and a 1-px speck at (9,0). The speck
+ is under the share, so the box stops at the object rather than stretching to
+ the corner of the frame.
+ """
+ shaped = shapes_from(speckled(), allowed=BOX_ONLY, at=[(3.0, 5.0)])
+ assert shaped[0].geometry == BboxGeometry(x=1.0, y=3.0, width=6.0, height=6.0)
+
+
+def test_a_box_never_pays_for_the_close_at_all(monkeypatch: pytest.MonkeyPatch) -> None:
+ """A performance rule with no behavioural signature, so it is asserted structurally.
+
+ A close only ever adds pixels whose neighbourhood was already reachable, so
+ it cannot move an extent — which is exactly why skipping it for a box is safe
+ and exactly why no assertion about the *box* could ever notice the skip. So
+ the assertion is that the step is not reached: at 4K it was twelve bitset
+ passes per click, thrown away (#557).
+ """
+ monkeypatch.setattr(masks, "filled", lambda mask: pytest.fail("a box class ran the close"))
+ shaped = shapes_from(islands(), allowed=BOX_ONLY, at=[(3.0, 4.0)])
+
+ assert shaped[0].geometry == BboxGeometry(x=1.0, y=2.0, width=17.0, height=5.0)
def test_closing_a_notch_reaches_the_polygon_and_leaves_the_box_alone() -> None:
- """Both halves at once, because each is how the other's mistake stays hidden."""
- ragged = shapes_from(notched(2, size=41), allowed=POLYGON_ONLY, fill_holes=0.0)
- smoothed = shapes_from(notched(2, size=41), allowed=POLYGON_ONLY, fill_holes=0.005)
+ """Both halves at once, because each is how the other's mistake stays hidden.
+
+ The reach is fixed now, so the comparison is against a piece small enough to
+ fall under it rather than against a setting of zero.
+ """
+ ragged = shapes_from(notched(2, size=40), allowed=POLYGON_ONLY)
+ smoothed = shapes_from(notched(2, size=64), allowed=POLYGON_ONLY)
+ assert closing_radius(notched(2, size=40)) == 0, "under the reach: nothing bridged"
+ assert closing_radius(notched(2, size=64)) >= 1, "over it: the notch is bridged"
assert len(ragged[0].contour) > len(smoothed[0].contour)
- assert (
- shapes_from(notched(2, size=41), allowed=BOX_ONLY, fill_holes=0.005)[0].geometry
- == shapes_from(notched(2, size=41), allowed=BOX_ONLY, fill_holes=0.0)[0].geometry
- )
+ assert shapes_from(notched(2, size=64), allowed=BOX_ONLY)[0].geometry == shapes_from(
+ notched(2, size=40), allowed=BOX_ONLY
+ )[0].geometry.model_copy(update={"width": 64.0, "height": 64.0})
def test_a_mask_with_nothing_in_it_proposes_nothing() -> None:
diff --git a/tests/kernel/test_suggestion_parameters.py b/tests/kernel/test_suggestion_parameters.py
index 188eae3..8ff7208 100644
--- a/tests/kernel/test_suggestion_parameters.py
+++ b/tests/kernel/test_suggestion_parameters.py
@@ -7,7 +7,7 @@
or, under a kinder default, appear on a box class where it does nothing.
So the first test here is the one that matters. It sweeps `SuggestParameter`
-itself, so a fourth parameter arrives with its applicability stated or the suite
+itself, so a second parameter arrives with its applicability stated or the suite
goes red naming it.
"""
@@ -15,11 +15,8 @@
from visionset.kernel.domain import (
DEFAULT_DETAIL,
- DEFAULT_FILL_HOLES,
- DEFAULT_FRAGMENTS,
PARAMETER_APPLIES_TO,
Detail,
- Fragments,
GeometryType,
SuggestParameter,
suggest_parameters,
@@ -52,17 +49,14 @@ def test_a_declared_geometry_is_one_the_domain_actually_stores(
def test_a_polygon_is_offered_every_parameter() -> None:
- assert suggest_parameters(GeometryType.POLYGON) == (
- SuggestParameter.DETAIL,
- SuggestParameter.FILL_HOLES,
- SuggestParameter.FRAGMENTS,
- )
+ assert suggest_parameters(GeometryType.POLYGON) == (SuggestParameter.DETAIL,)
-def test_a_box_is_offered_only_the_one_that_moves_it() -> None:
- # `detail` and `fill_holes` change an outline, and a box has none: its extent
- # is the same whichever way either is set.
- assert suggest_parameters(GeometryType.BBOX) == (SuggestParameter.FRAGMENTS,)
+def test_a_box_is_offered_nothing_at_all() -> None:
+ # `detail` changes an outline and a box has none, so a box class declares no
+ # parameters — which is what tells a client to render no adjustments rather
+ # than an empty section (#557).
+ assert suggest_parameters(GeometryType.BBOX) == ()
def test_a_kind_that_holds_no_shape_is_offered_nothing() -> None:
@@ -77,10 +71,5 @@ def test_the_reader_answers_in_declaration_order() -> None:
assert list(offered) == [p for p in SuggestParameter if p in offered]
-def test_the_defaults_are_members_of_their_own_vocabularies() -> None:
+def test_the_default_is_a_member_of_its_own_vocabulary() -> None:
assert DEFAULT_DETAIL in Detail
- assert DEFAULT_FRAGMENTS in Fragments
-
-
-def test_the_default_hole_share_is_a_share() -> None:
- assert 0.0 <= DEFAULT_FILL_HOLES <= 1.0
diff --git a/tests/server/test_suggest.py b/tests/server/test_suggest.py
index 28e9e8f..d00c642 100644
--- a/tests/server/test_suggest.py
+++ b/tests/server/test_suggest.py
@@ -573,7 +573,7 @@ def test_a_request_that_sends_no_parameters_gets_the_defaults_back(
body = ask(client, project=project, asset=asset, connection=connection).json()
- assert body["applied"] == {"detail": "balanced", "fill_holes": 0.002, "fragments": "one"}
+ assert body["applied"] == {"detail": "balanced"}
def test_the_answer_echoes_the_parameters_it_was_given(
@@ -588,14 +588,12 @@ def test_the_answer_echoes_the_parameters_it_was_given(
asset=asset,
connection=connection,
detail="fine",
- fill_holes=0.0,
- fragments="all",
).json()
- assert body["applied"] == {"detail": "fine", "fill_holes": 0.0, "fragments": "all"}
+ assert body["applied"] == {"detail": "fine"}
-def test_a_polygon_class_is_told_every_parameter_applies(
+def test_a_polygon_class_is_told_the_one_parameter_applies(
client: TestClient, runner: InlineDispatcher, project: str, tmp_path: Path, answering: list[Any]
) -> None:
connection = a_connection(client)
@@ -603,15 +601,16 @@ def test_a_polygon_class_is_told_every_parameter_applies(
body = ask(client, project=project, asset=asset, connection=connection).json()
- assert body["parameters"] == ["detail", "fill_holes", "fragments"]
+ assert body["parameters"] == ["detail"]
-def test_a_box_class_is_told_only_fragments_applies(
+def test_a_box_class_is_told_nothing_applies(
client: TestClient, runner: InlineDispatcher, project: str, tmp_path: Path, answering: list[Any]
) -> None:
"""What the editor renders on a box class, and the whole of why it renders it.
- A client works none of this out. Remove `fill_holes` from the polygon row of
+ A client works none of this out: an empty list is what tells it to render no
+ adjustments at all. Remove `detail` from the polygon row of
`PARAMETER_APPLIES_TO` and the assertion above goes red; declare it for a box
and this one does.
"""
@@ -620,7 +619,7 @@ def test_a_box_class_is_told_only_fragments_applies(
body = ask(client, project=project, asset=asset, connection=connection, allowed=["bbox"]).json()
- assert body["parameters"] == ["fragments"]
+ assert body["parameters"] == []
def test_an_answer_with_nothing_in_it_still_carries_its_controls(
@@ -635,7 +634,7 @@ def test_an_answer_with_nothing_in_it_still_carries_its_controls(
connection = a_connection(client)
asset = an_asset(client, runner, project, tmp_path)
- body = ask(client, project=project, asset=asset, connection=connection, fragments="all").json()
+ body = ask(client, project=project, asset=asset, connection=connection).json()
empty = ask(
client,
project=project,
@@ -690,34 +689,58 @@ def test_a_coarser_setting_comes_back_with_no_more_vertices(
assert counts[0] < counts[1] < counts[2], counts
-def test_every_piece_comes_back_when_every_piece_was_asked_for(
+def test_a_split_mask_is_one_polygon_and_one_union_box(
client: TestClient,
runner: InlineDispatcher,
project: str,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """`fragments` over HTTP, and the plural answer acceptance is all-or-nothing over."""
+ """Decision 4 over HTTP: a click asks about one object, however it arrived.
+
+ A polygon class gets the piece under the click; a box class gets one box over
+ both, because a mask in two pieces is nearly always one object seen around an
+ occlusion.
+ """
scripted(monkeypatch, two_blocks())
connection = a_connection(client)
asset = an_asset(client, runner, project, tmp_path)
- one = ask(client, project=project, asset=asset, connection=connection).json()
- every = ask(client, project=project, asset=asset, connection=connection, fragments="all").json()
+ outlined = ask(client, project=project, asset=asset, connection=connection).json()
+ boxed = ask(
+ client, project=project, asset=asset, connection=connection, allowed=["bbox"]
+ ).json()
- assert len(one["regions"]) == 1
- assert len(every["regions"]) == 2
+ assert len(outlined["regions"]) == 1
+ assert len(boxed["regions"]) == 1
+ box = boxed["regions"][0]["geometry"]
+ outline = outlined["regions"][0]["geometry"]["points"]
+ assert box["width"] > max(x for x, _ in outline) - min(x for x, _ in outline), (
+ "the box spans both pieces, so it is wider than the piece the outline traced"
+ )
-def test_a_share_outside_nought_to_one_is_refused_by_the_schema(
+def test_a_setting_the_request_no_longer_takes_is_refused(
client: TestClient, runner: InlineDispatcher, project: str, tmp_path: Path, answering: list[Any]
) -> None:
+ """`extra="forbid"`, doing the work of the deprecation nobody has to write.
+
+ A client still sending the two settings that came out (#557) is told plainly
+ rather than having them silently ignored.
+ """
connection = a_connection(client)
asset = an_asset(client, runner, project, tmp_path)
- answer = ask(client, project=project, asset=asset, connection=connection, fill_holes=1.5)
-
- assert answer.status_code == 422
+ assert (
+ ask(client, project=project, asset=asset, connection=connection, fill_holes=0.5).status_code
+ == 422
+ )
+ assert (
+ ask(
+ client, project=project, asset=asset, connection=connection, fragments="all"
+ ).status_code
+ == 422
+ )
def test_a_detail_step_the_vocabulary_does_not_have_is_refused(