mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-15 05:50:45 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f020115ca2 | ||
|
|
62be1ef456 | ||
|
|
4d7c2b458e | ||
|
|
96b8ab9ae2 | ||
|
|
5b30f0169a | ||
|
|
003dabfae4 | ||
|
|
4c090c5ae2 | ||
|
|
8c025985f7 | ||
|
|
5f1dea95f4 | ||
|
|
00d9771c6a | ||
|
|
de03748849 | ||
|
|
1cfdf192d8 |
No files matched your search
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Agent skill and Claude Code plugin for the remove-ai-watermarks CLI.",
|
||||
"version": "1.0.5"
|
||||
"version": "1.0.6"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -94,7 +94,15 @@ jobs:
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install ffmpeg
|
||||
run: sudo apt-get update && sudo apt-get install --yes ffmpeg
|
||||
# Third-party apt sources baked into the runner image (google-chrome et
|
||||
# al.) can publish a broken index and fail `apt-get update`, while this
|
||||
# job only needs ffmpeg from Ubuntu's own repositories: drop every
|
||||
# non-Ubuntu source definition before updating.
|
||||
run: |
|
||||
sudo find /etc/apt/sources.list.d -mindepth 1 -maxdepth 1 \
|
||||
! -name 'ubuntu.sources' -delete
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes ffmpeg
|
||||
- name: Sync dev environment
|
||||
run: uv sync --frozen --extra dev
|
||||
- name: Run full-clip video test
|
||||
|
||||
@@ -59,9 +59,13 @@ jobs:
|
||||
import json, os
|
||||
from pathlib import Path
|
||||
identities = []
|
||||
for path in Path("distribution-identities").glob("*/release-identity.json"):
|
||||
for path in Path("distribution-identities").rglob("release-identity.json"):
|
||||
item = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert path.parent.name == 'distributed-release-' + item['run_attempt'], "Artifact attempt mismatch"
|
||||
# download-artifact@v8 extracts the only matched artifact directly
|
||||
# into the path, with no artifact-name directory; several matches
|
||||
# each get one, so the attempt is name-checked only in that layout.
|
||||
if path.parent.name.startswith('distributed-release-'):
|
||||
assert path.parent.name == 'distributed-release-' + item['run_attempt'], "Artifact attempt mismatch"
|
||||
identities.append(item)
|
||||
assert identities, "No distributed release identity found"
|
||||
assert all(item["run_id"] == os.environ["DISTRIBUTION_RUN_ID"] for item in identities), "Distribution run mismatch"
|
||||
|
||||
+32
-18
@@ -54,10 +54,11 @@ Do these in order after the skill is on the default branch.
|
||||
brand-new skills ("give your skill time to mature and gain users before
|
||||
submitting"). Re-attempt after the skill has real installs and a
|
||||
release that ships it (0.38.0+).
|
||||
- `VoltAgent/awesome-openclaw-skills`: DEFERRED. Entries are indexed via
|
||||
clawskills.sh, so the skill must be published to ClawHub first, and the
|
||||
list asks contributors not to submit skills "created 3 hours ago".
|
||||
Re-attempt after the ClawHub publish plus visible adoption.
|
||||
- `VoltAgent/awesome-openclaw-skills`: DEFERRED on adoption only. Entries
|
||||
are indexed via clawskills.sh, and the ClawHub prerequisite in step 6 is
|
||||
now met. The list still asks contributors not to submit skills "created
|
||||
3 hours ago", and `clawhub search` reports 0 installs over 60 days.
|
||||
Re-attempt once the listing shows real installs.
|
||||
5. **Claude community marketplace.** Form at
|
||||
[platform.claude.com/plugins/submit](https://platform.claude.com/plugins/submit)
|
||||
(individuals) or the claude.ai directory form (Team/Enterprise). Validate
|
||||
@@ -68,20 +69,33 @@ Do these in order after the skill is on the default branch.
|
||||
official Anthropic marketplace is
|
||||
curated and has no application.
|
||||
6. **ClawHub.** `clawhub login` then `clawhub skill publish` from
|
||||
`skills/remove-ai-watermarks/`. PUBLISHED 2026-09-07 (1.0.3) and
|
||||
republished 2026-09-08 (1.0.4, identical content) — both sit in
|
||||
`pending.publication` with CLEAN moderation, succeeded scan frameworks,
|
||||
and null ClawScan / VirusTotal / Skillspector verdicts: a recurring
|
||||
ClawHub pipeline defect tracked as openclaw/clawhub#3643 (prior reports:
|
||||
#3624, #3466, #3351, #3284). Release-time publishing is automated through
|
||||
the `clawhub` job in `distribute.yml` (see
|
||||
[release-and-distribution.md](release-and-distribution.md)); do not bump
|
||||
the version again to re-queue until the issue resolves, and re-check the
|
||||
listing before any listing-dependent step such as the awesome-openclaw
|
||||
submission. The public catalog already has an add-watermark
|
||||
skill; this is the removal counterpart, framed as the user's own
|
||||
content. ClawHub disallows deception, impersonation, and fake-engagement
|
||||
install loops.
|
||||
`skills/remove-ai-watermarks/`. LISTED 2026-09-09: version 1.0.5, submitted
|
||||
by the `clawhub` job in `distribute.yml` during the 0.39.0 release, is
|
||||
public. A green job proves only a submission, so read the catalog itself:
|
||||
`clawhub search remove-ai-watermarks` returns the row and
|
||||
`https://clawhub.ai/api/v1/skills/remove-ai-watermarks` reports
|
||||
`tags.latest` 1.0.5 with a single published version. The earlier 1.0.3 and
|
||||
1.0.4 submissions never became public and do not appear in that version
|
||||
list, so they were bypassed rather than recovered. The issue that tracked
|
||||
them, openclaw/clawhub#3643, was closed by the reporter on 2026-09-09 once
|
||||
1.0.5 went through; nothing upstream was confirmed fixed, and it is the
|
||||
fifth report of this shape (#3624, #3466, #3351, #3284), so treat a stalled
|
||||
submission as recurring and file a fresh one rather than assuming the
|
||||
pipeline is repaired. Release-time publishing is automated (see
|
||||
[release-and-distribution.md](release-and-distribution.md)), and the job
|
||||
fires only on a published GitHub Release: a skill version bumped after a
|
||||
release, as 1.0.6 was, reaches ClawHub with the next one. Do not publish by
|
||||
hand to close that gap. The listing records the license as MIT-0 while
|
||||
`SKILL.md` declares Apache-2.0, and that is not a listing error to repair:
|
||||
MIT-0 is a platform-wide constant in ClawHub's own client
|
||||
(`dist/schema/license.js`, and its version schema accepts only `"MIT-0"`
|
||||
or null), so every skill published there is offered under MIT No
|
||||
Attribution. Publishing to ClawHub means offering this skill on those
|
||||
terms in addition to the repository's Apache-2.0; do not try to edit the
|
||||
field, and do not read the mismatch as drift. The public catalog
|
||||
already has an add-watermark skill; this is the removal counterpart, framed
|
||||
as the user's own content. ClawHub disallows deception, impersonation, and
|
||||
fake-engagement install loops.
|
||||
7. **OpenAI ChatGPT / Codex plugin directory.** Skills-only plugins are a
|
||||
supported submission type at
|
||||
[platform.openai.com/plugins](https://platform.openai.com/plugins). This is
|
||||
|
||||
@@ -388,7 +388,11 @@ OpenCV does not decode these formats in the project. `image_io.imread` falls
|
||||
back to Pillow with `pillow-heif` when the `heif` extra is installed alongside
|
||||
a pixel feature. The
|
||||
default metadata path scans these containers without that plugin. A corrupt or
|
||||
truncated file may still fail to decode.
|
||||
truncated file may still fail to decode. A camera-style AVIF's EXIF
|
||||
orientation is exposed by this plugin neither through `getexif()` nor
|
||||
`info["original_orientation"]`, so the display-tag carry cannot re-declare it
|
||||
(HEIC can; see the display-tags contract in
|
||||
[`module-internals.md`](module-internals.md)).
|
||||
|
||||
### Some metadata removal requires ffmpeg
|
||||
|
||||
|
||||
@@ -2148,7 +2148,25 @@ Contracts:
|
||||
pixels on save. Callers that know the decode state pass
|
||||
`orientation_applied=False` for raw pixels and `True` for a transposed
|
||||
raster. Dimensions alone cannot distinguish mirrors or 180-degree rotation.
|
||||
The omitted value retains the compatibility shape heuristic. Display tags
|
||||
TIFF and HEIF are the container exceptions on the explicit claim. Every
|
||||
reader in the stack (cv2's libtiff path under `IMREAD_UNCHANGED` too, and
|
||||
Pillow's TIFF plugin, identically) applies the TIFF orientation tag on
|
||||
decode, so `_read_display_tags` ignores the claim for TIFF and decides on
|
||||
the IFD's stored `ImageWidth`/`ImageLength` against the raster, via
|
||||
`_stored_size` -- `Image.size` already reports the upright geometry there,
|
||||
which is what let a turned raster be tagged into a second rotation
|
||||
(issue #106). HEIF blanks the tag instead: pillow-heif's opener rewrites
|
||||
the EXIF orientation to 1 (the real value waits in
|
||||
`info["original_orientation"]`), and libheif turns the raster exactly
|
||||
when the container declares an effective `irot`/`imir` transform
|
||||
(`isobmff.heif_transform_applied` scans the `meta`/`iprp`/`ipco`
|
||||
property store), so `Image.size` matches the raster in both cases and the
|
||||
shape heuristic has nothing to compare. A camera HEIC (stored raster,
|
||||
EXIF-only rotation, issue #105) re-declares its turn on the output; a
|
||||
re-encoded one whose writer baked `irot` decodes upright and is not
|
||||
tagged again. AVIF keeps the generic path: this plugin build neither
|
||||
blanks its tag nor applies the transform there. The omitted value retains
|
||||
the compatibility shape heuristic. Display tags
|
||||
are captured before encoding, including when source and destination match.
|
||||
|
||||
The metadata strip is the other half of that contract: Pillow cannot hold 16-bit
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
# on it, including the ComfyUI node package. The console script below carries
|
||||
# the same weight, since users have it on PATH.
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.39.0"
|
||||
version = "0.39.1"
|
||||
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11,<3.15"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remove-ai-watermarks",
|
||||
"displayName": "Remove AI Watermarks",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"description": "Identify and remove AI provenance marks from images and video the user generated or edited, using the remove-ai-watermarks CLI.",
|
||||
"author": {
|
||||
"name": "wiltodelta",
|
||||
|
||||
@@ -5,7 +5,7 @@ license: Apache-2.0
|
||||
compatibility: Python 3.11-3.14. Invisible image removal needs NVIDIA CUDA. Some video writes need ffmpeg. Installer may be uv, pipx, or pip.
|
||||
metadata:
|
||||
author: wiltodelta
|
||||
version: "1.0.5"
|
||||
version: "1.0.6"
|
||||
homepage: https://raiw.cc
|
||||
repository: https://github.com/wiltodelta/remove-ai-watermarks
|
||||
---
|
||||
|
||||
@@ -26,7 +26,8 @@ Image mark keys: `gemini`, `doubao`, `jimeng`, `qwen`, `kling`, `yuanbao`,
|
||||
`--mark auto` removes every selected match. `microsoft` is the top-right AI
|
||||
badge, separate from the invisible InvisMark watermark on the same vendor's
|
||||
images. `jimeng_pill` is a weak detector and needs corroboration. The compact
|
||||
`liblib_pill` likewise requires LiblibAI metadata or the bottom-center wordmark.
|
||||
`liblib_pill` likewise requires LiblibAI metadata or the bottom-center wordmark
|
||||
(since CLI 0.39.0).
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks erase image.png --region 1640,1930,400,100 -o clean.png
|
||||
@@ -88,8 +89,8 @@ run on.
|
||||
Pipeline profiles: `qwen-zimage` (the default), `sdxl-zimage`, `chroma-zimage`,
|
||||
`auto`. All are CUDA-only and all install from the same `qwen-zimage` extra.
|
||||
`auto` picks the engine from the file's own provenance -- chroma-zimage for
|
||||
Microsoft, qwen-zimage for OpenAI, Google, Meta and unknown -- so prefer it
|
||||
over naming an engine when the user has no reason to care. `sdxl-zimage` is the
|
||||
Microsoft, qwen-zimage for OpenAI (since CLI 0.39.0), Google, Meta and unknown
|
||||
-- so prefer it over naming an engine when the user has no reason to care. `sdxl-zimage` is the
|
||||
heavier alternative and `chroma-zimage` is the Apache-2.0 global stage.
|
||||
|
||||
```bash
|
||||
|
||||
@@ -24,13 +24,14 @@ from typing import Any
|
||||
_GPU_MARKERS = ("GPU ", "gpu:")
|
||||
# Below this, the CLI predates flags and behavior this skill documents. Raise it in
|
||||
# the same change as any reference that names a flag the previous release lacked:
|
||||
# 0.37.0 is here because `classify` arrived with it (`--pipeline auto` needed
|
||||
# 0.36.0; chroma-zimage and the doubao video mark 0.35.0;
|
||||
# the `microsoft` mark 0.34.0; `--vendor` 0.32.0), and a stale floor told an agent
|
||||
# its CLI was current right before it typed a value that build rejects.
|
||||
# tests/test_agent_skill.py pins it against the package version, so it can never
|
||||
# advertise a release that does not exist.
|
||||
MIN_CLI_VERSION = (0, 38, 0)
|
||||
# 0.39.0 is here because the `liblib_pill` mark and `auto` routing OpenAI
|
||||
# provenance to qwen-zimage arrived with it; 0.38.0 added the receipt gate;
|
||||
# `classify` needed 0.37.0 (`--pipeline auto` 0.36.0; chroma-zimage and the
|
||||
# doubao video mark 0.35.0; the `microsoft` mark 0.34.0; `--vendor` 0.32.0),
|
||||
# and a stale floor told an agent its CLI was current right before it typed a
|
||||
# value that build rejects. tests/test_agent_skill.py pins it against the
|
||||
# package version and against every "(since CLI X.Y.Z)" annotation in the skill.
|
||||
MIN_CLI_VERSION = (0, 39, 0)
|
||||
# Markers a build without the pixel stack prints. The first is the current CLI's own
|
||||
# install hint; the rest are what older builds emit before the guard existed, and the
|
||||
# Homebrew formula ships exactly such a build.
|
||||
|
||||
@@ -33,7 +33,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.39.0"
|
||||
__version__ = "0.39.1"
|
||||
|
||||
__all__ = [
|
||||
"BatchItemResult",
|
||||
|
||||
@@ -344,6 +344,68 @@ def is_isobmff(data: bytes) -> bool:
|
||||
return len(data) >= 8 and data[4:8] == b"ftyp"
|
||||
|
||||
|
||||
# What a HEIF ``meta`` box's first child can be; used to probe whether the box
|
||||
# carries the ISO FullBox header (children start 4 bytes into the payload) or
|
||||
# the QuickTime bare form (children start at 0).
|
||||
_HEIF_META_CHILD_TYPES = frozenset({b"hdlr", b"pitm", b"iinf", b"iprp", b"iloc", b"iref", b"idat"})
|
||||
|
||||
|
||||
def heif_transform_applied(path: str | Path) -> bool:
|
||||
"""Whether a HEIF/AVIF file declares an effective container transform.
|
||||
|
||||
Scans the ``meta`` box's ``iprp``/``ipco`` property store for an ``irot``
|
||||
box with a non-zero angle or any ``imir`` box. libheif applies those
|
||||
transforms on decode, and that is the only signal separating a re-encoded
|
||||
HEIF -- the writer baked the rotation as ``irot`` and the decode comes back
|
||||
upright -- from a camera-style one that stores the raw raster and carries
|
||||
the rotation only in its EXIF payload: pillow-heif blanks that tag on open
|
||||
for both and stashes the real value in ``info["original_orientation"]"
|
||||
(issue #105). Association through ``ipma`` is not re-derived: a stray
|
||||
unassociated ``irot`` reads as "transformed", the direction that drops the
|
||||
tag and keeps today's tagless output rather than risking a double rotation.
|
||||
A malformed or missing structure reads as "no transform".
|
||||
"""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
sniff = f.read(8)
|
||||
if len(sniff) < 8 or sniff[4:8] != b"ftyp":
|
||||
return False
|
||||
f.seek(0, 2)
|
||||
file_size = f.tell()
|
||||
for _start, end, box_type, payload_off in iter_file_boxes(f, 0, file_size):
|
||||
if box_type != b"meta":
|
||||
continue
|
||||
# ISO serializes ``meta`` as a FullBox, so children start 4 bytes
|
||||
# into the payload; the QuickTime bare form starts at 0. The form
|
||||
# whose first child has a known type is the real one.
|
||||
children = None
|
||||
for offset in (4, 0):
|
||||
first = _read_box_header(f, payload_off + offset, end)
|
||||
if first is not None and first[1] in _HEIF_META_CHILD_TYPES:
|
||||
children = iter_file_boxes(f, payload_off + offset, end)
|
||||
break
|
||||
if children is None:
|
||||
return False
|
||||
for _s, iprp_end, iprp_type, iprp_payload in children:
|
||||
if iprp_type != b"iprp":
|
||||
continue
|
||||
for _s2, ipco_end, ipco_type, ipco_payload in iter_file_boxes(f, iprp_payload, iprp_end):
|
||||
if ipco_type != b"ipco":
|
||||
continue
|
||||
for _s3, _prop_end, prop_type, prop_payload in iter_file_boxes(f, ipco_payload, ipco_end):
|
||||
if prop_type == b"irot":
|
||||
f.seek(prop_payload)
|
||||
angle = f.read(1)
|
||||
if angle and angle[0] & 0x03:
|
||||
return True
|
||||
elif prop_type == b"imir":
|
||||
return True
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def scan_c2pa_region(path: str | Path, *, max_total: int = 4 * 1024 * 1024, strict: bool = False) -> bytes:
|
||||
"""Concatenated payloads of top-level ``uuid`` / ``jumb`` boxes in an ISOBMFF
|
||||
file, found by seeking past other boxes (``mdat`` etc.) by size.
|
||||
|
||||
@@ -29,6 +29,7 @@ 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__)
|
||||
|
||||
@@ -72,7 +73,10 @@ 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 does not apply EXIF orientation."""
|
||||
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
|
||||
|
||||
@@ -152,6 +156,8 @@ def _register_heif() -> None:
|
||||
# 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
|
||||
@@ -176,6 +182,23 @@ def _orientation_exif(orient: int | None, *, prefixed: bool) -> bytes | None:
|
||||
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]:
|
||||
@@ -195,6 +218,26 @@ def _read_display_tags(
|
||||
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:
|
||||
@@ -205,11 +248,29 @@ def _read_display_tags(
|
||||
with Image.open(source) as im:
|
||||
icc = im.info.get("icc_profile")
|
||||
orient = im.getexif().get(_ORIENT_EXIF_TAG)
|
||||
stored_w, stored_h = im.size
|
||||
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:
|
||||
|
||||
@@ -104,6 +104,35 @@ def test_probe_min_cli_version_never_exceeds_the_package_version() -> None:
|
||||
sys.modules.pop("probe", None)
|
||||
|
||||
|
||||
def test_probe_floor_covers_every_since_annotation() -> None:
|
||||
"""MIN_CLI_VERSION must be at least the newest named "(since CLI X.Y.Z)" value.
|
||||
|
||||
A reference that names a value the previous release lacked has to raise the
|
||||
floor in the same change; this reads the annotations back out of the skill
|
||||
tree so a stale floor fails the suite instead of an agent's CLI.
|
||||
"""
|
||||
|
||||
sys.path.insert(0, str(ROOT / "skills" / "remove-ai-watermarks" / "scripts"))
|
||||
try:
|
||||
import probe
|
||||
|
||||
annotated = []
|
||||
for path in (ROOT / "skills" / "remove-ai-watermarks").rglob("*"):
|
||||
if path.suffix not in {".md", ".py"} or not path.is_file():
|
||||
continue
|
||||
for match in re.finditer(r"\(since CLI (\d+)\.(\d+)\.(\d+)\)", path.read_text(encoding="utf-8")):
|
||||
annotated.append(tuple(int(part) for part in match.groups()))
|
||||
assert annotated, "no (since CLI X.Y.Z) annotations found; the floor guard lost its source"
|
||||
highest = max(annotated)
|
||||
assert highest <= probe.MIN_CLI_VERSION, (
|
||||
f"probe.py MIN_CLI_VERSION {probe.MIN_CLI_VERSION} is below {highest}, the newest "
|
||||
"(since CLI X.Y.Z) annotation in the skill; a build that old rejects a named value"
|
||||
)
|
||||
finally:
|
||||
sys.path.remove(str(ROOT / "skills" / "remove-ai-watermarks" / "scripts"))
|
||||
sys.modules.pop("probe", None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"relative",
|
||||
[
|
||||
|
||||
@@ -409,3 +409,154 @@ class TestRemoverWriteCarriesDisplayTags:
|
||||
with Image.open(out) as im:
|
||||
assert im.info.get("icc_profile") == icc
|
||||
assert im.getexif().get(0x0112) is None
|
||||
|
||||
|
||||
class TestTiffOrientation:
|
||||
"""Pillow's TIFF reader applies the EXIF orientation itself, and reports the
|
||||
upright size while doing it. A stored-size comparison against ``Image.size``
|
||||
therefore matches for a raster that is already upright, and the tag is carried
|
||||
onto it -- a viewer then turns the picture a second time."""
|
||||
|
||||
ORIENT = 6 # Rotate 90 CW.
|
||||
|
||||
@staticmethod
|
||||
def _tagged(path: Path, orient: int) -> Path:
|
||||
"""A 96x64 stored raster carrying ``orient``, written by the container itself."""
|
||||
import piexif
|
||||
from PIL import Image
|
||||
|
||||
exif = piexif.dump({"0th": {piexif.ImageIFD.Orientation: orient}})
|
||||
Image.new("RGB", (96, 64), "red").save(path, exif=exif)
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _out_orientation(path: Path) -> object:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(path) as im:
|
||||
return im.getexif().get(0x0112)
|
||||
|
||||
def test_a_turned_tiff_decode_is_not_tagged_into_a_second_rotation(self, tmp_path: Path) -> None:
|
||||
src = self._tagged(tmp_path / "src.tif", self.ORIENT)
|
||||
raster = image_io.imread(src, cv2.IMREAD_UNCHANGED)
|
||||
assert raster is not None
|
||||
# Pillow already applied the rotation: the raster is upright, 64x96.
|
||||
assert (raster.shape[1], raster.shape[0]) == (64, 96)
|
||||
out = tmp_path / "out.png"
|
||||
assert image_io.imwrite(out, raster, display_tags_from=src) is True
|
||||
assert self._out_orientation(out) is None
|
||||
|
||||
def test_the_visible_api_path_does_not_tag_a_turned_tiff(self, tmp_path: Path) -> None:
|
||||
# The visible write passes orientation_applied=False ("raw decode"), which is
|
||||
# a lie for TIFF: both readers turn the raster. _read_display_tags must ignore
|
||||
# that claim for TIFF and let the IFD geometry decide, or a CLI/library user
|
||||
# gets a turned raster TAGGED for the same turn again (issue #106).
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks import api
|
||||
|
||||
src = self._tagged(tmp_path / "src.tif", self.ORIENT)
|
||||
out = tmp_path / "out.png"
|
||||
_, removed = api.remove_visible(src, out, strip_metadata=False)
|
||||
assert removed == [] # plain red: no mark, the passthrough write is exercised
|
||||
with Image.open(out) as im:
|
||||
assert im.size == (64, 96) # the upright raster, not the stored landscape
|
||||
assert im.getexif().get(0x0112) is None # and no tag asking for another turn
|
||||
|
||||
|
||||
class TestHeifOrientation:
|
||||
"""A camera HEIC stores its raster and carries the rotation in the EXIF
|
||||
payload; a pillow-heif-written one bakes the rotation as an ``irot`` transform
|
||||
that the decode applies. pillow-heif blanks the EXIF tag on open for both and
|
||||
stashes the real value in ``info["original_orientation"]``, and ``Image.size``
|
||||
matches the raster either way, so the container's transform boxes are the only
|
||||
signal telling the two apart (issue #105)."""
|
||||
|
||||
ORIENT = 6 # Rotate 90 CW.
|
||||
|
||||
@staticmethod
|
||||
def _baked(path: Path) -> Path:
|
||||
"""A 96x64 stored raster whose rotation is baked as an irot transform."""
|
||||
import piexif
|
||||
from PIL import Image
|
||||
|
||||
exif = piexif.dump({"0th": {piexif.ImageIFD.Orientation: TestHeifOrientation.ORIENT}})
|
||||
Image.new("RGB", (96, 64), "red").save(path, exif=exif)
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _camera_style(path: Path) -> Path:
|
||||
"""The camera counterpart: same EXIF payload, identity transform, so the
|
||||
decode returns the stored raster and only the tag says to turn it."""
|
||||
data = bytearray(TestHeifOrientation._baked(path).read_bytes())
|
||||
meta_end = data.find(b"mdat")
|
||||
irot = data.find(b"irot", 0, meta_end if meta_end != -1 else len(data))
|
||||
assert irot != -1, "fixture lost its irot box"
|
||||
data[irot + 4] = 0 # angle 0 is the identity transform
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
def test_malformed_input_reads_as_no_transform(self, tmp_path: Path) -> None:
|
||||
from remove_ai_watermarks._internal import isobmff
|
||||
|
||||
not_bmff = tmp_path / "garbage.bin"
|
||||
not_bmff.write_bytes(b"\x00\x00\x00\x0cJUNKgarbage")
|
||||
assert isobmff.heif_transform_applied(not_bmff) is False
|
||||
empty = tmp_path / "empty.heic"
|
||||
empty.write_bytes(b"")
|
||||
assert isobmff.heif_transform_applied(empty) is False
|
||||
assert isobmff.heif_transform_applied(tmp_path / "missing.heic") is False
|
||||
|
||||
@pytest.mark.skipif(not _heif_writable("HEIF"), reason="no HEIC encoder in this env")
|
||||
def test_the_transform_box_alone_reports_the_decode_state(self, tmp_path: Path) -> None:
|
||||
from remove_ai_watermarks._internal import isobmff
|
||||
|
||||
assert isobmff.heif_transform_applied(self._baked(tmp_path / "baked.heic")) is True
|
||||
assert isobmff.heif_transform_applied(self._camera_style(tmp_path / "cam.heic")) is False
|
||||
|
||||
@pytest.mark.skipif(not _heif_writable("HEIF"), reason="no HEIC encoder in this env")
|
||||
def test_a_camera_heic_carries_its_orientation_tag(self, tmp_path: Path) -> None:
|
||||
# Issue #105: the blanked tag used to be dropped, so the output went out
|
||||
# sideways with nothing on it saying so.
|
||||
from PIL import Image
|
||||
|
||||
src = self._camera_style(tmp_path / "cam.heic")
|
||||
raster = image_io.imread(src, cv2.IMREAD_UNCHANGED)
|
||||
assert raster is not None
|
||||
assert (raster.shape[1], raster.shape[0]) == (96, 64) # the stored raster
|
||||
out = tmp_path / "out.png"
|
||||
assert image_io.imwrite(out, raster, display_tags_from=src) is True
|
||||
with Image.open(out) as im:
|
||||
assert im.size == (96, 64)
|
||||
assert im.getexif().get(0x0112) == self.ORIENT # the turn re-declared
|
||||
|
||||
@pytest.mark.skipif(not _heif_writable("HEIF"), reason="no HEIC encoder in this env")
|
||||
def test_a_baked_heif_is_not_tagged_into_a_second_rotation(self, tmp_path: Path) -> None:
|
||||
from PIL import Image
|
||||
|
||||
src = self._baked(tmp_path / "baked.heic")
|
||||
raster = image_io.imread(src, cv2.IMREAD_UNCHANGED)
|
||||
assert raster is not None
|
||||
assert (raster.shape[1], raster.shape[0]) == (64, 96) # libheif turned it upright
|
||||
out = tmp_path / "out.png"
|
||||
assert image_io.imwrite(out, raster, display_tags_from=src) is True
|
||||
with Image.open(out) as im:
|
||||
assert im.getexif().get(0x0112) is None
|
||||
|
||||
@pytest.mark.skipif(not _heif_writable("HEIF"), reason="no HEIC encoder in this env")
|
||||
def test_the_visible_api_path_carries_a_camera_heic_tag(self, tmp_path: Path) -> None:
|
||||
# The visible write passes orientation_applied=False ("raw decode"), a
|
||||
# claim it cannot make for HEIF: whether libheif turned the raster is a
|
||||
# per-file fact of the container. _read_display_tags must override the
|
||||
# claim the way it does for TIFF (issue #105).
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks import api
|
||||
|
||||
src = self._camera_style(tmp_path / "cam.heic")
|
||||
out = tmp_path / "out.png"
|
||||
_, removed = api.remove_visible(src, out, strip_metadata=False)
|
||||
assert removed == [] # plain red: no mark, the passthrough write is exercised
|
||||
with Image.open(out) as im:
|
||||
assert im.size == (96, 64) # the stored raster...
|
||||
assert im.getexif().get(0x0112) == self.ORIENT # ...with its turn re-declared
|
||||
@@ -135,6 +135,35 @@ def test_distributed_version_survives_a_newer_pypi_release(tmp_path: Path) -> No
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix" or shutil.which("bash") is None, reason="Executes an Ubuntu Bash workflow")
|
||||
def test_distributed_version_reads_the_flat_single_artifact_layout(tmp_path: Path) -> None:
|
||||
producer = _step("distribute.yml", "resolve", "Record distributed release")
|
||||
consumer = _step("verify-release.yml", "verify", "Resolve target version")
|
||||
env = {
|
||||
**os.environ,
|
||||
"VERSION": "0.37.0",
|
||||
"GITHUB_RUN_ID": "123",
|
||||
"GITHUB_RUN_ATTEMPT": "2",
|
||||
"GITHUB_OUTPUT": str(tmp_path / "output"),
|
||||
"EVENT_NAME": "workflow_run",
|
||||
"INPUT_VERSION": "",
|
||||
"DISTRIBUTION_RUN_ID": "123",
|
||||
"DISTRIBUTION_RUN_ATTEMPT": "2",
|
||||
}
|
||||
subprocess.run(["bash", "-eu", "-c", producer["run"]], cwd=tmp_path, env=env, check=True)
|
||||
# download-artifact@v8 extracts the only pattern-matched artifact directly
|
||||
# into the target path, with no artifact-name directory.
|
||||
artifact = tmp_path / "distribution-identities"
|
||||
artifact.mkdir()
|
||||
(artifact / "release-identity.json").write_bytes((tmp_path / "release-identity.json").read_bytes())
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-eu", "-c", consumer["run"]], cwd=tmp_path, env=env, capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "version=0.37.0" in (tmp_path / "output").read_text()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix" or shutil.which("bash") is None, reason="Executes an Ubuntu Bash workflow")
|
||||
def test_clawhub_uses_local_install(tmp_path: Path) -> None:
|
||||
script = _step("distribute.yml", "clawhub", "Publish the agent skill when its version moved")["run"]
|
||||
|
||||
Reference in new issue
Block a user