Removing EXIF from an AI export is a file-processing task: decode the pixels, create a fresh image with an explicit metadata policy, save it, and reopen the output to check the result. Do not infer metadata removal from how the picture looks.
This example exports a single, already-oriented RGB or RGBA image to PNG. It drops EXIF and unrequested text metadata while retaining an existing ICC profile. That is a deliberate color-management choice, not a claim that every possible identifying byte has been removed.
Define a narrow input contract
The two fixtures are public GPT Image 2 examples from the source image workspace. The sneaker is 1024 × 1024 RGB; the fragrance image is 1024 × 1536 RGB. Both currently have zero EXIF entries. They test pixel preservation; a separate synthetic fixture below tests removal of deliberately added metadata. No location metadata was discovered or invented in these product images.
Accept one frame, RGB/RGBA, at most 25 million pixels, and orientation absent or equal to 1. Reject orientation 6 instead of silently rotating or deleting the instruction that tells a viewer how to display it. Perform orientation normalization in a separate stage.
Export from a fresh pixel buffer
python3 -m pip install Pillow==12.3.0
python3 clean_export.py source.png clean.png
from pathlib import Path
from PIL import Image
def clean_export(source, destination):
"""Export one already-oriented RGB/RGBA image to a fresh PNG."""
source, destination = Path(source), Path(destination)
if source.resolve() == destination.resolve():
raise ValueError('Use a separate output path')
if destination.suffix.lower() != '.png':
raise ValueError('Output must be PNG')
with Image.open(source) as im:
if getattr(im, 'n_frames', 1) != 1:
raise ValueError('Only single-frame inputs are supported')
if im.width * im.height > 25_000_000:
raise ValueError('Pixel budget exceeded')
if im.mode not in ('RGB', 'RGBA'):
raise ValueError('Normalize color mode in a separate stage')
im.load()
if im.getexif().get(274, 1) != 1:
raise ValueError('Normalize orientation before removing EXIF')
profile = im.info.get('icc_profile')
clean = Image.frombytes(im.mode, im.size, im.tobytes())
options = {'icc_profile': profile} if profile else {}
clean.save(destination, format='PNG', **options)
with Image.open(destination) as check:
check.load()
if len(check.getexif()) or check.info.get('exif'):
raise RuntimeError('EXIF survived export')
return destination
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('source')
parser.add_argument('destination')
args = parser.parse_args()
print(clean_export(args.source, args.destination))
Image.frombytes builds a new image from decoded pixels without carrying the source info dictionary. The only metadata this function deliberately passes to the PNG encoder is the ICC profile. It rejects palette and CMYK files instead of performing an undocumented color conversion. Use a separate destination: this example never overwrites its input.
The Pillow file-format reference documents PNG EXIF and ICC options. A fresh RGB/RGBA PNG preserves the decoded pixel values; converting a JPEG to PNG does not recover detail lost in the original JPEG.
Test metadata and pixels separately
The second image deliberately differs in size and content. The product names and visual marks in these generated concepts are not endorsements or evidence about actual products.
Save this as test_clean_export.py alongside the implementation:
import tempfile, unittest
from pathlib import Path
from PIL import Image, ImageCms, PngImagePlugin
from clean_export import clean_export
class ExportTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
def tearDown(self): self.tmp.cleanup()
def fixture(self, mode='RGB', orientation=1):
path = self.root / 'input.png'
im = Image.new(mode, (4, 3))
exif = Image.Exif(); exif[315] = 'TEST AUTHOR'; exif[274] = orientation
info = PngImagePlugin.PngInfo(); info.add_text('private_note','TEST ONLY')
profile = ImageCms.ImageCmsProfile(ImageCms.createProfile('sRGB')).tobytes()
im.save(path, exif=exif, pnginfo=info, icc_profile=profile)
return path
def test_exif_and_text_removed_pixels_and_icc_preserved(self):
src=self.fixture(); out=self.root/'out.png'; clean_export(src,out)
with Image.open(src) as a, Image.open(out) as b:
a.load(); b.load()
self.assertEqual(a.tobytes(),b.tobytes())
self.assertEqual(a.info['icc_profile'],b.info['icc_profile'])
self.assertEqual(len(b.getexif()),0)
self.assertNotIn('private_note',b.info)
def test_rgba_preserved(self):
src=self.fixture('RGBA');out=self.root/'out.png';clean_export(src,out)
with Image.open(out) as im:self.assertEqual(im.mode,'RGBA')
def test_orientation_rejected(self):
with self.assertRaises(ValueError):clean_export(self.fixture(orientation=6),self.root/'out.png')
def test_same_path_rejected(self):
src=self.fixture()
with self.assertRaises(ValueError):clean_export(src,src)
def test_wrong_extension_rejected(self):
with self.assertRaises(ValueError):clean_export(self.fixture(),self.root/'out.jpg')
def test_palette_rejected(self):
with self.assertRaises(ValueError):clean_export(self.fixture('P'),self.root/'out.png')
def test_animation_rejected(self):
src=self.root/'anim.png';a=Image.new('RGB',(4,3),'red');b=Image.new('RGB',(4,3),'blue');a.save(src,save_all=True,append_images=[b],duration=100,loop=0)
with self.assertRaises(ValueError):clean_export(src,self.root/'out.png')
def test_corrupt_rejected(self):
src=self.root/'bad.png';src.write_bytes(b'not an image')
with self.assertRaises(OSError):clean_export(src,self.root/'out.png')
if __name__=='__main__':unittest.main()
python3 -m unittest -v test_clean_export.py
The September 25 run passed all eight tests. Both real fixtures exported with identical decoded pixel bytes and zero output EXIF entries. The synthetic TEST AUTHOR and private_note values existed only in the test fixture; its test confirms their removal and preserves a generated sRGB profile. File hashes may differ because metadata and PNG encoding differ, so compare decoded pixels for this invariant.
Keep the limitations explicit
This is a reproducible export function, not a complete untrusted-upload service. It does not remove visible text, watermarks, faces, steganographic data in pixels, or information retained in filenames and external logs. An ICC profile can contain descriptive metadata; remove or standardize it only under an explicit color policy. Add process time and memory limits before exposing decoders to arbitrary uploads.
The code rejects animation, unusual color modes and rotated inputs. It writes directly to the destination and does not implement atomic replacement or concurrent-job isolation. A production pipeline should validate into a temporary output, then promote it only after all checks pass.
Obtain another fixture only when needed
Existing files are enough to run the tests. If you choose to generate another visual, the inspected GPT Image 2 form showed Prompt, Reference images, Mask, 3:4, one output, Low quality and 5 credits. Uploads required sign-in; visibility was Public · watermarked. Generate reached sign-in with the setup retained. The quote depends on the current configuration.
Prepare a different image fixture only after deciding what additional file property it will test. Keep metadata assertions and visual approval as separate checks.


Top comments (0)