From 515eb0f919bdd6f0bfc7dccacf73ad52d351058d Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:44:35 -0700 Subject: [PATCH 1/2] feat(kernel): refuse a recipe step kind no installed driver applies PreprocessingDriverNotFound, with its server rule, its api.md rows and its exposure entry. No route raises it yet; the pre-processing registry does. --- docs/content/api.md | 5 +++-- src/visionset/kernel/__init__.py | 2 ++ src/visionset/kernel/errors.py | 30 ++++++++++++++++++++++++++++++ src/visionset/server/errors.py | 10 ++++++++++ tests/server/test_errors.py | 4 ++++ 5 files changed, 49 insertions(+), 2 deletions(-) diff --git a/docs/content/api.md b/docs/content/api.md index a1b9f1a1..7195bca5 100644 --- a/docs/content/api.md +++ b/docs/content/api.md @@ -568,7 +568,7 @@ sentence. The real message and traceback go to the server log under the same id greps one string, and a response body never becomes a channel for filesystem paths, SQL text, or a stack trace. -Eight errors opt out and expose their real message, each because that message *is* the remedy: +Nine errors opt out and expose their real message, each because that message *is* the remedy: | Code | Why the message is published | | --- | --- | @@ -580,6 +580,7 @@ Eight errors opt out and expose their real message, each because that message *i | `INFERENCE_CONNECTION_NOT_RUNNABLE` | Says which of the two things nothing installed here can run - a recorded driver that is not installed, or a model family (declared by the config, or by the endpoint) no installed driver serves - and lists what is installed instead. A fact about the installation rather than about the request, and one that changes when a driver is installed. | | `INFERENCE_OUT_OF_MEMORY` | Names which memory ran out - the device's or the machine's - and the ways off it, which are not the same ways: a full device can be answered by moving the connection to the CPU, and a full machine is only made worse by it. No generic sentence can carry that. | | `INFERENCE_ENDPOINT_UNAVAILABLE` | Names the endpoint an `http` connection points at and what it did - unreachable, timed out, a bad status, or a body outside the contract - which is the whole remedy: look at the endpoint, not at this connection or this machine. | +| `PREPROCESSING_DRIVER_NOT_FOUND` | Names the recipe step kind no installed driver applies and lists the kinds that are installed. A fact about the installation rather than about the request: the recipe grammar admits only the kinds this distribution ships drivers for, so reaching it means a plugin the build was made with is missing. | A **mapped** 5xx keeps its own code (`WORKSPACE_CORRUPT`, `CONSTRAINT_VIOLATED`). An exception no rule covers - a bug - gets `INTERNAL_ERROR`. That difference is how the two are told apart in a @@ -614,7 +615,7 @@ argument for branching on `code`. | **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` · `EXPORT_TARGET_CONFLICT` · `INVALID_EXPORT_TARGET` · `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` · `PREPROCESSING_DRIVER_NOT_FOUND` · `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/kernel/__init__.py b/src/visionset/kernel/__init__.py index 8fe86949..a6b0b01d 100644 --- a/src/visionset/kernel/__init__.py +++ b/src/visionset/kernel/__init__.py @@ -68,6 +68,7 @@ MissingRequiredAttribute, NoSplitRecipe, NotAWorkspace, + PreprocessingDriverNotFound, PreprocessingStepUnsupportedGeometry, ProjectNameTaken, ProjectNotFound, @@ -152,6 +153,7 @@ "MissingRequiredAttribute", "NoSplitRecipe", "NotAWorkspace", + "PreprocessingDriverNotFound", "PreprocessingStepUnsupportedGeometry", "ProjectNameTaken", "ProjectNotFound", diff --git a/src/visionset/kernel/errors.py b/src/visionset/kernel/errors.py index 75526ffb..3ae3a8e7 100644 --- a/src/visionset/kernel/errors.py +++ b/src/visionset/kernel/errors.py @@ -1345,3 +1345,33 @@ def __init__( self.geometry = geometry if asset_id is not None: self.asset_id = asset_id + + +class PreprocessingDriverNotFound(VisionSetError): + """No installed driver applies steps of that kind. + + ``ExportFormatNotFound``'s shape for the pre-processing registry, which + lives in ``visionset.preprocessing`` for the same reason the format + registry lives outside the kernel: the kernel takes driver *instances* and + may not scan entry points itself. The class lives here anyway, with every + other refusal, so that no surface invents a second error shape for it. + + Unlike a format, a step kind is not something a caller can mistype — the + recipe grammar admits only the kinds this distribution ships drivers for — + so reaching this means the installation is missing a plugin it was built + with. What is wrong is the machine, on ``MediaToolUnavailable``'s terms, + and the message names the kind and lists the drivers that are installed. + """ + + installed: tuple[str, ...] | None = None + """Every step kind an installed driver applies, sorted. + + A class attribute with a ``None`` default and not a constructor parameter, + as ``ExportTargetNotFound.installed`` is, so this error stays constructible + from one message. The registry 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 diff --git a/src/visionset/server/errors.py b/src/visionset/server/errors.py index 6e59d9ab..21af5299 100644 --- a/src/visionset/server/errors.py +++ b/src/visionset/server/errors.py @@ -105,6 +105,7 @@ MissingRequiredAttribute, NoSplitRecipe, NotAWorkspace, + PreprocessingDriverNotFound, PreprocessingStepUnsupportedGeometry, ProjectNameTaken, ProjectNotFound, @@ -485,6 +486,15 @@ class ErrorRule: # 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"), + # A recipe step kind with no installed driver. The grammar admits only the + # kinds this distribution ships drivers for, so a caller cannot reach this + # by naming something wrong — the installation is missing a plugin it was + # built with. MEDIA_TOOL_UNAVAILABLE's status and its exposed message, which + # lists what is installed. No route raises it yet; mapped for + # BATCH_IMMUTABLE's reason. + PreprocessingDriverNotFound: ErrorRule( + 500, "PREPROCESSING_DRIVER_NOT_FOUND", expose_message=True + ), } ERROR_RESPONSES: Final[dict[int | str, dict[str, Any]]] = { diff --git a/tests/server/test_errors.py b/tests/server/test_errors.py index 6b54c1c3..e3f4a4fa 100644 --- a/tests/server/test_errors.py +++ b/tests/server/test_errors.py @@ -148,6 +148,7 @@ "InferenceConnectionNotRunnable": (500, "INFERENCE_CONNECTION_NOT_RUNNABLE"), "ExportTargetConflict": (500, "EXPORT_TARGET_CONFLICT"), "InvalidExportTarget": (500, "INVALID_EXPORT_TARGET"), + "PreprocessingDriverNotFound": (500, "PREPROCESSING_DRIVER_NOT_FOUND"), } # A code outlives the class name it was derived from. Rename a class and its @@ -240,6 +241,9 @@ def test_message_exposure_is_opt_in_and_only_for_5xx() -> None: # neighbours: the message names the endpoint and what it did, which is # the whole remedy. "InferenceEndpointUnavailable", + # INFERENCE_CONNECTION_NOT_RUNNABLE's shape for pre-processing: a step + # kind no installed driver applies, listing the drivers that are. + "PreprocessingDriverNotFound", } assert all(rule.status >= 500 for rule in ERROR_RULES.values() if rule.expose_message) From fa747d34e4a18cddabe94f0c52ed0da55e2853f4 Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:44:35 -0700 Subject: [PATCH 2/2] feat(preprocessing): Pillow resize and augmentation drivers A new visionset.preprocessing package outside the kernel: a registry over the visionset.preprocessing entry-point group, keyed by step kind, and the two built-in Pillow drivers. Resize orients, then stretches or letterboxes onto a pad_value canvas at the kernel's letterbox_fit numbers; augmentation reads every draw from the kernel's seed functions. Bytes are re-encoded in the source format with no metadata. Import-linter contract 1 now also forbids the kernel from importing the package. --- pyproject.toml | 11 + src/visionset/preprocessing/__init__.py | 7 + .../preprocessing/pillow/__init__.py | 201 +++++++++++ src/visionset/preprocessing/registry.py | 86 +++++ tests/architecture/test_kernel_purity.py | 2 +- tests/packaging/test_wheel.py | 3 + tests/preprocessing/test_driver_registry.py | 87 +++++ tests/preprocessing/test_pillow_drivers.py | 312 ++++++++++++++++++ 8 files changed, 708 insertions(+), 1 deletion(-) create mode 100644 src/visionset/preprocessing/__init__.py create mode 100644 src/visionset/preprocessing/pillow/__init__.py create mode 100644 src/visionset/preprocessing/registry.py create mode 100644 tests/preprocessing/test_driver_registry.py create mode 100644 tests/preprocessing/test_pillow_drivers.py diff --git a/pyproject.toml b/pyproject.toml index 5bdf54fe..a87bfb4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,6 +136,13 @@ openlane-2d = "visionset.formats.lanes:OpenLane2dExporter" # Inference driver discovery. A third-party distribution registers its own # provider in this same group, and pins the VisionSet it was built for in its # ordinary `dependencies` — discovery reads that pin and skips a mismatch. +# The pixel side of a pre-processing recipe, one driver per step kind. Discovered +# the way exporters are, so a distribution can register a driver for a kind the +# built-ins do not apply. +[project.entry-points."visionset.preprocessing"] +pillow-resize = "visionset.preprocessing.pillow:PillowResizeDriver" +pillow-augment = "visionset.preprocessing.pillow:PillowAugmentDriver" + [project.entry-points."visionset.providers"] sam = "visionset.inference.sam_provider:SamProvider" grounding-dino = "visionset.inference.transformers_provider:GroundingDinoProvider" @@ -257,6 +264,10 @@ forbidden_modules = [ # implementation. The kernel knows the configuration and the protocol; what # runs is on the other side of this line. "visionset.inference", + # The pre-processing driver registry and the Pillow drivers behind the + # `PreprocessingDriver` port. Same direction as `visionset.formats`: the kernel + # takes driver instances, and discovery stays on the other side of the line. + "visionset.preprocessing", "fastapi", "typer", "mcp", diff --git a/src/visionset/preprocessing/__init__.py b/src/visionset/preprocessing/__init__.py new file mode 100644 index 00000000..d846b2f1 --- /dev/null +++ b/src/visionset/preprocessing/__init__.py @@ -0,0 +1,7 @@ +"""Pre-processing drivers: the pixel side of a recipe, outside the kernel. + +The kernel owns the geometry of every recipe step and takes a +:class:`~visionset.kernel.ports.PreprocessingDriver` *instance* per step kind; +it may not scan entry points, so discovery lives here, beside the built-in +Pillow drivers, the way ``visionset.formats`` sits beside its plugins. +""" diff --git a/src/visionset/preprocessing/pillow/__init__.py b/src/visionset/preprocessing/pillow/__init__.py new file mode 100644 index 00000000..ade30ed3 --- /dev/null +++ b/src/visionset/preprocessing/pillow/__init__.py @@ -0,0 +1,201 @@ +# usage: from visionset.preprocessing.pillow import PillowResizeDriver, PillowAugmentDriver +"""The built-in drivers: Pillow doing the pixels for every v1 recipe step. + +Both drivers decode, orient, transform and re-encode; they never move a +coordinate. The orientation step mirrors ingest — ``PillowImageProcessor`` +bakes the EXIF turn in before it measures, so the manifest's width and height +already describe the oriented picture, and a driver that skipped the turn +would resize a picture the kernel's geometry never saw. Where a letterbox +lands and what a variant draws are read from the kernel, never recomputed. + +Re-encoding keeps the source's format: a JPEG comes back a JPEG at quality 95 +with its chroma subsampling kept, a PNG comes back a lossless PNG, and any +other encoding comes back a PNG. No metadata travels — the EXIF that named +the orientation is gone because the orientation is now in the pixels, and an +ICC profile or a text chunk would be a second input to bytes that should +depend on the pixels alone. Byte stability is promised within one environment +only: the same Pillow, the same codecs. +""" + +from __future__ import annotations + +import io +from dataclasses import dataclass +from typing import Final + +from PIL import Image, ImageEnhance, ImageOps +from PIL.JpegImagePlugin import get_sampling + +from visionset.kernel.domain import ( + AugmentOp, + AugmentStep, + ResizeStep, + ResizeStrategy, + Step, + brightness_contrast_factors, + hflip_applied, + letterbox_fit, + rot90_quarter_turns, +) +from visionset.kernel.errors import UnsupportedMedia + +JPEG_QUALITY: Final = 95 + +#: The bytes a driver returns are always in one of these, keyed by Pillow's name. +#: Anything else the decoder can open — an MPO is a JPEG container — is written +#: as a PNG, the one lossless encoding every reader has. +MEDIA_TYPES: Final[dict[str, str]] = {"JPEG": "image/jpeg", "PNG": "image/png"} + +#: Modes the transforms keep as they are. Everything else — palette, bilevel, +#: 16-bit, CMYK — is converted first: Pillow silently resamples a palette image +#: nearest-neighbour, and a letterbox canvas needs a mode a grey can be spelled in. +_KEPT_MODES: Final = frozenset({"L", "LA", "RGB", "RGBA"}) +_ALPHA_MODES: Final = frozenset({"RGBA", "LA", "PA"}) + +_QUARTER_TURNS: Final = { + 1: Image.Transpose.ROTATE_90, + 2: Image.Transpose.ROTATE_180, + 3: Image.Transpose.ROTATE_270, +} + + +@dataclass(frozen=True) +class _Decoded: + """An oriented, transform-ready image and what its bytes were encoded as.""" + + image: Image.Image + pillow_format: str + jpeg_sampling: int + + +def _decode(data: bytes) -> _Decoded: + with Image.open(io.BytesIO(data)) as opened: + pillow_format = "JPEG" if opened.format == "MPO" else (opened.format or "") + opened.load() + sampling = get_sampling(opened) if pillow_format == "JPEG" else -1 + ImageOps.exif_transpose(opened, in_place=True) + image = opened if opened.mode in _KEPT_MODES else _converted(opened) + image = image.copy() if image is opened else image + return _Decoded(image=image, pillow_format=pillow_format, jpeg_sampling=sampling) + + +def _converted(image: Image.Image) -> Image.Image: + has_alpha = image.mode in _ALPHA_MODES or "transparency" in image.info + return image.convert("RGBA" if has_alpha else "RGB") + + +def _encode(image: Image.Image, decoded: _Decoded) -> bytes: + buffer = io.BytesIO() + if decoded.pillow_format == "JPEG": + options: dict[str, object] = {"quality": JPEG_QUALITY} + if decoded.jpeg_sampling >= 0: + options["subsampling"] = decoded.jpeg_sampling + image.convert("RGB").save(buffer, format="JPEG", **options) + else: + image.save(buffer, format="PNG") + return buffer.getvalue() + + +def media_type(data: bytes) -> str: + """The media type of bytes a driver returned, or would return for this source.""" + with Image.open(io.BytesIO(data)) as opened: + pillow_format = "JPEG" if opened.format == "MPO" else (opened.format or "") + return MEDIA_TYPES.get(pillow_format, MEDIA_TYPES["PNG"]) + + +def _pad_colour(mode: str, pad_value: int) -> tuple[int, ...]: + bands = Image.getmodebands(mode) + if mode in _ALPHA_MODES: + return (pad_value,) * (bands - 1) + (255,) + return (pad_value,) * bands + + +def _resample(source: tuple[int, int], target: tuple[int, int]) -> Image.Resampling: + downscaling = target[0] * target[1] < source[0] * source[1] + return Image.Resampling.LANCZOS if downscaling else Image.Resampling.BICUBIC + + +def _resized(image: Image.Image, size: tuple[int, int]) -> Image.Image: + if size[0] < 1 or size[1] < 1: + raise UnsupportedMedia( + f"letterboxing a {image.width}×{image.height} image leaves no pixels on one " + f"side; choose a canvas closer to its aspect ratio or use the stretch strategy" + ) + return image.resize(size, _resample(image.size, size), reducing_gap=None) + + +class PillowResizeDriver: + """``resize`` steps: stretch to the size, or letterbox onto a padded canvas. + + Stretch is one ``Image.resize`` per axis, LANCZOS when the pixel count + shrinks and BICUBIC when it grows. Letterbox reads ``letterbox_fit`` for + the content size and offset and pastes the resized content there on a + canvas filled with ``pad_value`` — the same numbers the kernel used to + place the annotations, so pixels and labels cannot disagree by a rounding. + Neither reads the seed or the variant: a resize is the same for every + variant of an image. + """ + + step_kinds: frozenset[str] = frozenset({"resize"}) + + def apply(self, step: Step, image: bytes, *, seed: bytes, variant: int) -> bytes: + """The image at ``step.width × step.height``, re-encoded in its own format. + + Raises: + UnsupportedMedia: a letterbox whose aspect ratio rounds one side of + the content to zero pixels. + """ + if not isinstance(step, ResizeStep): + raise TypeError(f"{type(self).__name__} applies resize steps, not {step.kind!r}") + decoded = _decode(image) + with decoded.image as source: + if step.strategy is ResizeStrategy.STRETCH: + result = _resized(source, (step.width, step.height)) + else: + result = _letterboxed(source, step) + with result: + return _encode(result, decoded) + + +def _letterboxed(source: Image.Image, step: ResizeStep) -> Image.Image: + fit = letterbox_fit( + source.width, source.height, target_width=step.width, target_height=step.height + ) + canvas = Image.new( + source.mode, (step.width, step.height), _pad_colour(source.mode, step.pad_value) + ) + with _resized(source, (fit.content_width, fit.content_height)) as content: + canvas.paste(content, (fit.offset_x, fit.offset_y)) + return canvas + + +class PillowAugmentDriver: + """``augment`` steps: mirror, brightness then contrast, or a quarter turn. + + Every draw comes from the kernel — ``hflip_applied``, + ``brightness_contrast_factors``, ``rot90_quarter_turns`` — over the seed + the caller passes, so the pixels land where the geometry transform put the + labels. Variant 0 is the base image and is returned untouched apart from + orientation and re-encoding, which is what a step applied to it means. + """ + + step_kinds: frozenset[str] = frozenset({"augment"}) + + def apply(self, step: Step, image: bytes, *, seed: bytes, variant: int) -> bytes: + """The variant this seed draws for one augmentation, in the source's format.""" + if not isinstance(step, AugmentStep): + raise TypeError(f"{type(self).__name__} applies augment steps, not {step.kind!r}") + decoded = _decode(image) + with decoded.image as source: + result = source if variant == 0 else _augmented(source, step, seed) + return _encode(result, decoded) + + +def _augmented(source: Image.Image, step: AugmentStep, seed: bytes) -> Image.Image: + if step.op is AugmentOp.HFLIP: + return ImageOps.mirror(source) if hflip_applied(seed) else source + if step.op is AugmentOp.BRIGHTNESS_CONTRAST: + brightness, contrast = brightness_contrast_factors(seed, step.amount) + brightened = ImageEnhance.Brightness(source).enhance(brightness) + return ImageEnhance.Contrast(brightened).enhance(contrast) + return source.transpose(_QUARTER_TURNS[rot90_quarter_turns(seed)]) diff --git a/src/visionset/preprocessing/registry.py b/src/visionset/preprocessing/registry.py new file mode 100644 index 00000000..b067368f --- /dev/null +++ b/src/visionset/preprocessing/registry.py @@ -0,0 +1,86 @@ +# usage: from visionset.preprocessing.registry import driver, drivers, driver_for +"""Finding the driver that applies a recipe step. + +``visionset.formats.registry``'s shape, one port over. The kernel takes +``PreprocessingDriver`` instances because import-linter forbids it from +importing this package, so resolving a step *kind* to an implementation is +work for whoever composed the call, and every surface reaches it here rather +than keeping its own map. + +Discovery is ``importlib.metadata`` over the ``visionset.preprocessing`` +entry-point group, never a hardcoded dict: a third-party distribution registers +into the same group and is indistinguishable from a built-in. What comes out +is filtered by the port itself — ``isinstance`` on an instance, because +``PreprocessingDriver`` is a ``@runtime_checkable`` protocol with a data +member — and keyed by every step kind the driver declares. + +Nothing is cached. Entry points are read from installed metadata, so the cost +is one scan and the alternative is a process that must be restarted after an +install. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from importlib.metadata import entry_points + +from visionset.kernel.domain import Step +from visionset.kernel.errors import PreprocessingDriverNotFound +from visionset.kernel.ports import PreprocessingDriver + +GROUP = "visionset.preprocessing" + + +def drivers() -> dict[str, PreprocessingDriver]: + """Every installed driver, keyed by each step kind it applies. + + A driver declaring two kinds appears under both. Two drivers claiming one + kind resolve to whichever the scan met last, as two exporters sharing a + ``format_name`` do; the built-in pair claims one kind each. + """ + found: dict[str, PreprocessingDriver] = {} + for entry_point in entry_points(group=GROUP): + plugin = entry_point.load()() + if isinstance(plugin, PreprocessingDriver): + for kind in plugin.step_kinds: + found[kind] = plugin + return found + + +def pick(installed: Mapping[str, PreprocessingDriver], step_kind: str) -> PreprocessingDriver: + """One driver out of a set already in hand, or say none applies that kind. + + Split from :func:`driver` so the refusal has one wording no matter who + scanned the entry points. A caller holding the mapping must not index it + directly: a ``KeyError`` is outside the ``VisionSetError`` tree and would + answer 500 with no message to a request the installation cannot serve. + + Raises: + PreprocessingDriverNotFound: no installed driver applies ``step_kind``. + """ + if step_kind not in installed: + known = tuple(sorted(installed)) + raise PreprocessingDriverNotFound( + f"no pre-processing driver is installed for step kind {step_kind!r}; " + f"installed step kinds: {', '.join(known) or 'none'}", + installed=known, + ) + return installed[step_kind] + + +def driver_for(installed: Mapping[str, PreprocessingDriver], step: Step) -> PreprocessingDriver: + """The driver that applies this step, out of a set already in hand. + + Raises: + PreprocessingDriverNotFound: no installed driver applies the step's kind. + """ + return pick(installed, step.kind) + + +def driver(step_kind: str) -> PreprocessingDriver: + """The installed driver for that step kind. + + Raises: + PreprocessingDriverNotFound: no installed driver applies it. + """ + return pick(drivers(), step_kind) diff --git a/tests/architecture/test_kernel_purity.py b/tests/architecture/test_kernel_purity.py index 6a1460f8..487aa8bb 100644 --- a/tests/architecture/test_kernel_purity.py +++ b/tests/architecture/test_kernel_purity.py @@ -11,7 +11,7 @@ import visionset.kernel.ports import visionset.kernel.adapters import visionset.kernel.services -forbidden = {"fastapi", "typer", "mcp", "uvicorn"} +forbidden = {"fastapi", "typer", "mcp", "uvicorn", "visionset.formats", "visionset.preprocessing"} loaded = forbidden & set(sys.modules) assert not loaded, f"kernel import pulled in forbidden modules: {loaded}" """ diff --git a/tests/packaging/test_wheel.py b/tests/packaging/test_wheel.py index 9b2caa6b..e88dca71 100644 --- a/tests/packaging/test_wheel.py +++ b/tests/packaging/test_wheel.py @@ -187,6 +187,9 @@ def test_the_entry_points_ship_so_the_command_and_the_plugins_exist( assert "[visionset.formats]" in declared for plugin in ("dummy", "ultralytics", "yolov5-yaml", "coco", "voc"): assert f"{plugin} = visionset.formats." in declared + assert "[visionset.preprocessing]" in declared + for plugin in ("pillow-resize", "pillow-augment"): + assert f"{plugin} = visionset.preprocessing.pillow:" in declared # --- what is not ------------------------------------------------------------- diff --git a/tests/preprocessing/test_driver_registry.py b/tests/preprocessing/test_driver_registry.py new file mode 100644 index 00000000..b729fd71 --- /dev/null +++ b/tests/preprocessing/test_driver_registry.py @@ -0,0 +1,87 @@ +"""Turning a step kind into a driver, and refusing when nothing applies it. + +The pre-processing twin of ``tests/formats/test_registry.py`` and +``test_entry_points.py``: the group is reachable through ``importlib.metadata`` +the way a third-party driver distribution would reach it, what comes out +satisfies the port, and the lookup on top refuses with a message that lists +what is installed. +""" + +from __future__ import annotations + +from importlib.metadata import entry_points + +import pytest + +from visionset.kernel.domain import AugmentOp, AugmentStep, ResizeStep, ResizeStrategy, Step +from visionset.kernel.errors import PreprocessingDriverNotFound +from visionset.kernel.ports import PreprocessingDriver +from visionset.preprocessing.pillow import PillowAugmentDriver, PillowResizeDriver +from visionset.preprocessing.registry import GROUP, driver, driver_for, drivers, pick + + +class _NotADriver: + """Registered under the group by mistake: no ``step_kinds``, so the port drops it.""" + + def apply(self, step: Step, image: bytes, *, seed: bytes, variant: int) -> bytes: + return image + + +class _Crop: + step_kinds = frozenset({"crop"}) + + def apply(self, step: Step, image: bytes, *, seed: bytes, variant: int) -> bytes: + return image + + +def test_both_built_in_drivers_are_declared_in_the_entry_point_group() -> None: + names = {entry_point.name for entry_point in entry_points(group=GROUP)} + + assert names >= {"pillow-resize", "pillow-augment"} + + +def test_a_discovered_driver_satisfies_the_port() -> None: + for entry_point in entry_points(group=GROUP): + plugin = entry_point.load()() + assert isinstance(plugin, PreprocessingDriver), entry_point.name + + +def test_discovery_keys_every_step_kind_to_the_driver_that_applies_it() -> None: + installed = drivers() + + assert set(installed) == {"resize", "augment"} + assert isinstance(installed["resize"], PillowResizeDriver) + assert isinstance(installed["augment"], PillowAugmentDriver) + + +def test_discovery_drops_what_does_not_satisfy_the_port() -> None: + assert not isinstance(_NotADriver(), PreprocessingDriver) + + +def test_driver_for_resolves_a_step_by_its_kind() -> None: + installed = drivers() + resize = ResizeStep(strategy=ResizeStrategy.STRETCH, width=32, height=32) + augment = AugmentStep(op=AugmentOp.HFLIP) + + assert driver_for(installed, resize) is installed["resize"] + assert driver_for(installed, augment) is installed["augment"] + assert driver("resize") is not None + + +def test_a_kind_nothing_applies_is_refused_naming_what_is_installed() -> None: + crop = _Crop() + + with pytest.raises(PreprocessingDriverNotFound) as caught: + pick({"crop": crop}, "resize") + + assert "'resize'" in str(caught.value) + assert "crop" in str(caught.value) + assert caught.value.installed == ("crop",) + + +def test_the_refusal_with_nothing_installed_says_so() -> None: + with pytest.raises(PreprocessingDriverNotFound) as caught: + pick({}, "augment") + + assert "none" in str(caught.value) + assert caught.value.installed == () diff --git a/tests/preprocessing/test_pillow_drivers.py b/tests/preprocessing/test_pillow_drivers.py new file mode 100644 index 00000000..3e8ed5f5 --- /dev/null +++ b/tests/preprocessing/test_pillow_drivers.py @@ -0,0 +1,312 @@ +"""The Pillow drivers agree with the kernel, pixel for pixel. + +Every image is built in memory: the point of each test is a dimension, a +pixel position or a byte identity, none of which needs a fixture file. +""" + +from __future__ import annotations + +import io + +import pytest +from PIL import Image + +from visionset.kernel.domain import ( + AugmentOp, + AugmentStep, + ResizeStep, + ResizeStrategy, + brightness_contrast_factors, + hflip_applied, + letterbox_fit, + rot90_quarter_turns, + variant_seed, +) +from visionset.kernel.errors import UnsupportedMedia +from visionset.kernel.ports import PreprocessingDriver +from visionset.preprocessing.pillow import PillowAugmentDriver, PillowResizeDriver, media_type + +RESIZE = PillowResizeDriver() +AUGMENT = PillowAugmentDriver() + +NO_SEED = b"\0" * 32 + +#: Odd and even on both axes, wider and taller than every target, and targets +#: that are odd themselves — the roundings a letterbox can get wrong. +SOURCE_SIZES = [(33, 47), (64, 64), (101, 37), (40, 90), (65, 32)] +TARGET_SIZES = [(64, 64), (96, 32), (33, 65)] + + +def _png(size: tuple[int, int], colour: int | tuple[int, ...] = 200, mode: str = "RGB") -> bytes: + image = Image.new(mode, size, colour) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + +def _jpeg(size: tuple[int, int], **options: object) -> bytes: + image = Image.new("RGB", size, (200, 120, 60)) + buffer = io.BytesIO() + image.save(buffer, format="JPEG", **options) # type: ignore[arg-type] + return buffer.getvalue() + + +def _marked(size: tuple[int, int], marker: tuple[int, int]) -> bytes: + """A black image with one white pixel, so a transform's effect can be read back.""" + image = Image.new("L", size, 0) + image.putpixel(marker, 255) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + +def _open(data: bytes) -> Image.Image: + image = Image.open(io.BytesIO(data)) + image.load() + return image + + +def _white_pixels(image: Image.Image) -> set[tuple[int, int]]: + return { + (x, y) + for y in range(image.height) + for x in range(image.width) + if image.getpixel((x, y)) == 255 + } + + +def _seed_where(predicate: object, *, start: int = 1) -> bytes: + """The first variant seed for which ``predicate(seed)`` holds.""" + for k in range(start, start + 64): + seed = variant_seed("recipe", "content", k) + if predicate(seed): # type: ignore[operator] + return seed + raise AssertionError("no seed in 64 draws satisfied the predicate") + + +# --- the port ---------------------------------------------------------------- + + +def test_both_drivers_satisfy_the_port_and_split_the_step_kinds() -> None: + assert isinstance(RESIZE, PreprocessingDriver) + assert isinstance(AUGMENT, PreprocessingDriver) + assert RESIZE.step_kinds == {"resize"} + assert AUGMENT.step_kinds == {"augment"} + + +def test_a_driver_refuses_a_step_of_the_other_kind() -> None: + with pytest.raises(TypeError): + RESIZE.apply(AugmentStep(op=AugmentOp.HFLIP), _png((40, 40)), seed=NO_SEED, variant=1) + with pytest.raises(TypeError): + AUGMENT.apply( + ResizeStep(strategy=ResizeStrategy.STRETCH, width=32, height=32), + _png((40, 40)), + seed=NO_SEED, + variant=0, + ) + + +# --- resize: dimensions equal the kernel's ------------------------------------ + + +@pytest.mark.parametrize("source", SOURCE_SIZES) +@pytest.mark.parametrize("target", TARGET_SIZES) +def test_stretch_lands_on_the_step_size(source: tuple[int, int], target: tuple[int, int]) -> None: + step = ResizeStep(strategy=ResizeStrategy.STRETCH, width=target[0], height=target[1]) + out = _open(RESIZE.apply(step, _png(source), seed=NO_SEED, variant=0)) + + assert out.size == target + + +@pytest.mark.parametrize("source", SOURCE_SIZES) +@pytest.mark.parametrize("target", TARGET_SIZES) +def test_letterbox_places_the_content_where_letterbox_fit_says( + source: tuple[int, int], target: tuple[int, int] +) -> None: + """White content on a black canvas: the white rectangle is the fit, exactly.""" + step = ResizeStep( + strategy=ResizeStrategy.LETTERBOX, width=target[0], height=target[1], pad_value=0 + ) + fit = letterbox_fit(*source, target_width=target[0], target_height=target[1]) + out = _open(RESIZE.apply(step, _png(source, 255, "L"), seed=NO_SEED, variant=0)) + + assert out.size == target + expected = { + (x, y) + for y in range(fit.offset_y, fit.offset_y + fit.content_height) + for x in range(fit.offset_x, fit.offset_x + fit.content_width) + } + assert _white_pixels(out) == expected + + +def test_letterbox_pads_with_the_pad_value_in_every_band() -> None: + step = ResizeStep(strategy=ResizeStrategy.LETTERBOX, width=64, height=32, pad_value=114) + out = _open(RESIZE.apply(step, _png((32, 32), (0, 0, 0, 255), "RGBA"), seed=NO_SEED, variant=0)) + + assert out.mode == "RGBA" + assert out.getpixel((0, 0)) == (114, 114, 114, 255) + assert out.getpixel((32, 16)) == (0, 0, 0, 255) + + +def test_a_letterbox_that_leaves_no_pixels_on_one_side_is_refused() -> None: + step = ResizeStep(strategy=ResizeStrategy.LETTERBOX, width=32, height=32) + assert letterbox_fit(1, 200, target_width=32, target_height=32).content_width == 0 + + with pytest.raises(UnsupportedMedia): + RESIZE.apply(step, _png((1, 200)), seed=NO_SEED, variant=0) + + +def test_a_palette_image_is_resized_as_true_colour() -> None: + palette = Image.new("RGB", (40, 40), (200, 120, 60)).quantize(colors=4) + buffer = io.BytesIO() + palette.save(buffer, format="PNG") + step = ResizeStep(strategy=ResizeStrategy.STRETCH, width=32, height=48) + + out = _open(RESIZE.apply(step, buffer.getvalue(), seed=NO_SEED, variant=0)) + + assert out.size == (32, 48) + assert out.mode == "RGB" + + +# --- orientation and re-encoding --------------------------------------------- + + +def test_the_exif_turn_is_applied_and_the_exif_dropped() -> None: + """A 40×20 JPEG tagged 'rotate 90' is a 20×40 picture, which is what ingest measured.""" + exif = Image.Exif() + exif[0x0112] = 6 + source = _jpeg((40, 20), exif=exif.tobytes()) + assert _open(source).size == (40, 20) + step = ResizeStep(strategy=ResizeStrategy.LETTERBOX, width=64, height=64, pad_value=0) + + out = _open(RESIZE.apply(step, source, seed=NO_SEED, variant=0)) + + # Oriented, the picture is 20×40 and the letterbox pads the sides, not the + # top; sampled away from the content edge, where JPEG ringing bleeds. + fit = letterbox_fit(20, 40, target_width=64, target_height=64) + assert (fit.offset_x, fit.offset_y) == (16, 0) + assert max(out.getpixel((32, 32))) > 100 + assert max(out.getpixel((4, 32))) < 40 + assert out.getexif() == {} + + +def test_a_jpeg_comes_back_a_jpeg_with_its_subsampling_kept() -> None: + from PIL.JpegImagePlugin import get_sampling + + step = ResizeStep(strategy=ResizeStrategy.STRETCH, width=32, height=32) + for sampling in (0, 2): + out = _open( + RESIZE.apply(step, _jpeg((40, 40), subsampling=sampling), seed=NO_SEED, variant=0) + ) + assert out.format == "JPEG" + assert get_sampling(out) == sampling + + +def test_a_png_comes_back_a_lossless_png() -> None: + step = ResizeStep(strategy=ResizeStrategy.STRETCH, width=32, height=32) + out = _open(RESIZE.apply(step, _png((32, 32), (10, 20, 30)), seed=NO_SEED, variant=0)) + + assert out.format == "PNG" + assert out.getpixel((5, 5)) == (10, 20, 30) + + +def test_any_other_encoding_comes_back_a_png() -> None: + buffer = io.BytesIO() + Image.new("RGB", (40, 40), (1, 2, 3)).save(buffer, format="BMP") + step = ResizeStep(strategy=ResizeStrategy.STRETCH, width=32, height=32) + + data = RESIZE.apply(step, buffer.getvalue(), seed=NO_SEED, variant=0) + + assert _open(data).format == "PNG" + assert media_type(data) == "image/png" + assert media_type(_jpeg((8, 8))) == "image/jpeg" + + +# --- augmentation: draws come from the kernel --------------------------------- + + +def test_variant_zero_is_the_base_image_re_encoded() -> None: + source = _marked((40, 30), (3, 7)) + step = AugmentStep(op=AugmentOp.ROT90) + + out = _open(AUGMENT.apply(step, source, seed=NO_SEED, variant=0)) + + assert out.size == (40, 30) + assert _white_pixels(out) == {(3, 7)} + + +def test_hflip_mirrors_exactly_when_the_kernel_says_so() -> None: + step = AugmentStep(op=AugmentOp.HFLIP) + flipping = _seed_where(hflip_applied) + keeping = _seed_where(lambda seed: not hflip_applied(seed)) + + flipped = _open(AUGMENT.apply(step, _marked((40, 30), (3, 7)), seed=flipping, variant=1)) + kept = _open(AUGMENT.apply(step, _marked((40, 30), (3, 7)), seed=keeping, variant=1)) + + assert _white_pixels(flipped) == {(40 - 1 - 3, 7)} + assert _white_pixels(kept) == {(3, 7)} + + +@pytest.mark.parametrize("turns", [1, 2, 3]) +def test_rot90_turns_counter_clockwise_the_number_of_times_the_kernel_drew(turns: int) -> None: + """``(x, y)`` lands at ``(y, W - 1 - x)`` per turn, the kernel's ``_rotated_once`` on pixels.""" + step = AugmentStep(op=AugmentOp.ROT90) + seed = _seed_where(lambda seed: rot90_quarter_turns(seed) == turns) + width, height, marker = 40, 30, (3, 7) + + out = _open(AUGMENT.apply(step, _marked((width, height), marker), seed=seed, variant=1)) + + expected = marker + size = (width, height) + for _ in range(turns): + expected = (expected[1], size[0] - 1 - expected[0]) + size = (size[1], size[0]) + assert out.size == size + assert _white_pixels(out) == {expected} + + +def test_brightness_contrast_scales_a_flat_image_by_the_kernels_brightness_factor() -> None: + """A flat image has no contrast to change, so the pixel reads the brightness factor alone.""" + step = AugmentStep(op=AugmentOp.BRIGHTNESS_CONTRAST, amount=0.3) + seed = variant_seed("recipe", "content", 1) + brightness, _ = brightness_contrast_factors(seed, 0.3) + + out = _open(AUGMENT.apply(step, _png((16, 16), 100, "L"), seed=seed, variant=1)) + + assert out.getpixel((8, 8)) == pytest.approx(100 * brightness, abs=1) + + +# --- determinism, within one environment -------------------------------------- + + +@pytest.mark.parametrize("op", list(AugmentOp)) +def test_the_same_seed_gives_identical_bytes(op: AugmentOp) -> None: + step = AugmentStep(op=op) + seed = variant_seed("recipe", "content", 2) + source = _jpeg((48, 36)) + + first = AUGMENT.apply(step, source, seed=seed, variant=2) + second = AUGMENT.apply(step, source, seed=seed, variant=2) + + assert first == second + + +def test_resize_is_the_same_bytes_whatever_the_seed_or_variant() -> None: + step = ResizeStep(strategy=ResizeStrategy.LETTERBOX, width=64, height=48) + source = _jpeg((48, 36)) + + base = RESIZE.apply(step, source, seed=NO_SEED, variant=0) + variant = RESIZE.apply(step, source, seed=variant_seed("r", "c", 3), variant=3) + + assert base == variant + + +def test_different_seeds_draw_different_brightness() -> None: + step = AugmentStep(op=AugmentOp.BRIGHTNESS_CONTRAST) + source = _png((16, 16), 100, "L") + outputs = { + AUGMENT.apply(step, source, seed=variant_seed("recipe", "content", k), variant=k) + for k in range(1, 5) + } + + assert len(outputs) > 1