From d9b2b5d1431fad9b84be93d270b4c249a7db63aa Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:51:20 -0700 Subject: [PATCH 1/2] feat(kernel): declare export targets on the exporter port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An export target is the model a person will train; each exporter now declares its targets beside the five existing port attributes. The kernel gains the frozen ExportTarget model with its Task, TargetFamily and ResizeStrategy vocabularies and the PreprocessingHints a target carries, pure resolution from a target name to the declaring exporter, and a validate_targets check holding every target's geometries within its exporter's own. ExportTargetNotFound, ExportTargetConflict and InvalidExportTarget are typed in kernel/errors.py and mapped in server/errors.py — no route raises them yet; the exact-correspondence test keeps the table total. Every shipped exporter declares one self-named target, family other, with no trainer tasks, so discovery keeps returning them under the widened port. TuSimple's target names the polyline it writes, because its supported set is empty — everything it carries arrives degraded. --- docs/content/api.md | 4 +- src/visionset/formats/_dummy.py | 3 + src/visionset/formats/_targets.py | 43 ++++ .../formats/classification/__init__.py | 3 + src/visionset/formats/coco/__init__.py | 3 + src/visionset/formats/lanes/__init__.py | 14 ++ src/visionset/formats/voc/__init__.py | 3 + src/visionset/formats/yolo/__init__.py | 3 + src/visionset/kernel/__init__.py | 6 + src/visionset/kernel/domain/__init__.py | 14 ++ src/visionset/kernel/domain/export_target.py | 121 ++++++++++ src/visionset/kernel/errors.py | 48 ++++ src/visionset/kernel/ports/__init__.py | 9 +- src/visionset/kernel/ports/exporter.py | 82 ++++++- src/visionset/server/errors.py | 18 ++ tests/formats/test_entry_points.py | 1 + tests/formats/test_registry.py | 54 +++++ tests/kernel/test_export_target.py | 206 ++++++++++++++++++ tests/mcp/test_release_tools.py | 3 + tests/server/test_errors.py | 3 + 20 files changed, 636 insertions(+), 5 deletions(-) create mode 100644 src/visionset/formats/_targets.py create mode 100644 src/visionset/kernel/domain/export_target.py create mode 100644 tests/kernel/test_export_target.py diff --git a/docs/content/api.md b/docs/content/api.md index 87d46783..e27b9851 100644 --- a/docs/content/api.md +++ b/docs/content/api.md @@ -607,13 +607,13 @@ argument for branching on `code`. | Status | Codes | | --- | --- | | **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` · `THUMBNAIL_NOT_CACHED` · `NOT_FOUND` (no such route) | +| **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` | | **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` | -| **500** | `WORKSPACE_CORRUPT` · `NOT_A_WORKSPACE` · `WORKSPACE_FORMAT_TOO_NEW` · `WORKSPACE_SCHEMA_MISMATCH` · `ENTITY_NOT_FOUND` · `ENTITY_ALREADY_EXISTS` · `CONSTRAINT_VIOLATED` · `MEDIA_TOOL_UNAVAILABLE` · `LOCAL_INFERENCE_UNAVAILABLE` · `INFERENCE_CONNECTION_NOT_RUNNABLE` · `INFERENCE_OUT_OF_MEMORY` · `INTERNAL_ERROR` | +| **500** | `WORKSPACE_CORRUPT` · `NOT_A_WORKSPACE` · `WORKSPACE_FORMAT_TOO_NEW` · `WORKSPACE_SCHEMA_MISMATCH` · `ENTITY_NOT_FOUND` · `ENTITY_ALREADY_EXISTS` · `CONSTRAINT_VIOLATED` · `MEDIA_TOOL_UNAVAILABLE` · `LOCAL_INFERENCE_UNAVAILABLE` · `INFERENCE_CONNECTION_NOT_RUNNABLE` · `INFERENCE_OUT_OF_MEMORY` · `EXPORT_TARGET_CONFLICT` · `INVALID_EXPORT_TARGET` · `INTERNAL_ERROR` | Every row but `VALIDATION_ERROR`, `NOT_FOUND`, `METHOD_NOT_ALLOWED`, `UNAUTHORIZED` and `INTERNAL_ERROR` — the five the framework and the auth guard raise — comes from `ERROR_RULES` diff --git a/src/visionset/formats/_dummy.py b/src/visionset/formats/_dummy.py index 3a36a1fc..63613ad8 100644 --- a/src/visionset/formats/_dummy.py +++ b/src/visionset/formats/_dummy.py @@ -7,6 +7,7 @@ from pathlib import Path +from visionset.formats._targets import self_target from visionset.kernel.domain import GeometryType, Manifest, Release from visionset.kernel.ports import ContentReader @@ -40,6 +41,8 @@ class DummyExporter: #: that named it would have to grow when the domain does. supported_modalities = frozenset({"image", "video", "point_cloud"}) + targets = self_target(format_name, supported_geometries) + def export( self, release: Release, diff --git a/src/visionset/formats/_targets.py b/src/visionset/formats/_targets.py new file mode 100644 index 00000000..c1c6fa2f --- /dev/null +++ b/src/visionset/formats/_targets.py @@ -0,0 +1,43 @@ +# usage: from visionset.formats._targets import self_target +"""The one-target declaration every non-YOLO exporter shares. + +An exporter that is not a trainer's format still declares exactly one target, +named after itself, family ``other``, with no trainer tasks — so a surface +renders one control for every export rather than a target select beside a +format select. Spelled once here for the same reason ``_layout`` exists: +the day two spellings of the rule disagree, the catalog and the format list +stop describing the same thing. + +Private to :mod:`visionset.formats`, like ``_layout``: importable, but not part +of the ``Exporter`` contract. +""" + +from __future__ import annotations + +from visionset.kernel.domain import ( + ExportTarget, + GeometryType, + PreprocessingHints, + TargetFamily, +) + + +def self_target(format_name: str, geometries: frozenset[GeometryType]) -> frozenset[ExportTarget]: + """The whole ``targets`` declaration for a format that is its own target.""" + return frozenset( + { + ExportTarget( + name=format_name, + label=format_name, + family=TargetFamily.OTHER, + tasks=frozenset(), + supported_geometries=geometries, + hints=PreprocessingHints( + recommended_size=None, + recommended_strategy=None, + trainer_resizes=True, + augmentation_common=False, + ), + ) + } + ) diff --git a/src/visionset/formats/classification/__init__.py b/src/visionset/formats/classification/__init__.py index 2ffbd427..70442a10 100644 --- a/src/visionset/formats/classification/__init__.py +++ b/src/visionset/formats/classification/__init__.py @@ -58,6 +58,7 @@ class index on the *from-the-schema-not-the-data* half of its rule — deriving from typing import Final from visionset.formats._layout import IMAGES_DIRNAME, folds_of, write_image +from visionset.formats._targets import self_target from visionset.kernel.domain import ( ClassificationGeometry, GeometryType, @@ -107,6 +108,8 @@ class ClassificationExporter: #: A classification dataset is a directory of pictures. supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) + def export( self, release: Release, diff --git a/src/visionset/formats/coco/__init__.py b/src/visionset/formats/coco/__init__.py index 031d9c5c..14f4270d 100644 --- a/src/visionset/formats/coco/__init__.py +++ b/src/visionset/formats/coco/__init__.py @@ -54,6 +54,7 @@ folds_of, write_image, ) +from visionset.formats._targets import self_target from visionset.kernel.domain import ( BboxGeometry, GeometryType, @@ -115,6 +116,8 @@ class CocoExporter: supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) + def export( self, release: Release, diff --git a/src/visionset/formats/lanes/__init__.py b/src/visionset/formats/lanes/__init__.py index e264ed35..2210cd7a 100644 --- a/src/visionset/formats/lanes/__init__.py +++ b/src/visionset/formats/lanes/__init__.py @@ -52,6 +52,7 @@ folds_of, write_image, ) +from visionset.formats._targets import self_target from visionset.formats.lanes._core import ( BDD100K_CATEGORIES, CULANE_SLOTS, @@ -106,6 +107,11 @@ class TuSimpleExporter: supported_modalities = frozenset({"image"}) + #: The one self-named target whose geometries are not the supported set: + #: that set is empty here, everything arriving degraded, and a target must + #: carry at least one geometry — so it names the polyline this format writes. + targets = self_target(format_name, frozenset({GeometryType.POLYLINE})) + def export( self, release: Release, @@ -138,6 +144,8 @@ class CurveLanesExporter: degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) + def export( self, release: Release, @@ -164,6 +172,8 @@ class Bdd100kLaneExporter: degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) + def export( self, release: Release, @@ -196,6 +206,8 @@ class CuLaneExporter: degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) + def export( self, release: Release, @@ -229,6 +241,8 @@ class OpenLane2dExporter: degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) + def export( self, release: Release, diff --git a/src/visionset/formats/voc/__init__.py b/src/visionset/formats/voc/__init__.py index f088bcbf..17f59672 100644 --- a/src/visionset/formats/voc/__init__.py +++ b/src/visionset/formats/voc/__init__.py @@ -45,6 +45,7 @@ folds_of, write_image, ) +from visionset.formats._targets import self_target from visionset.kernel.domain import ( BboxGeometry, GeometryType, @@ -101,6 +102,8 @@ class VocExporter: supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) + def export( self, release: Release, diff --git a/src/visionset/formats/yolo/__init__.py b/src/visionset/formats/yolo/__init__.py index 18f52f89..5f01f0a3 100644 --- a/src/visionset/formats/yolo/__init__.py +++ b/src/visionset/formats/yolo/__init__.py @@ -62,6 +62,7 @@ folds_of, write_image, ) +from visionset.formats._targets import self_target from visionset.kernel.domain import ( BboxGeometry, Geometry, @@ -118,6 +119,8 @@ class YoloDetectionExporter: #: A YOLO dataset is a directory of pictures. supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) + def export( self, release: Release, diff --git a/src/visionset/kernel/__init__.py b/src/visionset/kernel/__init__.py index 63d9262b..b0690caf 100644 --- a/src/visionset/kernel/__init__.py +++ b/src/visionset/kernel/__init__.py @@ -34,6 +34,8 @@ EntityNotFound, ExportFormatNotFound, ExportSourceUnreadable, + ExportTargetConflict, + ExportTargetNotFound, GeometryNotProduced, InferenceConnectionInvalid, InferenceConnectionModelFixed, @@ -49,6 +51,7 @@ IngestJobNotFound, InvalidAnnotation, InvalidAttributeValue, + InvalidExportTarget, InvalidName, InvalidPartition, InvalidSchema, @@ -124,10 +127,13 @@ "EntityNotFound", "ExportFormatNotFound", "ExportSourceUnreadable", + "ExportTargetConflict", + "ExportTargetNotFound", "GeometryNotProduced", "IngestJobNotFound", "InvalidAnnotation", "InvalidAttributeValue", + "InvalidExportTarget", "InvalidName", "InvalidPartition", "InvalidSchema", diff --git a/src/visionset/kernel/domain/__init__.py b/src/visionset/kernel/domain/__init__.py index bf43fccf..a6d89829 100644 --- a/src/visionset/kernel/domain/__init__.py +++ b/src/visionset/kernel/domain/__init__.py @@ -66,6 +66,14 @@ IngestCompleted, ReleasePublished, ) +from visionset.kernel.domain.export_target import ( + TARGET_NAME_PATTERN, + ExportTarget, + PreprocessingHints, + ResizeStrategy, + TargetFamily, + Task, +) from visionset.kernel.domain.geometry import ( IMPLEMENTED_GEOMETRIES, BboxGeometry, @@ -379,6 +387,12 @@ "ClassExportStatus", "ExportCompatibility", "ExportResult", + "ExportTarget", + "PreprocessingHints", + "ResizeStrategy", + "TargetFamily", + "Task", + "TARGET_NAME_PATTERN", "Geometry", "GeometryType", "ImageFormat", diff --git a/src/visionset/kernel/domain/export_target.py b/src/visionset/kernel/domain/export_target.py new file mode 100644 index 00000000..17c899c2 --- /dev/null +++ b/src/visionset/kernel/domain/export_target.py @@ -0,0 +1,121 @@ +# usage: from visionset.kernel.domain import ExportTarget, Task, TargetFamily +"""Export targets: the model a person will train, declared by an exporter. + +The user-facing unit of export is a target, and the format that writes for it +is an implementation detail of the declaration: a target resolves to exactly +one exporter, never to a runtime switch. Exporters declare their targets on the +``Exporter`` port, so the catalog every surface renders is derived from what is +installed rather than kept anywhere by hand. + +``ResizeStrategy`` lives here rather than with the pre-processing steps because +:class:`PreprocessingHints` references it: a target recommends a strategy, and +a recipe later applies one. +""" + +from __future__ import annotations + +import re +from enum import StrEnum +from typing import Final + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from visionset.kernel.domain.schema import GeometryType + +TARGET_NAME_PATTERN: Final = re.compile(r"^[a-z0-9][a-z0-9-]*$") +"""What a target may be called: a lowercase slug, as typed in a URL or a flag. + +A target name is an identifier a person types and a script repeats — ``yolo11``, +never a display string. The label field is where capitals and spaces belong. +""" + + +class Task(StrEnum): + """A trainer-side task an export target accepts.""" + + DETECT = "detect" + SEGMENT = "segment" + CLASSIFY = "classify" + POSE = "pose" + OBB = "obb" + SEMANTIC = "semantic" + DEPTH = "depth" + + +class ResizeStrategy(StrEnum): + """How an image reaches a requested size. + + ``stretch`` scales each axis independently onto the size; ``letterbox`` + scales by the limiting axis and pads the rest, preserving aspect ratio. + """ + + STRETCH = "stretch" + LETTERBOX = "letterbox" + + +class PreprocessingHints(BaseModel): + """What a target's trainer expects of its input images. + + Hints, never requirements: an export is valid without honouring any of + them. ``recommended_size`` is ``(width, height)``. ``trainer_resizes`` says + the trainer resizes on its own, so pre-resizing is an optimization rather + than a need; ``augmentation_common`` says augmentation is the ordinary + practice when training this target. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + recommended_size: tuple[int, int] | None + recommended_strategy: ResizeStrategy | None + trainer_resizes: bool + augmentation_common: bool + + @model_validator(mode="after") + def _strategy_requires_a_size(self) -> PreprocessingHints: + if self.recommended_strategy is not None and self.recommended_size is None: + raise ValueError("a recommended strategy without a recommended size resizes to nothing") + return self + + +class TargetFamily(StrEnum): + """Which group of trainers a target belongs to. + + ``other`` is the family of every exporter that is not a YOLO trainer: such + an exporter declares one target named after itself, so every export is + addressed the same way. + """ + + ULTRALYTICS_YOLO = "ultralytics-yolo" + COMMUNITY_YOLO = "community-yolo" + OTHER = "other" + + +class ExportTarget(BaseModel): + """One model a person can train on an exporter's output. + + Declared on the ``Exporter`` port; names are unique across every installed + plugin, which is what lets a caller name a target and nothing else. + ``tasks`` is what the trainer accepts — empty for family ``other``, where + there is no trainer to accept anything. ``supported_geometries`` is what an + export addressed to this target carries, never wider than what the + declaring exporter can write. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str + label: str + family: TargetFamily + tasks: frozenset[Task] + supported_geometries: frozenset[GeometryType] = Field(min_length=1) + hints: PreprocessingHints + + @field_validator("name") + @classmethod + def _name_is_a_slug(cls, value: str) -> str: + if not TARGET_NAME_PATTERN.match(value): + raise ValueError( + f"target name {value!r} is not a slug: lowercase letters, digits and " + "hyphens, starting with a letter or digit" + ) + return value diff --git a/src/visionset/kernel/errors.py b/src/visionset/kernel/errors.py index 4e9167a6..44c25887 100644 --- a/src/visionset/kernel/errors.py +++ b/src/visionset/kernel/errors.py @@ -774,6 +774,54 @@ class ExportFormatNotFound(VisionSetError): """ +class ExportTargetNotFound(VisionSetError): + """No installed exporter declares a target with that name. + + ``ExportFormatNotFound``'s sibling one vocabulary over: a format is what an + exporter calls itself, a target is a model the exporter declares it can + write for, and a caller may name either. Like that one, this is the caller + naming something that is not there — installing a distribution whose + exporter declares the target is what fixes it. + """ + + installed: tuple[str, ...] | None = None + """Every target name the installed exporters declare, sorted. + + A class attribute with a ``None`` default and **not** a constructor + parameter, exactly as ``LossyExportNotConsented.compatibility`` is — so this + error stays constructible from one message. ``resolve_target`` sets it as a + keyword. + """ + + def __init__(self, message: str, *, installed: tuple[str, ...] | None = None) -> None: + super().__init__(message) + if installed is not None: + self.installed = installed + + +class ExportTargetConflict(VisionSetError): + """Two installed exporters declare a target under one name. + + Names resolve to exactly one exporter, so a duplicated one no longer + identifies anything and picking either plugin would be a guess. Not a + ``ExportTargetNotFound``: the target is very much there, twice, and the + remedy is to remove one of the distributions rather than to install one. + The message names the formats making the claim. + """ + + +class InvalidExportTarget(VisionSetError): + """An exporter declares a target it cannot deliver. + + A target's ``supported_geometries`` must stay within the declaring + exporter's own, because the target is a promise about that exporter's + output — one claiming a geometry the exporter never writes would make the + catalog describe files that do not appear. Raised by ``validate_targets``, + which is a check on the *declaration*: nothing about the caller's request + is wrong, the installed plugin is. + """ + + class ExportSourceUnreadable(VisionSetError): """A release names bytes an export cannot use: gone, or not decodable. diff --git a/src/visionset/kernel/ports/__init__.py b/src/visionset/kernel/ports/__init__.py index 27702589..843af19e 100644 --- a/src/visionset/kernel/ports/__init__.py +++ b/src/visionset/kernel/ports/__init__.py @@ -7,7 +7,12 @@ from visionset.kernel.ports.auth_provider import AuthProvider from visionset.kernel.ports.blob_store import BlobStore from visionset.kernel.ports.event_bus import EventBus -from visionset.kernel.ports.exporter import ContentReader, Exporter +from visionset.kernel.ports.exporter import ( + ContentReader, + Exporter, + resolve_target, + validate_targets, +) from visionset.kernel.ports.image_processor import ( DEFAULT_THUMBNAIL_MAX_EDGE, THUMBNAIL_FORMAT, @@ -55,4 +60,6 @@ "UnitOfWork", "VideoProcessor", "WeightsSource", + "resolve_target", + "validate_targets", ] diff --git a/src/visionset/kernel/ports/exporter.py b/src/visionset/kernel/ports/exporter.py index b74face4..b3d3fb98 100644 --- a/src/visionset/kernel/ports/exporter.py +++ b/src/visionset/kernel/ports/exporter.py @@ -1,8 +1,13 @@ -from collections.abc import Callable +from collections.abc import Callable, Mapping from pathlib import Path from typing import BinaryIO, Protocol, runtime_checkable -from visionset.kernel.domain import GeometryType, Manifest, Release +from visionset.kernel.domain import ExportTarget, GeometryType, Manifest, Release +from visionset.kernel.errors import ( + ExportTargetConflict, + ExportTargetNotFound, + InvalidExportTarget, +) ContentReader = Callable[[str], BinaryIO] """Resolve one content hash to the bytes behind it, for the duration of a call. @@ -136,6 +141,22 @@ class Exporter(Protocol): #: ``image``. supported_modalities: frozenset[str] + #: Which models a person can train on this format's output. + #: + #: At least one, so every installed format is reachable through the one + #: target control a surface renders — an exporter with no target would be a + #: format nothing can address. A non-YOLO format declares a single target + #: named after its own ``format_name``, family ``other``, so exporting to it + #: is the same gesture as exporting to a trainer. + #: + #: Names are unique across every installed plugin — :func:`resolve_target` + #: is what turns one into an exporter — and each target's + #: ``supported_geometries`` stays within the exporter's own, which + #: :func:`validate_targets` checks: a target is a promise about this + #: exporter's output, and one promising a geometry the format never writes + #: would make the catalog describe files that do not appear. + targets: frozenset[ExportTarget] + def export( self, release: Release, @@ -144,3 +165,60 @@ def export( *, content: ContentReader, ) -> None: ... + + +def validate_targets(exporter: Exporter) -> None: + """Check an exporter's target declarations against the exporter itself. + + Every target's ``supported_geometries`` must be a subset of the exporter's + own, so a defective declaration is refused where it can be named rather + than surfacing as a catalog entry whose exports are missing what it + promised. + + Raises: + InvalidExportTarget: a target claims a geometry the exporter does not + write. + """ + for target in exporter.targets: + undeliverable = target.supported_geometries - exporter.supported_geometries + if undeliverable: + claimed = ", ".join(sorted(one.value for one in undeliverable)) + raise InvalidExportTarget( + f"format {exporter.format_name!r} declares target {target.name!r} " + f"supporting geometries it does not write: {claimed}" + ) + + +def resolve_target(installed: Mapping[str, Exporter], name: str) -> tuple[Exporter, ExportTarget]: + """The exporter declaring that target, and the declaration itself. + + Pure resolution over exporters already in hand, the way ``pick`` resolves a + format name: the kernel may not scan entry points, so whoever composed the + call passes what is installed. + + Raises: + ExportTargetNotFound: no installed exporter declares the name. + ExportTargetConflict: more than one installed exporter declares it. + """ + matches = [ + (exporter, target) + for exporter in installed.values() + for target in exporter.targets + if target.name == name + ] + if not matches: + every = tuple( + sorted(target.name for exporter in installed.values() for target in exporter.targets) + ) + known = ", ".join(every) or "none" + raise ExportTargetNotFound( + f"no installed exporter declares target {name!r}; installed targets: {known}", + installed=every, + ) + if len(matches) > 1: + formats = ", ".join(sorted(exporter.format_name for exporter, _ in matches)) + raise ExportTargetConflict( + f"target {name!r} is declared by more than one installed format ({formats}); " + "remove one of the distributions, or export by format name" + ) + return matches[0] diff --git a/src/visionset/server/errors.py b/src/visionset/server/errors.py index 32de741d..85a3a3cd 100644 --- a/src/visionset/server/errors.py +++ b/src/visionset/server/errors.py @@ -71,6 +71,8 @@ EntityNotFound, ExportFormatNotFound, ExportSourceUnreadable, + ExportTargetConflict, + ExportTargetNotFound, GeometryNotProduced, InferenceConnectionInvalid, InferenceConnectionModelFixed, @@ -86,6 +88,7 @@ IngestJobNotFound, InvalidAnnotation, InvalidAttributeValue, + InvalidExportTarget, InvalidName, InvalidPartition, InvalidSchema, @@ -240,6 +243,12 @@ class ErrorRule: # missing a tool it should have"; it is "there is no such thing here", and # ``GET /formats`` is what says which things there are. ExportFormatNotFound: ErrorRule(404, "EXPORT_FORMAT_NOT_FOUND"), + # The same reading one vocabulary over: the caller named a target no + # installed exporter declares. No route raises it yet — the target routes are + # not built — mapped anyway for BATCH_IMMUTABLE's reason: the + # exact-correspondence test keeps this table total, and an unmapped kernel + # error would answer 500 the day a route appears. + ExportTargetNotFound: ErrorRule(404, "EXPORT_TARGET_NOT_FOUND"), # A preview that was never rendered, which is not damage: a thumbnail hash is # a cache key, so NULL is an ordinary state with three causes and one remedy. # A 404 rather than an empty 200 because the caller asked for a specific @@ -454,6 +463,15 @@ class ErrorRule: InferenceConnectionNotRunnable: ErrorRule( 500, "INFERENCE_CONNECTION_NOT_RUNNABLE", expose_message=True ), + # A deployment condition on NOT_RUNNABLE's reading: two installed + # distributions claim one target name, and no edit to the request changes + # what is installed. No route raises it yet; mapped for BATCH_IMMUTABLE's + # reason. + ExportTargetConflict: ErrorRule(500, "EXPORT_TARGET_CONFLICT"), + # A defective installed plugin — a target promising geometries its own + # exporter never writes — which is nothing a caller can fix. No route + # raises it yet; mapped for BATCH_IMMUTABLE's reason. + InvalidExportTarget: ErrorRule(500, "INVALID_EXPORT_TARGET"), } ERROR_RESPONSES: Final[dict[int | str, dict[str, Any]]] = { diff --git a/tests/formats/test_entry_points.py b/tests/formats/test_entry_points.py index 32061edc..ab756d7c 100644 --- a/tests/formats/test_entry_points.py +++ b/tests/formats/test_entry_points.py @@ -18,3 +18,4 @@ def test_discovered_exporter_satisfies_the_port() -> None: exporter = exporter_cls() assert isinstance(exporter, Exporter) assert exporter.format_name == "dummy" + assert {target.name for target in exporter.targets} == {"dummy"} diff --git a/tests/formats/test_registry.py b/tests/formats/test_registry.py index 4ea985bc..a4a0b9c7 100644 --- a/tests/formats/test_registry.py +++ b/tests/formats/test_registry.py @@ -13,6 +13,7 @@ import pytest +from visionset.formats._targets import self_target from visionset.formats.registry import exporter, exporters, pick from visionset.kernel.domain import Annotation, GeometryType, Manifest, Release from visionset.kernel.errors import ExportFormatNotFound @@ -38,6 +39,7 @@ class _AnExporter: supported_geometries = frozenset(GeometryType) degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) def export( self, @@ -59,6 +61,30 @@ def test_a_discovered_exporter_declares_whether_it_is_lossy() -> None: assert exporters()["dummy"].lossy is False +def test_a_discovered_exporter_declares_its_targets() -> None: + (target,) = exporters()["dummy"].targets + + assert target.name == "dummy" + assert target.tasks == frozenset() + + +def test_every_installed_exporter_stays_discovered() -> None: + """Discovery filters on the port, so one missing member silently drops a + plugin from every surface — this is what would say which one.""" + assert set(exporters()) >= { + "dummy", + "yolo", + "coco", + "voc", + "classification", + "tusimple", + "curvelanes", + "bdd100k-lane", + "culane", + "openlane-2d", + } + + def test_exporters_are_keyed_by_what_they_call_themselves() -> None: """Not by their entry-point name: only one of the two is the caller's contract.""" assert all(name == plugin.format_name for name, plugin in exporters().items()) @@ -130,3 +156,31 @@ def export( return None assert not isinstance(_Outdated(), Exporter) + + +def test_a_plugin_missing_the_targets_member_is_not_an_exporter() -> None: + """An exporter with no target would be a format no target control can reach. + + Unlike ``_Outdated`` above, this plugin carries every *other* member of the + port, so what fails the check is ``targets`` alone. + """ + from visionset.kernel.ports import Exporter + + class _Targetless: + format_name = "targetless" + lossy = False + supported_geometries = frozenset(GeometryType) + degraded_geometries: frozenset[GeometryType] = frozenset() + supported_modalities = frozenset({"image"}) + + def export( + self, + release: Release, + manifest: Manifest, + dest: Path, + *, + content: ContentReader, + ) -> None: + return None + + assert not isinstance(_Targetless(), Exporter) diff --git a/tests/kernel/test_export_target.py b/tests/kernel/test_export_target.py new file mode 100644 index 00000000..d61dd3a9 --- /dev/null +++ b/tests/kernel/test_export_target.py @@ -0,0 +1,206 @@ +"""The export-target domain: what a target may declare, and how a name resolves. + +The port-side contract — an exporter without ``targets`` is not an ``Exporter`` +at all — is asserted in ``tests/formats/test_registry.py`` beside its siblings; +this file owns the model's invariants and the pure kernel resolution. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from visionset.kernel.domain import ( + ExportTarget, + GeometryType, + Manifest, + PreprocessingHints, + Release, + ResizeStrategy, + TargetFamily, + Task, +) +from visionset.kernel.errors import ( + ExportTargetConflict, + ExportTargetNotFound, + InvalidExportTarget, +) +from visionset.kernel.ports import ContentReader, resolve_target, validate_targets + +NO_HINTS = PreprocessingHints( + recommended_size=None, + recommended_strategy=None, + trainer_resizes=True, + augmentation_common=False, +) + + +def _target( + name: str = "a-target", + geometries: frozenset[GeometryType] = frozenset({GeometryType.BBOX}), +) -> ExportTarget: + return ExportTarget( + name=name, + label=name, + family=TargetFamily.OTHER, + tasks=frozenset(), + supported_geometries=geometries, + hints=NO_HINTS, + ) + + +class _Format: + lossy = False + degraded_geometries: frozenset[GeometryType] = frozenset() + supported_modalities = frozenset({"image"}) + + def __init__( + self, + format_name: str, + supported: frozenset[GeometryType], + targets: frozenset[ExportTarget], + ) -> None: + self.format_name = format_name + self.supported_geometries = supported + self.targets = targets + + def export( + self, + release: Release, + manifest: Manifest, + dest: Path, + *, + content: ContentReader, + ) -> None: + return None + + +def test_a_target_carries_its_whole_declaration() -> None: + target = ExportTarget( + name="yolo11", + label="YOLO11", + family=TargetFamily.ULTRALYTICS_YOLO, + tasks=frozenset({Task.DETECT, Task.SEGMENT}), + supported_geometries=frozenset({GeometryType.BBOX, GeometryType.POLYGON}), + hints=PreprocessingHints( + recommended_size=(640, 640), + recommended_strategy=ResizeStrategy.LETTERBOX, + trainer_resizes=True, + augmentation_common=True, + ), + ) + + assert target.name == "yolo11" + assert target.hints.recommended_size == (640, 640) + + +@pytest.mark.parametrize("name", ["YOLO11", "-lead", "a_b", "", "yolo 11", "über"]) +def test_a_target_name_must_be_a_slug(name: str) -> None: + with pytest.raises(ValidationError): + _target(name=name) + + +@pytest.mark.parametrize("name", ["yolo11", "bdd100k-lane", "26", "a"]) +def test_slug_names_are_accepted(name: str) -> None: + assert _target(name=name).name == name + + +def test_a_target_must_carry_at_least_one_geometry() -> None: + with pytest.raises(ValidationError): + _target(geometries=frozenset()) + + +def test_a_recommended_strategy_requires_a_recommended_size() -> None: + with pytest.raises(ValidationError): + PreprocessingHints( + recommended_size=None, + recommended_strategy=ResizeStrategy.LETTERBOX, + trainer_resizes=True, + augmentation_common=True, + ) + + +def test_a_recommended_size_needs_no_strategy() -> None: + hints = PreprocessingHints( + recommended_size=(640, 640), + recommended_strategy=None, + trainer_resizes=True, + augmentation_common=False, + ) + + assert hints.recommended_strategy is None + + +def test_targets_live_in_a_frozenset() -> None: + """The port field's type: a declaration that is not hashable cannot be one.""" + declared = frozenset({_target("one"), _target("two")}) + + assert len(declared) == 2 + + +def test_resolving_returns_the_declaring_exporter_and_the_declaration() -> None: + target = _target("wanted") + plugin = _Format("a-format", frozenset({GeometryType.BBOX}), frozenset({target})) + other = _Format("b-format", frozenset({GeometryType.BBOX}), frozenset({_target("unwanted")})) + + found_exporter, found_target = resolve_target( + {plugin.format_name: plugin, other.format_name: other}, "wanted" + ) + + assert found_exporter is plugin + assert found_target is target + + +def test_an_unknown_target_is_refused_listing_what_is_installed() -> None: + plugin = _Format("a-format", frozenset({GeometryType.BBOX}), frozenset({_target("real")})) + + with pytest.raises(ExportTargetNotFound) as refusal: + resolve_target({plugin.format_name: plugin}, "reall") + + assert "reall" in str(refusal.value) + assert "real" in str(refusal.value) + assert refusal.value.installed == ("real",) + + +def test_the_refusal_says_none_when_nothing_declares_a_target() -> None: + with pytest.raises(ExportTargetNotFound) as refusal: + resolve_target({}, "anything") + + assert "none" in str(refusal.value) + + +def test_a_target_declared_twice_is_a_conflict_naming_both_formats() -> None: + first = _Format("a-format", frozenset({GeometryType.BBOX}), frozenset({_target("taken")})) + second = _Format("b-format", frozenset({GeometryType.BBOX}), frozenset({_target("taken")})) + + with pytest.raises(ExportTargetConflict) as refusal: + resolve_target({first.format_name: first, second.format_name: second}, "taken") + + assert "a-format" in str(refusal.value) + assert "b-format" in str(refusal.value) + + +def test_a_target_within_its_exporter_validates() -> None: + plugin = _Format( + "a-format", + frozenset({GeometryType.BBOX, GeometryType.POLYGON}), + frozenset({_target(geometries=frozenset({GeometryType.BBOX}))}), + ) + + validate_targets(plugin) + + +def test_a_target_wider_than_its_exporter_is_refused_by_name() -> None: + plugin = _Format( + "a-format", + frozenset({GeometryType.BBOX}), + frozenset({_target("wide", frozenset({GeometryType.BBOX, GeometryType.POLYGON}))}), + ) + + with pytest.raises(InvalidExportTarget) as refusal: + validate_targets(plugin) + + assert "wide" in str(refusal.value) + assert str(refusal.value).endswith("polygon") diff --git a/tests/mcp/test_release_tools.py b/tests/mcp/test_release_tools.py index ba607288..528d366e 100644 --- a/tests/mcp/test_release_tools.py +++ b/tests/mcp/test_release_tools.py @@ -9,6 +9,7 @@ import pytest from tests.mcp._flow import BBOX, SCHEMA_CLASSES, call, error, open_batch, payload +from visionset.formats._targets import self_target from visionset.kernel.domain import GeometryType from visionset.kernel.ports import ContentReader, Exporter from visionset.kernel.services import EXPORT_REPORT_FILENAME @@ -352,6 +353,7 @@ class LossyExporter: supported_geometries = frozenset(GeometryType) degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) def export( self, @@ -413,6 +415,7 @@ class PolygonsOnlyExporter: supported_geometries = frozenset({GeometryType.POLYGON}) degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) def export( self, diff --git a/tests/server/test_errors.py b/tests/server/test_errors.py index 838d640b..491298f2 100644 --- a/tests/server/test_errors.py +++ b/tests/server/test_errors.py @@ -69,6 +69,7 @@ "AssetNotInDataset": (404, "ASSET_NOT_IN_DATASET"), "NoSplitRecipe": (404, "NO_SPLIT_RECIPE"), "ExportFormatNotFound": (404, "EXPORT_FORMAT_NOT_FOUND"), + "ExportTargetNotFound": (404, "EXPORT_TARGET_NOT_FOUND"), # A release naming bytes an export cannot use. 409 rather than 500 for # `UnserializableManifest`'s reason — the request is fine, the stored state is # not — so the message naming the asset reaches the caller. @@ -143,6 +144,8 @@ "LocalInferenceUnavailable": (500, "LOCAL_INFERENCE_UNAVAILABLE"), "InferenceOutOfMemory": (500, "INFERENCE_OUT_OF_MEMORY"), "InferenceConnectionNotRunnable": (500, "INFERENCE_CONNECTION_NOT_RUNNABLE"), + "ExportTargetConflict": (500, "EXPORT_TARGET_CONFLICT"), + "InvalidExportTarget": (500, "INVALID_EXPORT_TARGET"), } # A code outlives the class name it was derived from. Rename a class and its From f80f794f8ddb62902c9c917efc1d70f2c68d1bfb 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 2/2] 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 e27b9851..b8bac1cd 100644 --- a/docs/content/api.md +++ b/docs/content/api.md @@ -609,7 +609,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 99e5e1ae..4feff58e 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 843af19e..2e188d9e 100644 --- a/src/visionset/kernel/ports/__init__.py +++ b/src/visionset/kernel/ports/__init__.py @@ -28,6 +28,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 ( @@ -53,6 +54,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"),