From 61ed10420524da655078d1c29b9e3218251ef557 Mon Sep 17 00:00:00 2001
From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com>
Date: Wed, 26 Aug 2026 08:05:06 -0700
Subject: [PATCH 1/2] feat(preprocessing): a showcase preview applies a step at
its declared strength
---
docs/content/api.md | 2 +-
.../backend/preprocessing-drivers.md | 5 ++-
docs/content/preprocessing.md | 10 ++++-
frontend/ui-core/src/generated/api.ts | 14 ++++++-
openapi.json | 9 ++++-
src/visionset/kernel/domain/__init__.py | 2 +
src/visionset/kernel/domain/preprocessing.py | 21 +++++++++-
.../kernel/domain/preprocessing_transform.py | 13 +++++-
.../services/preprocessing_recipe_service.py | 16 +++++++-
.../kernel/services/release_service.py | 8 +++-
src/visionset/server/models.py | 6 +++
.../server/routes/preprocessing_recipes.py | 11 ++++-
tests/kernel/test_preprocessing.py | 7 ++++
tests/kernel/test_preprocessing_transform.py | 18 +++++++++
tests/preprocessing/test_pillow_drivers.py | 40 +++++++++++++++++++
tests/server/test_preprocessing_recipes.py | 29 ++++++++++++++
16 files changed, 196 insertions(+), 15 deletions(-)
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/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/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:
From c755291613fcd57ec5647fd814d7e85848167615 Mon Sep 17 00:00:00 2001
From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com>
Date: Wed, 26 Aug 2026 08:07:38 -0700
Subject: [PATCH 2/2] feat(ui): the recipe preview shows one sample and the
last step at full strength
---
docs/content/ui.md | 15 +-
frontend/app/cycle/cycle.spec.ts | 5 +-
.../ui-core/src/patterns/RecipeEditor.tsx | 2 +-
.../ui-core/src/screens/PreprocessingTab.tsx | 138 ++++++++++--------
.../src/screens/preprocessing.test.tsx | 72 +++++++--
frontend/ui-core/src/screens/queries.ts | 18 ++-
6 files changed, 165 insertions(+), 85 deletions(-)
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/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
Nothing to preview yet — the project has no images. The recipe can still be saved. @@ -439,54 +450,64 @@ function PreviewGrid({