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.