If you've ever uploaded a photo to an online tool and watched a watermark vanish in seconds, you've seen image inpainting at work. It looks like magic: a semi-transparent logo sitting on top of a sky, a face, or a product shot simply… disappears, and the pixels underneath look convincingly reconstructed.
This post breaks down what's actually happening under the hood. We'll cover the progression from classical pixel-filling to modern deep-learning approaches, look at real code, and discuss the engineering tradeoffs you'll hit if you build (or integrate) one of these systems yourself.
Quick note on ethics: Watermark removal has legitimate uses — cleaning up your own assets, restoring old scanned photos, removing your own branding from drafts, processing images you have rights to. Removing watermarks to use someone else's copyrighted work without permission is not one of them. Treat the techniques below as you would any image-editing capability.
The core problem: it's not "removal", it's "reconstruction"
A watermark isn't a layer you can peel off. Once an image is rasterized, the watermark's pixels are baked into the underlying image. "Removing" it really means:
- Detecting where the watermark pixels are (producing a mask).
- Reconstructing what the image probably looked like underneath those masked pixels.
Step 2 is the hard part, and it's a classic computer-vision problem called inpainting — filling in missing or corrupted regions of an image using clues from the surrounding pixels.
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Input image │ → │ + Mask of │ → │ Inpainting │ → Clean image
│ (with mark) │ │ watermark │ │ model │
└──────────────┘ └──────────────┘ └──────────────┘
So an "AI watermark remover" is really two problems chained together: segmentation (find the mark) and inpainting (fill the hole). Let's look at how each evolved.
Step 1 — Producing the mask
You need a binary mask telling the inpainting model which pixels to replace. Depending on the watermark, there are a few strategies:
Manual or UI-assisted masking
For a consumer tool, the user paints over the watermark with a brush. Simple and reliable — you don't need to detect anything, the user tells you exactly where to work. This is what most "brush and erase" editors do.
Template matching (for a known, repeated logo)
If the same watermark is stamped repeatedly (think a stock-photo site's diagonal pattern), you can extract one instance as a template and use normalized cross-correlation to find all occurrences:
import cv2
import numpy as np
img = cv2.imread("watermarked.png", cv2.IMREAD_GRAYSCALE)
template = cv2.imread("logo_patch.png", cv2.IMREAD_GRAYSCALE)
res = cv2.matchTemplate(img, template, cv2.TM_CCOEFF_NORMED)
threshold = 0.8
loc = np.where(res >= threshold)
mask = np.zeros_like(img)
h, w = template.shape
for pt in zip(*loc[::-1]):
mask[pt[1]:pt[1]+h, pt[0]:pt[0]+w] = 255 # mark the region to inpaint
This is fast and deterministic, but it only works when you already know the watermark's exact appearance.
Learned segmentation
For arbitrary watermarks — text overlays, varied logos, signatures — a small segmentation model (U-Net or similar) trained on (watermarked image, mask) pairs is the flexible answer. The harder case is translucent watermarks, where the mark is a blend of a color and the background. A good mask then carries per-pixel opacity rather than just on/off, which the inpainter can use to subtract the watermark color rather than blindly overwriting.
Step 2 — Filling the hole: the inpainting evolution
The classical era: diffusion and patch-based
OpenCV still ships two classic algorithms via cv2.inpaint:
- Telea (Fast Marching Method) — propagates from the boundary inward. Great for thin scratches and small dots.
- Navier-Stokes — treats the region as a fluid-flow problem, carrying edge information into the hole.
# radius 3 — works for thin scratches / small spots
result = cv2.inpaint(img, mask, 3, cv2.INPAINT_TELEA)
These are instant and need no GPU, but they fall over for large regions. They blur across big gaps and ignore global structure. For a 200×80 logo slapped across a textured background, they produce obvious smears.
Patch-based methods (PatchMatch, used by Photoshop's Content-Aware Fill) did much better by copying similar patches from elsewhere in the image. But they assume the missing content exists somewhere in the frame — true for grass or sky, false for a face half-covered by a watermark.
The deep-learning era
This is where results started looking like actual reconstruction. A few milestones worth knowing:
| Method | Key idea | Strength |
|---|---|---|
| Context Encoders (2016) | First CNN encoder-decoder for inpainting | Learned global context |
| Partial Convolutions (2018) | Convolutions that ignore masked pixels | Clean boundaries, no color bleed |
| Gated Convolutions (2019) | Learned soft gating per layer | Handles irregular masks better |
| LaMa (2021) | Fast Fourier convolutions → huge receptive field | State-of-the-art quality, fast |
| Diffusion-based (2022+) | Iterative denoising conditioned on context | Spectacular, but slow |
The reason a watermark sitting across a complex region (a reflection, a patterned shirt) can be filled convincingly is that these models have learned a strong prior of what natural images look like. Given the surrounding context, they hallucinate a plausible continuation. It may not be the exact original pixel — but it's usually perceptually close enough that you can't tell.
A closer look: Partial Convolutions
The breakthrough insight in partial convolutions is elegant. A regular convolution slides a kernel over the image and blends everything under the kernel — including masked (invalid) pixels you told it to ignore. That's why naive CNN inpainting bleeds watermark color outward.
A partial convolution instead computes its output from valid (unmasked) pixels only, and renormalizes:
import torch
import torch.nn as nn
import torch.nn.functional as F
class PartialConv(nn.Module):
def __init__(self, in_ch, out_ch, kernel_size, padding=0):
super().__init__()
# weight is frozen to all-ones; only the conv learns
self.input_conv = nn.Conv2d(in_ch, out_ch, kernel_size, padding=padding)
self.mask_conv = nn.Conv2d(in_ch, out_ch, kernel_size,
padding=padding, bias=False)
with torch.no_grad():
self.mask_conv.weight.fill_(1.0)
self.mask_conv.weight.requires_grad = False
def forward(self, x, mask):
# mask: 1.0 for valid pixels, 0.0 for holes
output = self.input_conv(x * mask)
with torch.no_grad():
mask_sum = self.mask_conv(mask) # count of valid pixels in window
valid_window = self.mask_conv.weight.numel() # total pixels in kernel
new_mask = (mask_sum > 0).float()
# renormalize so the output only depends on valid pixels
norm = valid_window / mask_sum.clamp(min=1e-8)
output = output * norm
return output, new_mask
The mask shrinks layer by layer (a hole gets "eaten" from the edges), and at every step the network only ever mixes information it was actually allowed to see. The result: no color bleeding from the watermark into the repaired region.
A closer look: LaMa
LaMa (Large Mask inpainting) is the workhorse you'll reach for first, because it hits the sweet spot of quality and speed. Its secret sauce is Fast Fourier Convolutions (FFC): instead of convolving only in the spatial domain, FFC mixes information in the frequency domain, giving each layer an essentially global receptive field with the first layer.
Why does that matter for watermarks? A watermark often spans a large chunk of the image. A regular CNN needs many stacked layers before its receptive field grows large enough to "see" the whole mark at once. FFC sees the whole thing immediately, which is why LaMa reconstructs repeating structures and long lines (think a watermark crossing a fence or brick wall) far better than earlier CNNs — and in a single forward pass.
You can run a pretrained LaMa locally on a GPU:
# pip install simple-lama-inpainting
from simple_lama_inpainting import SimpleLama
from PIL import Image
image = Image.open("watermarked.png").convert("RGB")
mask = Image.open("mask.png").convert("L") # white = area to remove
lama = SimpleLama()
clean = lama(image, mask)
clean.save("clean.png")
Diffusion models: beautiful but expensive
The latest inpainting uses pretrained diffusion models (Stable Diffusion's inpainting variant, etc.), conditioned on the unmasked context. Results can be stunning and follow text prompts ("a clear blue sky") — but each generation requires tens to hundreds of denoising steps, so latency and cost are an order of magnitude higher than LaMa. For a high-throughput service, diffusion is usually reserved for the hard cases or upscaled final passes.
The engineering reality
If you're building a product around this — or evaluating one — the model is maybe a third of the work. The rest is engineering:
Latency and throughput. A LaMa forward pass on a 1024px image is a few hundred milliseconds on a decent GPU. Diffusion inpainting is seconds. Batch size, image resolution caps, and GPU pooling dominate your unit economics.
Mask quality is everything. "Garbage in, garbage out" applies brutally here. A mask that's too tight leaves watermark residue; too loose erases real detail. Consumer tools hide this with UI (brush size) or learned segmentation.
Translucent vs. opaque watermarks. A solid white logo is one problem; a 30%-opacity gray logo is another. The latter requires estimating and subtracting the overlay, not just painting over it.
Safety and storage. Users are (rightly) nervous about uploading sensitive images to a server. Processing in-memory and deleting promptly, supporting ephemeral sessions, and eventually client-side inference (WebGPU / WASM) all matter for trust.
If you'd rather not stand up all of that infrastructure, there are hosted options that package the segmentation + inpainting pipeline behind a simple API and web UI — for example, Watermark Remover AI exposes this as a drag-and-drop tool with API access, which is convenient for evaluating the technique before committing to building your own.
Try it yourself: a minimal pipeline
Here's a self-contained sketch tying mask + inpainting together, using a user-supplied mask and LaMa:
from simple_lama_inpainting import SimpleLama
from PIL import Image, ImageDraw
# 1. Load image
image = Image.open("photo.png").convert("RGB")
# 2. Create a mask (here: a user "painted" rectangle over the watermark)
mask = Image.new("L", image.size, 0)
draw = ImageDraw.Draw(mask)
draw.rectangle([180, 60, 520, 140], fill=255)
mask.save("mask.png")
# 3. Inpaint
clean = SimpleLama()(image, mask)
clean.save("clean.png")
print(f"Done. Removed region {image.size} -> {clean.size}")
A good next exercise: replace the hand-drawn mask with a thresholding step that detects a translucent logo, then measure reconstruction quality (PSNR/SSIM against a known clean version).
Wrapping up
AI watermark removal is a tidy case study in how modern computer vision composes two ideas — segmentation to localize, inpainting to reconstruct — and how the inpainting side has moved from local pixel diffusion → patch copying → partial convolutions → FFC-based (LaMa) → diffusion models, each trading off quality against speed and cost.
The mental model worth taking away: the model isn't recovering the "true" pixels. It's sampling from a learned distribution of plausible natural images conditioned on the surrounding context. That's why it can look perfect and still be a fabrication — and why, for anything where pixel-level accuracy matters (forensics, medical, legal), you treat these outputs as reconstructions, not ground truth.
If you want to see the end-user result without building the pipeline, give the Gemini Watermark Remover demo a try — drop in an image you own and watch the mask + inpainting pipeline do its thing. Then come back and build your own.
What's your go-to inpainting model? I'm curious whether more teams will move to client-side inference as WebGPU matures — let me know in the comments.
Top comments (0)