diff --git a/docs/content/api.md b/docs/content/api.md index 5ce36c1a..9f449deb 100644 --- a/docs/content/api.md +++ b/docs/content/api.md @@ -114,7 +114,7 @@ POST /projects/{project_id}/preprocessing-recipes GET /projects/{project_id}/preprocessing-recipes/{name} PUT /projects/{project_id}/preprocessing-recipes/{name} whole-value; a rename is `name` in the body DELETE /projects/{project_id}/preprocessing-recipes/{name} no confirmation gate -POST /projects/{project_id}/preprocessing-preview one asset through a spec; never cached +POST /projects/{project_id}/preprocessing-preview one asset through a spec, seeded or `showcase`; never cached GET /inference/connections POST /inference/connections diff --git a/docs/content/architecture/backend/preprocessing-drivers.md b/docs/content/architecture/backend/preprocessing-drivers.md index 43619a54..010680a2 100644 --- a/docs/content/architecture/backend/preprocessing-drivers.md +++ b/docs/content/architecture/backend/preprocessing-drivers.md @@ -49,7 +49,10 @@ re-encoding, and a resize reads neither argument because it is deterministic. `visionset.kernel.domain`; `hflip` draws nothing and always mirrors. A driver that read the seed any other way, or drew from anywhere else, would put its pixels where the geometry transform did not put the labels. The built-in `rot90` turns counter-clockwise because the kernel's -`_rotated_once` does, and a driver applying that op must turn the same way. +`_rotated_once` does, and a driver applying that op must turn the same way. A preview in +`showcase` mode passes `SHOWCASE_SEED` where a variant seed goes, and the two helpers answer it +with the step at its declared strength - which is why a driver reads them rather than the seed's +bytes, and why the port needs no second argument for it. The step grammar is closed. Which kinds exist, and which augmentation ops, is decided by the kernel's `Step` union, so a driver applies one of the kinds the kernel already names and cannot diff --git a/docs/content/preprocessing.md b/docs/content/preprocessing.md index 8d3ab206..067205f6 100644 --- a/docs/content/preprocessing.md +++ b/docs/content/preprocessing.md @@ -103,7 +103,8 @@ position of that digest. `hflip` draws nothing and always mirrors, for the reaso draws zero turns: a variant that came out identical to its source would be the base image under a variant's name. The same recipe over the same bytes therefore draws the same variant on any machine, and the geometry arithmetic is exact -everywhere. +everywhere. This is the export's path; the preview's `showcase` mode, described below, is the +one place a draw is fixed rather than seeded. **Byte stability is promised within one environment only.** The pixels a resize or an enhancement produces depend on the codec and resampling code that produced them, so two @@ -167,6 +168,13 @@ manifest with the asset in the train fold - so every variant a spec declares can whether or not a release exists. The image is capped to 512 pixels on its longer side, labels scaled to match, and the response is never cached: the spec is the request's own. +The body also takes `showcase`, false by default. With it true the variant's draws are fixed at +each step's declared strength - `hflip` mirrors, `rot90` makes one quarter turn, brightness and +contrast use the full `amount` - so the picture shows what a step *does* rather than one seeded +draw of it. That is a preview's concern only: an export always takes the seeded path above, and +the kernel spells the fixed draws as one sentinel seed (`SHOWCASE_SEED`) that both draw functions +recognise, so the pixel driver and the geometry transform still agree through the same code. + ## Refusals | Error | When | diff --git a/docs/content/ui.md b/docs/content/ui.md index e4f5a5bb..7a71c704 100644 --- a/docs/content/ui.md +++ b/docs/content/ui.md @@ -267,12 +267,15 @@ it and the footer carries the first one beside a shut *Save recipe*. Those are t shape, not a kernel rule the client is mirroring: the server still answers 422 to a body the form did not build. -*Preview* renders three sample assets through `POST /projects/{id}/preprocessing-preview`, -the export's own kernel path over a one-asset manifest: the first three train-fold members -of the newest release with a split — variants are the train fold's — or the project's first -three assets when no release has one. Three columns: the asset as it is, after the resize -step alone, and the first augmented variant; a stage the recipe does not have says so -(*No resize step*) rather than repeating the original. Each cell is one request keyed on +*Preview* renders one sample asset through `POST /projects/{id}/preprocessing-preview`, +the export's own kernel path over a one-asset manifest: the first train-fold member of the +newest release with a split — variants are the train fold's — or the project's first asset +when no release has one. Three columns: the asset as it is, after the resize step alone, and +the last augmentation step ticked, headed by its name (*After horizontal flip*) and asked for +in `showcase` mode — the draws fixed at the step's declared strength, so the cell shows what +the step does rather than one seeded draw of it; an export still takes the seeded path. A +stage the recipe does not have says so (*No resize step*) rather than repeating the +original. Each cell is one request keyed on the spec it renders, and the spec settles for 400 ms before a cell asks, so typing `640` does not pay for `6` and `64`. The cell is the member dialog's own picture-with-labels mechanism (`patterns/StaticAnnotationOverlay.tsx`): the rendered image, and the response's diff --git a/frontend/app/cycle/cycle.spec.ts b/frontend/app/cycle/cycle.spec.ts index 460723c6..892e67e6 100644 --- a/frontend/app/cycle/cycle.spec.ts +++ b/frontend/app/cycle/cycle.spec.ts @@ -1569,8 +1569,9 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa await expect(page.getByTestId("augment-variants")).toHaveValue("1"); await expect(page.getByTestId("recipe-step-augment")).toHaveAttribute("data-state", "complete"); - // The preview: three cells of the first row, each one a real render of a - // frame this walk ingested — the original, the letterbox, and variant 1. + // The preview: three cells of the one row, each one a real render of a + // frame this walk ingested — the original, the letterbox, and the flip + // shown at full strength. // Generous, because the preview debounces the draft and then goes through // Pillow three times. for (const cell of ["original", "resize", "augment"]) { diff --git a/frontend/ui-core/src/generated/api.ts b/frontend/ui-core/src/generated/api.ts index e5d0baf4..8d36de7b 100644 --- a/frontend/ui-core/src/generated/api.ts +++ b/frontend/ui-core/src/generated/api.ts @@ -2189,7 +2189,9 @@ export interface paths { * train fold, so every variant the spec declares can be seen whether or not a * release exists. The image is capped to 512 pixels on its longer side, with * the annotations scaled to match, and comes back base64-encoded beside its - * `media_type`. Never cached: the spec is the request's own. + * `media_type`. Never cached: the spec is the request's own. With `showcase` + * true the variant's draws are fixed at each step's declared strength, so + * the picture shows what a step does rather than one seeded draw of it. * * An unknown project is 404 `PROJECT_NOT_FOUND` and an asset outside it 404 * `ASSET_NOT_FOUND`. A step that cannot transform a geometry the asset carries @@ -4890,6 +4892,11 @@ export interface components { * * `variant` 0 is the base image; `1` to `spec.variants_per_asset` are the * augmented outputs. A variant the spec does not make is refused. + * + * When `showcase` is true the variant's draws are fixed — hflip mirrors, + * rot90 makes one quarter turn, brightness and contrast use the full + * `amount` — so the image shows the step at its declared strength rather + * than one seeded draw; exports never use it. */ PreprocessingPreviewBody: { /** @@ -4897,6 +4904,11 @@ export interface components { * Format: uuid */ asset_id: string; + /** + * Showcase + * @default false + */ + showcase: boolean; spec: components["schemas"]["RecipeSpecBody"]; /** * Variant diff --git a/frontend/ui-core/src/patterns/RecipeEditor.tsx b/frontend/ui-core/src/patterns/RecipeEditor.tsx index 9128a2ce..7f151da0 100644 --- a/frontend/ui-core/src/patterns/RecipeEditor.tsx +++ b/frontend/ui-core/src/patterns/RecipeEditor.tsx @@ -324,7 +324,7 @@ export function RecipeEditor({ testId="recipe-step-preview" aside={ - 3 sample assets · seeded + 1 sample asset } last diff --git a/frontend/ui-core/src/screens/PreprocessingTab.tsx b/frontend/ui-core/src/screens/PreprocessingTab.tsx index a95f1c61..c3965d4c 100644 --- a/frontend/ui-core/src/screens/PreprocessingTab.tsx +++ b/frontend/ui-core/src/screens/PreprocessingTab.tsx @@ -15,12 +15,14 @@ * * `POST /projects/{id}/preprocessing-preview` renders one asset through a spec * on the same kernel path an export takes, so what the cells show is what the - * archive would hold. Three sample assets, and the choice is the release's: the - * first three train-fold members of the newest release with a split, because - * variants are written for the train fold only; without one, the first three - * assets of the project. The columns are the stages — the asset as it is, after - * the resize step alone, and the first augmented variant — each a request - * keyed on the spec it renders, so a keystroke re-renders only what it changed. + * archive would hold. One sample asset, and the choice is the release's: the + * first train-fold member of the newest release with a split, because variants + * are written for the train fold only; without one, the project's first asset. + * The columns are the stages — the asset as it is, after the resize step alone, + * and the last augmentation step chosen, in `showcase` mode: the draws fixed at + * the step's declared strength, so the cell shows what the step does rather + * than one seeded draw of it. Each cell is a request keyed on the spec it + * renders, so a keystroke re-renders only what it changed. * * `PreviewCell` hands the rendered image and its placed annotations to the * static overlay pattern, so a label is drawn where the export would write it. @@ -62,11 +64,13 @@ import { type Release, } from "./queries"; import { + AUGMENT_OPS, canonicalSpec, draftFromSpec, draftToSpec, EMPTY_DRAFT, sameSpec, + type AugmentStepSpec, type RecipeDraft, type RecipeSpec, } from "./recipeDraft"; @@ -366,21 +370,25 @@ function Editor({ ); } -/** The asset ids the preview renders: the train fold's first three, or the project's. */ -function useSampleAssets(projectId: string, datasetId: string | undefined): readonly string[] | undefined { +/** + * The asset the preview renders: the train fold's first member, or the + * project's first asset; `null` when the project has none, `undefined` while + * the choice is still being read. + */ +function useSampleAsset(projectId: string, datasetId: string | undefined): string | null | undefined { const releases = useReleases(datasetId); const newest = newestRelease(releases.data?.items); const withSplit = newest !== undefined && newest.split !== null && newest.split !== undefined; const assignment = useReleaseAssignment(withSplit ? newest.id : undefined); - const project = useProjectAssets(projectId, 3); + const project = useProjectAssets(projectId, 1); if (datasetId !== undefined && releases.data === undefined && !releases.isError) return undefined; if (withSplit) { if (assignment.data === undefined && !assignment.isError) return undefined; - const train = assignment.data?.train.slice(0, 3) ?? []; - if (train.length > 0) return train; + const train = assignment.data?.train[0]; + if (train !== undefined) return train; } if (project.data === undefined) return undefined; - return project.data.items.slice(0, 3).map((asset) => asset.id); + return project.data.items[0]?.id ?? null; } function newestRelease(items: readonly Release[] | undefined): Release | undefined { @@ -416,17 +424,20 @@ function PreviewGrid({ readonly classes: readonly LabelClass[] | undefined; readonly onReady: (ready: boolean) => void; }): JSX.Element { - const samples = useSampleAssets(projectId, datasetId); + const sample = useSampleAsset(projectId, datasetId); const settled = useSettledSpec(spec); + const resizeSteps = settled === null ? [] : settled.steps.filter((step) => step.kind === "resize"); const resizeOnly: RecipeSpec | null = - settled === null + settled === null ? null : { target: settled.target ?? null, steps: resizeSteps, variants_per_asset: 0 }; + const hasResize = resizeSteps.length > 0; + const lastAugment = settled === null ? undefined : lastAugmentStep(settled); + const showcase: RecipeSpec | null = + settled === null || lastAugment === undefined ? null - : { target: settled.target ?? null, steps: settled.steps.filter((step) => step.kind === "resize"), variants_per_asset: 0 }; - const hasResize = resizeOnly !== null && resizeOnly.steps.length > 0; - const hasAugment = settled !== null && settled.variants_per_asset > 0; + : { target: settled.target ?? null, steps: [...resizeSteps, lastAugment], variants_per_asset: 1 }; - if (samples === undefined) return ; - if (samples.length === 0) { + if (sample === undefined) return ; + if (sample === null) { return (

