PSA Using Python Pillow to foil camera image PRNU fingerprinting
Maria Sophia <[email protected]> Wed, 29 Jul 2026 22:22:37 -0700
| Newsgroups | gmane.comp.python.general |
|---|---|
| Organization | BWH Usenet Archive (https://usenet.blueworldhosting.com) |
| Message-ID | <[email protected]> |
--===============6326055819303582216== Content-Type: text/plain; charset=US-ASCII Content-Language: en-GB Content-Transfer-Encoding: 7bit Today, this article showed up in my technical-news feed about using Python Pillow to discern the reliability of watermarking in AI-generated images. https://arstechnica.com/ai/2026/07/tested-google-synthid-works-great-but-labeling-ai-content-may-be-a-losing-game/ As is my wont, I hacked out a Python script on Windows to test their methods and I posted that script so that others can try it out too. Newsgroups: rec.photo.digital,alt.comp.os.windows-10,comp.lang.python Subject: PSA: Python Pillow was used for image crushing to foil Gemini AI image identification Date: Wed, 29 Jul 2026 09:31:42 -0700 Message-ID: <[email protected]> Having never used Python Pillow, and while I was already writing code, I decided to try to use it to foil camera sensor PRNU fingerprinting. To that end, here's a script that others can test out, but I don't know of a place to upload the original & the scrubbed image to test. So it's just a guess if this script really scrubs PRNU fingerprints. How would I know if it worked? Do you know of a web site that compares two images to tell you if both came from the same camera based on the unique PRNU fingerprint? # prnu.py # Obfuscate camera sensor PRNU fingerprints # Place an image called input.jpg in the current directory. # Run: python prnu.py # ---------------------------------------------------------------------- # v1p6 20260729 Added EXIF stripping, tiny noise injection, dual-pass scrubbing # v1p5 20260729 Brought the margin in a few pixels to handle interpolation # v1p4 20260729 Changed to trigonometry to figure out the crop angles # v1p3 20260729 Switched to making the rotation triangles transparent # v1p2 20260729 Further refined as a mask is needed to remove triangles # v1p1 20260729 Refined crop to remove the white rotation edge triangles # v1p0 20260729 Original version # blur, rotate, crop, recompress, resize # ---------------------------------------------------------------------- import math import random import numpy as np from PIL import Image, ImageFilter INPUT_IMAGE = "input.jpg" OUTPUT_IMAGE = "scrubbed.jpg" def maximal_inner_rect(w, h, angle): """ Compute the largest axis-aligned rectangle inside a rotated rectangle. """ theta = abs(angle) if theta == 0: return w, h t = math.radians(theta) W = w H = h W_prime = W * math.cos(t) - H * math.sin(t) H_prime = H * math.cos(t) - W * math.sin(t) return int(W_prime), int(H_prime) def scrub_once(img): """ One full PRNU scrubbing pass: blur ¡÷ rotate ¡÷ crop ¡÷ resize ¡÷ noise ¡÷ JPEG recompress """ # Blur to kill PRNU high-frequency noise img = img.filter(ImageFilter.GaussianBlur(radius=1.2)) # Random slight rotation angle = random.uniform(-2.0, 2.0) rotated = img.rotate(angle, expand=True) # Compute maximal inner rectangle W, H = img.size crop_w, crop_h = maximal_inner_rect(W, H, angle) # Safety margin margin = 3 crop_w = max(1, crop_w - 2 * margin) crop_h = max(1, crop_h - 2 * margin) # Center crop cx, cy = rotated.size left = (cx - crop_w) // 2 top = (cy - crop_h) // 2 right = left + crop_w bottom = top + crop_h cropped = rotated.crop((left, top, right, bottom)) # Optional slight resize scale = random.uniform(0.97, 1.00) new_w = max(1, int(cropped.width * scale)) new_h = max(1, int(cropped.height * scale)) resized = cropped.resize((new_w, new_h), Image.LANCZOS) # Add tiny random noise (¡Ó3) arr = np.array(resized).astype(np.int16) noise = np.random.randint(-3, 4, arr.shape, dtype=np.int16) arr = np.clip(arr + noise, 0, 255).astype(np.uint8) resized = Image.fromarray(arr) return resized # Load image img = Image.open(INPUT_IMAGE).convert("RGB") # Strip EXIF metadata img.info.pop("exif", None) # First scrubbing pass img = scrub_once(img) # Second scrubbing pass (different random parameters) img = scrub_once(img) # Final JPEG recompression quality = random.randint(70, 90) img.save(OUTPUT_IMAGE, "JPEG", quality=quality) print("Saved:", OUTPUT_IMAGE) # end of prnu.py -- Posted out of the goodness of my heart to help others & to learn from them. --===============6326055819303582216== Content-Type: text/plain; charset="us-ascii" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit Content-Disposition: inline