mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-14 21:40:47 +02:00
pillow-heif's opener rewrites the EXIF orientation to 1 and stashes the real value in info["original_orientation"], while libheif turns the raster on decode exactly when the container declares an effective irot/imir transform. A camera HEIC (stored raster, EXIF-only rotation) and a re-encoded one (rotation baked as irot) both decode with Image.size matching the raster and the tag blanked, so _read_display_tags dropped the turn for both (issue #105): a camera HEIC went through erase and came out sideways with nothing on it saying so. For HEIF the caller's orientation_applied claim is now ignored the way it is for TIFF, and the container decides: isobmff.heif_transform_applied scans meta/iprp/ipco for an irot with a non-zero angle or any imir, the orientation comes from original_orientation, and a transform-bearing file drops the tag. AVIF keeps the generic path: this plugin build neither blanks its tag nor applies the transform there, and a camera-style AVIF's orientation stays unreachable from Pillow (recorded in known-limitations). Tests build the camera fixture without exiftool: a pillow-heif-written file with its irot angle neutralized to the identity behaves like the reporter's exiftool-built camera file. Mutation-checked in both directions: forcing the detector either way fails exactly the tests whose property broke. pre-commit: 1) maintain.sh - exit 2 at the uv-secure gate, the three triaged no-fix advisories in docs/development.md (rechecked 2026-09-09: accelerate 1.15.0 and lightning 2.6.5 still carry no fix); ruff check+format clean, pyright src/ 0 errors, 2368 passed 3 skipped run separately; 2) /simplify - single pass, no fan-out available, one duplicate test helper inlined, no other findings; 3) docs sync - heif_transform_applied, original_orientation, irot, imir live only in module-internals.md and known-limitations.md, both updated, no other refs; 4) CLAUDE.md - audited against the structure rule, no additions; revise-claude-md: no CLAUDE.md changes
603 lines
26 KiB
Python
603 lines
26 KiB
Python
"""Unicode-safe cv2 image IO (issue #17).
|
|
|
|
``cv2.imread`` / ``cv2.imwrite`` pass the path to the platform C runtime, which
|
|
on Windows uses the narrow (ANSI) code-page API and therefore fails on paths
|
|
containing non-ASCII characters (Chinese, Cyrillic, ...). The symptom is a
|
|
``can't open/read file`` warning and a ``None`` decode even though the file
|
|
exists.
|
|
|
|
These wrappers route through numpy buffers instead: ``np.fromfile`` /
|
|
``ndarray.tofile`` open the path in Python (full Unicode), and
|
|
``cv2.imdecode`` / ``cv2.imencode`` do the codec work. The decoded/encoded
|
|
bytes are byte-for-byte identical to ``imread`` / ``imwrite``. On macOS/Linux
|
|
cv2 already accepts UTF-8 paths, so the wrappers are behavior-neutral there.
|
|
|
|
cv2/numpy are imported lazily inside the functions so importing this module
|
|
stays cheap in a bare environment (matching the rest of the package).
|
|
"""
|
|
|
|
# cv2 ships no type stubs; mirror the pragma used by the other cv2-using modules.
|
|
# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from remove_ai_watermarks._internal.constants import PNG_SIGNATURE
|
|
|
|
if TYPE_CHECKING:
|
|
from numpy.typing import NDArray
|
|
from PIL.Image import Image as PILImage
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def imread(path: str | Path, flags: int | None = None) -> NDArray[Any] | None:
|
|
"""Unicode-safe ``cv2.imread`` with a Pillow fallback for HEIC/AVIF.
|
|
|
|
``flags`` defaults to ``cv2.IMREAD_COLOR`` (same as ``cv2.imread``). Returns
|
|
``None`` when the file is missing or cannot be decoded, matching ``cv2.imread``
|
|
semantics so existing ``if img is None`` checks keep working.
|
|
|
|
OpenCV cannot decode HEIC/AVIF (and some other containers), so when its decode
|
|
returns None we fall back to Pillow (:func:`_pil_read`): AVIF is native in modern
|
|
Pillow, HEIC works when the optional ``pillow-heif`` plugin is installed. This lets
|
|
the pixel path (visible removal) read the same formats the metadata path already
|
|
scans; normal PNG/JPEG/WebP never reach the fallback, so they are unaffected.
|
|
"""
|
|
import cv2
|
|
import numpy as np
|
|
|
|
if flags is None:
|
|
flags = cv2.IMREAD_COLOR
|
|
try:
|
|
data = np.fromfile(str(path), dtype=np.uint8)
|
|
except OSError:
|
|
return None
|
|
if data.size == 0:
|
|
return None
|
|
img = cv2.imdecode(data, flags)
|
|
# cv2.imdecode returns None on an undecodable container (HEIC/AVIF); the type stub
|
|
# omits that, hence the ignore.
|
|
if img is not None: # pyright: ignore[reportUnnecessaryComparison]
|
|
return img
|
|
return _pil_read(path, flags)
|
|
|
|
|
|
_heif_registered = False
|
|
|
|
|
|
def _pil_read(path: str | Path, flags: int) -> NDArray[Any] | None:
|
|
"""Decode via Pillow (HEIC/AVIF and any other Pillow-readable container) into the
|
|
cv2 layout ``flags`` implies: grayscale, 3-channel BGR, or BGRA when the source has
|
|
alpha and ``IMREAD_UNCHANGED`` was requested. Returns None if Pillow (with the
|
|
optional HEIF plugin) still cannot open it. This fallback asks for no EXIF
|
|
auto-rotation, but a container reader can apply it regardless: Pillow's TIFF
|
|
reader always turns the raster upright. That decode state is reconciled where
|
|
it matters, in :func:`_read_display_tags`."""
|
|
import cv2
|
|
import numpy as np
|
|
|
|
try:
|
|
from PIL import Image
|
|
except Exception:
|
|
return None
|
|
_register_heif()
|
|
try:
|
|
with Image.open(path) as im:
|
|
im.load()
|
|
if flags == cv2.IMREAD_GRAYSCALE:
|
|
return np.asarray(im.convert("L"))
|
|
has_alpha = im.mode in ("RGBA", "LA", "PA") or "transparency" in im.info
|
|
if flags == cv2.IMREAD_UNCHANGED and has_alpha:
|
|
return cv2.cvtColor(np.asarray(im.convert("RGBA")), cv2.COLOR_RGBA2BGRA)
|
|
return cv2.cvtColor(np.asarray(im.convert("RGB")), cv2.COLOR_RGB2BGR)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def load_image_bgr(path: str | Path) -> NDArray[Any]:
|
|
"""Read ``path`` as a BGR ndarray, raising instead of returning ``None``.
|
|
|
|
:func:`imread` keeps cv2's ``None``-on-failure contract because the removal paths
|
|
branch on it. Scripts and tests want the opposite -- fail loudly at the read -- so
|
|
they call this. Each vendor engine used to carry its own verbatim copy.
|
|
"""
|
|
image = imread(path)
|
|
if image is None:
|
|
raise FileNotFoundError(f"Failed to read image: {path}")
|
|
return image
|
|
|
|
|
|
def to_bgr(image: NDArray[Any]) -> NDArray[Any]:
|
|
"""Return a 3-channel BGR view of ``image``, promoting grayscale and BGRA.
|
|
|
|
The cv2-based engines (sparkle + the text-mark detectors/localizers) assume a
|
|
3-channel BGR array for their channel reductions (``mean(axis=2)``, the top-hat
|
|
glyph extraction). A 2D grayscale or 4-channel BGRA input -- a real Gemini-app
|
|
export is opaque RGBA -- would otherwise crash or mis-broadcast.
|
|
Centralizes the shape coercion that was inlined across the engines. A 3-channel
|
|
input is returned unchanged (no copy).
|
|
"""
|
|
import cv2
|
|
|
|
if image.ndim == 2 or image.shape[2] == 1:
|
|
return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
|
if image.shape[2] == 4:
|
|
return cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
|
|
return image
|
|
|
|
|
|
def _register_heif() -> None:
|
|
"""Register the HEIF+AVIF Pillow opener/saver via libheif (idempotent, best-effort)."""
|
|
global _heif_registered
|
|
if _heif_registered:
|
|
return
|
|
_heif_registered = True
|
|
import contextlib
|
|
|
|
with contextlib.suppress(Exception):
|
|
import pillow_heif # pyright: ignore[reportMissingImports]
|
|
|
|
# Documented public API; pillow-heif does not mark its typed re-export.
|
|
pillow_heif.register_heif_opener() # pyright: ignore[reportPrivateImportUsage]
|
|
|
|
|
|
# ── Display-tag carry across re-encodes (issue #98) ─────────────────────────
|
|
#
|
|
# cv2's encoders write no container metadata, so every pixel-path output used to
|
|
# come back without the source's ICC colour profile and EXIF orientation: a
|
|
# Display P3 portrait lost a chunk of its saturation and displayed sideways even
|
|
# though the user never asked the tool to touch metadata. Those two tags are
|
|
# display fidelity, not provenance, so they travel from the decode source to the
|
|
# re-encoded output. Only those two: wholesale EXIF copying would resurrect the
|
|
# AI-provenance tags the metadata strip exists to drop.
|
|
|
|
_ORIENT_EXIF_TAG = 0x0112 # EXIF ImageIFD.Orientation
|
|
_TIFF_IMAGE_WIDTH = 256 # TIFF ImageWidth, the stored raster's columns
|
|
_TIFF_IMAGE_LENGTH = 257 # TIFF ImageLength, the stored raster's rows
|
|
# Orientations that transpose width and height (the 90-degree rotations). Only these
|
|
# can be detected by comparing the raster's shape with the source's stored size;
|
|
# the mirror/180 values (2, 3, 4) keep today's tagless behaviour because no cheap
|
|
# signal distinguishes a rotated decode from a stored one for them.
|
|
_TRANSPOSING_ORIENTATIONS = frozenset({5, 6, 7, 8})
|
|
|
|
|
|
def _orientation_exif(orient: int | None, *, prefixed: bool) -> bytes | None:
|
|
"""EXIF bytes holding ONLY the orientation tag, in the form a container wants.
|
|
|
|
``piexif.dump`` returns the JPEG APP1 payload (``Exif\\x00\\x00`` + TIFF); the
|
|
PNG ``eXIf`` chunk and the WebP ``EXIF`` chunk carry the bare TIFF structure, so
|
|
the prefix is stripped there. Orientation 1 (upright) is the absence of a tag.
|
|
"""
|
|
if orient is None or orient == 1:
|
|
return None
|
|
import piexif
|
|
|
|
dumped = piexif.dump({"0th": {piexif.ImageIFD.Orientation: orient}})
|
|
if prefixed:
|
|
return dumped
|
|
return dumped[6:] if dumped.startswith(b"Exif\x00\x00") else dumped
|
|
|
|
|
|
def _stored_size(im: PILImage) -> tuple[int, int]:
|
|
"""The size the container STORES, which is not always ``Image.size``.
|
|
|
|
Pillow's TIFF reader applies the EXIF orientation itself: it hands back a turned
|
|
raster and reports the upright size. Comparing that against a turned raster says
|
|
"still in stored orientation" for pixels that are already upright, and the tag
|
|
rides along into a second rotation. The TIFF IFD keeps the real stored geometry in
|
|
ImageWidth/ImageLength, and no other Pillow plugin exposes ``tag_v2``.
|
|
"""
|
|
tags = getattr(im, "tag_v2", None)
|
|
if tags is not None:
|
|
width, height = tags.get(_TIFF_IMAGE_WIDTH), tags.get(_TIFF_IMAGE_LENGTH)
|
|
if isinstance(width, int) and isinstance(height, int):
|
|
return width, height
|
|
return im.size
|
|
|
|
|
|
def _read_display_tags(
|
|
source: Path, raster_shape: tuple[int, int], *, orientation_applied: bool | None = None
|
|
) -> tuple[bytes | None, int | None]:
|
|
"""Extract ``(icc_profile, orientation)`` from ``source`` for a raster of ``raster_shape``.
|
|
|
|
The contract is that ``source`` is the file whose decode produced the pixels
|
|
being written, and ``raster_shape`` is that raster's ``(rows, cols)``. Whether the
|
|
decode turned the raster upright cannot be assumed from the container: cv2
|
|
applies EXIF orientation under ``IMREAD_COLOR`` depending on the codec and
|
|
build, including for WebP in newer builds. ``IMREAD_UNCHANGED``, which the
|
|
pixel paths use, never applies orientation.
|
|
``orientation_applied`` carries that decode state explicitly when known.
|
|
Otherwise the legacy inference uses raster geometry: for the
|
|
transposing orientations (5-8) the tag is carried exactly when the raster still
|
|
has the source's STORED dimensions, and dropped when it has the upright ones;
|
|
anything else (a resized pipeline output, the rare mirror/180 values) drops the
|
|
tag and keeps today's behaviour rather than risk a double rotation. ICC travels
|
|
in every case: it describes colour, not geometry. Failures read as "no tags":
|
|
the restore is an enhancement, never a reason to fail a write.
|
|
|
|
TIFF is the exception on the explicit state: every reader in this module's
|
|
stack applies the TIFF orientation tag on decode (cv2's libtiff path under
|
|
``IMREAD_UNCHANGED`` too, and Pillow's TIFF plugin, identically), so a caller's
|
|
blanket ``False`` -- which encodes cv2's JPEG/PNG/WebP contract -- is not true
|
|
for TIFF. For TIFF sources the parameter is ignored and the geometry check
|
|
decides against the IFD's stored size: a raster that no longer has the stored
|
|
dimensions was turned by the decode and must not be tagged again (issue #106).
|
|
|
|
HEIF is the other exception: pillow-heif's opener blanks the EXIF orientation
|
|
on open (the real value waits in ``info["original_orientation"]``), and
|
|
libheif turns the raster exactly when the container declares an effective
|
|
``irot``/``imir`` transform, so the geometry check has nothing to compare --
|
|
``Image.size`` matches the raster in both cases. For HEIF the parameter is
|
|
likewise ignored and the container decides (issue #105). AVIF keeps the
|
|
generic path: this plugin build neither blanks its tag nor applies the
|
|
transform there.
|
|
|
|
The stored size cannot be read straight off ``Image``: Pillow's TIFF reader
|
|
reports the upright one, so it comes through :func:`_stored_size`.
|
|
"""
|
|
_register_heif()
|
|
try:
|
|
from PIL import Image
|
|
except Exception:
|
|
return None, None
|
|
try:
|
|
with Image.open(source) as im:
|
|
icc = im.info.get("icc_profile")
|
|
orient = im.getexif().get(_ORIENT_EXIF_TAG)
|
|
fmt = im.format
|
|
# pillow-heif's opener rewrites the EXIF orientation to 1 and stashes
|
|
# the real value here, so `orient` is blanked for every HEIF.
|
|
heif_original = im.info.get("original_orientation")
|
|
stored_w, stored_h = _stored_size(im)
|
|
except Exception:
|
|
return None, None
|
|
if not (isinstance(icc, bytes) and icc):
|
|
icc = None
|
|
if fmt == "TIFF":
|
|
orientation_applied = None
|
|
if fmt == "HEIF":
|
|
# The second container whose decode state a caller cannot claim: libheif
|
|
# turns the raster on decode exactly when the file declares an effective
|
|
# irot/imir transform, and pillow-heif has blanked the EXIF tag either
|
|
# way, so the container decides and `original_orientation` supplies the
|
|
# value (issue #105). AVIF keeps the generic path: this plugin build
|
|
# neither blanks its tag nor applies the transform there.
|
|
from remove_ai_watermarks._internal import isobmff
|
|
|
|
orientation_applied = isobmff.heif_transform_applied(source)
|
|
if orient in (None, 1):
|
|
orient = heif_original
|
|
if orientation_applied is True:
|
|
orient = None
|
|
elif orientation_applied is False:
|
|
orient = orient if isinstance(orient, int) and 1 <= orient <= 8 else None
|
|
elif orient in _TRANSPOSING_ORIENTATIONS:
|
|
rows, cols = raster_shape
|
|
# Upright dimensions are the stored ones swapped; only a raster still in
|
|
# stored orientation may carry the tag.
|
|
if (cols, rows) != (stored_w, stored_h):
|
|
orient = None
|
|
else:
|
|
orient = None
|
|
return icc, orient
|
|
|
|
|
|
def _png_splice_display_tags(data: bytes, icc: bytes | None, orient: int | None) -> bytes:
|
|
"""Insert ``iCCP`` / ``eXIf`` chunks after IHDR, leaving IDAT verbatim.
|
|
|
|
Replaces same-kind chunks rather than duplicating them, so rewriting an
|
|
already-tagged PNG cannot stack profiles. Returns the input unchanged when the
|
|
chunk list does not walk cleanly (the file still decodes exactly as written).
|
|
"""
|
|
import struct
|
|
import zlib
|
|
|
|
def _chunk(kind: bytes, payload: bytes) -> bytes:
|
|
return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", zlib.crc32(kind + payload))
|
|
|
|
iccp = _chunk(b"iCCP", b"ICC Profile\x00\x00" + zlib.compress(icc, 9)) if icc else None
|
|
exif_payload = _orientation_exif(orient, prefixed=False)
|
|
exif_chunk = _chunk(b"eXIf", exif_payload) if exif_payload else None
|
|
if iccp is None and exif_chunk is None:
|
|
return data
|
|
|
|
out = bytearray(data[:8])
|
|
pos, n = 8, len(data)
|
|
inserted = False
|
|
while pos + 8 <= n:
|
|
(length,) = struct.unpack(">I", data[pos : pos + 4])
|
|
kind = data[pos + 4 : pos + 8]
|
|
end = pos + 12 + length
|
|
if end > n:
|
|
return data # malformed length: leave the encoded bytes alone
|
|
replaced = (kind == b"iCCP" and iccp is not None) or (kind == b"eXIf" and exif_chunk is not None)
|
|
if not replaced:
|
|
out += data[pos:end]
|
|
if kind == b"IHDR" and not inserted:
|
|
out += (iccp or b"") + (exif_chunk or b"")
|
|
inserted = True
|
|
if kind == b"IEND":
|
|
break
|
|
pos = end
|
|
if not inserted:
|
|
return data
|
|
return bytes(out)
|
|
|
|
|
|
def _jpeg_splice_display_tags(data: bytes, icc: bytes | None, orient: int | None) -> bytes:
|
|
"""Insert APP1 (EXIF) and APP2 (ICC) markers without touching the DCT scan.
|
|
|
|
The new segments go at the end of the existing APP block (after JFIF APP0, ahead
|
|
of DQT/SOF), and same-purpose segments are replaced instead of duplicated. A large
|
|
ICC profile is chunked across APP2 markers per the ICC-in-JPEG spec: one-byte
|
|
sequence and total counters, the layout Pillow's writer and the spec share.
|
|
Returns the input unchanged when the marker walk does not reach the scan cleanly.
|
|
"""
|
|
import struct
|
|
|
|
exif = _orientation_exif(orient, prefixed=True)
|
|
segments: list[bytes] = []
|
|
if exif:
|
|
segments.append(b"\xff\xe1" + struct.pack(">H", len(exif) + 2) + exif)
|
|
if icc:
|
|
# 65535 per segment minus the 2-byte length and the 14-byte ICC header
|
|
# ("ICC_PROFILE\x00" + 1-byte sequence + 1-byte total).
|
|
part_size = 65535 - 2 - 14
|
|
parts = [icc[i : i + part_size] for i in range(0, len(icc), part_size)]
|
|
for seq, part in enumerate(parts, start=1):
|
|
payload = b"ICC_PROFILE\x00" + bytes((seq, len(parts))) + part
|
|
segments.append(b"\xff\xe2" + struct.pack(">H", len(payload) + 2) + payload)
|
|
if not segments:
|
|
return data
|
|
|
|
out = bytearray(data[:2]) # SOI
|
|
pos, n = 2, len(data)
|
|
while pos + 4 <= n:
|
|
if data[pos] != 0xFF:
|
|
return data # lost the marker boundary: leave the encoded bytes alone
|
|
marker = data[pos + 1]
|
|
if marker not in range(0xE0, 0xF0):
|
|
break # first segment outside the APP block: insertion point found
|
|
(seg_len,) = struct.unpack(">H", data[pos + 2 : pos + 4])
|
|
end = pos + 2 + seg_len
|
|
if seg_len < 2 or end > n:
|
|
return data
|
|
payload = data[pos + 4 : end]
|
|
replaced = (marker == 0xE1 and exif and payload.startswith(b"Exif\x00\x00")) or (
|
|
marker == 0xE2 and icc and payload.startswith(b"ICC_PROFILE\x00")
|
|
)
|
|
if not replaced:
|
|
out += data[pos:end]
|
|
pos = end
|
|
out += b"".join(segments)
|
|
out += data[pos:]
|
|
return bytes(out)
|
|
|
|
|
|
def _webp_restore_display_tags(path: Path, icc: bytes | None, orient: int | None) -> None:
|
|
"""Attach the display tags to a written WebP by re-saving it losslessly.
|
|
|
|
A hand-spliced ``ICCP``/``EXIF`` RIFF chunk is invisible to readers: libwebp
|
|
surfaces metadata only when a ``VP8X`` header announces it, and stitching that
|
|
header by hand duplicates what Pillow's writer already does correctly (measured:
|
|
a spliced file fails to open in both Pillow and cv2). Pillow's WebP save in
|
|
lossless mode is pixel-identical, so the container bytes change and the picture
|
|
does not.
|
|
"""
|
|
try:
|
|
from PIL import Image
|
|
except Exception:
|
|
return
|
|
exif = _orientation_exif(orient, prefixed=True)
|
|
save_kwargs: dict[str, Any] = {"format": "WEBP", "lossless": True, "exact": True}
|
|
if icc:
|
|
save_kwargs["icc_profile"] = icc
|
|
if exif:
|
|
save_kwargs["exif"] = exif
|
|
try:
|
|
with Image.open(path) as im:
|
|
im.load()
|
|
im.save(path, **save_kwargs)
|
|
except Exception:
|
|
log.debug("webp display-tag restore failed on %s", path, exc_info=True)
|
|
|
|
|
|
def _restore_display_tags(path: Path, icc: bytes | None, orient: int | None) -> None:
|
|
"""Best-effort splice of the display tags into an already-written output.
|
|
|
|
Never raises and never re-encodes pixels: a failure leaves the un-tagged (but
|
|
valid) file the codec wrote, which is exactly the pre-fix behaviour.
|
|
"""
|
|
if icc is None and (orient is None or orient == 1):
|
|
return
|
|
try:
|
|
data = path.read_bytes()
|
|
except OSError:
|
|
log.debug("display-tag restore could not read back %s", path, exc_info=True)
|
|
return
|
|
try:
|
|
if data.startswith(PNG_SIGNATURE):
|
|
path.write_bytes(_png_splice_display_tags(data, icc, orient))
|
|
elif data[:2] == b"\xff\xd8":
|
|
path.write_bytes(_jpeg_splice_display_tags(data, icc, orient))
|
|
elif data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
_webp_restore_display_tags(path, icc, orient)
|
|
# else: a container without a restore path (BMP/TIFF): leave as written
|
|
except Exception:
|
|
log.debug("display-tag restore failed on %s", path, exc_info=True)
|
|
|
|
|
|
# Containers cv2 cannot encode -> written via Pillow (pillow-heif).
|
|
_HEIF_WRITE_EXTS = {".heic", ".heif", ".avif"}
|
|
|
|
|
|
def _encode_params(ext: str) -> list[int]:
|
|
"""cv2 encode params that PRESERVE quality. The removal only touches the mark's
|
|
footprint, so the container re-encode must not degrade the untouched pixels:
|
|
JPEG at quality 100 with 4:4:4 chroma (no subsampling), WebP at max. Lossless
|
|
containers (PNG/BMP/TIFF) need no params. getattr-guarded so an older OpenCV
|
|
build without the chroma/subsampling flags still gets quality 100."""
|
|
import cv2
|
|
|
|
if ext in (".jpg", ".jpeg"):
|
|
params = [cv2.IMWRITE_JPEG_QUALITY, 100]
|
|
cq = getattr(cv2, "IMWRITE_JPEG_CHROMA_QUALITY", None)
|
|
if cq is not None:
|
|
params += [cq, 100]
|
|
sf = getattr(cv2, "IMWRITE_JPEG_SAMPLING_FACTOR", None)
|
|
sf444 = getattr(cv2, "IMWRITE_JPEG_SAMPLING_FACTOR_444", None)
|
|
if sf is not None and sf444 is not None:
|
|
params += [sf, sf444]
|
|
return params
|
|
if ext == ".webp":
|
|
# cv2 WebP: quality 1-100 is LOSSY; a value > 100 selects LOSSLESS mode.
|
|
# "work with originals" requires lossless so a mark-removal re-encode does not
|
|
# degrade the untouched pixels the fill composites over (regression: q100
|
|
# round-tripped a random image at maxdiff ~230, q101 at 0).
|
|
return [cv2.IMWRITE_WEBP_QUALITY, 101]
|
|
return []
|
|
|
|
|
|
def _pil_write(
|
|
path: str | Path,
|
|
img: NDArray[Any],
|
|
*,
|
|
icc_profile: bytes | None = None,
|
|
orientation: int | None = None,
|
|
) -> bool:
|
|
"""Encode HEIC/AVIF via Pillow (+pillow-heif) at high quality -- cv2 has no encoder
|
|
for them. BGR / BGRA in; returns False if Pillow (with the plugin) cannot save.
|
|
The display tags, when given, ride along in the save call: Pillow's HEIF encoder
|
|
writes both natively, so this branch needs no post-write splice."""
|
|
import cv2
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
_register_heif()
|
|
if img.ndim == 3 and img.shape[2] == 4:
|
|
arr, mode = cv2.cvtColor(img, cv2.COLOR_BGRA2RGBA), "RGBA"
|
|
else:
|
|
arr, mode = cv2.cvtColor(to_bgr(img), cv2.COLOR_BGR2RGB), "RGB"
|
|
save_kwargs: dict[str, Any] = {"quality": 100}
|
|
if icc_profile:
|
|
save_kwargs["icc_profile"] = icc_profile
|
|
exif = _orientation_exif(orientation, prefixed=True)
|
|
if exif:
|
|
save_kwargs["exif"] = exif
|
|
try:
|
|
Image.fromarray(np.ascontiguousarray(arr), mode).save(str(path), **save_kwargs)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def imwrite(
|
|
path: str | Path,
|
|
img: NDArray[Any],
|
|
*,
|
|
display_tags_from: str | Path | None = None,
|
|
orientation_applied: bool | None = None,
|
|
) -> bool:
|
|
"""Unicode-safe image write that PRESERVES the input format at max quality.
|
|
|
|
Format is taken from the path extension. HEIC/AVIF (which cv2 cannot encode) go
|
|
through Pillow; everything else through cv2 with quality-preserving params (see
|
|
:func:`_encode_params`) so a lossy re-encode of the untouched pixels stays near-
|
|
lossless. Returns ``True`` on success, ``False`` if the codec rejects the image or
|
|
the path cannot be written (matching ``cv2.imwrite``, never raising).
|
|
|
|
When ``display_tags_from`` names the file whose decode produced ``img``'s pixels,
|
|
the source's ICC profile and EXIF orientation are carried into the re-encoded
|
|
output (issue #98): cv2's encoders write no container metadata, and colour and
|
|
geometry are display fidelity, not provenance. The tags are read BEFORE the write
|
|
so an in-place rewrite (source == output) still finds them.
|
|
Set ``orientation_applied=False`` for an unchanged/raw decode, or ``True``
|
|
when EXIF has already been applied, including mirrors and square images.
|
|
Without that explicit state, orientation is re-tagged only when the raster
|
|
still has the source's stored dimensions, using the legacy heuristic in
|
|
:func:`_read_display_tags`. Pass the known decode state to avoid ambiguous
|
|
geometry, especially square images and mirrors."""
|
|
import cv2
|
|
|
|
icc, orient = (
|
|
_read_display_tags(
|
|
Path(display_tags_from), (img.shape[0], img.shape[1]), orientation_applied=orientation_applied
|
|
)
|
|
if display_tags_from is not None
|
|
else (None, None)
|
|
)
|
|
ext = (Path(path).suffix or ".png").lower()
|
|
if ext in _HEIF_WRITE_EXTS:
|
|
return _pil_write(path, img, icc_profile=icc, orientation=orient)
|
|
try:
|
|
ok, buf = cv2.imencode(ext, img, _encode_params(ext))
|
|
except cv2.error:
|
|
return False
|
|
if not ok:
|
|
return False
|
|
try:
|
|
buf.tofile(str(path))
|
|
except OSError:
|
|
return False
|
|
_restore_display_tags(Path(path), icc, orient)
|
|
return True
|
|
|
|
|
|
# Container extensions that carry an alpha channel (for read/write-with-alpha).
|
|
ALPHA_FORMATS = {".png", ".webp", ".heic", ".heif", ".avif"}
|
|
|
|
|
|
def read_bgr_and_alpha(path: str | Path) -> tuple[NDArray[Any] | None, NDArray[Any] | None]:
|
|
"""Read an image preserving its alpha channel separately.
|
|
|
|
Returns ``(bgr, alpha)`` where ``alpha`` is a single-channel ndarray when the
|
|
source has transparency, else ``None``. Grayscale inputs are promoted to BGR.
|
|
Returns ``(None, None)`` if the image cannot be decoded.
|
|
"""
|
|
import cv2
|
|
|
|
image = imread(path, cv2.IMREAD_UNCHANGED)
|
|
if image is None:
|
|
return None, None
|
|
if image.ndim == 2:
|
|
return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR), None
|
|
if image.shape[2] == 4:
|
|
return image[:, :, :3].copy(), image[:, :, 3].copy()
|
|
return image, None
|
|
|
|
|
|
def write_bgr_with_alpha(
|
|
path: str | Path,
|
|
bgr: NDArray[Any],
|
|
alpha: NDArray[Any] | None,
|
|
*,
|
|
display_tags_from: str | Path | None = None,
|
|
orientation_applied: bool | None = None,
|
|
) -> bool:
|
|
"""Write BGR (with optional alpha) to ``path``. Returns ``imwrite``'s success flag.
|
|
|
|
When ``alpha`` is provided and the output extension supports it, the original
|
|
alpha plane is rejoined unchanged. The watermark region is NOT made transparent:
|
|
the fill reconstructs real pixels there, so zeroing alpha would punch a
|
|
transparent hole that renders as a white box on any non-transparent viewer
|
|
(issue #30). Preserving the input alpha keeps genuinely transparent backgrounds
|
|
intact without inventing new holes.
|
|
|
|
Returning the flag is load-bearing: :func:`imwrite` is contractually non-raising, so
|
|
this is the ONLY signal a caller gets that the file was not created. Discarding it let
|
|
a failed write (read-only directory, full disk) run on to ``output.stat()`` and die
|
|
with a bare ``FileNotFoundError`` traceback instead of a readable error.
|
|
Regression: ``tests/test_cli_robustness.py::TestFailedWriteIsReported``.
|
|
"""
|
|
import numpy as np
|
|
|
|
if alpha is None or Path(path).suffix.lower() not in ALPHA_FORMATS:
|
|
return imwrite(path, bgr, display_tags_from=display_tags_from, orientation_applied=orientation_applied)
|
|
return imwrite(
|
|
path, np.dstack([bgr, alpha]), display_tags_from=display_tags_from, orientation_applied=orientation_applied
|
|
)
|