From 9d324ffb825d92d7a77c4661b602031520d91fe2 Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:26:18 -0700 Subject: [PATCH] feat(kernel): preprocessing recipe domain and driver port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pre-processing recipe is a frozen value: a RecipeSpec holds at most one resize step, first, followed by augmentation steps applied at most once each, and says how many augmented variants each train-fold asset gets. recipe_hash names a spec through the manifest's own canonical encoder, whose parameter widens from Manifest to any pydantic model so the recipe reuses it rather than copying it; the manifest's bytes are untouched. Everything random about a variant is derived from sha256(f"{recipe_hash}:{content_hash}:{k}") at fixed positions — bit 0 for hflip, words 1 and 2 for the brightness and contrast factors, word 3 for the quarter turns — so the geometry transform and a pixel driver read the same draw. transform_manifest produces a TransformedView: per file the transformed size, the moved annotations (variants suffixed -aug{k}), the source content hash, the variant index and the fold. Variants exist for the train fold only; a spec that augments against a release without a split recipe raises AugmentationRequiresSplit, and rot90 over a polyline raises PreprocessingStepUnsupportedGeometry. letterbox_fit is the one spelling of the letterbox arithmetic both sides call. PreprocessingDriver is the port a pixel engine implements: step kinds and apply(step, image, seed=, variant=). Both new errors are mapped in server/errors.py and listed in the api.md table — no route raises them yet — so the exact-correspondence gate stays total. --- docs/content/api.md | 2 +- src/visionset/kernel/__init__.py | 4 + src/visionset/kernel/domain/__init__.py | 40 ++ src/visionset/kernel/domain/preprocessing.py | 185 ++++++++ .../kernel/domain/preprocessing_transform.py | 398 ++++++++++++++++++ src/visionset/kernel/domain/release.py | 11 +- src/visionset/kernel/errors.py | 55 +++ src/visionset/kernel/ports/__init__.py | 2 + src/visionset/kernel/ports/preprocessing.py | 28 ++ src/visionset/server/errors.py | 13 + tests/kernel/test_preprocessing.py | 196 +++++++++ tests/kernel/test_preprocessing_transform.py | 388 +++++++++++++++++ tests/server/test_errors.py | 2 + 13 files changed, 1320 insertions(+), 4 deletions(-) create mode 100644 src/visionset/kernel/domain/preprocessing.py create mode 100644 src/visionset/kernel/domain/preprocessing_transform.py create mode 100644 src/visionset/kernel/ports/preprocessing.py create mode 100644 tests/kernel/test_preprocessing.py create mode 100644 tests/kernel/test_preprocessing_transform.py diff --git a/docs/content/api.md b/docs/content/api.md index ed1fa29f..a1b9f1a1 100644 --- a/docs/content/api.md +++ b/docs/content/api.md @@ -610,7 +610,7 @@ argument for branching on `code`. | **401** | `UNAUTHORIZED` — with a `WWW-Authenticate: Bearer` challenge | | **404** | `PROJECT_NOT_FOUND` · `SCHEMA_NOT_FOUND` · `SCHEMA_DRAFT_NOT_FOUND` · `BATCH_NOT_FOUND` · `JOB_NOT_FOUND` · `INGEST_JOB_NOT_FOUND` · `BACKGROUND_JOB_NOT_FOUND` · `ASSET_NOT_FOUND` · `SOURCE_NOT_FOUND` · `DATASET_NOT_FOUND` · `ANNOTATION_NOT_FOUND` · `RELEASE_NOT_FOUND` · `TOKEN_NOT_FOUND` · `INFERENCE_CONNECTION_NOT_FOUND` · `ASSET_NOT_IN_JOB` · `ASSET_NOT_IN_DATASET` · `NO_SPLIT_RECIPE` · `EXPORT_FORMAT_NOT_FOUND` · `EXPORT_TARGET_NOT_FOUND` · `THUMBNAIL_NOT_CACHED` · `NOT_FOUND` (no such route) | | **405** | `METHOD_NOT_ALLOWED` | -| **409** | `PROJECT_NAME_TAKEN` · `RELEASE_TAG_TAKEN` · `TOKEN_NAME_TAKEN` · `INFERENCE_CONNECTION_NAME_TAKEN` · `WORKSPACE_ALREADY_EXISTS` · `WORKSPACE_NOT_EMPTY` · `SCHEMA_VERSION_CONFLICT` · `INVALID_TRANSITION` · `STALE_WRITE` · `BATCH_NOT_EDITABLE` · `BATCH_IMMUTABLE` · `BATCH_NOT_IN_ANNOTATION` · `ASSET_NOT_WRITABLE` · `JOB_FINISHED` · `BATCH_NOT_COMPLETE` · `JOB_NOT_COMPLETE` · `EMPTY_BATCH` · `EMPTY_RELEASE` · `RELEASE_CONTENT_WOULD_VIOLATE_SCHEMA` · `CONFIRMATION_REQUIRED` · `DESTRUCTIVE_SCHEMA_CHANGE` · `SCHEMA_CHANGE_WOULD_ORPHAN` · `SCHEMA_HAS_NO_DETECTABLE_CLASS` · `UNSERIALIZABLE_MANIFEST` · `LOSSY_EXPORT_NOT_CONSENTED` · `EXPORT_SOURCE_UNREADABLE` · `INFERENCE_CONNECTION_NOT_DOWNLOADABLE` · `INFERENCE_CONNECTION_NOT_CHECKABLE` · `INFERENCE_CONNECTION_NOT_TESTABLE` · `INFERENCE_CONNECTION_MODEL_FIXED` · `WEIGHTS_DAMAGED` · `INFERENCE_CONNECTION_NOT_SET_UP` | +| **409** | `PROJECT_NAME_TAKEN` · `RELEASE_TAG_TAKEN` · `TOKEN_NAME_TAKEN` · `INFERENCE_CONNECTION_NAME_TAKEN` · `WORKSPACE_ALREADY_EXISTS` · `WORKSPACE_NOT_EMPTY` · `SCHEMA_VERSION_CONFLICT` · `INVALID_TRANSITION` · `STALE_WRITE` · `BATCH_NOT_EDITABLE` · `BATCH_IMMUTABLE` · `BATCH_NOT_IN_ANNOTATION` · `ASSET_NOT_WRITABLE` · `JOB_FINISHED` · `BATCH_NOT_COMPLETE` · `JOB_NOT_COMPLETE` · `EMPTY_BATCH` · `EMPTY_RELEASE` · `RELEASE_CONTENT_WOULD_VIOLATE_SCHEMA` · `CONFIRMATION_REQUIRED` · `DESTRUCTIVE_SCHEMA_CHANGE` · `SCHEMA_CHANGE_WOULD_ORPHAN` · `SCHEMA_HAS_NO_DETECTABLE_CLASS` · `UNSERIALIZABLE_MANIFEST` · `LOSSY_EXPORT_NOT_CONSENTED` · `EXPORT_SOURCE_UNREADABLE` · `INFERENCE_CONNECTION_NOT_DOWNLOADABLE` · `INFERENCE_CONNECTION_NOT_CHECKABLE` · `INFERENCE_CONNECTION_NOT_TESTABLE` · `INFERENCE_CONNECTION_MODEL_FIXED` · `WEIGHTS_DAMAGED` · `INFERENCE_CONNECTION_NOT_SET_UP` · `AUGMENTATION_REQUIRES_SPLIT` · `PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY` | | **422** | `VALIDATION_ERROR` · `ASSET_NOT_IN_BATCH` · `ANNOTATION_NOT_FROM_MODEL` · `INVALID_NAME` · `INFERENCE_CONNECTION_INVALID` · `INVALID_SCHEMA` · `UNSUPPORTED_GEOMETRY` · `INVALID_ANNOTATION` · `LABEL_CLASS_NOT_IN_SCHEMA` · `DISALLOWED_GEOMETRY` · `ANNOTATION_GEOMETRY_OUT_OF_BOUNDS` · `DUPLICATE_CLASSIFICATION_TAG` · `MISSING_REQUIRED_ATTRIBUTE` · `UNKNOWN_ATTRIBUTE` · `INVALID_ATTRIBUTE_VALUE` · `INVALID_PARTITION` · `UNKNOWN_JOB_TYPE` · `MEDIA_ERROR` · `UNSUPPORTED_MEDIA` · `CORRUPT_MEDIA` · `UNSUPPORTED_PROMPT` · `PROMPT_POINT_OUT_OF_BOUNDS` · `GEOMETRY_NOT_PRODUCED` | | **502** | `INFERENCE_ENDPOINT_UNAVAILABLE` | | **503** | `WORKSPACE_BUSY` | diff --git a/src/visionset/kernel/__init__.py b/src/visionset/kernel/__init__.py index b0690caf..8fe86949 100644 --- a/src/visionset/kernel/__init__.py +++ b/src/visionset/kernel/__init__.py @@ -15,6 +15,7 @@ AssetNotInDataset, AssetNotInJob, AssetNotWritable, + AugmentationRequiresSplit, BackgroundJobNotFound, BatchImmutable, BatchNotComplete, @@ -67,6 +68,7 @@ MissingRequiredAttribute, NoSplitRecipe, NotAWorkspace, + PreprocessingStepUnsupportedGeometry, ProjectNameTaken, ProjectNotFound, PromptPointOutOfBounds, @@ -108,6 +110,7 @@ "AssetNotInDataset", "AssetNotInJob", "AssetNotWritable", + "AugmentationRequiresSplit", "BackgroundJobNotFound", "BatchImmutable", "BatchNotComplete", @@ -149,6 +152,7 @@ "MissingRequiredAttribute", "NoSplitRecipe", "NotAWorkspace", + "PreprocessingStepUnsupportedGeometry", "ProjectNameTaken", "ProjectNotFound", "PromptPointOutOfBounds", diff --git a/src/visionset/kernel/domain/__init__.py b/src/visionset/kernel/domain/__init__.py index a6d89829..2a85a804 100644 --- a/src/visionset/kernel/domain/__init__.py +++ b/src/visionset/kernel/domain/__init__.py @@ -170,6 +170,28 @@ TextPrompt, require_points_on_asset, ) +from visionset.kernel.domain.preprocessing import ( + AugmentOp, + AugmentStep, + PreprocessingRecipe, + RecipeSpec, + ResizeStep, + Step, + brightness_contrast_factors, + hflip_applied, + recipe_hash, + rot90_quarter_turns, + variant_seed, +) +from visionset.kernel.domain.preprocessing_transform import ( + Fold, + LetterboxFit, + TransformedAnnotation, + TransformedFile, + TransformedView, + letterbox_fit, + transform_manifest, +) from visionset.kernel.domain.project import Project, ProjectPreview, ProjectStats from visionset.kernel.domain.release import ( MANIFEST_VERSION, @@ -393,6 +415,24 @@ "TargetFamily", "Task", "TARGET_NAME_PATTERN", + "AugmentOp", + "AugmentStep", + "Fold", + "LetterboxFit", + "PreprocessingRecipe", + "RecipeSpec", + "ResizeStep", + "Step", + "TransformedAnnotation", + "TransformedFile", + "TransformedView", + "brightness_contrast_factors", + "hflip_applied", + "letterbox_fit", + "recipe_hash", + "rot90_quarter_turns", + "transform_manifest", + "variant_seed", "Geometry", "GeometryType", "ImageFormat", diff --git a/src/visionset/kernel/domain/preprocessing.py b/src/visionset/kernel/domain/preprocessing.py new file mode 100644 index 00000000..9d64bb04 --- /dev/null +++ b/src/visionset/kernel/domain/preprocessing.py @@ -0,0 +1,185 @@ +# usage: from visionset.kernel.domain import RecipeSpec, recipe_hash, variant_seed +"""Pre-processing recipes: resize and augmentation, declared and hashed. + +A recipe is a value, not a process: :class:`RecipeSpec` says what an export +does to every image, and :func:`recipe_hash` names that value the way +``canonical_bytes`` names a manifest — so two exports carrying the same spec +carry the same hash whatever order the fields were written in. + +Everything random about a variant is derived, never drawn: :func:`variant_seed` +turns ``(recipe, image, k)`` into a digest, and the three draw functions read +fixed positions of that digest. The geometry transform and the pixel driver +read the same positions, which is what keeps a variant's annotations on its +pixels. Byte stability is promised within one environment only; the geometry +arithmetic here is exact everywhere. +""" + +from __future__ import annotations + +import hashlib +from datetime import datetime +from enum import StrEnum +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from visionset.kernel.domain.export_target import ResizeStrategy +from visionset.kernel.domain.release import canonical_bytes, sha256_hex + + +class ResizeStep(BaseModel): + """Bring every exported image to one size, by one strategy. + + ``pad_value`` is the grey a letterbox pads with — 114 is the value YOLO + trainers letterbox with themselves — and is ignored by ``stretch``, which + has nothing to pad. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + kind: Literal["resize"] = "resize" + strategy: ResizeStrategy + width: int = Field(ge=32, le=8192) + height: int = Field(ge=32, le=8192) + pad_value: int = Field(default=114, ge=0, le=255) + + +class AugmentOp(StrEnum): + """An augmentation a recipe can apply when generating variants.""" + + HFLIP = "hflip" + BRIGHTNESS_CONTRAST = "brightness_contrast" + ROT90 = "rot90" + + +class AugmentStep(BaseModel): + """One augmentation in a recipe. + + ``amount`` bounds the brightness and contrast factors — each is drawn + uniformly from ``[1 - amount, 1 + amount]`` — and means nothing to + ``hflip`` or ``rot90``, whose draws have no magnitude. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + kind: Literal["augment"] = "augment" + op: AugmentOp + amount: float = Field(default=0.2, gt=0, le=0.5) + + +Step = Annotated[ResizeStep | AugmentStep, Field(discriminator="kind")] +"""Every step a recipe can hold, discriminated the way ``Geometry`` is.""" + + +class RecipeSpec(BaseModel): + """The value an export snapshots: the steps, and how many variants they make. + + ``target`` records which export target's hints the recipe was written from + and is informational — nothing resolves it, and a recipe applies to any + export. ``variants_per_asset`` counts *augmented* outputs: variant 0 is the + base image and always exists, variants 1..n exist only when augmentation + does, which is what the cross-field rules below hold in both directions. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + target: str | None + steps: tuple[Step, ...] + variants_per_asset: int = Field(default=0, ge=0, le=8) + + @model_validator(mode="after") + def _steps_and_variants_agree(self) -> RecipeSpec: + resize_positions = [ + index for index, step in enumerate(self.steps) if isinstance(step, ResizeStep) + ] + if len(resize_positions) > 1: + raise ValueError("a recipe holds at most one resize step") + if resize_positions and resize_positions[0] != 0: + raise ValueError("a resize step comes before every augmentation step") + ops = [step.op for step in self.steps if isinstance(step, AugmentStep)] + if len(set(ops)) != len(ops): + raise ValueError("a recipe applies each augmentation at most once") + if ops and self.variants_per_asset < 1: + raise ValueError( + "augmentation steps with variants_per_asset 0 would never run; " + "ask for at least one variant or drop the steps" + ) + if self.variants_per_asset >= 1 and not ops: + raise ValueError( + "variants_per_asset asks for augmented outputs and no augmentation " + "step says what they are; add one or set it to 0" + ) + return self + + +class PreprocessingRecipe(BaseModel): + """A stored, named recipe: project resource around a :class:`RecipeSpec`. + + The spec is what an export snapshots by value; the wrapper is what a + project lists and edits. Editing or deleting one never alters a past + export, because the export kept the spec, not the name. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + id: str + project_id: str + name: str + spec: RecipeSpec + created_at: datetime + updated_at: datetime + + +def recipe_hash(spec: RecipeSpec) -> str: + """The one name of a spec's content, over its canonical bytes. + + The same encoder and digest the manifest uses, so key order, construction + path and whitespace cannot make two equal specs hash apart. + """ + return sha256_hex(canonical_bytes(spec)) + + +def variant_seed(recipe_hash: str, content_hash: str, k: int) -> bytes: + """The digest every draw for variant ``k`` of one image reads from. + + ``sha256(f"{recipe_hash}:{content_hash}:{k}")`` — the recipe, the image and + the variant index, nothing else, so the same recipe over the same bytes + draws the same variant on any machine. Variant 0 is the base image and + never draws; variants are ``1..n``. + """ + return hashlib.sha256(f"{recipe_hash}:{content_hash}:{k}".encode()).digest() + + +def hflip_applied(seed: bytes) -> bool: + """Whether this variant mirrors, read off bit 0 of the seed.""" + return bool(seed[0] & 1) + + +def brightness_contrast_factors(seed: bytes, amount: float) -> tuple[float, float]: + """This variant's brightness and contrast factors, in ``[1 - amount, 1 + amount]``. + + Brightness reads word 1 of the seed and contrast word 2 — fixed positions, + whatever other steps the recipe holds, so adding a step never re-rolls the + others. + """ + return ( + 1.0 - amount + 2.0 * amount * _fraction(seed, 1), + 1.0 - amount + 2.0 * amount * _fraction(seed, 2), + ) + + +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. + """ + return 1 + _word(seed, 3) % 3 + + +def _word(seed: bytes, index: int) -> int: + return int.from_bytes(seed[4 * index : 4 * index + 4], "big") + + +def _fraction(seed: bytes, index: int) -> float: + return _word(seed, index) / 0xFFFFFFFF diff --git a/src/visionset/kernel/domain/preprocessing_transform.py b/src/visionset/kernel/domain/preprocessing_transform.py new file mode 100644 index 00000000..59dbcad2 --- /dev/null +++ b/src/visionset/kernel/domain/preprocessing_transform.py @@ -0,0 +1,398 @@ +# usage: from visionset.kernel.domain import transform_manifest, letterbox_fit +"""What a recipe does to a manifest's geometry, worked out without a pixel. + +The kernel owns every coordinate an export writes: the pixel driver moves +bytes, this module moves annotations, and the two agree because both read the +same arithmetic — :func:`letterbox_fit` is the single spelling of where +letterboxed content lands, and the per-variant draws come from +:mod:`visionset.kernel.domain.preprocessing`. :func:`transform_manifest` +produces a :class:`TransformedView`: one entry per file the export will write, +with the transformed size, the transformed labels, and where the bytes come +from. It is a view over the manifest, never a manifest — the manifest's shape +is a hash-pinned contract and this document is derived and transient. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from visionset.kernel.domain.annotation import Provenance +from visionset.kernel.domain.export_target import ResizeStrategy +from visionset.kernel.domain.geometry import ( + BboxGeometry, + ClassificationGeometry, + Geometry, + PolygonGeometry, + PolylineGeometry, +) +from visionset.kernel.domain.preprocessing import ( + AugmentOp, + AugmentStep, + RecipeSpec, + ResizeStep, + hflip_applied, + recipe_hash, + rot90_quarter_turns, + variant_seed, +) +from visionset.kernel.domain.release import ( + Manifest, + ManifestAnnotation, + ManifestAsset, + SplitAssignment, +) +from visionset.kernel.domain.schema import AttributeValue +from visionset.kernel.errors import ( + AugmentationRequiresSplit, + ExportSourceUnreadable, + PreprocessingStepUnsupportedGeometry, +) + +Fold = Literal["train", "val", "test"] + + +class LetterboxFit(BaseModel): + """Where letterboxed content lands on the padded canvas. + + The reference arithmetic for both sides of the export seam: the geometry + transform reads it to place annotations and the pixel driver reads the + same values to place pixels, so neither can be half a pixel off the other. + ``scale`` is ``min(W'/W, H'/H)``; the content is ``round(W·scale)`` by + ``round(H·scale)``; each offset is the integer-floored half of the margin. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + scale: float = Field(gt=0.0) + content_width: int = Field(ge=0) + content_height: int = Field(ge=0) + offset_x: int = Field(ge=0) + offset_y: int = Field(ge=0) + + +def letterbox_fit( + width: int, height: int, *, target_width: int, target_height: int +) -> LetterboxFit: + """Fit ``width × height`` content onto a ``target`` canvas, aspect kept.""" + scale = min(target_width / width, target_height / height) + content_width = round(width * scale) + content_height = round(height * scale) + return LetterboxFit( + scale=scale, + content_width=content_width, + content_height=content_height, + offset_x=(target_width - content_width) // 2, + offset_y=(target_height - content_height) // 2, + ) + + +class TransformedAnnotation(BaseModel): + """One label as an export writes it, placed on the transformed image. + + A ``ManifestAnnotation`` with its geometry moved and its identity widened: + variant 0 keeps the annotation's own id, and variant ``k`` carries + ``"{id}-aug{k}"``, so every label in the output traces to the manifest + annotation it came from. Class, attributes, provenance, model reference + and confidence are copied as they stood. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + id: str + label_class: str + schema_version: int = Field(ge=1) + geometry: Geometry + attributes: dict[str, AttributeValue] = Field(default_factory=dict) + provenance: Provenance + model_ref: str | None = None + confidence: float | None = Field(default=None, ge=0.0, le=1.0) + + +class TransformedFile(BaseModel): + """One image an export will write, and everything to write on and about it. + + ``content_hash`` stays the *source* image's: the transformed bytes do not + exist yet, and this row is the instruction the pixel driver produces them + from. ``variant`` 0 is the base image; augmented variants count from 1. + ``width`` and ``height`` are the transformed size — ``None`` only where + the manifest never recorded a size and no resize step decides one. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + asset_id: UUID + content_hash: str + variant: int = Field(ge=0) + fold: Fold | None + width: int | None + height: int | None + annotations: tuple[TransformedAnnotation, ...] = () + + +class TransformedView(BaseModel): + """What one recipe does to one manifest, per file the export will write. + + Files keep the manifest's canonical asset order, each asset's base image + first and its variants after it in ascending ``variant`` — so the view is + as deterministic as the manifest it was derived from. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + files: tuple[TransformedFile, ...] = () + + @property + def source_file_count(self) -> int: + """How many base images the export writes — one per manifest asset.""" + return sum(1 for file in self.files if file.variant == 0) + + @property + def augmented_file_count(self) -> int: + """How many augmented variants the export writes, across all assets.""" + return sum(1 for file in self.files if file.variant > 0) + + +def transform_manifest( + manifest: Manifest, spec: RecipeSpec, folds: SplitAssignment | None +) -> TransformedView: + """Every file one recipe makes of one manifest, geometry included. + + Augmented variants are generated for ``train``-fold assets only — a model + must never validate on a variant of an image it trained on — so a spec + that augments requires ``folds``. Base images are emitted for every asset + whatever its fold, resized when the spec says so. + + Raises: + AugmentationRequiresSplit: the spec asks for variants and ``folds`` is + ``None`` — the release was published without a split recipe. + PreprocessingStepUnsupportedGeometry: a step met a geometry it cannot + transform; today that is ``rot90`` over a polyline, whose point + order carries meaning relative to the frame's axes. + ExportSourceUnreadable: a step needs the source image's dimensions and + the manifest never recorded them. + """ + if spec.variants_per_asset >= 1 and folds is None: + raise AugmentationRequiresSplit( + f"the recipe asks for {spec.variants_per_asset} augmented variant(s) per asset " + "and this release has no split recipe, so there is no train fold to augment; " + "publish a release with a split recipe, or export with a recipe that does not " + "augment" + ) + fold_of = _fold_of(folds) + spec_hash = recipe_hash(spec) + resize = next((step for step in spec.steps if isinstance(step, ResizeStep)), None) + augments = tuple(step for step in spec.steps if isinstance(step, AugmentStep)) + files: list[TransformedFile] = [] + for asset in manifest.assets: + fold = fold_of.get(asset.asset_id) + files.append(_base_file(asset, resize, fold)) + if fold != "train": + continue + for k in range(1, spec.variants_per_asset + 1): + seed = variant_seed(spec_hash, asset.content_hash, k) + files.append(_variant_file(asset, resize, augments, variant=k, seed=seed)) + return TransformedView(files=tuple(files)) + + +def _fold_of(folds: SplitAssignment | None) -> dict[UUID, Fold]: + if folds is None: + return {} + by_asset: dict[UUID, Fold] = {} + for asset_id in folds.train: + by_asset[asset_id] = "train" + for asset_id in folds.val: + by_asset[asset_id] = "val" + for asset_id in folds.test: + by_asset[asset_id] = "test" + return by_asset + + +def _base_file( + asset: ManifestAsset, resize: ResizeStep | None, fold: Fold | None +) -> TransformedFile: + geometries = [annotation.geometry for annotation in asset.annotations] + width, height = asset.width, asset.height + if resize is not None: + geometries, width, height = _resized(asset, resize, geometries) + return TransformedFile( + asset_id=asset.asset_id, + content_hash=asset.content_hash, + variant=0, + fold=fold, + width=width, + height=height, + annotations=tuple( + _copied(annotation, geometry, str(annotation.id)) + for annotation, geometry in zip(asset.annotations, geometries, strict=True) + ), + ) + + +def _variant_file( + asset: ManifestAsset, + resize: ResizeStep | None, + augments: tuple[AugmentStep, ...], + *, + variant: int, + seed: bytes, +) -> TransformedFile: + geometries = [annotation.geometry for annotation in asset.annotations] + width, height = asset.width, asset.height + if resize is not None: + geometries, width, height = _resized(asset, resize, geometries) + for step in augments: + if step.op is AugmentOp.HFLIP: + if hflip_applied(seed) and _any_coordinates(geometries): + mirror_width = float(_known_width(width, asset, step.op.value)) + geometries = [_mirrored(geometry, mirror_width) for geometry in geometries] + elif step.op is AugmentOp.ROT90: + _refuse_polylines(asset, geometries) + for _ in range(rot90_quarter_turns(seed)): + if _any_coordinates(geometries): + turn_width = float(_known_width(width, asset, step.op.value)) + geometries = [_rotated_once(geometry, turn_width) for geometry in geometries] + width, height = height, width + return TransformedFile( + asset_id=asset.asset_id, + content_hash=asset.content_hash, + variant=variant, + fold="train", + width=width, + height=height, + annotations=tuple( + _copied(annotation, geometry, f"{annotation.id}-aug{variant}") + for annotation, geometry in zip(asset.annotations, geometries, strict=True) + ), + ) + + +def _resized( + asset: ManifestAsset, step: ResizeStep, geometries: list[Geometry] +) -> tuple[list[Geometry], int, int]: + if _any_coordinates(geometries): + source_width, source_height = _dimensions(asset, step.kind) + if step.strategy is ResizeStrategy.STRETCH: + scale_x = step.width / source_width + scale_y = step.height / source_height + offset_x = offset_y = 0.0 + else: + fit = letterbox_fit( + source_width, + source_height, + target_width=step.width, + target_height=step.height, + ) + scale_x = scale_y = fit.scale + offset_x, offset_y = float(fit.offset_x), float(fit.offset_y) + geometries = [ + _scaled(geometry, scale_x, scale_y, offset_x, offset_y) for geometry in geometries + ] + return geometries, step.width, step.height + + +def _copied( + annotation: ManifestAnnotation, geometry: Geometry, identifier: str +) -> TransformedAnnotation: + return TransformedAnnotation( + id=identifier, + label_class=annotation.label_class, + schema_version=annotation.schema_version, + geometry=geometry, + attributes=dict(annotation.attributes), + provenance=annotation.provenance, + model_ref=annotation.model_ref, + confidence=annotation.confidence, + ) + + +def _refuse_polylines(asset: ManifestAsset, geometries: Sequence[Geometry]) -> None: + if any(isinstance(geometry, PolylineGeometry) for geometry in geometries): + raise PreprocessingStepUnsupportedGeometry( + f"the 'rot90' step cannot transform a polyline (asset {asset.asset_id} carries " + "one): the path's point order carries meaning relative to the frame's axes, and " + "a quarter turn re-axes the frame under it. Remove the step, or export a release " + "without polylines", + step=AugmentOp.ROT90.value, + geometry="polyline", + asset_id=str(asset.asset_id), + ) + + +def _dimensions(asset: ManifestAsset, step: str) -> tuple[int, int]: + if asset.width is None or asset.height is None: + raise ExportSourceUnreadable( + f"asset {asset.asset_id} (content {asset.content_hash}) records no pixel " + f"dimensions, and the {step!r} step cannot place its annotations without them; " + "re-ingest the asset and publish a new release" + ) + return asset.width, asset.height + + +def _known_width(width: int | None, asset: ManifestAsset, step: str) -> int: + if width is None: + return _dimensions(asset, step)[0] + return width + + +def _any_coordinates(geometries: Sequence[Geometry]) -> bool: + return any(not isinstance(geometry, ClassificationGeometry) for geometry in geometries) + + +def _scaled( + geometry: Geometry, scale_x: float, scale_y: float, offset_x: float, offset_y: float +) -> Geometry: + if isinstance(geometry, ClassificationGeometry): + return geometry + if isinstance(geometry, BboxGeometry): + return BboxGeometry( + x=geometry.x * scale_x + offset_x, + y=geometry.y * scale_y + offset_y, + width=geometry.width * scale_x, + height=geometry.height * scale_y, + ) + points = [(x * scale_x + offset_x, y * scale_y + offset_y) for x, y in geometry.points] + if isinstance(geometry, PolygonGeometry): + return PolygonGeometry(points=points) + return PolylineGeometry(points=points) + + +def _mirrored(geometry: Geometry, width: float) -> Geometry: + if isinstance(geometry, ClassificationGeometry): + return geometry + if isinstance(geometry, BboxGeometry): + return BboxGeometry( + x=width - geometry.x - geometry.width, + y=geometry.y, + width=geometry.width, + height=geometry.height, + ) + points = [(width - x, y) for x, y in geometry.points] + if isinstance(geometry, PolygonGeometry): + return PolygonGeometry(points=points) + return PolylineGeometry(points=points) + + +def _rotated_once(geometry: Geometry, width: float) -> Geometry: + """One counter-clockwise quarter turn: ``(x, y)`` lands at ``(y, W - x)``. + + Counter-clockwise because that is what the pixel side's ``ROTATE_90`` + means, and the two must turn the same way. A box's corners rotate and the + result is rebuilt axis-aligned, which is exact at a quarter turn. + """ + if isinstance(geometry, ClassificationGeometry): + return geometry + if isinstance(geometry, BboxGeometry): + return BboxGeometry( + x=geometry.y, + y=width - geometry.x - geometry.width, + width=geometry.height, + height=geometry.width, + ) + points = [(y, width - x) for x, y in geometry.points] + if isinstance(geometry, PolygonGeometry): + return PolygonGeometry(points=points) + return PolylineGeometry(points=points) diff --git a/src/visionset/kernel/domain/release.py b/src/visionset/kernel/domain/release.py index 71786cc3..44cc0edf 100644 --- a/src/visionset/kernel/domain/release.py +++ b/src/visionset/kernel/domain/release.py @@ -180,8 +180,13 @@ def annotation_count(self) -> int: return sum(len(asset.annotations) for asset in self.assets) -def canonical_bytes(manifest: Manifest) -> bytes: - """The one serialization of a manifest, and the bytes its hash is over. +def canonical_bytes(model: BaseModel) -> bytes: + """The one serialization of a hashed document, and the bytes its hash is over. + + Written for the :class:`Manifest` and shared by everything else whose hash + is a contract — the pre-processing recipe hash is computed over exactly + this encoding, because two spellings of "canonical" would eventually + disagree. ``sort_keys`` recurses, so ``attributes`` — the only dict in the document — is ordered by this too rather than by whatever order it was written in. @@ -198,7 +203,7 @@ def canonical_bytes(manifest: Manifest) -> bytes: Raises: UnserializableManifest: a number in the document is NaN or infinite. """ - document = manifest.model_dump(mode="json") + document = model.model_dump(mode="json") try: text = json.dumps( document, diff --git a/src/visionset/kernel/errors.py b/src/visionset/kernel/errors.py index 44c25887..75526ffb 100644 --- a/src/visionset/kernel/errors.py +++ b/src/visionset/kernel/errors.py @@ -1290,3 +1290,58 @@ class GeometryNotProduced(VisionSetError): to change and no wait helps, so the remedy is a different selection, or no selection, which means every shape the model produces. """ + + +class AugmentationRequiresSplit(VisionSetError): + """An augmenting recipe met a release that was published without a split recipe. + + Augmented variants are written for the train fold only — a model must + never validate on a variant of an image it trained on — and a release + without a split recipe has no folds, so "the train fold" names nothing. + Raised at pre-flight and again at export, so a consent dialog and a job + runner give the same answer. The remedy is a release published with a + split recipe, or a recipe that does not augment. + """ + + +class PreprocessingStepUnsupportedGeometry(VisionSetError): + """A recipe step met an annotation geometry it cannot transform. + + A refusal, never a consent: pre-processing moves images and their labels + together, so an export that dropped or guessed a label it could not move + would ship a training set silently disagreeing with its own images — + which is exactly what the lossy-consent path exists to prevent, one stage + earlier. Today's one case is ``rot90`` over a polyline, whose point order + carries meaning relative to the frame's axes. + """ + + step: str | None = None + """Which step refused — a step kind or an augmentation op name.""" + + geometry: str | None = None + """Which geometry it could not transform, as the ``GeometryType`` value.""" + + asset_id: str | None = None + """The first manifest asset carrying the geometry, for the report. + + All three are class attributes with ``None`` defaults and **not** + constructor parameters, exactly as ``LossyExportNotConsented.compatibility`` + is — so this error stays constructible from one message. + ``transform_manifest`` sets them as keywords. + """ + + def __init__( + self, + message: str, + *, + step: str | None = None, + geometry: str | None = None, + asset_id: str | None = None, + ) -> None: + super().__init__(message) + if step is not None: + self.step = step + if geometry is not None: + self.geometry = geometry + if asset_id is not None: + self.asset_id = asset_id diff --git a/src/visionset/kernel/ports/__init__.py b/src/visionset/kernel/ports/__init__.py index 1e2ae01f..4df53d64 100644 --- a/src/visionset/kernel/ports/__init__.py +++ b/src/visionset/kernel/ports/__init__.py @@ -29,6 +29,7 @@ ) from visionset.kernel.ports.model_provider import ModelProvider from visionset.kernel.ports.point_segmenter import PointSegmenter +from visionset.kernel.ports.preprocessing import PreprocessingDriver from visionset.kernel.ports.progress_reporter import ProgressReporter from visionset.kernel.ports.provider import Provider, Runner, WeightsSource from visionset.kernel.ports.video_processor import ( @@ -54,6 +55,7 @@ "MetadataStore", "ModelProvider", "PointSegmenter", + "PreprocessingDriver", "ProgressReporter", "Provider", "Repository", diff --git a/src/visionset/kernel/ports/preprocessing.py b/src/visionset/kernel/ports/preprocessing.py new file mode 100644 index 00000000..3d8cfc42 --- /dev/null +++ b/src/visionset/kernel/ports/preprocessing.py @@ -0,0 +1,28 @@ +from typing import Protocol, runtime_checkable + +from visionset.kernel.domain import Step + + +@runtime_checkable +class PreprocessingDriver(Protocol): + """A pixel engine for one kind of recipe step. + + Drivers do pixels only: every coordinate an export writes comes from the + kernel's own geometry transform, so a driver that moved annotations would + be a second spelling of arithmetic the kernel already owns. ``step_kinds`` + says which step kinds this driver applies — ``{"resize"}`` or + ``{"augment"}`` — and the kernel takes driver *instances*, never names: + discovery and composition happen outside it, the way exporters arrive. + + ``apply`` turns one image's bytes into the transformed bytes for one step. + ``seed`` is the variant's digest from ``variant_seed`` and ``variant`` its + index; a resize, or any step applied to variant 0, is deterministic and + reads neither. Everything random must be derived from the seed through the + kernel's draw functions — ``hflip_applied``, + ``brightness_contrast_factors``, ``rot90_quarter_turns`` — so the pixels + land where the geometry transform already put the annotations. + """ + + step_kinds: frozenset[str] + + def apply(self, step: Step, image: bytes, *, seed: bytes, variant: int) -> bytes: ... diff --git a/src/visionset/server/errors.py b/src/visionset/server/errors.py index 85a3a3cd..6e59d9ab 100644 --- a/src/visionset/server/errors.py +++ b/src/visionset/server/errors.py @@ -52,6 +52,7 @@ AssetNotInDataset, AssetNotInJob, AssetNotWritable, + AugmentationRequiresSplit, BackgroundJobNotFound, BatchImmutable, BatchNotComplete, @@ -104,6 +105,7 @@ MissingRequiredAttribute, NoSplitRecipe, NotAWorkspace, + PreprocessingStepUnsupportedGeometry, ProjectNameTaken, ProjectNotFound, PromptPointOutOfBounds, @@ -349,6 +351,17 @@ class ErrorRule: # no state change can fix — precisely the pair that proves a client must # branch on the code and never on the status. InferenceConnectionNotSetUp: ErrorRule(409, "INFERENCE_CONNECTION_NOT_SET_UP"), + # An augmenting recipe against a release published without a split recipe. + # Change-the-state-and-resubmit: publish a release with a split and the + # identical export succeeds. No route raises it yet — the recipe routes are + # not built — mapped for BATCH_IMMUTABLE's reason. + AugmentationRequiresSplit: ErrorRule(409, "AUGMENTATION_REQUIRES_SPLIT"), + # A recipe step meeting a geometry this release carries and the step cannot + # move. LOSSY_EXPORT_NOT_CONSENTED's reading — a well-formed request refused + # by the release's content — without the consent flag, because a label that + # cannot follow its image is never something to consent to. No route raises + # it yet; mapped for BATCH_IMMUTABLE's reason. + PreprocessingStepUnsupportedGeometry: ErrorRule(409, "PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY"), # --- 422: the payload itself is wrong ---------------------------------- InvalidName: ErrorRule(422, "INVALID_NAME"), InferenceConnectionInvalid: ErrorRule(422, "INFERENCE_CONNECTION_INVALID"), diff --git a/tests/kernel/test_preprocessing.py b/tests/kernel/test_preprocessing.py new file mode 100644 index 00000000..80dd7442 --- /dev/null +++ b/tests/kernel/test_preprocessing.py @@ -0,0 +1,196 @@ +"""The recipe domain: what a spec may say, how it is named, and how variants draw.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from pydantic import ValidationError + +from visionset.kernel.domain import ( + AugmentOp, + AugmentStep, + PreprocessingRecipe, + RecipeSpec, + ResizeStep, + ResizeStrategy, + brightness_contrast_factors, + hflip_applied, + recipe_hash, + rot90_quarter_turns, + variant_seed, +) + +RESIZE = ResizeStep(strategy=ResizeStrategy.LETTERBOX, width=640, height=640) +HFLIP = AugmentStep(op=AugmentOp.HFLIP) +ROT90 = AugmentStep(op=AugmentOp.ROT90) + + +def test_a_spec_with_a_resize_then_augmentations_is_accepted() -> None: + spec = RecipeSpec(target="yolo11", steps=(RESIZE, HFLIP, ROT90), variants_per_asset=2) + assert [step.kind for step in spec.steps] == ["resize", "augment", "augment"] + + +def test_a_spec_may_hold_no_steps_at_all() -> None: + assert RecipeSpec(target=None, steps=()).variants_per_asset == 0 + + +def test_a_resize_step_must_come_first() -> None: + with pytest.raises(ValidationError, match="comes before"): + RecipeSpec(target=None, steps=(HFLIP, RESIZE), variants_per_asset=1) + + +def test_at_most_one_resize_step() -> None: + with pytest.raises(ValidationError, match="at most one resize"): + RecipeSpec(target=None, steps=(RESIZE, RESIZE)) + + +def test_augmentation_steps_require_at_least_one_variant() -> None: + with pytest.raises(ValidationError, match="variants_per_asset 0"): + RecipeSpec(target=None, steps=(HFLIP,), variants_per_asset=0) + + +def test_variants_require_at_least_one_augmentation_step() -> None: + with pytest.raises(ValidationError, match="no augmentation step"): + RecipeSpec(target=None, steps=(RESIZE,), variants_per_asset=1) + + +def test_an_augmentation_is_applied_at_most_once() -> None: + with pytest.raises(ValidationError, match="at most once"): + RecipeSpec(target=None, steps=(HFLIP, HFLIP), variants_per_asset=1) + + +@pytest.mark.parametrize("field", ["width", "height"]) +@pytest.mark.parametrize("value", [31, 8193]) +def test_a_resize_size_stays_within_bounds(field: str, value: int) -> None: + with pytest.raises(ValidationError): + ResizeStep.model_validate({"strategy": "stretch", "width": 64, "height": 64, field: value}) + + +@pytest.mark.parametrize("value", [-1, 256]) +def test_a_pad_value_is_one_byte(value: int) -> None: + with pytest.raises(ValidationError): + ResizeStep(strategy=ResizeStrategy.LETTERBOX, width=64, height=64, pad_value=value) + + +@pytest.mark.parametrize("value", [0.0, 0.51]) +def test_an_amount_is_strictly_positive_and_at_most_a_half(value: float) -> None: + with pytest.raises(ValidationError): + AugmentStep(op=AugmentOp.BRIGHTNESS_CONTRAST, amount=value) + + +def test_a_step_refuses_a_field_it_does_not_declare() -> None: + with pytest.raises(ValidationError): + AugmentStep.model_validate({"op": "hflip", "probability": 0.5}) + + +def test_a_spec_discriminates_steps_on_kind() -> None: + spec = RecipeSpec.model_validate( + { + "target": None, + "steps": [ + {"kind": "resize", "strategy": "stretch", "width": 320, "height": 240}, + {"kind": "augment", "op": "rot90"}, + ], + "variants_per_asset": 1, + } + ) + assert isinstance(spec.steps[0], ResizeStep) + assert isinstance(spec.steps[1], AugmentStep) + + +def test_a_recipe_wraps_a_spec_with_its_identity() -> None: + now = datetime.now(UTC) + recipe = PreprocessingRecipe( + id="r1", + project_id="p1", + name="yolo-640", + spec=RecipeSpec(target="yolo11", steps=(RESIZE,)), + created_at=now, + updated_at=now, + ) + assert recipe.spec.steps == (RESIZE,) + + +# --- the hash --------------------------------------------------------------- + + +def test_the_hash_does_not_depend_on_key_order() -> None: + forward = RecipeSpec.model_validate( + { + "target": "yolo11", + "steps": [ + {"kind": "resize", "strategy": "letterbox", "width": 640, "height": 640}, + {"kind": "augment", "op": "hflip"}, + ], + "variants_per_asset": 2, + } + ) + backward = RecipeSpec.model_validate( + { + "variants_per_asset": 2, + "steps": [ + {"height": 640, "width": 640, "strategy": "letterbox", "kind": "resize"}, + {"op": "hflip", "kind": "augment"}, + ], + "target": "yolo11", + } + ) + assert recipe_hash(forward) == recipe_hash(backward) + + +def test_the_hash_does_not_depend_on_whether_a_default_was_spelled_out() -> None: + spelled = RecipeSpec( + target=None, steps=(AugmentStep(op=AugmentOp.HFLIP, amount=0.2),), variants_per_asset=1 + ) + implied = RecipeSpec(target=None, steps=(HFLIP,), variants_per_asset=1) + assert recipe_hash(spelled) == recipe_hash(implied) + + +def test_the_hash_is_a_sha256_hex_digest_that_moves_with_the_content() -> None: + one = recipe_hash(RecipeSpec(target=None, steps=(RESIZE,))) + other = recipe_hash( + RecipeSpec(target=None, steps=(RESIZE.model_copy(update={"pad_value": 0}),)) + ) + assert len(one) == 64 and int(one, 16) >= 0 + assert one != other + + +# --- the draws -------------------------------------------------------------- + + +def test_a_variant_seed_is_deterministic_and_distinct_per_variant_and_image() -> None: + seed = variant_seed("recipe", "image", 1) + assert seed == variant_seed("recipe", "image", 1) + assert len(seed) == 32 + assert seed != variant_seed("recipe", "image", 2) + assert seed != variant_seed("recipe", "other", 1) + assert seed != variant_seed("other", "image", 1) + + +def test_hflip_reads_bit_zero_of_the_seed() -> None: + assert hflip_applied(bytes([0x01]) + bytes(31)) is True + assert hflip_applied(bytes([0xFE]) + bytes(31)) is False + + +def test_brightness_and_contrast_read_words_one_and_two() -> None: + lowest = bytes(4) + bytes(4) + bytes(4) + bytes(20) + highest = bytes(4) + bytes([0xFF] * 4) + bytes([0xFF] * 4) + bytes(20) + assert brightness_contrast_factors(lowest, 0.2) == pytest.approx((0.8, 0.8)) + assert brightness_contrast_factors(highest, 0.2) == pytest.approx((1.2, 1.2)) + mixed = bytes(4) + bytes(4) + bytes([0xFF] * 4) + bytes(20) + assert brightness_contrast_factors(mixed, 0.5) == pytest.approx((0.5, 1.5)) + + +def test_brightness_and_contrast_stay_within_the_amount() -> None: + for k in range(1, 50): + brightness, contrast = brightness_contrast_factors(variant_seed("r", "c", k), 0.3) + assert 0.7 <= brightness <= 1.3 + assert 0.7 <= contrast <= 1.3 + + +def test_rot90_reads_word_three_and_never_draws_zero_turns() -> None: + for remainder in range(3): + 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} diff --git a/tests/kernel/test_preprocessing_transform.py b/tests/kernel/test_preprocessing_transform.py new file mode 100644 index 00000000..2c372973 --- /dev/null +++ b/tests/kernel/test_preprocessing_transform.py @@ -0,0 +1,388 @@ +"""The geometry side of a recipe: every step over every geometry, and the gating. + +Expected coordinates are worked by hand from the source frame rather than +through the module's own arithmetic, so a wrong sign in the rotation or a +swapped offset in the letterbox fails here instead of agreeing with itself. +""" + +from __future__ import annotations + +from uuid import UUID, uuid4 + +import pytest + +from visionset.kernel.domain import ( + AugmentOp, + AugmentStep, + BboxGeometry, + ClassificationGeometry, + Geometry, + Manifest, + ManifestAnnotation, + ManifestAsset, + PolygonGeometry, + PolylineGeometry, + RecipeSpec, + ResizeStep, + ResizeStrategy, + SplitAssignment, + TransformedFile, + hflip_applied, + letterbox_fit, + recipe_hash, + rot90_quarter_turns, + transform_manifest, + variant_seed, +) +from visionset.kernel.errors import ( + AugmentationRequiresSplit, + ExportSourceUnreadable, + PreprocessingStepUnsupportedGeometry, +) + +BBOX = BboxGeometry(x=10.0, y=20.0, width=30.0, height=40.0) +POLYGON = PolygonGeometry(points=[(0.0, 0.0), (50.0, 0.0), (50.0, 100.0)]) +POLYLINE = PolylineGeometry(points=[(5.0, 10.0), (60.0, 150.0)]) +TAG = ClassificationGeometry() +EVERY_GEOMETRY: tuple[Geometry, ...] = (BBOX, POLYGON, POLYLINE, TAG) + + +def _annotation(geometry: Geometry, identifier: UUID | None = None) -> ManifestAnnotation: + return ManifestAnnotation( + id=identifier or uuid4(), + label_class="thing", + schema_version=1, + geometry=geometry, + attributes={"colour": "red"}, + provenance="model", + model_ref="detector:1", + confidence=0.75, + ) + + +def _asset( + *geometries: Geometry, + content_hash: str = "aa", + width: int | None = 100, + height: int | None = 200, +) -> ManifestAsset: + return ManifestAsset( + asset_id=uuid4(), + content_hash=content_hash, + uri=f"/img/{content_hash}.jpg", + width=width, + height=height, + annotations=tuple(_annotation(geometry) for geometry in geometries), + ) + + +def _manifest(*assets: ManifestAsset) -> Manifest: + return Manifest(schema_version=1, assets=assets) + + +def _train(*assets: ManifestAsset) -> SplitAssignment: + return SplitAssignment(train=tuple(asset.asset_id for asset in assets)) + + +def _spec(*steps: ResizeStep | AugmentStep, variants: int = 0) -> RecipeSpec: + return RecipeSpec(target=None, steps=steps, variants_per_asset=variants) + + +def _geometry_of(file: TransformedFile, kind: type[Geometry]) -> Geometry: + (match,) = [one.geometry for one in file.annotations if isinstance(one.geometry, kind)] + return match + + +def _content_hash_where_hflip(spec: RecipeSpec, *, applied: bool) -> str: + """A content hash whose variant-1 seed draws hflip the way the test wants.""" + spec_hash = recipe_hash(spec) + for candidate in range(1000): + content_hash = f"content-{candidate}" + if hflip_applied(variant_seed(spec_hash, content_hash, 1)) is applied: + return content_hash + raise AssertionError("a thousand seeds never drew the wanted bit") + + +# --- letterbox arithmetic --------------------------------------------------- + + +@pytest.mark.parametrize( + ("width", "height", "target", "expected"), + [ + # A 4:3 landscape onto a square: the width already fits, the height pads. + (640, 480, (640, 640), (1.0, 640, 480, 0, 80)), + # Downscale, limited by the wider axis. + (1000, 750, (640, 640), (0.64, 640, 480, 0, 80)), + # Upscale, limited by the taller axis; the margin splits evenly. + (50, 100, (640, 640), (6.4, 320, 640, 160, 0)), + # Odd sizes: the content rounds and the odd margin floors. + (100, 99, (64, 64), (0.64, 64, 63, 0, 0)), + (99, 100, (65, 65), (0.65, 64, 65, 0, 0)), + # A non-square canvas. + (200, 100, (300, 300), (1.5, 300, 150, 0, 75)), + ], +) +def test_letterbox_fit_matches_the_hand_computed_reference( + width: int, + height: int, + target: tuple[int, int], + expected: tuple[float, int, int, int, int], +) -> None: + fit = letterbox_fit(width, height, target_width=target[0], target_height=target[1]) + assert ( + fit.scale, + fit.content_width, + fit.content_height, + fit.offset_x, + fit.offset_y, + ) == pytest.approx(expected) + + +# --- resize × geometry ------------------------------------------------------ + + +def test_stretch_scales_each_axis_on_its_own() -> None: + step = ResizeStep(strategy=ResizeStrategy.STRETCH, width=200, height=100) + (file,) = transform_manifest(_manifest(_asset(*EVERY_GEOMETRY)), _spec(step), None).files + + assert (file.width, file.height) == (200, 100) + assert _geometry_of(file, BboxGeometry) == BboxGeometry(x=20.0, y=10.0, width=60.0, height=20.0) + assert _geometry_of(file, PolygonGeometry) == PolygonGeometry( + points=[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0)] + ) + assert _geometry_of(file, PolylineGeometry) == PolylineGeometry( + points=[(10.0, 5.0), (120.0, 75.0)] + ) + assert _geometry_of(file, ClassificationGeometry) == TAG + + +def test_letterbox_scales_then_offsets() -> None: + # 100×200 onto 400×400: scale 2, content 200×400, offset (100, 0). + step = ResizeStep(strategy=ResizeStrategy.LETTERBOX, width=400, height=400) + (file,) = transform_manifest(_manifest(_asset(*EVERY_GEOMETRY)), _spec(step), None).files + + assert (file.width, file.height) == (400, 400) + assert _geometry_of(file, BboxGeometry) == BboxGeometry( + x=120.0, y=40.0, width=60.0, height=80.0 + ) + assert _geometry_of(file, PolygonGeometry) == PolygonGeometry( + points=[(100.0, 0.0), (200.0, 0.0), (200.0, 200.0)] + ) + assert _geometry_of(file, PolylineGeometry) == PolylineGeometry( + points=[(110.0, 20.0), (220.0, 300.0)] + ) + assert _geometry_of(file, ClassificationGeometry) == TAG + + +def test_a_resize_needs_the_source_size_only_when_something_has_coordinates() -> None: + step = ResizeStep(strategy=ResizeStrategy.STRETCH, width=64, height=64) + (file,) = transform_manifest( + _manifest(_asset(TAG, width=None, height=None)), _spec(step), None + ).files + assert (file.width, file.height) == (64, 64) + + with pytest.raises(ExportSourceUnreadable, match="records no pixel dimensions"): + transform_manifest(_manifest(_asset(BBOX, width=None, height=None)), _spec(step), None) + + +def test_without_a_resize_the_base_file_keeps_the_source_size() -> None: + (file,) = transform_manifest(_manifest(_asset(BBOX)), _spec(), None).files + assert (file.width, file.height) == (100, 200) + assert _geometry_of(file, BboxGeometry) == BBOX + + +# --- augmentation × geometry ---------------------------------------------- + + +def _only_variant(spec: RecipeSpec, *geometries: Geometry, applied: bool = True) -> TransformedFile: + asset = _asset(*geometries, content_hash=_content_hash_where_hflip(spec, applied=applied)) + view = transform_manifest(_manifest(asset), spec, _train(asset)) + (variant,) = [file for file in view.files if file.variant == 1] + return variant + + +def test_hflip_mirrors_in_the_frame_width_and_keeps_polyline_order() -> None: + spec = _spec(AugmentStep(op=AugmentOp.HFLIP), variants=1) + variant = _only_variant(spec, *EVERY_GEOMETRY, applied=True) + + assert (variant.width, variant.height) == (100, 200) + assert _geometry_of(variant, BboxGeometry) == BboxGeometry( + x=60.0, y=20.0, width=30.0, height=40.0 + ) + assert _geometry_of(variant, PolygonGeometry) == PolygonGeometry( + points=[(100.0, 0.0), (50.0, 0.0), (50.0, 100.0)] + ) + assert _geometry_of(variant, PolylineGeometry) == PolylineGeometry( + points=[(95.0, 10.0), (40.0, 150.0)] + ) + assert _geometry_of(variant, ClassificationGeometry) == TAG + + +def test_hflip_not_drawn_leaves_the_variant_unmirrored() -> None: + spec = _spec(AugmentStep(op=AugmentOp.HFLIP), variants=1) + variant = _only_variant(spec, BBOX, POLYLINE, applied=False) + assert _geometry_of(variant, BboxGeometry) == BBOX + assert _geometry_of(variant, PolylineGeometry) == POLYLINE + + +def test_brightness_contrast_changes_no_geometry_and_no_size() -> None: + spec = _spec(AugmentStep(op=AugmentOp.BRIGHTNESS_CONTRAST, amount=0.5), variants=1) + variant = _only_variant(spec, *EVERY_GEOMETRY) + assert (variant.width, variant.height) == (100, 200) + assert len(variant.annotations) == 4 + for geometry in EVERY_GEOMETRY: + assert _geometry_of(variant, type(geometry)) == geometry + + +def test_rot90_rotates_boxes_and_polygons_counter_clockwise() -> None: + spec = _spec(AugmentStep(op=AugmentOp.ROT90), variants=1) + asset = _asset(BBOX, POLYGON, TAG, content_hash="rot") + view = transform_manifest(_manifest(asset), spec, _train(asset)) + (variant,) = [file for file in view.files if file.variant == 1] + turns = rot90_quarter_turns(variant_seed(recipe_hash(spec), "rot", 1)) + + # Hand-derived for a 100×200 frame, one counter-clockwise quarter turn at a + # time: (x, y) → (y, W − x), and the frame becomes H×W. + expected_bbox = { + 1: BboxGeometry(x=20.0, y=60.0, width=40.0, height=30.0), + 2: BboxGeometry(x=60.0, y=140.0, width=30.0, height=40.0), + 3: BboxGeometry(x=140.0, y=10.0, width=40.0, height=30.0), + } + expected_polygon = { + 1: PolygonGeometry(points=[(0.0, 100.0), (0.0, 50.0), (100.0, 50.0)]), + 2: PolygonGeometry(points=[(100.0, 200.0), (50.0, 200.0), (50.0, 100.0)]), + 3: PolygonGeometry(points=[(200.0, 0.0), (200.0, 50.0), (100.0, 50.0)]), + } + expected_size = {1: (200, 100), 2: (100, 200), 3: (200, 100)} + + assert (variant.width, variant.height) == expected_size[turns] + assert _geometry_of(variant, BboxGeometry) == expected_bbox[turns] + assert _geometry_of(variant, PolygonGeometry) == expected_polygon[turns] + assert _geometry_of(variant, ClassificationGeometry) == TAG + + +def test_rot90_refuses_a_polyline_and_names_the_step_geometry_and_asset() -> None: + spec = _spec(AugmentStep(op=AugmentOp.ROT90), variants=1) + asset = _asset(POLYLINE) + with pytest.raises(PreprocessingStepUnsupportedGeometry) as caught: + transform_manifest(_manifest(asset), spec, _train(asset)) + assert caught.value.step == "rot90" + assert caught.value.geometry == "polyline" + assert caught.value.asset_id == str(asset.asset_id) + + +def test_rot90_leaves_a_polyline_outside_the_train_fold_alone() -> None: + spec = _spec(AugmentStep(op=AugmentOp.ROT90), variants=1) + asset = _asset(POLYLINE) + view = transform_manifest(_manifest(asset), spec, SplitAssignment(val=(asset.asset_id,))) + assert [file.variant for file in view.files] == [0] + + +def test_rot90_swaps_the_size_of_a_tag_only_asset_without_needing_coordinates() -> None: + spec = _spec(AugmentStep(op=AugmentOp.ROT90), variants=1) + asset = _asset(TAG, content_hash="tag-only", width=None, height=None) + view = transform_manifest(_manifest(asset), spec, _train(asset)) + assert {(file.width, file.height) for file in view.files} == {(None, None)} + + +def test_steps_compose_in_recipe_order_resize_first() -> None: + resize = ResizeStep(strategy=ResizeStrategy.STRETCH, width=200, height=100) + spec = _spec(resize, AugmentStep(op=AugmentOp.HFLIP), variants=1) + variant = _only_variant(spec, BBOX, applied=True) + # Stretched to (20, 10, 60, 20) in a 200-wide frame, then mirrored: x = 200 − 20 − 60. + assert (variant.width, variant.height) == (200, 100) + assert _geometry_of(variant, BboxGeometry) == BboxGeometry( + x=120.0, y=10.0, width=60.0, height=20.0 + ) + + +# --- gating and identity ---------------------------------------------------- + + +def test_augmentation_without_a_split_recipe_is_refused() -> None: + spec = _spec(AugmentStep(op=AugmentOp.HFLIP), variants=1) + with pytest.raises(AugmentationRequiresSplit, match="no split recipe"): + transform_manifest(_manifest(_asset(BBOX)), spec, None) + + +def test_variants_are_generated_for_the_train_fold_only() -> None: + train, val, test = ( + _asset(BBOX, content_hash="t"), + _asset(BBOX, content_hash="v"), + _asset(BBOX, content_hash="s"), + ) + folds = SplitAssignment(train=(train.asset_id,), val=(val.asset_id,), test=(test.asset_id,)) + spec = _spec(AugmentStep(op=AugmentOp.HFLIP), variants=2) + + view = transform_manifest(_manifest(train, val, test), spec, folds) + + by_asset = { + asset_id: [(file.variant, file.fold) for file in view.files if file.asset_id == asset_id] + for asset_id in (train.asset_id, val.asset_id, test.asset_id) + } + assert by_asset[train.asset_id] == [(0, "train"), (1, "train"), (2, "train")] + assert by_asset[val.asset_id] == [(0, "val")] + assert by_asset[test.asset_id] == [(0, "test")] + assert (view.source_file_count, view.augmented_file_count) == (3, 2) + + +def test_a_release_without_a_split_still_exports_base_images_when_nothing_augments() -> None: + step = ResizeStep(strategy=ResizeStrategy.LETTERBOX, width=64, height=64) + view = transform_manifest( + _manifest(_asset(BBOX), _asset(TAG, content_hash="bb")), _spec(step), None + ) + assert [(file.variant, file.fold) for file in view.files] == [(0, None), (0, None)] + + +def test_files_follow_the_manifest_order_base_first_then_variants() -> None: + first, second = _asset(BBOX, content_hash="0001"), _asset(BBOX, content_hash="0002") + spec = _spec(AugmentStep(op=AugmentOp.HFLIP), variants=2) + view = transform_manifest(_manifest(second, first), spec, _train(first, second)) + assert [(file.content_hash, file.variant) for file in view.files] == [ + ("0001", 0), + ("0001", 1), + ("0001", 2), + ("0002", 0), + ("0002", 1), + ("0002", 2), + ] + + +def test_a_variant_annotation_is_the_source_annotation_with_a_suffixed_id() -> None: + identifier = uuid4() + asset = ManifestAsset( + asset_id=uuid4(), + content_hash="cc", + uri="/img/cc.jpg", + width=100, + height=200, + annotations=(_annotation(BBOX, identifier),), + ) + spec = _spec(AugmentStep(op=AugmentOp.BRIGHTNESS_CONTRAST), variants=2) + base, first, second = transform_manifest(_manifest(asset), spec, _train(asset)).files + + assert [one.id for one in base.annotations] == [str(identifier)] + assert [one.id for one in first.annotations] == [f"{identifier}-aug1"] + assert [one.id for one in second.annotations] == [f"{identifier}-aug2"] + (copied,) = second.annotations + assert (copied.label_class, copied.schema_version, copied.provenance) == ("thing", 1, "model") + assert (copied.attributes, copied.model_ref, copied.confidence) == ( + {"colour": "red"}, + "detector:1", + 0.75, + ) + assert base.content_hash == first.content_hash == second.content_hash == "cc" + + +def test_the_view_is_deterministic_for_one_spec_and_one_manifest() -> None: + asset = _asset(BBOX, POLYGON) + spec = _spec( + ResizeStep(strategy=ResizeStrategy.LETTERBOX, width=640, height=640), + AugmentStep(op=AugmentOp.HFLIP), + AugmentStep(op=AugmentOp.ROT90), + variants=3, + ) + manifest = _manifest(asset) + assert transform_manifest(manifest, spec, _train(asset)) == transform_manifest( + manifest, spec, _train(asset) + ) diff --git a/tests/server/test_errors.py b/tests/server/test_errors.py index 491298f2..6b54c1c3 100644 --- a/tests/server/test_errors.py +++ b/tests/server/test_errors.py @@ -108,6 +108,8 @@ "SchemaHasNoDetectableClass": (409, "SCHEMA_HAS_NO_DETECTABLE_CLASS"), "UnserializableManifest": (409, "UNSERIALIZABLE_MANIFEST"), "LossyExportNotConsented": (409, "LOSSY_EXPORT_NOT_CONSENTED"), + "AugmentationRequiresSplit": (409, "AUGMENTATION_REQUIRES_SPLIT"), + "PreprocessingStepUnsupportedGeometry": (409, "PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY"), # 422 — the payload itself is wrong "AnnotationNotFromModel": (422, "ANNOTATION_NOT_FROM_MODEL"), "InvalidName": (422, "INVALID_NAME"),