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
8 changes: 8 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ omitted.

::: sdata.sclass.dataframe

## sdata.imagemeta

::: sdata.imagemeta

## sdata.sclass.image

::: sdata.sclass.image

## sdata.schema

::: sdata.schema
Expand Down
126 changes: 126 additions & 0 deletions docs/rfc/0005-native-image-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# RFC 0005 — Native, format-übergreifende Bild-Metadaten

| Feld | Wert |
|-------------|--------------------------------------------------------------|
| Status | Accepted — implementiert (PNG/JPEG/JP2/GIF/WebP) |
| Datum | 2026-06-29 |
| Autor | lepy <lepy@tuta.io> |
| Komponente | `sdata/imagemeta.py`, `sdata/sclass/image.py` |
| Betrifft | Einbettung von sdata-Metadaten direkt in Bilddateien |
| Validierung | `imagemeta.py` 100 %; Pillow-Round-Trips für 5 Formate |

> **Umsetzungsstand.** Implementiert. `sdata/imagemeta.py` bettet sdata-Metadaten
> **nativ und Pillow-frei** in PNG, JPEG, JP2, GIF und WebP ein; `Image` nutzt es
> für eine **einheitliche** `save`/`from_file`-API über alle Formate.

## 1. Zusammenfassung

sdata bekommt **eigenen** Code, um Metadaten (das sdata-Metadaten-JSON) direkt in
Bilddateien zu schreiben und zu lesen — mit **einer** API über alle Formate. Bisher
konnte `Image` Metadaten nur in **PNG** einbetten (über Pillows `PngInfo`); JPEG,
JPEG 2000 und andere Formate blieben außen vor.

Die neue Schicht `sdata.imagemeta` ist **reiner Python-Code** (Standardbibliothek,
keine Pillow-Abhängigkeit). Sie erkennt das Format an den Magic-Bytes und schreibt
die Nutzlast in den **nativen** Metadaten-Träger des jeweiligen Containers:

| Format | Träger der sdata-Nutzlast | Kennung |
| ------ | -------------------------------------------------- | ------------------ |
| PNG | `iTXt`-Chunk (UTF-8) vor `IEND` | Keyword `sdata` |
| JPEG | `APP1`-Segment direkt hinter SOI | `sdata\0`-Präfix |
| JP2 | `uuid`-Box (ISO BMFF) vor der `jp2c`-Codestream-Box| feste sdata-UUID |
| GIF | Comment-Extension hinter dem Header | `sdata\0`-Präfix |
| WebP | eigener RIFF-Chunk `sdAT` | FourCC `sdAT` |

## 2. Motivation

* **Format-Unabhängigkeit:** eine Tabelle, ein Bild, ein PDF — Metadaten gehören in
das Asset, nicht nur in eine Sidecar-Datei. Für Bilder soll das **format-agnostisch**
und mit identischer API funktionieren.
* **Kein Tool-Zwang:** keine Abhängigkeit von `exiftool` o. ä.; das Schreiben/Lesen
ist Teil von sdata.
* **Pillow-frei lesen:** das Auslesen eingebetteter Metadaten darf nicht von einem
optionalen Bild-Backend abhängen — `imagemeta.extract` arbeitet auf den rohen Bytes.

## 3. Entwurf

### 3.1 Fassade (`sdata.imagemeta`)

```python
detect_format(data) -> "png"|"jpeg"|"jp2"|"gif"|"webp"|None
embed(data, payload, fmt=None) -> bytes # replace-Semantik
extract(data, fmt=None) -> str | None # lenient: unbekannt -> None
supported_formats() -> tuple[str, ...]
```

* **Replace-Semantik:** eine vorhandene sdata-Nutzlast wird **ersetzt**, nicht
dupliziert (idempotentes erneutes Einbetten).
* **Lenient lesen:** `extract` liefert für unbekannte Formate bzw. Bilder ohne
eingebettete Nutzlast `None` (kein Fehler). `embed` wirft
`UnsupportedImageFormatError` für nicht unterstützte Formate.
* **Registry:** `fmt -> (embed_fn, extract_fn)` — weitere Formate (TIFF, …) sind als
zwei kleine Funktionen + ein Registry-Eintrag nachrüstbar.

### 3.2 Pro Format (Byte-Ebene)

