Skip to content

Add 16-bit PNG support to Python package - #36

Open
yoraiyanivbria wants to merge 2 commits into
adobe:mainfrom
yoraiyanivbria:feat/16bit-png-support
Open

Add 16-bit PNG support to Python package#36
yoraiyanivbria wants to merge 2 commits into
adobe:mainfrom
yoraiyanivbria:feat/16bit-png-support

Conversation

@yoraiyanivbria

Copy link
Copy Markdown

Summary

  • PIL.Image.open silently flattens a 16-bit-per-channel PNG to 8-bit on load (no error, no signal anything was lost) — and no TrustMark model is trained on more than 8-bit input anyway. So tm.encode() alone can never deliver a genuinely 16-bit result: the source has already lost its extra precision before it reaches PIL.
  • Adds TrustMark.encode_high_bit_depth(raw_png_bytes, string_secret, ...): reads the true 16-bit pixels directly (bypassing PIL entirely), runs the real encoder on a throwaway 8-bit copy, and adds back only the watermark's own perturbation onto the untouched full-precision pixels before re-encoding as a 16-bit PNG. Unlike the rest of the class, it takes/returns raw PNG bytes rather than a PIL.Image, since PIL can't represent the data being preserved.
  • Supports plain RGB and RGBA 16-bit PNGs. RGB goes through OpenImageIO (full ICC/color-metadata pass-through via copy_metadata); RGBA goes through pypng instead, since OpenImageIO's PNG writer unconditionally premultiplies RGB by alpha on write with no way to disable it (PNG itself only ever stores straight alpha, so that would be non-conformant output). Alpha is carried through byte-exact and untouched — the model never sees or affects it.
  • Both codecs are gated behind a new optional pip install trustmark[highbitdepth] extra (pyproject.toml and setup.py, kept in sync) and lazily imported only once a cheap, dependency-free PNG header sniff (_png_header) confirms they're actually needed — so the base package gains no new hard dependency, and an RGBA-only caller is never forced to have OpenImageIO installed (or vice versa).
  • Anything that isn't a genuine 16-bit RGB/RGBA PNG (8-bit, grayscale, palette, non-PNG) raises ValueError pointing at TrustMark.encode() instead — no silent fallback to a lossy 8-bit path.
  • Decode is intentionally out of scope — there is no decode_high_bit_depth. PIL's implicit 8-bit flatten on read doesn't materially affect watermark detection, only the delivered pixel precision on encode, so the ordinary tm.decode() is documented as the correct way to read back a high-bit-depth watermarked image.
  • Docs: new "16-bit PNG support" section in python/CLAUDE.md (styled like the existing encode/decode sections) and a short pointer from python/README.md.

Test plan

  • Extended python/test.py (this repo's existing example/smoke-test convention) with a 16-bit section: pure I/O round trip for RGB and RGBA (byte-exact within uint16 quantization, gamma preserved), a real end-to-end encode_high_bit_depth → PIL-flattened decode() round trip confirming the watermark survives and the output PNG header still reports 16-bit, and a negative check that ordinary 8-bit PNG bytes are rejected with ValueError. Ran successfully against a real model_type='P' instance.
  • Confirmed import trustmark still works with OpenImageIO/pypng not installed, and that encode_high_bit_depth raises the actionable ImportError (pointing at the extra) only when a genuinely high-bit-depth input actually needs the missing codec — an 8-bit input still raises the ordinary ValueError without ever touching the optional imports.
  • Verified the base WM_STRENGTH threading is correct for the model_type='P' variant, where encode() already applies an internal 1.25× multiplier — the new residual helper passes WM_STRENGTH straight through rather than re-applying it.

PIL.Image.open silently flattens 16-bit-per-channel PNGs to 8-bit on load,
so any high-bit-depth source already loses precision before TrustMark.encode()
ever runs. TrustMark.encode_high_bit_depth() reads the true 16-bit pixels
directly (OpenImageIO for RGB, pypng for RGBA, avoiding OpenImageIO's PNG
writer premultiplying alpha on write), runs the real encoder on a throwaway
8-bit copy, and adds back only the watermark's own perturbation onto the
untouched full-precision pixels before re-encoding as 16-bit.

Both codecs are gated behind the new optional `trustmark[highbitdepth]`
extra and lazily imported, so the base package gains no new hard dependency.
Decode is intentionally out of scope: PIL's implicit 8-bit flatten on read
doesn't materially affect watermark detection, only encode-side precision.
pypng raises (e.g. ChunkError) on malformed input that still passes the
cheap PNG-header sniff, unlike OpenImageIO which reports decode failures
via has_error/return codes rather than a Python exception. Wrap the pypng
decode so corrupt-but-header-valid bytes return None like every other
non-applicable input, instead of letting the exception escape.

Also convert the RGBA writer's pixel array to a plain list before handing
it to pypng.Writer.write(), for portability across pypng versions.
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.

1 participant