Skip to content

feat(kernel): ImageProcessor — Pillow decoding, oriented dimensions, pinned thumbnails (#16) - #88

Merged
JArmandoAnaya merged 1 commit into
mainfrom
feat/image-processor
Jul 27, 2026
Merged

feat(kernel): ImageProcessor — Pillow decoding, oriented dimensions, pinned thumbnails (#16)#88
JArmandoAnaya merged 1 commit into
mainfrom
feat/image-processor

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Replaces the nine-line MediaProcessor placeholder with ImageProcessor — the first of M2's two media protocols — a Pillow adapter behind it, and the fourth port on the composition point.

Two protocols, not one

The placeholder promised probe() -> Mapping[str, object] for all media. It is replaced by ImageProcessor here and VideoProcessor in #17, each declared by the task that implements it: one shared protocol would force the ffmpeg adapter to declare thumbnails and the Pillow adapter to declare frame iteration — a runtime failure where a compile-time absence was available.

probe and thumbnail are separate calls because the callers are different. #20 probes every file; #21 thumbnails a subset. One fused call would make the first pay for an encode it discards.

Orientation is applied, not reported

ImageMetadata.width/.height are as-displayed: a 32×24 JPEG tagged EXIF orientation 6 probes as 24×32, and its thumbnail comes out 24×32 to match. There is deliberately no orientation_applied flag — a caller that could branch on it would be one who was handed the un-normalized case after all. The policy is format-independent; PNG carries EXIF in an eXIf chunk and is swept alongside JPEG.

Refusals, split by remedy

MediaError is the only error in the kernel with a constructor, carrying name and reason so a per-file report is a table rather than a list of sentences (reason never repeats the name). Two children, not one and not three:

  • UnsupportedMedia — not an image, a format outside ImageFormat, or a decompression bomb. Remedy: filter, convert, or ask for the format.
  • CorruptMedia — an accepted format whose bytes will not decode. Remedy: re-fetch the file.

Collapsed into one, an ingest summary cannot separate operator noise from data loss. A third (ThumbnailFailed) would have no independent cause.

Thumbnails

Always JPEG with a pinned encoder (quality 85, 4:4:4, no Huffman search, no progressive), never enlarged, aspect preserved, transparency composited onto white, and built on a fresh canvas so no ICC profile or JFIF density from the source can become an input to the bytes. Determinism holds within one Pillow/libjpeg build, so the tests assert repeatability, never a hardcoded hash — the rule tests/fixtures/media.py already states for ffmpeg. docs/media.md spells out that a thumbnail hash is a cache key, not an identity, which is a constraint on #21.

Decoder discipline

Validation is the decode — a file is accepted only once its pixels have come out, because a dataset that admits an asset on a convincing header discovers the truth during a training run. Image.verify() is never called (it produces no pixels and leaves a later load() raising AssertionError). UnidentifiedImageError is caught before OSError, which it subclasses; DecompressionBombError gets its own clause because it is not an OSError. Pillow's process-wide globals — MAX_IMAGE_PIXELS, LOAD_TRUNCATED_IMAGES — are left exactly where Pillow put them.

Composition

image_processor_factory on WorkspaceService.init/open and a workspace.image_processor property, the zero-argument shape the EventBus established in #13. No service below the composition point names PillowImageProcessor.

Fixtures

tests/fixtures/media.py stays the one door to test media, and gains two generators for branches that had none: write_image_in_unsupported_format (a valid BMP — decodable and still declined) and write_multi_picture_jpeg (an MPO, what phones write in burst and portrait modes, which the adapter accepts as a JPEG via an alias in its decoder table).

Not in scope

No migration — FORMAT_VERSION stays 6, VERSION stays 0.0.1.dev0, and no Asset field is added (#20 owns format/origin, #21 owns thumbnail_hash). No blob write, no video, no route change.

Checks

uv run ruff format .   1 file reformatted, 103 files left unchanged
uv run ruff check .    All checks passed!
uv run mypy src/visionset/kernel   Success: no issues found in 46 source files
uv run mypy src/visionset          Success: no issues found in 56 source files
uv run lint-imports                Contracts: 2 kept, 0 broken.
uv run pytest                      671 passed, 1 warning in 56.14s
uv run python examples/sdk_end_to_end.py   clean

Closes #16

…pinned thumbnails (#16)

Replaces the `MediaProcessor` placeholder with `ImageProcessor`, the first of the
two media protocols, plus a Pillow adapter behind it and the fourth port on the
composition point.

- `domain/media.py`: `ImageFormat` (jpeg, png) and a frozen `ImageMetadata`.
  Dimensions are as-displayed — EXIF orientation is applied before they are
  reported — and the bytes decide the format, not the filename.
- `ports/image_processor.py` (renamed from `media_processor.py`): `probe` and
  `thumbnail` as separate calls, `DEFAULT_THUMBNAIL_MAX_EDGE` and
  `THUMBNAIL_FORMAT`. Two protocols rather than one, so the ffmpeg adapter is
  never made to declare thumbnails.
- `adapters/pillow_image_processor.py`: validation *is* the decode, never a
  header sniff and never `verify()`. Pillow's process-wide globals are left
  alone.
- `errors.py`: `MediaError` with `name`/`reason`, split by remedy into
  `UnsupportedMedia` and `CorruptMedia` so a per-file report can separate
  operator noise from data loss.
- `WorkspaceService` gains `image_processor_factory` on `init`/`open` and a
  `workspace.image_processor` property, the way the event bus arrived.
- Thumbnails are JPEG with a pinned encoder, never enlarged, composited onto
  white and built on a fresh canvas so no source metadata reaches the bytes.
  Determinism is asserted as repeatability, never as a hardcoded hash.
- Fixtures gain `write_image_in_unsupported_format` and `write_multi_picture_jpeg`
  so the "decodable but declined" and MPO branches are covered by generated media.

No migration: `FORMAT_VERSION` stays 6 and no `Asset` field is added.

Closes #16
@JArmandoAnaya
JArmandoAnaya merged commit 1f19cad into main Jul 27, 2026
3 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/image-processor branch July 27, 2026 09:39
JArmandoAnaya added a commit that referenced this pull request Aug 21, 2026
…pinned thumbnails (#16) (#88)

Replaces the `MediaProcessor` placeholder with `ImageProcessor`, the first of the
two media protocols, plus a Pillow adapter behind it and the fourth port on the
composition point.

- `domain/media.py`: `ImageFormat` (jpeg, png) and a frozen `ImageMetadata`.
  Dimensions are as-displayed — EXIF orientation is applied before they are
  reported — and the bytes decide the format, not the filename.
- `ports/image_processor.py` (renamed from `media_processor.py`): `probe` and
  `thumbnail` as separate calls, `DEFAULT_THUMBNAIL_MAX_EDGE` and
  `THUMBNAIL_FORMAT`. Two protocols rather than one, so the ffmpeg adapter is
  never made to declare thumbnails.
- `adapters/pillow_image_processor.py`: validation *is* the decode, never a
  header sniff and never `verify()`. Pillow's process-wide globals are left
  alone.
- `errors.py`: `MediaError` with `name`/`reason`, split by remedy into
  `UnsupportedMedia` and `CorruptMedia` so a per-file report can separate
  operator noise from data loss.
- `WorkspaceService` gains `image_processor_factory` on `init`/`open` and a
  `workspace.image_processor` property, the way the event bus arrived.
- Thumbnails are JPEG with a pinned encoder, never enlarged, composited onto
  white and built on a fresh canvas so no source metadata reaches the bytes.
  Determinism is asserted as repeatability, never as a hardcoded hash.
- Fixtures gain `write_image_in_unsupported_format` and `write_multi_picture_jpeg`
  so the "decodable but declined" and MPO branches are covered by generated media.

No migration: `FORMAT_VERSION` stays 6 and no `Asset` field is added.

Closes #16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

kernel: MediaProcessor adapter — Pillow for image validation/dimensions/thumbnails

1 participant