Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/content/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
3 changes: 3 additions & 0 deletions src/visionset/formats/_dummy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions src/visionset/formats/_targets.py
Original file line number Diff line number Diff line change
@@ -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,
),
)
}
)
3 changes: 3 additions & 0 deletions src/visionset/formats/classification/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/visionset/formats/coco/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
folds_of,
write_image,
)
from visionset.formats._targets import self_target
from visionset.kernel.domain import (
BboxGeometry,
GeometryType,
Expand Down Expand Up @@ -115,6 +116,8 @@ class CocoExporter:

supported_modalities = frozenset({"image"})

targets = self_target(format_name, supported_geometries)

def export(
self,
release: Release,
Expand Down
14 changes: 14 additions & 0 deletions src/visionset/formats/lanes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/visionset/formats/voc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
folds_of,
write_image,
)
from visionset.formats._targets import self_target
from visionset.kernel.domain import (
BboxGeometry,
GeometryType,
Expand Down Expand Up @@ -101,6 +102,8 @@ class VocExporter:

supported_modalities = frozenset({"image"})

targets = self_target(format_name, supported_geometries)

def export(
self,
release: Release,
Expand Down
3 changes: 3 additions & 0 deletions src/visionset/formats/yolo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
folds_of,
write_image,
)
from visionset.formats._targets import self_target
from visionset.kernel.domain import (
BboxGeometry,
Geometry,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/visionset/kernel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
EntityNotFound,
ExportFormatNotFound,
ExportSourceUnreadable,
ExportTargetConflict,
ExportTargetNotFound,
GeometryNotProduced,
InferenceConnectionInvalid,
InferenceConnectionModelFixed,
Expand All @@ -49,6 +51,7 @@
IngestJobNotFound,
InvalidAnnotation,
InvalidAttributeValue,
InvalidExportTarget,
InvalidName,
InvalidPartition,
InvalidSchema,
Expand Down Expand Up @@ -124,10 +127,13 @@
"EntityNotFound",
"ExportFormatNotFound",
"ExportSourceUnreadable",
"ExportTargetConflict",
"ExportTargetNotFound",
"GeometryNotProduced",
"IngestJobNotFound",
"InvalidAnnotation",
"InvalidAttributeValue",
"InvalidExportTarget",
"InvalidName",
"InvalidPartition",
"InvalidSchema",
Expand Down
14 changes: 14 additions & 0 deletions src/visionset/kernel/domain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -379,6 +387,12 @@
"ClassExportStatus",
"ExportCompatibility",
"ExportResult",
"ExportTarget",
"PreprocessingHints",
"ResizeStrategy",
"TargetFamily",
"Task",
"TARGET_NAME_PATTERN",
"Geometry",
"GeometryType",
"ImageFormat",
Expand Down
121 changes: 121 additions & 0 deletions src/visionset/kernel/domain/export_target.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading