diff --git a/CLAUDE.md b/CLAUDE.md index 7a912d6..a0967af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r - `region_eraser.py` — universal region eraser (`erase` CLI). `erase(image, boxes=|mask=, backend=)`: `boxes_to_mask` → `cv2.inpaint` (`cv2` backend, default, no deps) or big-LaMa via onnxruntime (`lama` backend, extra `lama`, `Carve/LaMa-ONNX` Apache-2.0 model downloaded on first use, never bundled). `erase_lama` crops a padded region around the mask, runs LaMa at its fixed 512² input, pastes only masked pixels back (untouched areas stay pixel-exact). Lazy `_get_lama_session` singleton; `lama_available()` guards the optional import. **LaMa-ONNX costs ~3.5-4 GB peak RAM and ~5-6 s/call on CPU** (FFC working set, not arena — `enable_cpu_mem_arena=False` does not help), so it does NOT fit a minimal droplet; the cv2 backend (tens of MB, ~30 ms) does. LaMa quality at low RAM = serverless/GPU, mirroring how raiw.cc offloads SDXL to fal. - `invisible_watermark.py` — `detect_invisible_watermark(path)` decodes the OPEN DWT-DCT watermarks (public decoder, no key) embedded by Stable Diffusion / SDXL / FLUX via the `imwatermark` library. Known fixed patterns (verified against upstream source) live in `_BITS_48` (SDXL 48-bit, FLUX.2 48-bit) and `_SD1_STRING` ("StableDiffusionV1", SD 1.x/2.x). Optional dep (extra `detect`); returns None when absent. The `detect` extra pulls **torch** transitively (invisible-watermark declares torch a hard dep, and `WatermarkDecoder` eagerly imports `rivaGan` -> `torch` at import time), so detection needs torch present even though dwtDct runs CPU-only on cv2/numpy/pywavelets — no GPU and no separate `gpu` extra required. **Unlike SynthID this is locally detectable**, but the watermark is fragile (does not survive JPEG re-encode/resize — verified gone after JPEG q90), so it confirms origin only on pristine files. Add new known patterns here. The file carries a top-of-module pyright pragma because imwatermark/cv2 ship no type stubs. - `trustmark_detector.py` — `detect_trustmark(path)` decodes the OPEN, keyless **Adobe TrustMark** watermark (the soft binding behind Adobe Durable Content Credentials, `alg` `com.adobe.trustmark.P`) via the optional `trustmark` package (extra `trustmark`; pulls torch, downloads model weights on first use). Mirrors `invisible_watermark.py` (lazy singleton, top-of-module pyright pragma, returns None when absent). It detects *provenance*, not AI origin as such (TrustMark also marks human-authored content), so `identify` lists it as a watermark without setting `is_ai_generated`. Other soft-binding vendors (Digimarc/Imatag/Steg.AI/...) have no public decoder — they are only *named* via the `C2PA_SOFT_BINDINGS` scan, not decoded. **False-positive gate (added 2026-05-29):** TrustMark's `wm_present` is a BCH error-correction validity flag that spuriously validates on a content-correlated fraction of un-watermarked images — AI-generated textures trip it far more than camera photos (verified 2026-05-29 on real files: it fires on Gemini/OpenAI/Doubao output that *cannot* carry Adobe's watermark, with a random-bytes decoded secret, while signal-free camera photos did not trip it). A genuine TrustMark is a *durable* soft binding engineered to survive re-encoding, so `detect_trustmark` re-decodes after a mild JPEG round-trip (`_survives_reencode`, `_REENCODE_QUALITY` 95) and requires the same schema both times; every observed false positive collapsed (none survived even q95), so the gate is the durability property the watermark guarantees. The second decode runs only on the rare initial hit, so the cost is negligible. Do NOT remove the gate to "catch more" — a lone TrustMark hit without it is almost always content noise. -- `text_protector.py` — text-region protection for the `invisible` SDXL img2img pass (issue #21: CJK/small text deforms at watermark-removal strengths). `is_available()` gates on `cv2.dnn.TextDetectionModel_DB`; `TextProtector.detect_text_boxes(bgr)` runs the **PP-OCRv3 DB** ONNX detector (~2.4 MB, Apache-2.0, opencv_zoo, returns rotated quad polygons) — downloaded+cached to `~/.cache/remove-ai-watermarks` on first use via atomic temp-rename, never bundled, **no torch (cv2.dnn only)**. **Detection is script-agnostic** (DB segments text *regions*, not characters), so Latin / Cyrillic / CJK / Hangul / Arabic / digits all detect identically — language was never the recall lever, **resolution was**. `_detection_input_size(h, w)` (pure, unit-tested) detects at the **native long side capped at `_DET_MAX_LONG_SIDE` (1536), never upscaled**: the old fixed 736 downscaled large canvases so small text fell below the detector and was missed (issue #14, e.g. ~16 px text on a 2048 image). `scripts/text_detection_benchmark.py` measures recall across scripts × sizes × canvas: the cap fix lifts overall hit-rate 0.91 → 1.00 (worst cell 2048/16 px: 0.06 → 1.00) at ~100 ms CPU. Very large canvases with tiny text may still need tiling (documented limit, not built). `build_change_map(boxes, h, w, preserve=0.9, feather=15)` paints a Differential-Diffusion change map. **Polarity (verified empirically):** white(1.0)=PRESERVE original pixels, black(0.0)=MAX change; map is black bg + `preserve` inside text polygons, Gaussian-feathered edges, clipped to [0,1]. `preserve` stays below a hard 1.0 freeze by default so text still scrubs lightly (SynthID survives cropping). Wired into `watermark_remover._run_differential` via the community `pipeline_stable_diffusion_xl_differential_img2img` (loaded with `custom_revision="0.38.0"` — HF resolves the **PyPI** version string, not the `v0.38.0` git tag); gated to the SDXL `DEFAULT_MODEL_ID` only (`_can_protect_text`), falls back to plain img2img otherwise. **Autonomous by default** (`protect_text=True` in `invisible_engine`/`watermark_remover`, mirroring `protect_faces`): the detector runs per image and `_run_differential` falls back to plain img2img when **no boxes** are found, so text-free inputs pay only the cheap cv2 detection (no differential-pipeline load). CLI exposes a single off-switch `--no-protect-text` on `invisible`/`all` (passed as `protect_text=not no_protect_text`); the unavailable-model case logs at debug, not warning, since it is now the default path. The diff pipeline upcasts the VAE to fp32 internally, so do **not** add `upcast_vae()`/`enable_attention_slicing` (both produced NaN/black on fp16 MPS). `build_change_map` is unit-tested without any model download (`tests/test_text_protector.py`). +- `text_protector.py` — text-region protection for the `invisible` SDXL img2img pass (issue #21: CJK/small text deforms at watermark-removal strengths). `is_available()` gates on `cv2.dnn.TextDetectionModel_DB`; `TextProtector.detect_text_boxes(bgr)` runs the **PP-OCRv3 DB** ONNX detector (~2.4 MB, Apache-2.0, opencv_zoo, returns rotated quad polygons) — downloaded+cached to `~/.cache/remove-ai-watermarks` on first use via atomic temp-rename, never bundled, **no torch (cv2.dnn only)**. **Detection is script-agnostic** (DB segments text *regions*, not characters), so Latin / Cyrillic / CJK / Hangul / Arabic / digits all detect identically — language was never the recall lever, **resolution was**. `_detection_input_size(h, w)` (pure, unit-tested) detects at the **native long side capped at `_DET_MAX_LONG_SIDE` (1536), never upscaled**: the old fixed 736 downscaled large canvases so small text fell below the detector and was missed (issue #14, e.g. ~16 px text on a 2048 image). `scripts/text_detection_benchmark.py` measures recall across scripts × sizes × canvas: the cap fix lifts overall hit-rate 0.91 → 1.00 (worst cell 2048/16 px: 0.06 → 1.00) at ~100 ms CPU. Very large canvases with tiny text may still need tiling (documented limit, not built). `build_change_map(boxes, h, w, preserve=0.9, feather=15)` paints a Differential-Diffusion change map. **Polarity (verified empirically):** white(1.0)=PRESERVE original pixels, black(0.0)=MAX change; map is black bg + `preserve` inside text polygons, Gaussian-feathered edges, clipped to [0,1]. `preserve` stays below a hard 1.0 freeze by default so text still scrubs lightly (SynthID survives cropping). **Default text protection is `watermark_remover._run_region_hires`, NOT the differential change map.** Differential Diffusion froze text in latent space (`preserve`<1.0), so the watermark survived *inside* text — violating the "remove SynthID everywhere" requirement; and the SDXL VAE's 8px latent cell softens sub-8px strokes regardless of `preserve` (architectural limit, confirmed by the DD authors — see `docs/text-protection-research.md`). `_run_region_hires` instead: (1) scrubs the whole image (plain img2img), (2) RE-scrubs each detected text block at HIGH resolution and feather-composites it back. `merge_text_regions(boxes,h,w)` groups boxes into local blocks; each crop is upscaled by an **integer** factor (`_REGION_HIRES_SCALE` 3, capped so a region stays under `_REGION_MAX_MEGAPIXELS` 1.3 to avoid OOM; skipped if it can't reach 2x — very large text areas then fall back to the global scrub, tiling is the future fix), img2img-scrubbed, downscaled, **phase-correlated back to the original crop to null the ~1-2px round-trip offset** (a sub-pixel shift garbles the composite even when text is crisp; integer scale alone did NOT fix it because the diffusion pipeline rounds dims to a multiple of 8), then `feather_paste`d. Every pixel is regenerated, so the watermark is removed everywhere AND small text stays crisp (high-res strokes span >1 latent cell). Validated on synthetic 18px multilingual text: text-region SSIM 0.28 (plain) → 0.48 (region-hires), visually garbled → readable across Latin/Cyrillic/CJK, residual shift ~0.5px. Gated to the SDXL `DEFAULT_MODEL_ID` + detector (`_can_protect_text`); no text → plain global scrub (text-free inputs pay only the cheap cv2 detection). CLI off-switch `--no-protect-text` on `invisible`/`all`. `merge_text_regions` + `feather_paste` are pure, unit-tested without a model (`tests/test_text_protector.py`). **MUST still be confirmed by the SynthID oracle** (openai.com/verify / Gemini app) that a region-rescrubbed text zone reads watermark-free before trusting it in prod. The legacy `_run_differential` / `build_change_map` / `_load_differential_pipeline` (community `pipeline_stable_diffusion_xl_differential_img2img`, `custom_revision="0.38.0"`) remain in the file but are no longer the default; the diff pipeline upcasts the VAE to fp32 internally, so do **not** add `upcast_vae()`/`enable_attention_slicing` there (NaN/black on fp16 MPS). `build_change_map` is still unit-tested. - `face_protector.py` — YOLO detect + soft-blend pattern; mirror this for any "protect region during diffusion" features - `image_io.py` — Unicode-safe cv2 IO (issue #17). `imread(path, flags=None)` / `imwrite(path, img)` wrap `np.fromfile`+`cv2.imdecode` / `cv2.imencode`+`tofile` so non-ASCII paths work on Windows -- bare `cv2.imread`/`cv2.imwrite` use the platform ANSI code-page API there and fail (empty decode + `can't open/read file`) on Chinese/Cyrillic/accented filenames. `imread` keeps `cv2.imread` semantics (defaults to `IMREAD_COLOR`, returns `None` on missing/empty/undecodable). **Every cv2 file read/write in the package routes through here; do not call `cv2.imread`/`cv2.imwrite` directly.** macOS/Linux already accept UTF-8 paths, so it is behavior-neutral there (the bug only reproduces on Windows). cv2/numpy are imported lazily inside the functions, so the module is cheap to import in a bare env. diff --git a/README.md b/README.md index 6745db8..894c2e7 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ SDXL is the default since May 2026: empirically defeats SynthID v2 on Gemini 3 P **Analog Humanizer**: optional film grain and chromatic aberration injection that mimics a photo of a screen, raising the bar for AI-generated image classifiers. (It frustrates generic classifiers but does not guarantee forensic invisibility — see the [arXiv:2605.09203](https://arxiv.org/abs/2605.09203) note above.) -**Text Protection** (automatic): SDXL img2img regenerates every pixel, so small text and glyphs get deformed at the strengths that defeat SynthID. The SDXL pipeline guards against this by default: a PP-OCRv3 text detector (a 2.4 MB ONNX model run on CPU via OpenCV's DNN module, downloaded and cached on first use) locates text regions, and if any are found the pass switches to Differential Diffusion so a per-pixel change map keeps the text regions largely intact while the background is regenerated normally. Detection is **language-agnostic** (it finds text regions, not characters), so Latin, Cyrillic, CJK, Hangul, Arabic, and digits are all protected, and it runs at the image's native resolution so small text on large images is not missed. Text-free images run the standard pass at no extra cost. Pass `--no-protect-text` to turn it off. SDXL default pipeline only. +**Text Protection** (automatic): SDXL img2img regenerates every pixel, so small text and glyphs get deformed at the strengths that defeat SynthID. The SDXL pipeline guards against this by default: a PP-OCRv3 text detector (a 2.4 MB ONNX model run on CPU via OpenCV's DNN module, downloaded and cached on first use) locates text regions, and if any are found the whole image is scrubbed normally and then each text block is **re-scrubbed at high resolution** and composited back. Because the text is regenerated (not copied), the watermark is still removed inside it, while running the text region at higher resolution keeps small strokes crisp through the regeneration. Detection is **language-agnostic** (it finds text regions, not characters), so Latin, Cyrillic, CJK, Hangul, Arabic, and digits are all protected, and it runs at the image's native resolution so small text on large images is not missed. Text-free images run the standard pass at no extra cost. Pass `--no-protect-text` to turn it off. SDXL default pipeline only. ### Stripping C2PA, EXIF, and "Made with AI" metadata diff --git a/src/remove_ai_watermarks/noai/watermark_remover.py b/src/remove_ai_watermarks/noai/watermark_remover.py index 7ab43b7..770ac44 100644 --- a/src/remove_ai_watermarks/noai/watermark_remover.py +++ b/src/remove_ai_watermarks/noai/watermark_remover.py @@ -481,7 +481,7 @@ class WatermarkRemover: generator, ) elif protect_text and self._can_protect_text(): - cleaned_image = self._run_differential( + cleaned_image = self._run_region_hires( init_image, strength, num_inference_steps, @@ -621,6 +621,90 @@ class WatermarkRemover: self._diff_pipeline = None return self._load_differential_pipeline() + # Region high-res text scrub: defaults tuned so each text block is upscaled + # enough that strokes exceed the VAE's ~8px latent cell, capped so a single + # region never blows past the GPU/MPS memory budget. + _REGION_HIRES_SCALE = 3.0 + _REGION_MAX_MEGAPIXELS = 1.3 + + def _run_region_hires( + self, + init_image: Image.Image, + strength: float, + num_inference_steps: int, + guidance_scale: float, + generator: Any, + ) -> Image.Image: + """Scrub the whole image, then RE-scrub each detected text block at high + resolution and composite it back. + + Unlike the Differential-Diffusion path (which freezes text in latent space + and so leaves the watermark intact there), every pixel here is regenerated + -- the watermark is removed everywhere. Small text survives because each + text block is upscaled before its img2img pass, so strokes span more than + one VAE latent cell (the ~8px floor that softens text at native scale); + the scrubbed crop is downscaled and feather-composited back. Falls back to + the plain global scrub when no text is detected. + """ + import math + + import cv2 + import numpy as np + + from remove_ai_watermarks import text_protector + + base = self._run_img2img(init_image, strength, num_inference_steps, guidance_scale, generator) + + bgr = cv2.cvtColor(np.array(init_image), cv2.COLOR_RGB2BGR) + try: + boxes = text_protector.TextProtector().detect_text_boxes(bgr) + except Exception as exc: + logger.warning("Text detection failed (%s); keeping the global scrub.", exc) + return base + if not boxes: + self._set_progress("No text detected; global scrub only.") + return base + + width, height = init_image.size + regions = text_protector.merge_text_regions(boxes, height, width) + orig_bgr = cv2.cvtColor(np.array(init_image), cv2.COLOR_RGB2BGR) + out_bgr = cv2.cvtColor(np.array(base), cv2.COLOR_RGB2BGR) + budget = self._REGION_MAX_MEGAPIXELS * 1_000_000 + + done = 0 + for x, y, w, h in regions: + area = max(1, w * h) + # INTEGER scale so the upscale -> scrub -> downscale round-trip is an + # exact dimensional inverse (a fractional factor truncates and shifts + # the composited text ~1-2px, which is invisible but tanks alignment). + scale = int(min(self._REGION_HIRES_SCALE, math.sqrt(budget / area))) + if scale < 2: + # Region too large to even double within the budget: upscaling + # buys nothing here; the global scrub covers it (documented limit + # for very large text areas -- tiling is the future fix). + continue + crop = orig_bgr[y : y + h, x : x + w] + up = cv2.resize(crop, (w * scale, h * scale), interpolation=cv2.INTER_LANCZOS4) + up_pil = Image.fromarray(cv2.cvtColor(up, cv2.COLOR_BGR2RGB)) + scrubbed = self._run_img2img(up_pil, strength, num_inference_steps, guidance_scale, generator) + down = cv2.resize(cv2.cvtColor(np.array(scrubbed), cv2.COLOR_RGB2BGR), (w, h), interpolation=cv2.INTER_AREA) + # The up -> scrub -> down round-trip can offset the re-rendered text by + # a pixel or two (the diffusion pipeline rounds dims to a multiple of + # 8, so the inverse resize is not perfectly centered). Phase-correlate + # the patch back to the original crop and translate it so the glyphs + # land exactly where they were -- otherwise a sub-pixel shift garbles + # the composite even though the text is crisp. + cg = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY).astype(np.float32) + dg = cv2.cvtColor(down, cv2.COLOR_BGR2GRAY).astype(np.float32) + (sx, sy), _resp = cv2.phaseCorrelate(cg, dg) + if abs(sx) > 0.1 or abs(sy) > 0.1: + m = np.float32([[1, 0, -sx], [0, 1, -sy]]) + down = cv2.warpAffine(down, m, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE) + out_bgr = text_protector.feather_paste(out_bgr, down, x, y) + done += 1 + self._set_progress(f"Re-scrubbed {done}/{len(regions)} text region(s) at high resolution.") + return Image.fromarray(cv2.cvtColor(out_bgr, cv2.COLOR_BGR2RGB)) + def _run_differential( self, init_image: Image.Image, diff --git a/src/remove_ai_watermarks/text_protector.py b/src/remove_ai_watermarks/text_protector.py index 1f61782..19eabb2 100644 --- a/src/remove_ai_watermarks/text_protector.py +++ b/src/remove_ai_watermarks/text_protector.py @@ -151,6 +151,87 @@ def build_change_map( return change_map +def merge_text_regions( + boxes: list[NDArray[Any]], + height: int, + width: int, + dilate_frac: float = 0.012, + pad_frac: float = 0.02, + max_regions: int = 8, +) -> list[tuple[int, int, int, int]]: + """Group detected text polygons into a few padded axis-aligned rectangles. + + The DB detector returns one box per word/line; the region-high-res text scrub + runs a separate diffusion pass per region, so we coalesce nearby boxes into a + handful of *local* blocks (a light dilation merges within a paragraph but not + across the whole image, so each block stays small enough to upscale within a + memory budget). Returns ``(x, y, w, h)`` rects, largest-area first, clipped to + the image and capped at ``max_regions``. + """ + import cv2 + import numpy as np + + mask = np.zeros((height, width), np.uint8) + if not boxes: + return [] + cv2.fillPoly(mask, [np.asarray(b, np.int32) for b in boxes], 1) + k = max(1, int(min(height, width) * dilate_frac)) + mask = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_RECT, (k, k))) + n, _labels, stats, _c = cv2.connectedComponentsWithStats(mask, 8) + pad = int(min(height, width) * pad_frac) + rects: list[tuple[int, int, int, int]] = [] + for i in range(1, n): + x, y, w, h = ( + int(stats[i, cv2.CC_STAT_LEFT]), + int(stats[i, cv2.CC_STAT_TOP]), + int(stats[i, cv2.CC_STAT_WIDTH]), + int(stats[i, cv2.CC_STAT_HEIGHT]), + ) + x0, y0 = max(0, x - pad), max(0, y - pad) + x1, y1 = min(width, x + w + pad), min(height, y + h + pad) + rects.append((x0, y0, x1 - x0, y1 - y0)) + rects.sort(key=lambda r: -(r[2] * r[3])) + return rects[:max_regions] + + +def feather_paste( + base: NDArray[Any], + patch: NDArray[Any], + x: int, + y: int, + feather: int = 8, +) -> NDArray[Any]: + """Alpha-composite ``patch`` into ``base`` at ``(x, y)`` with a feathered edge. + + Used to drop a separately re-scrubbed (high-resolution) text region back into + the globally-scrubbed image without a visible seam. Returns a new array; + ``base`` is not modified. ``patch`` is clipped to ``base`` bounds. + """ + import numpy as np + + out = base.copy() + bh, bw = base.shape[:2] + ph, pw = patch.shape[:2] + x0, y0 = max(0, x), max(0, y) + x1, y1 = min(bw, x + pw), min(bh, y + ph) + if x1 <= x0 or y1 <= y0: + return out + patch_roi = patch[y0 - y : y1 - y, x0 - x : x1 - x].astype(np.float32) + base_roi = out[y0:y1, x0:x1].astype(np.float32) + rh, rw = base_roi.shape[:2] + alpha = np.ones((rh, rw), np.float32) + f = max(0, min(feather, rh // 2, rw // 2)) + if f > 0: + ramp = np.linspace(0.0, 1.0, f, dtype=np.float32) + alpha[:f, :] *= ramp[:, None] + alpha[rh - f :, :] *= ramp[::-1, None] + alpha[:, :f] *= ramp[None, :] + alpha[:, rw - f :] *= ramp[None, ::-1] + a3 = alpha[:, :, None] + out[y0:y1, x0:x1] = (patch_roi * a3 + base_roi * (1.0 - a3)).astype(base.dtype) + return out + + class TextProtector: """Detect text regions with PP-OCRv3 for diffusion change-map protection.""" diff --git a/tests/test_text_protector.py b/tests/test_text_protector.py index fd80e97..35ffd8e 100644 --- a/tests/test_text_protector.py +++ b/tests/test_text_protector.py @@ -11,7 +11,75 @@ from __future__ import annotations import numpy as np -from remove_ai_watermarks.text_protector import _DET_MAX_LONG_SIDE, _detection_input_size, build_change_map +from remove_ai_watermarks.text_protector import ( + _DET_MAX_LONG_SIDE, + _detection_input_size, + build_change_map, + feather_paste, + merge_text_regions, +) + + +def _quad(x0, y0, x1, y1): + """An axis-aligned 4-vertex polygon as the detector returns.""" + return np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], np.int32) + + +class TestMergeTextRegions: + def test_empty(self): + assert merge_text_regions([], 256, 256) == [] + + def test_far_apart_boxes_stay_separate(self): + boxes = [_quad(10, 10, 60, 30), _quad(10, 200, 60, 220)] + regions = merge_text_regions(boxes, 256, 256, dilate_frac=0.005, pad_frac=0.0) + assert len(regions) == 2 + + def test_close_boxes_merge(self): + # two boxes on the same line, a few px apart -> one block + boxes = [_quad(10, 10, 60, 30), _quad(64, 10, 110, 30)] + # dilate_frac sized to close the few-px inter-word gap on one line + regions = merge_text_regions(boxes, 256, 256, dilate_frac=0.03) + assert len(regions) == 1 + + def test_rects_in_bounds_and_padded(self): + boxes = [_quad(100, 100, 150, 130)] + (x, y, w, h) = merge_text_regions(boxes, 256, 256, pad_frac=0.05)[0] + assert x >= 0 + assert y >= 0 + assert x + w <= 256 + assert y + h <= 256 + assert w > 50 # padded beyond the raw 50px box + + def test_caps_region_count(self): + boxes = [_quad(20 * i, 0, 20 * i + 8, 8) for i in range(20)] + regions = merge_text_regions(boxes, 64, 512, dilate_frac=0.002, pad_frac=0.0, max_regions=5) + assert len(regions) <= 5 + + +class TestFeatherPaste: + def test_patch_lands_at_location_center(self): + base = np.zeros((100, 100, 3), np.uint8) + patch = np.full((40, 40, 3), 200, np.uint8) + out = feather_paste(base, patch, 30, 30, feather=6) + # center of the pasted region is (near) the patch value + assert out[50, 50, 0] >= 190 + # far corner untouched + assert out[2, 2, 0] == 0 + + def test_does_not_mutate_base(self): + base = np.zeros((50, 50, 3), np.uint8) + feather_paste(base, np.full((20, 20, 3), 255, np.uint8), 10, 10) + assert base.sum() == 0 + + def test_shape_preserved(self): + base = np.zeros((50, 60, 3), np.uint8) + out = feather_paste(base, np.full((10, 10, 3), 100, np.uint8), 5, 5) + assert out.shape == base.shape + + def test_partial_out_of_bounds_no_crash(self): + base = np.zeros((40, 40, 3), np.uint8) + out = feather_paste(base, np.full((30, 30, 3), 150, np.uint8), 25, 25, feather=4) + assert out.shape == (40, 40, 3) class TestDetectionInputSize: