Re: PSA Using Python Pillow to foil camera image PRNU fingerprinting
Maria Sophia <[email protected]> Fri, 31 Jul 2026 14:51:06 -0800
| Newsgroups | gmane.comp.python.general |
|---|---|
| Organization | BWH Usenet Archive (https://usenet.blueworldhosting.com) |
| Message-ID | <[email protected]> |
Maria Sophia wrote:
> But how does this sound as a game plan for creating that flow on Windows?
> 1. Build a reference PRNU fingerprint from a folder of calibration images
> 2. Subtract that fingerprint from a target image we want to publish
Here's the first generation of an attempt to follow up on Lawrence's kind-hearted helpful astute suggestion to build a calibration database.
I tested this only on Windows but the process should work on any platform.
Here's what I did.
1. Run adbcopy.bat to copy your images over Wi-Fi to your desktop
2. Put those copied images from your phone into the calibration folder
3. Take one of those images to be scrubbed and copy it to input.jpg
Then run:
python prnu_wash.py
Drat. The first pass errored because the images have to be the same size.
Bummer. Apparently NumPy cannot average arrays of different shapes.
So the second pass worked, but I had to throw out images of other sizes.
So consider this only a test showing whether the wash concept is feasible.
C:\tmp\synthid\prnu_wash> python prnuwash.py
input.jpg resolution: (3264, 1468)
Calibrated: 20260710_014519.jpg
Skipping (size mismatch): 20260710_083333.jpg ((4000, 1800))
Calibrated: 20260710_091529.jpg
Calibrated: 20260710_091533.jpg
Calibrated: 20260710_091552.jpg
Skipping (size mismatch): 20260710_102702.jpg ((4000, 1800))
Skipping (size mismatch): 20260710_102752.jpg ((4000, 1800))
Skipping (size mismatch): 20260710_102954.jpg ((4000, 1800))
Calibrated: 20260710_111547.jpg
Skipping (size mismatch): 20260710_111600.jpg ((4128, 3096))
Skipping (size mismatch): 20260710_111603.jpg ((4128, 3096))
Calibrated: 20260710_112210.jpg
Skipping (size mismatch): 20260710_125740.jpg ((4000, 1800))
Skipping (size mismatch): 20260710_125741.jpg ((4000, 1800))
Skipping (size mismatch): 20260710_125743.jpg ((4000, 1800))
Skipping (size mismatch): 20260710_125744.jpg ((4000, 1800))
Skipping (size mismatch): 20260710_125745.jpg ((4000, 1800))
Skipping (size mismatch): 20260710_125746.jpg ((4000, 1800))
Skipping (size mismatch): 20260710_131330.jpg ((4000, 1800))
Skipping (size mismatch): 20260710_131332.jpg ((4000, 1800))
Skipping (size mismatch): 20260710_131334.jpg ((4000, 1800))
Skipping (size mismatch): 20260710_135144.jpg ((4000, 1800))
Skipping (size mismatch): 20260710_135147.jpg ((4000, 1800))
Calibrated: 20260710_135152.jpg
Calibrated: 20260710_184326.jpg
Calibrated: 20260710_184331.jpg
Skipping (size mismatch): 20260710_191700.jpg ((3408, 2556))
Skipping (size mismatch): 20260710_191702.jpg ((3408, 2556))
Skipping (size mismatch): 20260725_125158.jpg ((3408, 2556))
Skipping (size mismatch): 20260725_125318.jpg ((4000, 1800))
Skipping (size mismatch): 20260725_125329.jpg ((4000, 1800))
Skipping (size mismatch): 20260725_125332.jpg ((4000, 1800))
Skipping (size mismatch): 20260725_125338.jpg ((4000, 1800))
Skipping (size mismatch): 20260725_125341.jpg ((4000, 1800))
Calibrated: 20260725_141007.jpg
Calibrated: 20260725_144131.jpg
Calibrated: 20260725_144140.jpg
Skipping (size mismatch): input.jpg ((800, 1067))
Fingerprint built from 12 images.
Saved: scrubbed.jpg
C:\tmp\synthid\prnu_wash>
Here is the hash of the unadulterated original:
Name: input.jpg
Size: 1467088 bytes (1432 KiB)
SHA256: 231CA2774263739CF2351CA2FF9CCDB3309716D64B0BC2B4788326C14A8CF33F
Which happened to be the same file as one of the calibration images:
Name: 20260710_091552.jpg
Size: 1467088 bytes (1432 KiB)
SHA256: 231CA2774263739CF2351CA2FF9CCDB3309716D64B0BC2B4788326C14A8CF33F
Where this is the resulting hash of the scrubbed image that resulted.
Name: scrubbed.jpg
Size: 1311253 bytes (1280 KiB)
SHA256: D9B80BCBAE3CB679B858587186F373C5242D121D07B39E78844F843ECD31AD85
When you test this out, please let us all know what you think of the
fidelity of the resulting scrubbed image, and whether this is reasonable.
# prnuwash.py
# ---------------------------------------------------------------
# Build a PRNU fingerprint from a set of calibration images
# and subtract it from new images before posting them online.
# <https://en.wikipedia.org/wiki/Photo_response_non-uniformity>
#
# 1. Place 20-50 calibration images in ./calibration/
# 2. Place the image you want to scrub as input.jpg
# 3. Run: python prnuwash.py
# 4. Output: scrubbed.jpg (PRNU-reduced)
# ---------------------------------------------------------------
# PRNU subtraction only works when we control the camera such
# that we have access to multiple images from the same sensor.
# For PRNU cleaning of images from unknown cameras, use prnu.py
# For removal of AI fingerprints, use crush.py instead.
# So this script...
# 1. Reads input.jpg to determine the required resolution.
# 2. Scans all JPEGs in ./calibration/
# 3. Uses ONLY those images that match input.jpg's resolution.
# 4. Builds a fingerprint from matching images.
# 5. Subtracts that fingerprint from input.jpg.
# This resolution filtering is needed because phone cameras do
# NOT guarantee identical resolution across across all due to
# different modes (e.g., HDR, night mode, zoom, wide-angle,
# telephoto, screenshots, crops, panoramas, etc.).
# ---------------------------------------------------------------
# v1p1 20260731 added necessary automatic-resolution filtering
# v1p0 20260731 simple wavelet noise extraction + averaging
# ---------------------------------------------------------------
import os
import numpy as np
from PIL import Image, ImageFilter
CALIB_DIR = "calibration"
INPUT_IMAGE = "input.jpg"
OUTPUT_IMAGE = "scrubbed.jpg"
# Extract high-frequency noise (approx PRNU)
def noise_residual(img):
blur = img.filter(ImageFilter.GaussianBlur(radius=1.2))
arr = np.asarray(img).astype(np.float32)
blur_arr = np.asarray(blur).astype(np.float32)
return arr - blur_arr
# Build fingerprint only from images matching input.jpg resolution
def build_fingerprint(target_size):
noise_maps = []
for fname in sorted(os.listdir(CALIB_DIR)):
if not fname.lower().endswith((".jpg", ".jpeg", ".png")):
continue
path = os.path.join(CALIB_DIR, fname)
img = Image.open(path).convert("RGB")
if img.size != target_size:
print(f"Skipping (size mismatch): {fname} ({img.size})")
continue
noise_maps.append(noise_residual(img))
print("Calibrated:", fname)
if not noise_maps:
raise RuntimeError("No calibration images matched input.jpg resolution.")
fp = np.mean(noise_maps, axis=0)
print("Fingerprint built from", len(noise_maps), "images.")
return fp
# Subtract fingerprint
def subtract_fp(img, fp):
arr = np.asarray(img).astype(np.float32)
fp_norm = fp / (np.std(fp) + 1e-6)
cleaned = np.clip(arr - fp_norm, 0, 255).astype(np.uint8)
return Image.fromarray(cleaned)
# Main
img = Image.open(INPUT_IMAGE).convert("RGB")
target_size = img.size
print("input.jpg resolution:", target_size)
fp = build_fingerprint(target_size)
cleaned = subtract_fp(img, fp)
cleaned.save(OUTPUT_IMAGE, "JPEG", quality=95)
print("Saved:", OUTPUT_IMAGE)
# end of prnuwash.py
--
We all strive to add privacy that marketing doesn't want us to have.