mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-07 14:38:35 +02:00
196 lines
7.9 KiB
Python
196 lines
7.9 KiB
Python
"""Sliding-window tiled diffusion for large images.
|
|
|
|
The img2img / ControlNet pipeline denoises the WHOLE image in one forward pass,
|
|
so it OOMs on MPS/GPU above ~2K (issue #10). Tiling splits the image into
|
|
overlapping tiles -- each kept near SDXL's ~1024 training size -- regenerates
|
|
each tile independently, and feather-blends the overlaps. The result retains the
|
|
input's native dimensions without an explicit ``--max-resolution`` downscale, but
|
|
it is not pixel-lossless because every tile is regenerated.
|
|
|
|
The geometry (``plan_tiles``) and the blend weighting (``feather_weights``) are
|
|
pure functions, unit-tested without the diffusion model. ``run_tiled`` is the
|
|
orchestration loop; it takes a ``generate_tile`` callable (one img2img/ControlNet
|
|
pass on a single PIL tile) so it stays decoupled from the pipeline internals.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import TYPE_CHECKING, Any, NamedTuple
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
from numpy.typing import NDArray
|
|
from PIL import Image as PILImage
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Strictly-positive floor for the accumulated blend weights so a region covered
|
|
# by a single feathered tile edge (an image corner, no neighbour to blend with)
|
|
# never divides by zero.
|
|
_WEIGHT_EPS = 1e-3
|
|
|
|
|
|
class Tile(NamedTuple):
|
|
"""A tile crop box in the source image: top-left ``(x, y)`` + ``width``/``height``."""
|
|
|
|
x: int
|
|
y: int
|
|
width: int
|
|
height: int
|
|
|
|
|
|
def _axis_positions(length: int, tile: int, overlap: int) -> list[int]:
|
|
"""Tile start offsets along one axis, last tile flush to the far edge.
|
|
|
|
Every interior tile is exactly ``tile`` long; the final tile is pulled back
|
|
to ``length - tile`` so it ends exactly at the edge (it simply overlaps its
|
|
predecessor a little more). Keeping all tiles the same size is what lets the
|
|
diffusion pass run at SDXL's preferred dimension on every tile.
|
|
"""
|
|
if tile <= 0:
|
|
raise ValueError(f"tile must be positive, got {tile}")
|
|
if length <= tile:
|
|
return [0]
|
|
# Guarantee forward progress even on a pathological overlap >= tile.
|
|
overlap = min(max(overlap, 0), tile - 1)
|
|
step = tile - overlap
|
|
positions = list(range(0, length - tile + 1, step))
|
|
last = length - tile
|
|
if positions[-1] != last:
|
|
positions.append(last)
|
|
return positions
|
|
|
|
|
|
def plan_tiles(width: int, height: int, tile_size: int, overlap: int) -> list[Tile]:
|
|
"""Lay out a grid of overlapping tiles covering ``width`` x ``height``.
|
|
|
|
All tiles are ``min(tile_size, width)`` x ``min(tile_size, height)`` (uniform
|
|
size; the image itself when it fits in one tile). Returned in row-major order.
|
|
"""
|
|
xs = _axis_positions(width, tile_size, overlap)
|
|
ys = _axis_positions(height, tile_size, overlap)
|
|
tile_w = min(tile_size, width)
|
|
tile_h = min(tile_size, height)
|
|
return [Tile(x, y, tile_w, tile_h) for y in ys for x in xs]
|
|
|
|
|
|
def feather_weights(width: int, height: int, overlap: int) -> NDArray[Any]:
|
|
"""A 2D blend window: ~1 in the interior, ramping down toward each edge.
|
|
|
|
Separable linear taper over ``overlap`` pixels from every edge (capped at
|
|
half the tile so short tiles still taper symmetrically). Strictly positive
|
|
everywhere, so the normalized blend is well-defined even at an image corner
|
|
that only one tile covers.
|
|
"""
|
|
import numpy as np
|
|
|
|
def ramp(n: int) -> NDArray[Any]:
|
|
w = np.ones(n, dtype=np.float32)
|
|
if overlap > 0 and n > 1:
|
|
ramp_len = min(overlap, max(1, n // 2))
|
|
taper = (np.arange(ramp_len, dtype=np.float32) + 1.0) / (ramp_len + 1.0)
|
|
w[:ramp_len] = taper
|
|
w[n - ramp_len :] = taper[::-1]
|
|
return w
|
|
|
|
weights = np.outer(ramp(height), ramp(width))
|
|
np.maximum(weights, _WEIGHT_EPS, out=weights)
|
|
return weights
|
|
|
|
|
|
def feather_region_composite(
|
|
base: NDArray[Any],
|
|
regenerated: NDArray[Any],
|
|
box: tuple[int, int, int, int],
|
|
*,
|
|
feather: int = 64,
|
|
) -> NDArray[Any]:
|
|
"""Composite ``regenerated`` over ``base`` inside ``box`` only, feathering the seam.
|
|
|
|
For AI-ENHANCED composites (digitalSourceType ``compositeWithTrainedAlgorithmicMedia``):
|
|
the diffusion remover regenerates the whole frame, but only the AI-composited
|
|
REGION should change -- the rest is a real photo that must be preserved. This
|
|
blends the regenerated pixels in over ``box = (x, y, w, h)`` with a separable
|
|
linear taper of ``feather`` px at the box edges, so the result equals ``base``
|
|
EXACTLY outside the box and ramps smoothly (no hard seam) at the boundary.
|
|
|
|
Pure and model-free (unit-tested): ``base`` and ``regenerated`` must be the same
|
|
shape (H x W, or H x W x C). The output preserves ``base``'s dtype. ``feather`` is
|
|
clamped to half the box on each axis, so a small region still tapers symmetrically;
|
|
``feather=0`` is a hard-edged paste.
|
|
"""
|
|
import numpy as np
|
|
|
|
if base.shape != regenerated.shape:
|
|
raise ValueError(f"shape mismatch: base {base.shape} vs regenerated {regenerated.shape}")
|
|
h, w = base.shape[:2]
|
|
x, y, bw, bh = box
|
|
x0, y0 = max(0, x), max(0, y)
|
|
x1, y1 = min(w, x + bw), min(h, y + bh)
|
|
out = base.copy()
|
|
if x1 <= x0 or y1 <= y0:
|
|
return out # empty / off-image box -> nothing regenerated
|
|
|
|
def taper(n: int) -> NDArray[Any]:
|
|
win = np.ones(n, dtype=np.float32)
|
|
f = min(max(feather, 0), n // 2)
|
|
if f > 0:
|
|
ramp = (np.arange(f, dtype=np.float32) + 1.0) / (f + 1.0) # in (0, 1), 0 at the edge
|
|
win[:f] = ramp
|
|
win[n - f :] = ramp[::-1]
|
|
return win
|
|
|
|
rh, rw = y1 - y0, x1 - x0
|
|
wmap = np.outer(taper(rh), taper(rw)) # ~0 at the box edge, 1 in the interior
|
|
if base.ndim == 3:
|
|
wmap = wmap[:, :, None]
|
|
roi_base = base[y0:y1, x0:x1].astype(np.float32)
|
|
roi_gen = regenerated[y0:y1, x0:x1].astype(np.float32)
|
|
blended = roi_base * (1.0 - wmap) + roi_gen * wmap
|
|
out[y0:y1, x0:x1] = np.clip(blended, 0, 255).astype(base.dtype)
|
|
return out
|
|
|
|
|
|
def run_tiled(
|
|
generate_tile: Callable[[PILImage.Image], PILImage.Image],
|
|
image: PILImage.Image,
|
|
tile_size: int,
|
|
overlap: int,
|
|
set_progress: Callable[[str], None] | None = None,
|
|
) -> PILImage.Image:
|
|
"""Tile ``image``, run ``generate_tile`` per tile, and feather-blend the result.
|
|
|
|
``generate_tile`` runs one diffusion pass on a single RGB PIL tile and returns
|
|
the regenerated tile (the ControlNet control image is built per tile inside it,
|
|
so each tile gets its own edge map). A pass that rounds dimensions to the latent
|
|
grid is resized back to the exact tile size before blending.
|
|
"""
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
width, height = image.size
|
|
tiles = plan_tiles(width, height, tile_size, overlap)
|
|
accum = np.zeros((height, width, 3), dtype=np.float32)
|
|
weight_sum = np.zeros((height, width, 1), dtype=np.float32)
|
|
|
|
# All tiles share one size (plan_tiles is uniform), so the feather window is
|
|
# loop-invariant -- compute it once.
|
|
weights = feather_weights(tiles[0].width, tiles[0].height, overlap)[:, :, None]
|
|
|
|
total = len(tiles)
|
|
for index, tile in enumerate(tiles, start=1):
|
|
if set_progress is not None:
|
|
set_progress(f"Tiled diffusion: tile {index}/{total} at ({tile.x},{tile.y}) {tile.width}x{tile.height}...")
|
|
crop = image.crop((tile.x, tile.y, tile.x + tile.width, tile.y + tile.height))
|
|
result = generate_tile(crop)
|
|
if result.size != (tile.width, tile.height):
|
|
result = result.resize((tile.width, tile.height), Image.Resampling.LANCZOS)
|
|
arr = np.asarray(result.convert("RGB"), dtype=np.float32)
|
|
accum[tile.y : tile.y + tile.height, tile.x : tile.x + tile.width] += arr * weights
|
|
weight_sum[tile.y : tile.y + tile.height, tile.x : tile.x + tile.width] += weights
|
|
|
|
blended = accum / np.maximum(weight_sum, _WEIGHT_EPS)
|
|
return Image.fromarray(np.clip(blended, 0, 255).astype(np.uint8))
|