* **PNG** — Chunk-Struktur (`len|type|data|crc`). Ein unkomprimierter `iTXt`-Chunk mit
Keyword `sdata` wird vor `IEND` eingefügt; CRC-32 über `type+data`.
* **JPEG** — Marker-Segmente. Ein `APP1`-Segment (`sdata\0` + UTF-8) direkt hinter SOI;
der Marker-Walk stoppt bei SOS. Limit: 16-bit-Längenfeld → Nutzlast ≤ 65527 Byte
(`PayloadTooLargeError`).
* **JP2** — ISO-BMFF-Boxen (`LBox|TBox|DBox`, optional 64-bit `XLBox`). Eine `uuid`-Box
mit fester sdata-UUID wird vor der `jp2c`-Box eingefügt.
* **GIF** — Sub-Block-Streams. Eine Comment-Extension (`0x21 0xFE`) mit Präfix `sdata\0`
hinter Header + Logical Screen Descriptor (+ Global Color Table); Nutzlast in
255-Byte-Sub-Blöcken. Der Block-Walker überspringt Bild- und sonstige Extension-Daten
korrekt.
* **WebP** — RIFF-Container. Ein eigener Chunk `sdAT` wird angehängt und die RIFF-Größe
aktualisiert. Begründung der Wahl s. u.

### 3.3 `Image`-Integration

* `Image.save(path)` wählt den Container an der Datei-Endung. Liegt der Inhalt bereits
in diesem Container vor, wird die Nutzlast **ohne Re-Encoding** eingebettet
(verlustfrei, Pillow-frei); sonst transkodiert Pillow zuerst. Formate ohne nativen
Handler werden via Pillow geschrieben (Warnung, keine Einbettung).
* `Image.from_file`/`from_bytes` lesen eingebettete Metadaten über `imagemeta.extract`
zurück (Pillow-frei) und mergen sie (`update_from_usermetadata`).
* `Image.embedded_metadata()` liefert die eingebettete `Metadata` (oder `None`).

## 4. Designentscheidungen

* **WebP: eigener `sdAT`-Chunk statt VP8X+XMP.** Empirisch behält ein zusätzlicher,
unbekannter RIFF-Chunk die Dekodier-Integrität (libwebp/Pillow ignorieren unbekannte
Chunks; Bildgröße/Pixel bleiben unverändert). Das ist robuster und einfacher als eine
VP8X-Promotion mit XMP-Verpackung. **Trade-off:** ein pedantischer Validator könnte
einen „simple"-WebP mit Zusatz-Chunk bemängeln; funktional (Dekodierung + sdata-
Round-Trip) ist es einwandfrei. VP8X+XMP bleibt als spätere Verfeinerung möglich.
* **Hash/Identität.** Das Einbetten verändert die Datei-Bytes (und damit deren Hash).
Wer einen stabilen Inhalts-Hash braucht, hasht **vor** dem Einbetten oder die reinen
Pixel — analog zum Daten-vs-Metadaten-Hash bei `DataFrame` (RFC 0004).
* **Sidecar bleibt komplementär.** Für Formate ohne Handler (oder bewusst externe
Metadaten) bleibt der JSON-LD-Sidecar (`semantic.write_sidecar`) verfügbar.

## 5. Tests / Coverage

* `tests/test_imagemeta.py`: **synthetische** Container-Bytes (Pillow-frei) decken
`imagemeta.py` zu **100 %** ab — inkl. Replace-Semantik, fehlender Nutzlast, JPEG-
Standalone-/Non-FF-Marker, JP2-XLBox/`LBox==0`/malformed-Guard, GIF mit/ohne (Local)
Color Table und Nicht-Comment-Extensions, WebP-Padding. Zusätzlich Pillow-Round-Trips
über PNG/JPEG/JP2/GIF/WebP (Decodier-Integrität).
* `tests/test_image.py`: einheitliche `Image`-API über alle fünf Formate + Transkodierung.

## 6. Kompatibilität / Migration

* Strikt additiv: `imagemeta` ist neu; `Image.from_file`/`from_bytes`/`save` behalten
ihre Signaturen. PNG-Round-Trips bleiben kompatibel (jetzt über `iTXt` statt
`PngInfo`, identisches `sdata`-Keyword).
* `imagemeta.py` ist **gemessen** (100 %); `image.py` bleibt wegen des optionalen
Pillow-Transkodier-Pfads in der Coverage-`omit`.

## 7. Offene Punkte / Zukunft

* Weitere Container über die Registry: **TIFF** (IFD-Tag), **BMP** (kein nativer Träger
→ Sidecar).
* Optional: WebP **VP8X+XMP** für strikte Interop; PNG **`zTXt`** (komprimiert) für sehr
große Nutzlasten; JPEG **Multi-Segment-APP1** jenseits 64 KiB.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,5 @@ nav:
- "0002 — HDF5 DataFrame serialization": rfc/0002-hdf5-dataframe-serialization.md
- "0003 — Blob as data foundation": rfc/0003-blob-as-data-foundation.md
- "0004 — DataFrame and Blob": rfc/0004-dataframe-and-blob.md
- "0005 — Native image metadata": rfc/0005-native-image-metadata.md
- Releasing: releasing.md
Loading