pagedMark
AI watermark removal built for Apple Silicon. Strip provenance from images you generated yourself — invisible pixel watermarks, visible vendor labels, and the metadata that carries the rest — on the GPU already in your Mac.
Most tooling in this space assumes an NVIDIA card and treats anything else as a fallback. pagedMark is the opposite: Metal is the primary target, and every decision in the pipeline — which sampler runs, when the VAE tiles, how faces are repaired, where the numerical floors sit — was measured on Apple hardware rather than inherited from CUDA.
uv tool install --force "pagedmark[diffusion]"
pagedmark invisible photo.png -o clean.png
Input: photo.png
Pipeline: sdxl-zimage on mps
Strength: 0.15
Running SDXL Canny pass: strength=0.1500, steps=16 of 107...
Repairing face 1/4 with SDXL: strength=0.0500, steps=8...
Saved: clean.png (2253 KB, 152.1s)
Pipeline
flowchart LR
IN([input]) --> ID[identify<br/><i>what does it carry?</i>]
ID --> V[visible mark<br/>locate → mask → fill]
ID --> P[pixel watermark]
ID --> M[metadata<br/>C2PA · EXIF · XMP · IPTC]
P --> G[global regeneration<br/>SDXL + Canny ControlNet<br/><b>Metal</b> or CUDA]
G --> F[face repair<br/>YuNet → SAM → crop regen]
V --> OUT([clean output])
F --> OUT
M --> OUT
Each branch runs only where its signal was found, and each reports what it did. The two GPU stages are the only ones that need Metal; everything else runs on any machine.
Capabilities
| Signal | Approach | GPU |
|---|---|---|
| Invisible pixel watermarks — SynthID-class marks from gpt-image, Gemini, Nano Banana | Structure-guided diffusion regeneration disrupts the embedded pattern | Metal or CUDA |
| Visible AI labels — Gemini sparkle, Doubao, Jimeng, Qwen, Kling, Yuanbao, Baidu, LibLibAI, Samsung Galaxy AI | Located in its expected region, masked, and filled | None |
| Provenance metadata — C2PA Content Credentials, EXIF, XMP, IPTC, generator parameters, TC260 AIGC tags | Format-aware stripping that never recompresses the image | None |
| Video — Sora, Veo, Seedance, Dola, Hailuo, Kling marks, and video SynthID | Per-frame removal with audio copied untouched and timestamps preserved | None for marks and metadata; Metal or CUDA for SynthID |
| Region erase — anything you point at | User-directed inpainting with OpenCV, MI-GAN, or LaMa | None |
Always start by asking what a file actually carries:
pagedmark identify photo.png
identify reports unknown rather than clean when it finds nothing. Metadata is
erased by re-encoding, screenshots and uploads, and no public local decoder exists for
SynthID-class watermarks — so absence of evidence is reported as exactly that.
Why Metal-first matters
Apple Silicon is not a smaller CUDA card. Four differences change the output, and each
number below was measured on an M5 whose unified memory budget
(torch.mps.recommended_max_memory()) is 11.84 GiB.
The distilled sampler had to go
Four-step distillation LoRAs are trained for four timesteps spanning the entire noise range. A low-strength edit runs the tail of a long schedule instead, which is off that distribution — and wherever nothing conditions the model (flat dark fabric offers the ControlNet no edges) it fills the gap by inventing texture.
| Global stage · 1448×1080 · strength 0.15 | Invented texture | PSNR | Wall |
|---|---|---|---|
| Distilled, 4 steps | 1.73× source | 28.54 dB | 41 s |
| Distilled, 8 steps | 1.80× | 28.19 dB | 29 s |
| Distilled, 16 steps | 1.84× | 27.85 dB | 62 s |
| Undistilled base, 16 steps | 1.19× | 29.25 dB | 71 s |
| Undistilled base, 24 steps | 1.20× | 29.17 dB | 132 s |
More steps of the distilled model make it worse, which is what identifies the distillation rather than the step count as the cause. 24 undistilled steps buy nothing over 16, so 16 is a measured knee.
Faces are repaired by the stack already in memory
The high-fidelity face model in this lineage streams float8 weights, and Metal implements no float8 tensor type at all. So the stage keeps its shape — face detection, SAM masks, each crop regenerated from the original at a lower strength, feathered back in — and substitutes the SDXL stack already resident for the global pass. Nothing extra to load.
| Four faces · measured against the source | Whole frame | Faces (mean) |
|---|---|---|
| Global stage only | 29.00 dB | 27.39 dB |
| With the face stage | 29.14 dB | 30.50 dB |
Memory is measured, not hoped for
Metal answers an oversized allocation by paging rather than failing, so a run that "works" can silently take an hour. pagedMark reads the budget and decides:
- VAE tiling only where it pays. Untiled, a 1.57 MP frame needs 18.74 GiB against an 11.84 GiB budget and survives on swap alone, at 2.6× the wall time. On a 64 GiB Mac the same frame decodes whole, because tile boundaries leave a faint texture worth avoiding when there is room for none.
- Oversized frames tile at native geometry, so nothing is quietly downscaled to fit.
- Attention slicing always on, the device cache released between passes, and noise drawn on a CPU generator so one seed means one result across machines.
Video runs here too
Video is not a CUDA-only corner of this project. Visible mark removal and metadata stripping need no GPU at all, and SynthID regeneration — a VAE round-trip with one latent-noise field shared across time — resolves to Metal through the same probed device ladder as the image path, releasing the device cache per frame batch so a clip's peak stays at one batch instead of growing with its length.
pagedmark video identify clip.mp4 # what the container and pixels carry
pagedmark video all clip.mp4 -o clean.mp4 # visible marks + metadata, CPU
pagedmark video invisible clip.mp4 -o clean.mp4 # SynthID regeneration, Metal
Audio is copied without re-encoding, variable frame intervals survive through a timestamped bridge rather than being flattened, and the completed encode is published atomically. The regeneration profile's own numbers were measured on CUDA; the Metal path runs the same graph, and the wall time is the difference to expect.
8 GB Macs are a supported target, not a rounding error
The stack is 7.7 GiB of weights once the text encoders are gone, and an 8 GB Mac reports a working set of roughly 5.3 GiB — so it does not fit, with or without that saving. Rather than refuse, pagedMark reads the budget and streams the weights from the CPU one module at a time. Same output, and a peak that fits anything:
| Same frame, same seed | Peak device memory | Wall |
|---|---|---|
| Weights resident (16 GB Mac) | 7.70 GiB | 7.1 s |
| Weights streamed (8 GB Mac) | 0.28 GiB | 24.1 s |
The plan is chosen from the measured budget and then stated: a run three times slower than the fast path says so before you wait through it, instead of looking broken. Below about 2 GiB nothing is left to trade, and the refusal names the commands that still work there rather than failing inside a model loader.
One saving was measured, implemented, and then withdrawn. Encoding the two fixed prompts once and releasing the text encoders frees 1.52 GiB of a loaded 8.79 GiB — and with them released, two of the four face crops in a test photo came back as all-zero black rectangles, deterministically, at the same seed that produced correct faces with them resident. The embeddings are not at fault (CPU and Metal encodings of that prompt agree to 0.0009 on tensors with a standard deviation of 3.06, and the same crop generated in isolation is correct either way); the allocation pattern the crops meet after the global pass is. 1.5 GiB is not worth a black rectangle over someone's face, and the offloaded path does not need it. A guard now drops any empty crop and keeps the global stage's face instead, whatever produced it.
A preview, sized to be worth running
--preview answers "what will this do to my picture" before the real run. It caps the
frame at 512 px, shrinks the face crops with it, and repairs the largest face only —
because capping the frame alone changes nothing: the face stage scales every crop toward
a 768 px guide regardless of the frame, so a naive preview cost 66 s against the real
run's 113 s. With all three caps it is 46.6 s against 112.6 s on the measured photo.
It writes <source>_preview.png, never the _clean name, and prints what it is. Its
fidelity numbers are not the run's numbers: a preview shows character, not final quality,
because the downscale and upscale are part of what you are looking at.
Metal has numerical floors
Below a face strength of 0.05 the fp16 path stops producing an image: three of four faces returned as garbage at the 0.025 a naive policy computes. That floor lives in code and is pinned by a test, not carried as folklore.
Requirements
- Apple Silicon (M-series) running macOS, or an NVIDIA GPU
- Python 3.11 – 3.14
- ~10 GB disk for the diffusion model stack on first run
- 16 GB unified memory is enough for the Metal path; more removes the tiling compromises
There is no CPU path for invisible removal, and pagedMark refuses a device that cannot
run it instead of falling back to something that will not finish. Visible-mark removal,
metadata stripping and identify run anywhere.
Install
| You want | Install |
|---|---|
| Metadata inspection and stripping | pagedmark |
| Visible marks and region erasing | pagedmark[visible] |
| Invisible removal on Apple Silicon | pagedmark[diffusion] |
| Invisible removal on NVIDIA | pagedmark[qwen-zimage] |
| Video | pagedmark[video] |
| Everything available on your Python | pagedmark[all] |
On macOS the default PyTorch wheel already ships Metal support, so
pagedmark[diffusion] is the complete Apple Silicon install — it deliberately omits the
float8 model stack that cannot load there.
Usage
# What is in this file?
pagedmark identify photo.png
# Visible mark and metadata — no GPU required
pagedmark visible photo.png -o clean.png
pagedmark metadata photo.png --remove -o clean.png
# Erase a region you choose
pagedmark erase photo.png --region 1640,1930,400,100 -o clean.png
# A faster, lower-resolution look before committing to the real run
pagedmark invisible photo.png --preview
# The full pipeline: visible → invisible → metadata
pagedmark all photo.png -o clean.png
# A whole directory
pagedmark batch ./photos --mode all
# Video
pagedmark video identify clip.mp4
pagedmark video all clip.mp4 -o clean.mp4
pagedmark video invisible clip.mp4 -o clean.mp4
pagedmark video batch ./clips --mode all
The device is detected and the pipeline follows from it. Both are printed before the run starts, so a substituted stage is something you read rather than something you discover in the output.
Python
import pagedmark as pm
report = pm.identify("photo.png")
result, removed = pm.remove_visible("watermarked.png", "clean.png")
What this does not claim
- Regeneration is not payload deletion. The image changes; faces, text and fine detail can move. Every figure above is the measured size of that change, so the tradeoff is yours to accept rather than a promise to trust.
- Metal is not bit-identical to CUDA. The same seed and strength produce a different image on the two backends. Operating points transfer between them; verdicts recorded against a provider's verifier do not.
- The Metal face stage is a different model from the CUDA one. It measured better than no face stage, and face crops carry less disruption than the frame does — the same bargain the CUDA stage strikes.
- Verify what matters. Where a provider publishes a verifier, run the output through it before relying on the result.
Scope
For content you generated or own. pagedMark does not target stock-agency previews, marketplace or classifieds watermarks, or any mark protecting someone else's paid asset: the visible-mark registry accepts AI-generation labels only, and the region eraser is user-directed by design. See scope, safety and legal notes.
Documentation
License
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pagedmark-0.1.0.tar.gz.
File metadata
- Download URL: pagedmark-0.1.0.tar.gz
- Upload date:
- Size: 355.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e4d6b435e45c34176af4e89e734dc7bc191daae193280e71de12c0f9139f7570
|
|
| MD5 |
18b12592b1ad9a3198eecca7e1e6363e
|
|
| BLAKE2b-256 |
6ab1ef50994613e807fd2f9a47fd5b4d5db2e165cdcd8866ef398baee712abbc
|
File details
Details for the file pagedmark-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pagedmark-0.1.0-py3-none-any.whl
- Upload date:
- Size: 389.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2c492115eace0a075a18b7c7942777ea395648e2c4f5e846e3466f934d80d3a1
|
|
| MD5 |
fd00575de848729f6973e34387f3ccc6
|
|
| BLAKE2b-256 |
177f7af47a3c1b4934a28685d8d85546400e85d4f3da05de682fe82b16eaf58e
|