Nothing to preview yet — the project has no images. The recipe can still be saved. @@ -439,54 +450,64 @@ function PreviewGrid({

Original After resize - After augmentation + + {lastAugment === undefined ? "After augmentation" : `After ${opLabel(lastAugment.op)}`} +
- {samples.map((assetId, index) => ( -
+
+ + {hasResize ? ( + ) : ( + + )} + {showcase !== null ? ( + - {hasResize ? ( - - ) : ( - - )} - {hasAugment ? ( - - ) : ( - - )} -
- ))} + ) : ( + + )} +
); } +/** The augmentation the preview shows: the last one the draft holds, in the editor's own order. */ +function lastAugmentStep(spec: RecipeSpec): AugmentStepSpec | undefined { + const augments = spec.steps.filter((step): step is AugmentStepSpec => step.kind === "augment"); + return augments[augments.length - 1]; +} + +function opLabel(op: AugmentStepSpec["op"]): string { + return (AUGMENT_OPS.find((one) => one.op === op)?.label ?? op).toLowerCase(); +} + /** * One asset through one spec. The rendered image with its labels drawn where * the export would write them, or a placeholder while it has not arrived, or @@ -507,6 +528,7 @@ function PreviewCell({ classes, testId, onReady, + showcase = false, }: { readonly projectId: string; readonly assetId: string; @@ -515,6 +537,7 @@ function PreviewCell({ readonly classes: readonly LabelClass[] | undefined; readonly testId: string; readonly onReady?: (ready: boolean) => void; + readonly showcase?: boolean; }): JSX.Element { const preview = usePreprocessingPreview( projectId, @@ -522,6 +545,7 @@ function PreviewCell({ variant, spec, spec === null ? "" : canonicalSpec(spec), + showcase, ); const ready = preview.data !== undefined; useEffect(() => { diff --git a/frontend/ui-core/src/screens/preprocessing.test.tsx b/frontend/ui-core/src/screens/preprocessing.test.tsx index d5371f4e..c7648550 100644 --- a/frontend/ui-core/src/screens/preprocessing.test.tsx +++ b/frontend/ui-core/src/screens/preprocessing.test.tsx @@ -614,32 +614,44 @@ describe("deleting a recipe", () => { }); describe("the preview", () => { - it("samples the project's first three assets when no release has a split, and renders each stage", async () => { + it("samples the project's first asset when no release has a split, and renders each stage", async () => { baseline([recipeRow("yolo-640", LETTERBOX)]); render(mount()); const grid = await screen.findByTestId("preview-grid"); const rows = within(grid).getAllByTestId(/^preview-row-/); - expect(rows).toHaveLength(3); + expect(rows).toHaveLength(1); // Three cells and nothing else: the row spends its width on the images. - for (const row of rows) expect(row.children).toHaveLength(3); - expect(screen.getByTestId("preview-aside").textContent).toBe("3 sample assets · seeded"); + expect(rows[0]?.children).toHaveLength(3); + expect(screen.getByTestId("preview-aside").textContent).toBe("1 sample asset"); + // The third column names the last augmentation the draft holds. + expect(screen.getByTestId("preview-augment-heading").textContent).toBe("After brightness and contrast"); await waitFor(() => expect(screen.getByTestId("preview-0-augment").getAttribute("data-state")).toBe("rendered"), ); // The original is the asset through no transform; the resize column the - // resize step alone; the augmentation column variant 1 of the whole spec. + // resize step alone; the augmentation column the resize and the last + // augmentation step only, one variant, shown at its declared strength. const previews = sent.filter((r) => r.method === "POST" && pathOf(r).endsWith("/preprocessing-preview")); - const forFirst = previews.filter((r) => bodyOf(r)["asset_id"] === ASSET_A).map(bodyOf); - const specs = forFirst.map((body) => ({ + const requests = previews.map(bodyOf).map((body) => ({ variant: body["variant"], - steps: (body["spec"] as RecipeSpec).steps.map((step) => step.kind), + showcase: body["showcase"], + variants: (body["spec"] as RecipeSpec).variants_per_asset, + steps: (body["spec"] as RecipeSpec).steps.map((step) => + step.kind === "augment" ? `augment:${step.op}` : step.kind, + ), })); - expect(specs).toContainEqual({ variant: 0, steps: [] }); - expect(specs).toContainEqual({ variant: 0, steps: ["resize"] }); - expect(specs).toContainEqual({ variant: 1, steps: ["resize", "augment", "augment"] }); - expect(previews.some((r) => bodyOf(r)["asset_id"] === ASSET_D)).toBe(false); + expect(requests).toContainEqual({ variant: 0, showcase: false, variants: 0, steps: [] }); + expect(requests).toContainEqual({ variant: 0, showcase: false, variants: 0, steps: ["resize"] }); + expect(requests).toContainEqual({ + variant: 1, + showcase: true, + variants: 1, + steps: ["resize", "augment:brightness_contrast"], + }); + expect(requests).toHaveLength(3); + expect(new Set(previews.map((r) => bodyOf(r)["asset_id"]))).toEqual(new Set([ASSET_A])); const original = screen.getByTestId("preview-0-original"); const image = original.querySelector("img"); expect(image?.getAttribute("src")).toBe("data:image/png;base64,aGVsbG8="); @@ -650,6 +662,37 @@ describe("the preview", () => { expect(screen.getByTestId("recipe-step-preview").getAttribute("data-state")).toBe("complete"); }); + it("names the step the third column shows, and follows the draft's last augmentation", async () => { + baseline([recipeRow("yolo-640", LETTERBOX)]); + render(mount()); + await screen.findByTestId("preview-grid"); + + await userEvent.click(screen.getByTestId("augment-brightness_contrast")); + await waitFor(() => + expect(screen.getByTestId("preview-augment-heading").textContent).toBe("After horizontal flip"), + ); + await waitFor(() => + expect( + sent + .filter((r) => r.method === "POST" && pathOf(r).endsWith("/preprocessing-preview")) + .map(bodyOf) + .some( + (body) => + body["showcase"] === true && + (body["spec"] as RecipeSpec).steps.map((step) => (step.kind === "augment" ? step.op : step.kind)).join(",") === + "resize,hflip", + ), + ).toBe(true), + ); + + // The last augmentation unticked: no variant to show, and the column says so. + await userEvent.click(screen.getByTestId("augment-hflip")); + await waitFor(() => + expect(screen.getByTestId("preview-0-augment").textContent).toContain("No augmentation"), + ); + expect(screen.getByTestId("preview-augment-heading").textContent).toBe("After augmentation"); + }); + it("samples the newest release's train fold when it has a split", async () => { on("GET", /\/releases$/, { status: 200, @@ -680,18 +723,19 @@ describe("the preview", () => { await screen.findByTestId("preview-grid"); await waitFor(() => - expect(screen.getByTestId("preview-1-original").getAttribute("data-state")).toBe("rendered"), + expect(screen.getByTestId("preview-0-original").getAttribute("data-state")).toBe("rendered"), ); const sampled = new Set( sent .filter((r) => r.method === "POST" && pathOf(r).endsWith("/preprocessing-preview")) .map((r) => bodyOf(r)["asset_id"]), ); - expect(sampled).toEqual(new Set([ASSET_C, ASSET_D])); + expect(sampled).toEqual(new Set([ASSET_C])); // No resize step and no augmentation: the two stages say so rather than // repeating the original. expect(screen.getByTestId("preview-0-resize").textContent).toContain("No resize step"); expect(screen.getByTestId("preview-0-augment").textContent).toContain("No augmentation"); + expect(screen.getByTestId("preview-augment-heading").textContent).toBe("After augmentation"); }); it("shows a refused rendering as prose in the cell", async () => { diff --git a/frontend/ui-core/src/screens/queries.ts b/frontend/ui-core/src/screens/queries.ts index 13438355..f0bd0d16 100644 --- a/frontend/ui-core/src/screens/queries.ts +++ b/frontend/ui-core/src/screens/queries.ts @@ -2181,8 +2181,8 @@ export const recipeKeys = { recipes: (projectId: string) => ["projects", projectId, "preprocessing-recipes"] as const, // The spec is part of the key: two specs are two renderings of the same // asset, and sharing one key would make editing the draft a cache overwrite. - preview: (projectId: string, assetId: string, variant: number, spec: string) => - ["projects", projectId, "preprocessing-preview", assetId, variant, spec] as const, + preview: (projectId: string, assetId: string, variant: number, spec: string, showcase: boolean) => + ["projects", projectId, "preprocessing-preview", assetId, variant, spec, showcase] as const, assignment: (releaseId: string) => ["releases", releaseId, "assignment"] as const, }; @@ -2259,7 +2259,9 @@ export function useDeletePreprocessingRecipe(projectId: string) { * path over a one-asset manifest, capped to 512 pixels on the longer side. * * A `POST` read as a query: the request creates nothing, and the response is - * a function of `(asset, variant, spec)`, which is what the key spells. The + * a function of `(asset, variant, spec, showcase)`, which is what the key + * spells; `showcase` asks for the step at its declared strength rather than + * one seeded draw. The * previous picture is kept while a new spec is rendering, so a keystroke in the * width field changes the cell rather than blanking it. A spec that cannot be * sent yet (`null`) leaves the query idle. @@ -2270,10 +2272,11 @@ export function usePreprocessingPreview( variant: number, spec: RecipeSpecBody | null, specKey: string, + showcase = false, ): UseQueryResult { const client = useApiClient(); return useQuery({ - queryKey: recipeKeys.preview(projectId, assetId ?? "none", variant, specKey), + queryKey: recipeKeys.preview(projectId, assetId ?? "none", variant, specKey, showcase), enabled: assetId !== undefined && spec !== null, placeholderData: keepPreviousData, // The server says `no-store`, and a rendering of a spec that is still @@ -2283,7 +2286,12 @@ export function usePreprocessingPreview( unwrap( await client.POST("/projects/{project_id}/preprocessing-preview", { params: { path: { project_id: projectId } }, - body: { asset_id: assetId ?? "", variant, spec: spec ?? { steps: [], variants_per_asset: 0 } }, + body: { + asset_id: assetId ?? "", + variant, + spec: spec ?? { steps: [], variants_per_asset: 0 }, + showcase, + }, }), checkPreviewPreprocessing, ), diff --git a/openapi.json b/openapi.json index e455bc60..8e8802b0 100644 --- a/openapi.json +++ b/openapi.json @@ -4059,13 +4059,18 @@ }, "PreprocessingPreviewBody": { "additionalProperties": false, - "description": "One asset to render through a spec, and which variant of it.\n\n`variant` 0 is the base image; `1` to `spec.variants_per_asset` are the\naugmented outputs. A variant the spec does not make is refused.", + "description": "One asset to render through a spec, and which variant of it.\n\n`variant` 0 is the base image; `1` to `spec.variants_per_asset` are the\naugmented outputs. A variant the spec does not make is refused.\n\nWhen `showcase` is true the variant's draws are fixed \u2014 hflip mirrors,\nrot90 makes one quarter turn, brightness and contrast use the full\n`amount` \u2014 so the image shows the step at its declared strength rather\nthan one seeded draw; exports never use it.", "properties": { "asset_id": { "format": "uuid", "title": "Asset Id", "type": "string" }, + "showcase": { + "default": false, + "title": "Showcase", + "type": "boolean" + }, "spec": { "$ref": "#/components/schemas/RecipeSpecBody" }, @@ -12665,7 +12670,7 @@ }, "/projects/{project_id}/preprocessing-preview": { "post": { - "description": "Render one asset through a spec, the way an export would write it.\n\nThe same kernel path as an export, over the one asset as if it were in the\ntrain fold, so every variant the spec declares can be seen whether or not a\nrelease exists. The image is capped to 512 pixels on its longer side, with\nthe annotations scaled to match, and comes back base64-encoded beside its\n`media_type`. Never cached: the spec is the request's own.\n\nAn unknown project is 404 `PROJECT_NOT_FOUND` and an asset outside it 404\n`ASSET_NOT_FOUND`. A step that cannot transform a geometry the asset carries\nis 409 `PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY`, and a step needing a source\nsize the asset never recorded, or an asset whose bytes are gone, is 409\n`EXPORT_SOURCE_UNREADABLE`. A rendered image in an encoding this server\ncannot name is 422 `UNSUPPORTED_MEDIA`, and a step kind no installed driver\napplies is 500 `PREPROCESSING_DRIVER_NOT_FOUND`.", + "description": "Render one asset through a spec, the way an export would write it.\n\nThe same kernel path as an export, over the one asset as if it were in the\ntrain fold, so every variant the spec declares can be seen whether or not a\nrelease exists. The image is capped to 512 pixels on its longer side, with\nthe annotations scaled to match, and comes back base64-encoded beside its\n`media_type`. Never cached: the spec is the request's own. With `showcase`\ntrue the variant's draws are fixed at each step's declared strength, so\nthe picture shows what a step does rather than one seeded draw of it.\n\nAn unknown project is 404 `PROJECT_NOT_FOUND` and an asset outside it 404\n`ASSET_NOT_FOUND`. A step that cannot transform a geometry the asset carries\nis 409 `PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY`, and a step needing a source\nsize the asset never recorded, or an asset whose bytes are gone, is 409\n`EXPORT_SOURCE_UNREADABLE`. A rendered image in an encoding this server\ncannot name is 422 `UNSUPPORTED_MEDIA`, and a step kind no installed driver\napplies is 500 `PREPROCESSING_DRIVER_NOT_FOUND`.", "operationId": "preview_preprocessing", "parameters": [ { diff --git a/src/visionset/kernel/domain/__init__.py b/src/visionset/kernel/domain/__init__.py index 3624fa35..61775882 100644 --- a/src/visionset/kernel/domain/__init__.py +++ b/src/visionset/kernel/domain/__init__.py @@ -178,6 +178,7 @@ from visionset.kernel.domain.preprocessing import ( AUGMENT_GEOMETRIES, EVERY_GEOMETRY, + SHOWCASE_SEED, AugmentOp, AugmentStep, PreprocessingRecipe, @@ -443,6 +444,7 @@ "VARIANT_ID_NAMESPACE", "TransformedFile", "TransformedView", + "SHOWCASE_SEED", "brightness_contrast_factors", "letterbox_fit", "variant_content_hash", diff --git a/src/visionset/kernel/domain/preprocessing.py b/src/visionset/kernel/domain/preprocessing.py index 1d261f02..ab60e4c0 100644 --- a/src/visionset/kernel/domain/preprocessing.py +++ b/src/visionset/kernel/domain/preprocessing.py @@ -14,6 +14,12 @@ keeps a variant's annotations on its pixels. Byte stability is promised within one environment only; the geometry arithmetic here is exact everywhere. +A preview that wants to *show* a step rather than one draw of it passes +:data:`SHOWCASE_SEED` where a variant seed goes: both draw functions answer it +with the step at its declared strength — one quarter turn, brightness and +contrast at ``1 + amount`` — through the same code path an export takes, so +nothing about the pixel or geometry side is special-cased for a screen. + Every step declares the geometries it can transform, the way an exporter declares ``supported_geometries``: :data:`AUGMENT_GEOMETRIES` is the table per augmentation, and each step reads it back as ``supported_geometries``. The @@ -40,6 +46,13 @@ EVERY_GEOMETRY: Final[frozenset[GeometryType]] = frozenset(GeometryType) """What a step that moves every coordinate the same way can transform.""" +SHOWCASE_SEED: Final = b"showcase" +"""The seed a preview passes to see a step at its declared strength. + +Eight bytes where :func:`variant_seed` always digests thirty-two, so no +export can produce it by accident. Never written by an export. +""" + class ResizeStep(BaseModel): """Bring every exported image to one size, by one strategy. @@ -201,7 +214,10 @@ def brightness_contrast_factors(seed: bytes, amount: float) -> tuple[float, floa whatever other steps the recipe holds, so adding a step never re-rolls the others. Word 0 is read by nothing; the positions here are load-bearing, because moving one would re-roll every variant an export already wrote. + :data:`SHOWCASE_SEED` answers ``(1 + amount, 1 + amount)``. """ + if seed == SHOWCASE_SEED: + return (1.0 + amount, 1.0 + amount) return ( 1.0 - amount + 2.0 * amount * _fraction(seed, 1), 1.0 - amount + 2.0 * amount * _fraction(seed, 2), @@ -212,8 +228,11 @@ def rot90_quarter_turns(seed: bytes) -> int: """How many counter-clockwise quarter turns this variant rotates: 1, 2 or 3. Never 0 — a rot90 step that drew no rotation would emit the base image - under a variant's name. Reads word 3 of the seed. + under a variant's name. Reads word 3 of the seed; :data:`SHOWCASE_SEED` + answers one turn. """ + if seed == SHOWCASE_SEED: + return 1 return 1 + _word(seed, 3) % 3 diff --git a/src/visionset/kernel/domain/preprocessing_transform.py b/src/visionset/kernel/domain/preprocessing_transform.py index 8e2c0c4b..95567c89 100644 --- a/src/visionset/kernel/domain/preprocessing_transform.py +++ b/src/visionset/kernel/domain/preprocessing_transform.py @@ -30,6 +30,7 @@ PolylineGeometry, ) from visionset.kernel.domain.preprocessing import ( + SHOWCASE_SEED, AugmentOp, AugmentStep, RecipeSpec, @@ -186,7 +187,11 @@ class PreprocessingPreview(BaseModel): def transform_manifest( - manifest: Manifest, spec: RecipeSpec, folds: SplitAssignment | None + manifest: Manifest, + spec: RecipeSpec, + folds: SplitAssignment | None, + *, + showcase: bool = False, ) -> TransformedView: """Every file one recipe makes of one manifest, geometry included. @@ -195,6 +200,10 @@ def transform_manifest( that augments requires ``folds``. Base images are emitted for every asset whatever its fold, resized when the spec says so. + ``showcase`` replaces every variant's seed with :data:`SHOWCASE_SEED`, so + each augmentation lands at its declared strength rather than one draw of + it — a preview's way of showing what a step does. An export never sets it. + Raises: AugmentationRequiresSplit: the spec asks for variants and ``folds`` is ``None`` — the release was published without a split recipe. @@ -222,7 +231,7 @@ def transform_manifest( if fold != "train": continue for k in range(1, spec.variants_per_asset + 1): - seed = variant_seed(spec_hash, asset.content_hash, k) + seed = SHOWCASE_SEED if showcase else variant_seed(spec_hash, asset.content_hash, k) files.append(_variant_file(asset, resize, augments, variant=k, seed=seed)) return TransformedView(files=tuple(files)) diff --git a/src/visionset/kernel/services/preprocessing_recipe_service.py b/src/visionset/kernel/services/preprocessing_recipe_service.py index 9172e40c..307095e3 100644 --- a/src/visionset/kernel/services/preprocessing_recipe_service.py +++ b/src/visionset/kernel/services/preprocessing_recipe_service.py @@ -202,6 +202,7 @@ def preview( variant: int, drivers: Mapping[str, PreprocessingDriver], max_edge: int = PREVIEW_MAX_EDGE, + showcase: bool = False, ) -> PreprocessingPreview: """One asset through ``spec``, as the export would write it, sized for a screen. @@ -215,6 +216,10 @@ def preview( ``variant`` 0 is the base image; ``1..variants_per_asset`` are the augmented outputs. Asking for a variant the spec does not make is a caller's error and is refused by the surface before it reaches here. + ``showcase`` fixes the variant's draws at each step's declared strength + — hflip mirrors, rot90 makes one quarter turn, brightness and contrast + use the full ``amount`` — so the picture shows what a step does rather + than one seeded draw of it; an export never uses it. Raises: ProjectNotFound: no such project in this workspace. @@ -242,7 +247,9 @@ def preview( ), ) manifest = Manifest(schema_version=1, assets=(manifest_asset,)) - view = transform_manifest(manifest, spec, SplitAssignment(train=(asset.id,))) + view = transform_manifest( + manifest, spec, SplitAssignment(train=(asset.id,)), showcase=showcase + ) file = next(one for one in view.files if one.variant == variant) try: with self._workspace.blob_store.get(asset.content_hash) as stream: @@ -252,7 +259,12 @@ def preview( f"asset {asset.id} ({asset.content_hash}) is not in the blob store" ) from exc image = transformed_bytes( - spec, drivers, source, content_hash=asset.content_hash, variant=variant + spec, + drivers, + source, + content_hash=asset.content_hash, + variant=variant, + showcase=showcase, ) fitted = fit_within(file, max_edge) if (fitted.width, fitted.height) != (file.width, file.height): diff --git a/src/visionset/kernel/services/release_service.py b/src/visionset/kernel/services/release_service.py index 60e7ce88..7ad64c24 100644 --- a/src/visionset/kernel/services/release_service.py +++ b/src/visionset/kernel/services/release_service.py @@ -54,6 +54,7 @@ from visionset import __version__ from visionset.kernel.domain import ( IMPLEMENTED_GEOMETRIES, + SHOWCASE_SEED, Annotation, AnnotationSchema, Asset, @@ -844,6 +845,7 @@ def transformed_bytes( *, content_hash: str, variant: int, + showcase: bool = False, ) -> bytes: """One image through the recipe's steps, for one variant. @@ -851,12 +853,14 @@ def transformed_bytes( variants ``1..n`` only, each seeded from the recipe, the source and the variant index so the pixels land where ``transform_manifest`` put the labels. Shared by the export seam and the preview, which is what makes a - preview show what an export will write. + preview show what an export will write. ``showcase`` hands the drivers + ``SHOWCASE_SEED`` instead, the preview's way of showing each step at its + declared strength; an export never sets it. Raises: PreprocessingDriverNotFound: no driver in ``drivers`` applies a step. """ - seed = variant_seed(recipe_hash(spec), content_hash, variant) + seed = SHOWCASE_SEED if showcase else variant_seed(recipe_hash(spec), content_hash, variant) image = source for step in spec.steps: if isinstance(step, ResizeStep) or (isinstance(step, AugmentStep) and variant > 0): diff --git a/src/visionset/server/models.py b/src/visionset/server/models.py index 2045dda7..b0217bce 100644 --- a/src/visionset/server/models.py +++ b/src/visionset/server/models.py @@ -2444,6 +2444,11 @@ class PreprocessingPreviewBody(BaseModel): `variant` 0 is the base image; `1` to `spec.variants_per_asset` are the augmented outputs. A variant the spec does not make is refused. + + When `showcase` is true the variant's draws are fixed — hflip mirrors, + rot90 makes one quarter turn, brightness and contrast use the full + `amount` — so the image shows the step at its declared strength rather + than one seeded draw; exports never use it. """ model_config = ConfigDict(extra="forbid") @@ -2451,6 +2456,7 @@ class PreprocessingPreviewBody(BaseModel): spec: RecipeSpecBody asset_id: UUID variant: int = 0 + showcase: bool = False @model_validator(mode="after") def _the_variant_exists(self) -> Self: diff --git a/src/visionset/server/routes/preprocessing_recipes.py b/src/visionset/server/routes/preprocessing_recipes.py index 8d2c4a05..f98afa4a 100644 --- a/src/visionset/server/routes/preprocessing_recipes.py +++ b/src/visionset/server/routes/preprocessing_recipes.py @@ -130,7 +130,9 @@ def preview_preprocessing( train fold, so every variant the spec declares can be seen whether or not a release exists. The image is capped to 512 pixels on its longer side, with the annotations scaled to match, and comes back base64-encoded beside its - `media_type`. Never cached: the spec is the request's own. + `media_type`. Never cached: the spec is the request's own. With `showcase` + true the variant's draws are fixed at each step's declared strength, so + the picture shows what a step does rather than one seeded draw of it. An unknown project is 404 `PROJECT_NOT_FOUND` and an asset outside it 404 `ASSET_NOT_FOUND`. A step that cannot transform a geometry the asset carries @@ -141,7 +143,12 @@ def preview_preprocessing( applies is 500 `PREPROCESSING_DRIVER_NOT_FOUND`. """ preview = PreprocessingRecipeService(workspace).preview( - project_id, body.spec.to_domain(), body.asset_id, variant=body.variant, drivers=drivers + project_id, + body.spec.to_domain(), + body.asset_id, + variant=body.variant, + drivers=drivers, + showcase=body.showcase, ) response.headers["Cache-Control"] = _NO_STORE return PreprocessingPreviewOut.of(preview) diff --git a/tests/kernel/test_preprocessing.py b/tests/kernel/test_preprocessing.py index 1ee1a521..10e3d6e3 100644 --- a/tests/kernel/test_preprocessing.py +++ b/tests/kernel/test_preprocessing.py @@ -10,6 +10,7 @@ from visionset.kernel.domain import ( AUGMENT_GEOMETRIES, + SHOWCASE_SEED, AugmentOp, AugmentStep, GeometryType, @@ -204,3 +205,9 @@ def test_rot90_reads_word_three_and_never_draws_zero_turns() -> None: seed = bytes(12) + remainder.to_bytes(4, "big") + bytes(16) assert rot90_quarter_turns(seed) == 1 + remainder assert {rot90_quarter_turns(variant_seed("r", "c", k)) for k in range(1, 60)} <= {1, 2, 3} + + +def test_the_showcase_seed_answers_the_declared_strength_and_no_export_can_draw_it() -> None: + assert brightness_contrast_factors(SHOWCASE_SEED, 0.3) == pytest.approx((1.3, 1.3)) + assert rot90_quarter_turns(SHOWCASE_SEED) == 1 + assert len(SHOWCASE_SEED) != len(variant_seed("r", "c", 1)) diff --git a/tests/kernel/test_preprocessing_transform.py b/tests/kernel/test_preprocessing_transform.py index 39218dc7..827d2c26 100644 --- a/tests/kernel/test_preprocessing_transform.py +++ b/tests/kernel/test_preprocessing_transform.py @@ -416,6 +416,24 @@ def test_a_variant_annotation_is_the_source_annotation_with_a_suffixed_id() -> N assert base.content_hash == first.content_hash == second.content_hash == "cc" +def test_a_showcase_rotates_exactly_once_and_leaves_the_seeded_view_alone() -> None: + spec = _spec(AugmentStep(op=AugmentOp.ROT90), variants=1) + asset = _asset(BBOX, POLYGON, TAG, content_hash="rot") + seeded = transform_manifest(_manifest(asset), spec, _train(asset)) + shown = transform_manifest(_manifest(asset), spec, _train(asset), showcase=True) + + (variant,) = [file for file in shown.files if file.variant == 1] + assert (variant.width, variant.height) == (200, 100) + assert _geometry_of(variant, BboxGeometry) == BboxGeometry( + x=20.0, y=60.0, width=40.0, height=30.0 + ) + assert _geometry_of(variant, PolygonGeometry) == PolygonGeometry( + points=[(0.0, 100.0), (0.0, 50.0), (100.0, 50.0)] + ) + assert seeded == transform_manifest(_manifest(asset), spec, _train(asset)) + assert shown.files[0] == seeded.files[0] + + def test_the_view_is_deterministic_for_one_spec_and_one_manifest() -> None: asset = _asset(BBOX, POLYGON) spec = _spec( diff --git a/tests/preprocessing/test_pillow_drivers.py b/tests/preprocessing/test_pillow_drivers.py index a94ec079..4d52d9b7 100644 --- a/tests/preprocessing/test_pillow_drivers.py +++ b/tests/preprocessing/test_pillow_drivers.py @@ -12,6 +12,7 @@ from PIL import Image from visionset.kernel.domain import ( + SHOWCASE_SEED, AugmentOp, AugmentStep, ResizeStep, @@ -310,3 +311,42 @@ def test_different_seeds_draw_different_brightness() -> None: } assert len(outputs) > 1 + + +@pytest.mark.parametrize("op", list(AugmentOp)) +def test_the_showcase_seed_changes_the_source_and_is_deterministic(op: AugmentOp) -> None: + step = AugmentStep(op=op, amount=0.3) + source = ( + _marked((40, 30), (3, 7)) + if op is not AugmentOp.BRIGHTNESS_CONTRAST + else _png((16, 16), 100, "L") + ) + + first = AUGMENT.apply(step, source, seed=SHOWCASE_SEED, variant=1) + second = AUGMENT.apply(step, source, seed=SHOWCASE_SEED, variant=1) + + assert first != source + assert first == second + + +def test_a_showcase_rot90_turns_once_and_brightness_uses_the_full_amount() -> None: + turned = _open( + AUGMENT.apply( + AugmentStep(op=AugmentOp.ROT90), + _marked((40, 30), (3, 7)), + seed=SHOWCASE_SEED, + variant=1, + ) + ) + assert turned.size == (30, 40) + assert _white_pixels(turned) == {(7, 40 - 1 - 3)} + + bright = _open( + AUGMENT.apply( + AugmentStep(op=AugmentOp.BRIGHTNESS_CONTRAST, amount=0.3), + _png((16, 16), 100, "L"), + seed=SHOWCASE_SEED, + variant=1, + ) + ) + assert bright.getpixel((8, 8)) == pytest.approx(130, abs=1) diff --git a/tests/server/test_preprocessing_recipes.py b/tests/server/test_preprocessing_recipes.py index af04f3dc..71fd05af 100644 --- a/tests/server/test_preprocessing_recipes.py +++ b/tests/server/test_preprocessing_recipes.py @@ -198,6 +198,35 @@ def test_the_preview_renders_the_asset_through_the_spec( assert body["annotations"] == [] +def test_a_showcase_preview_renders_and_the_default_is_the_seeded_draw( + client: TestClient, asset: tuple[str, str] +) -> None: + project_id, asset_id = asset + spec = { + "target": None, + "steps": [{"kind": "augment", "op": "rot90"}], + "variants_per_asset": 1, + } + + shown = _preview( + client, project_id, {"spec": spec, "asset_id": asset_id, "variant": 1, "showcase": True} + ) + seeded = _preview(client, project_id, {"spec": spec, "asset_id": asset_id, "variant": 1}) + + assert shown.status_code == 200, shown.text + assert seeded.status_code == 200, seeded.text + with Image.open(io.BytesIO(base64.b64decode(shown.json()["image_base64"]))) as image: + assert image.size == (shown.json()["width"], shown.json()["height"]) + assert ( + seeded.json() + == _preview( + client, + project_id, + {"spec": spec, "asset_id": asset_id, "variant": 1, "showcase": False}, + ).json() + ) + + def test_the_preview_is_capped_to_512_on_its_longer_side( client: TestClient, asset: tuple[str, str] ) -> None: