Skip to content

About

Stamp images with a tiled, diagonal grid watermark that's hard to crop or inpaint out — CLI (Python/Pillow) with jitter, blend modes, invisible LSB backup mark, plus a no-build browser tool.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

14 Commits

Folders and files

Repository files navigation

Mộc — Diagonal Grid Watermark

A small tool that stamps images and video with a tiled, diagonal grid watermark instead of a single corner logo. Comes in two flavors:

  • cli/watermark.py — a Python script for images and video, single files or whole folders, parallelized across CPU cores with a progress bar.
  • web/index.html — a single-file, no-build browser tool with live preview and multi-image batch export (nothing is uploaded anywhere; it all runs on-canvas in your browser).

Why a grid instead of a corner logo?

A logo tucked in one corner is gone the moment someone crops it out, and small, localized AI inpainting can erase it in one pass. A watermark tiled diagonally across the entire frame is a different problem to remove: getting rid of it cleanly means repainting almost the whole image (or every frame of a video), which is far more work and tends to leave visible artifacts or blur — especially over detailed areas like faces or text.

Removal-resistance features (CLI)

A perfectly regular grid still has weaknesses: it has a strong signature in the frequency domain (FFT) that can be estimated and subtracted, especially across many images stamped with the same pattern, and a flat white layer can in principle be separated from the base image. The CLI addresses both:

  • Per-tile jitter — every tile gets its own random position offset, opacity, and rotation, derived from --seed (reuse the same seed to reproduce an identical pattern; omit it for a fresh one each run). This breaks the grid's periodicity so it's much harder to model out. Video gets this refreshed over time, too (--jitter-refresh-seconds, default 4.0): the jitter pattern is regenerated every N seconds of output instead of one static pattern for the whole clip. A single unchanging mark stamped over moving footage is itself a collusion/ averaging-attack target — the base content moves frame to frame but the mark wouldn't, so a per-pixel median across enough frames estimates and subtracts it, no separate files required the way the paragraph below otherwise assumes. Pass 0 to opt back into one static pattern for the whole video (only useful for exact reproducibility with older output). Performance note: each refresh rebuilds the whole worker pool, which has a real, roughly fixed cost per rebuild — so a short interval adds up fast on a short clip (rebuild overhead is a bigger fraction of total runtime) and gets worse with more --workers (more worker processes to spawn each time). Measured on one machine: a 6s clip took 1.82s at the 4.0 default vs. 6.29s at 0.3 (~3.5x); the tool prints a note if you set it under 1 second. Benchmark your own setup before committing to a very short interval for a long/production run, and only go that short if your threat model genuinely calls for defending against an adversary who can median/average frames over a window that narrow.
  • Blend modes (--blend multiply|overlay) — instead of a flat alpha-over, the mark mixes into the base image's own pixel values (--ink controls how strongly), so it can't be cleanly separated as its own layer. Applies to both images and video frames. Watch --ink with --blend overlay: with the overlay formula used here, --ink 0.5 is the exact mathematical no-op — it leaves every pixel unchanged, so the watermark is completely invisible, not just "medium strength." This is easy to hit by accident if you assume 0.5 is a safe middle value (it's the 0.32 default that's a safe middle value). Stay clearly above or below 0.5 for a visible mark. Multiply doesn't have this trap in the middle of the range — there, smaller --ink means a darker mark and the no-op point is instead at the top of the range (--ink 1.0).
  • Invisible backup mark (--invisible, images only) — a simple LSB (least significant bit) payload spread across the image, readable back with cli/extract_invisible.py. This is a lightweight fallback only — it does not survive recompression, resizing, or screenshots (that needs DCT/frequency-domain or learned watermarking, a much larger project) — but it can confirm provenance on a losslessly-saved copy even if the visible grid gets cropped or painted over. Save as PNG when using it. The payload is tiled back-to-back across the whole image for exactly this reason, and extract_invisible.py scans the entire image for a surviving repeat (not just the top-left corner) — a repeat is only trusted once a second copy elsewhere confirms it, to avoid false positives from ordinary pixel noise. If the corner and most other repeats are gone (e.g. a crop that changes both width and height, which can break up individual repeats even when some survive), it may still fall back to a single uncorroborated match rather than reporting nothing. Possible future layer: a frequency-domain mark (DCT/DWT) or spread-spectrum embedding (payload spread across many frequency coefficients rather than concentrated in one channel's low bits) would address LSB's core weakness here — surviving recompression/resize — and would sit in a genuinely different domain from the pixel-space grid and LSB payload already in this tool, so defeating all three cleanly would take breaking each independently rather than one shared trick. Not attempted yet: it's a substantially larger undertaking than anything else in this list (real DCT/DWT or spread-spectrum watermarking is usually its own research-grade project, not a quick addition), so it's left here as a direction rather than implemented.
  • Content-adaptive placement (--adaptive) — a lightweight pass (opencv's bundled Haar face detector, plus a local detail-energy signal that also catches other high-detail "main subject" areas: patterned clothing, foreground objects, on-image text) finds the regions of the frame where a clean inpaint-based removal is most likely to leave a visible seam, and stamps a second, denser grid on top exactly there (--adaptive-infill-divisor, default 2.0, sets how much denser). For --blend multiply|overlay, it also darkens the mark further in those same regions (--adaptive-strength, default 0.6, 0-1) — moving --ink further from that mode's own no-op point, the same one described above, never past it. Outside the detected regions the image is identical to the non-adaptive path — the boost is additive, not a global change. No extra model download or dependency: the face detector ships inside opencv-python-headless, already required for video. --adaptive-preview some.png (single images only) saves the detected mask itself (white = most boosted) so you can sanity-check or tune --adaptive-detail-weight (how much weight the general detail signal gets relative to face detection, default 0.5) before committing to a batch run. --face-cascade swaps in a different Haar cascade XML if the bundled frontal-face one isn't a good fit for your subjects (e.g. a profile-face cascade for photos that aren't mostly front-facing). For video, the regions are detected once from the first frame and reused for the whole clip (recomputing every jitter-refresh segment would need buffering a frame ahead of the encode pipeline) — fine for a stable subject/framing, weakest across a hard cut partway through.
  • Provenance metadata (--author, --copyright, images only) — written into real EXIF (JPEG, via piexif) or PNG text chunks, so tools that read standard metadata see attribution even without looking at the pixels. Anyone can strip metadata, so treat this as a complementary signal, not the main defense.

Threat model

Worth being explicit about what "removal-resistant" means here, because it means different things depending on who's trying to remove the mark.

What this defends against — casual / incidental removal. Someone crops the image, paints over part of it, takes a screenshot, re-uploads through a platform that recompresses it, or just doesn't notice a single corner logo. This is the everyday case the grid, the jitter, the blend modes, and the invisible LSB backup are built for, and it's a realistic bar to clear: most removal in the wild is casual, not adversarial.

What this does not defend against — a motivated adversary who knows the scheme. Per-tile jitter specifically defeats naive frequency-domain estimation (FFT-averaging a fixed periodic pattern across many stamped images), but that's one specific attack, not immunity in general. No publicly known image watermarking technique — DCT/DWT, spread-spectrum, or learned marks like HiDDEN/StegaStamp, including ones baked directly into a diffusion model's seed (Tree-Ring, Gaussian Shading) — reliably survives someone who deliberately targets it with tools like:

  • Regeneration / diffusion-purification attacks — encode the image, add noise, and let a diffusion model (or VAE) resynthesize it; the reconstruction preserves perceptual content but not a low-amplitude watermark signal sitting off the "natural image" manifold. Zhao et al. gave a provable treatment of this and showed it empirically breaking several watermarking schemes ("Invisible Image Watermarks Are Provably Removable Using Generative AI", NeurIPS 2024). Follow-up work extends the same idea to marks embedded in a diffusion model's own seed/latent rather than pixel space.
  • Collusion / averaging attacks — with several images carrying a correlated pattern, simply averaging them out can estimate and subtract a "robust" watermark; this is the pixel-domain reason per-tile jitter matters here.
  • Adaptive / white-box optimization — with access to the detector (or a good enough guess at the embedding scheme), directly optimizing a small perturbation to flip detection.
  • Desynchronization — rotation, scaling, or local warping breaks correlation-based detectors unless they resynchronize first; a classical weakness predating any of the generative-AI attacks above.

WAVES (An et al., ICML 2024) standardizes exactly this kind of stress test and found vulnerabilities in several widely-used watermarking methods once regeneration and adversarial attacks were included alongside ordinary distortions.

Provenance metadata (--author/--copyright) and manifest-style schemes like C2PA sidestep the pixel-robustness arms race entirely, but answer a different question — "does this exact file match something signed" rather than "is a mark still recoverable after edits" — so a full regeneration just produces an unsigned image rather than a false match, which is a different (arguably more honest) failure mode.

Bottom line: treat every mechanism in this tool as raising the cost of casual removal, not as cryptographic proof against a determined, resourced remover — that's still an open research problem industry-wide, not a gap specific to this project.

Known limitations

  • Tile overlap at default jitter — the per-tile position jitter (an internal default of create_watermark_layer in wmcore.py, currently ±0.35 of --spacing) is not yet exposed as its own CLI flag. Combined with the default --spacing 250, tiles that land close together can overlap slightly, which looks a bit busy in some regions of the image. It's cosmetic, not a correctness bug — if it bothers you, increase --spacing or shrink --font-size so tiles have more room.

Video support

Point the CLI at a video file (.mp4, .mov, .m4v, .avi, .mkv, .webm) and it's detected automatically — same command, no separate flag. The watermark grid is built once and stamped onto every frame; frame compositing is parallelized across --workers processes so it makes real use of multiple cores.

If ffmpeg is on your PATH, output is re-encoded to H.264 with the original audio track muxed back in, in a single pass. Without ffmpeg, it falls back to OpenCV's own writer — the video still gets watermarked, but the output has no audio and uses a much less efficient codec. Get ffmpeg from ffmpeg.org (or apt install ffmpeg / brew install ffmpeg) for real use.

--invisible, --author, and --copyright are image-only and are ignored (with a note) for video input.

Batch mode & performance

--batch processes a whole folder — images and videos can be mixed in the same folder. Images are processed in parallel across --workers processes (default: your CPU count) with a live progress bar; each video is still processed one at a time, but its own frames are parallelized internally the same way. A failure on one file (corrupt image, unreadable video) is reported and skipped rather than stopping the whole batch.

Every file in a batch gets its own jitter pattern (deterministically derived from --seed + filename), so a fixed --seed still gives you reproducible output without every file in the batch sharing an identical watermark pattern.

Web tool

Open web/index.html in a browser (or serve it as a static page, see below). Drag in one or more images, tune text / opacity / density / font size / angle / color, and:

  • Download this image — the currently-selected image only.
  • Download all as .zip — watermarks every loaded image with the same settings and bundles them into a single .zip, with a progress bar while it works. The zip is built entirely client-side (no library, no upload).

Switch between loaded images using the thumbnail strip above the preview; each keeps its own original resolution on export. Video isn't supported in the browser tool — use the CLI for that.

Run it locally

# no build step — just open the file
open web/index.html        # macOS
xdg-open web/index.html    # Linux

Host it (e.g. GitHub Pages)

Since it's a static, single HTML file, you can publish it directly with GitHub Pages: Settings → Pages → Deploy from a branch, and point it at the web/ folder (or copy index.html to the repo root / a docs/ folder, depending on how you want Pages configured).

CLI tool

pip install -r requirements.txt
# or: pip install pillow numpy opencv-python-headless tqdm --break-system-packages
# (video output with audio also needs an ffmpeg binary on PATH)

piexif (in requirements.txt) is optional: it's only needed for --author/--copyright metadata on JPEG output. If it's missing, those flags are silently skipped with a printed note — everything else, including PNG metadata, works without it.

If no TrueType font is found (see --font-path below): with Pillow >= 10.1 the tool falls back to Pillow's built-in scalable font, so --font-size still works, just with a different typeface than DejaVu/Arial. On older Pillow, the fallback is a tiny fixed-size bitmap font that ignores --font-size (a warning is printed). Either way, pass --font-path /path/to/some.ttf for full control (or brew install --cask font-dejavu-sans / your distro's fonts-dejavu package to match the Linux default look).

# single image
python3 cli/watermark.py input.jpg output.jpg --text "@yourhandle"

# single video (audio kept automatically if ffmpeg is installed)
python3 cli/watermark.py input.mp4 output.mp4 --text "@yourhandle"

# whole folder (images and videos mixed), parallel, reproducible jitter
python3 cli/watermark.py input_folder/ output_folder/ --text "@yourhandle" \
    --batch --seed 42 --workers 8

# texture-aware blend + invisible backup + provenance metadata (images)
python3 cli/watermark.py input.jpg output.png --text "@yourhandle" \
    --blend multiply --invisible --author "Jane Doe" --copyright "(c) 2026 Jane Doe"

# content-adaptive placement: denser + darker over faces/main subject
python3 cli/watermark.py input.jpg output.png --text "@yourhandle" \
    --blend multiply --adaptive --adaptive-strength 0.8 --adaptive-preview mask.png

# read back the invisible mark later (scans the whole image, not just one corner)
python3 cli/extract_invisible.py output.png
# --max-length caps how long a payload it will consider plausible while
# scanning (default 10000 bytes); raise it only if you embedded something
# longer than that with --invisible-text

Options

Flag Default Description
--text (required) Watermark text, e.g. @yourhandle
--opacity 90 Base opacity, 0–255
--font-size 36 Font size in px
--angle 30 Base rotation angle in degrees
--spacing 250 Spacing between watermark tiles, in px
--font-path (auto) Path to a custom .ttf font. Auto-detect tries DejaVu Sans Bold (Linux), Arial Bold (macOS), then Arial Bold (Windows), in that order; see DEFAULT_FONT_PATHS in cli/wmcore.py
--batch off Treat input/output as folders (images + video, mixed)
--workers CPU count Parallel workers: files-in-parallel for --batch images, frames-in-parallel for a video
--seed random Seed for per-tile jitter (reuse to reproduce the same pattern)
--blend alpha alpha, multiply, or overlay — how the mark mixes with the base pixels
--ink 0.32 Mark darkness for multiply/overlay, 0–1
--invisible off Also embed a hidden LSB backup mark (images only, save as PNG)
--invisible-text (same as --text) Text for the hidden mark
--author — Author name, embedded in EXIF/PNG metadata (images only)
--copyright — Copyright string, embedded in EXIF/PNG metadata (images only)
--adaptive off Detect faces/high-detail "main subject" regions and increase density there (+ darkness for multiply/overlay)
--adaptive-strength 0.6 0-1, how strongly to boost density/darkness in detected regions
--adaptive-detail-weight 0.5 0-1, weight of the general detail signal relative to face detection
--face-cascade (bundled) Path to a custom Haar cascade XML for face detection
--adaptive-infill-divisor 2.0 How much denser the extra grid is inside detected regions (spacing ÷ this)
--adaptive-preview — Images, single-file only: save the detected sensitivity mask as a PNG, for tuning
--no-shared-memory off Video only: disable the shared-memory frame transport, fall back to a slower pickle-per-frame path (useful if shared memory isn't available in your environment, e.g. some sandboxes without /dev/shm)
--jitter-refresh-seconds 4.0 Video only: regenerate the jitter pattern every N seconds of output (rebuilds the worker pool at each boundary — see note below). 0 disables this and uses one static pattern for the whole clip — see the per-tile-jitter section above for why that's weaker

Project structure

.
├── cli/
│   ├── wmcore.py               # Shared grid/blend/LSB/metadata logic (images + video)
│   ├── watermark.py            # CLI entry: images, batch + multiprocessing, dispatches video
│   ├── video.py                # Video pipeline: parallel frame workers, ffmpeg mux/encode
│   └── extract_invisible.py    # Reads back the hidden LSB payload
├── tests/                      # pytest unit tests for the blend math + LSB round trip
├── web/
│   └── index.html      # Browser tool: multi-image batch, zip export, runs entirely client-side
├── requirements.txt
├── requirements-dev.txt        # requirements.txt + pytest
└── README.md

Tests

pip install -r requirements-dev.txt
pytest tests/

Covers the blend-mode math (wmcore.blend_arrays, all three modes) and the invisible-LSB embed/extract round trip - the two places most likely to silently regress since neither has an obvious "it crashed" failure mode (a wrong blend or a broken payload just looks fine at a glance).

License

MIT — see LICENSE.

About

Stamp images with a tiled, diagonal grid watermark that's hard to crop or inpaint out — CLI (Python/Pillow) with jitter, blend modes, invisible LSB backup mark, plus a no-build browser tool.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Contributors

Languages