# content-credentials-survival: the full experiment behind # "How to Tell If an Image Is AI Generated, and When the Check Stops Working" # https://learnai24.com/ai-basics/how-to-tell-if-an-image-is-ai-generated/ # # What it does: generates a signing certificate, builds a test image, signs it with # Content Credentials (C2PA), then applies 18 ordinary image operations and reports # for each one whether the credential is still valid, still present but broken, or gone. # # Requirements: # pip install c2pa-python pillow # command line tools: openssl, exiftool, convert (ImageMagick), jpegtran, ffmpeg # Run: python3 content_credentials_survival.py # Takes well under a minute. Everything is written into ./cc_test/. import json, os, shutil, subprocess, sys import c2pa from PIL import Image, ImageDraw D = 'cc_test' OUT = os.path.join(D, 'out') os.makedirs(OUT, exist_ok=True) CERT, KEY = os.path.join(D, 'cert.pem'), os.path.join(D, 'key.pem') ORIG, SIGNED = os.path.join(D, 'orig.jpg'), os.path.join(D, 'signed.jpg') def make_cert(): subprocess.run([ 'openssl', 'req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-days', '3650', '-nodes', '-keyout', KEY, '-out', CERT, '-subj', '/CN=content credentials survival test/O=test/C=DE', '-addext', 'keyUsage=critical,digitalSignature', '-addext', 'extendedKeyUsage=emailProtection', '-addext', 'basicConstraints=critical,CA:FALSE', ], capture_output=True, check=True) def make_image(): W, H = 1600, 1067 im = Image.new('RGB', (W, H)) px = im.load() for y in range(H): for x in range(W): px[x, y] = ((x * 255) // W, (y * 200) // H + 30, 200 - (x * 120) // W) d = ImageDraw.Draw(im) import random random.seed(5) for _ in range(40): x0, y0 = random.randint(0, W), random.randint(0, H) d.ellipse([x0, y0, x0 + random.randint(40, 300), y0 + random.randint(40, 300)], outline=(random.randint(0, 255),) * 3, width=random.randint(1, 6)) d.text((40, 40), 'content credentials test', fill=(255, 255, 255)) im.save(ORIG, quality=95) def sign(): manifest = { 'claim_generator_info': [{'name': 'content credentials survival test', 'version': '1.0'}], 'title': 'content credentials survival test', 'assertions': [{'label': 'c2pa.actions', 'data': {'actions': [{ 'action': 'c2pa.created', 'digitalSourceType': 'http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia', }]}}], } info = c2pa.C2paSignerInfo( alg=b'es256', sign_cert=open(CERT, 'rb').read(), private_key=open(KEY, 'rb').read(), ta_url=None, # an empty string is rejected; None means no timestamp authority ) signer = c2pa.Signer.from_info(info) c2pa.Builder(manifest).sign_file(ORIG, SIGNED, signer) def state(path): """valid | present but invalid | gone | unreadable""" try: r = c2pa.Reader(path) return 'valid' if str(r.get_validation_state()) == 'Valid' else 'present but invalid' except Exception as e: return 'gone' if 'ManifestNotFound' in type(e).__name__ or 'ManifestNotFound' in str(e) \ else 'unreadable (' + str(e)[:40] + ')' def run(): rows = [] def rec(name, path): rows.append((name, state(path), os.path.getsize(path) if os.path.exists(path) else 0)) def sh(cmd, shell=False): subprocess.run(cmd, shell=shell, capture_output=True) rec('nothing, the signed file itself', SIGNED) p = f'{OUT}/copy.jpg'; shutil.copyfile(SIGNED, p); rec('copied byte for byte', p) p = f'{OUT}/renamed.png'; shutil.copyfile(SIGNED, p); rec('renamed to .png', p) p = f'{OUT}/exif_one_tag.jpg'; shutil.copyfile(SIGNED, p) sh(['exiftool', '-Artist=nobody', '-overwrite_original', p]) rec('exiftool writes one EXIF field', p) p = f'{OUT}/jpegtran_all.jpg' sh(f'jpegtran -copy all -optimize {SIGNED} > {p}', shell=True) rec('jpegtran lossless optimize, metadata kept', p) p = f'{OUT}/jpegtran_rot.jpg' sh(f'jpegtran -copy all -rotate 90 {SIGNED} > {p}', shell=True) rec('jpegtran lossless rotate, metadata kept', p) p = f'{OUT}/jpegtran_none.jpg' sh(f'jpegtran -copy none -optimize {SIGNED} > {p}', shell=True) rec('jpegtran lossless optimize, metadata dropped', p) im = Image.open(SIGNED) for q in (95, 80, 60): p = f'{OUT}/reencode_q{q}.jpg'; Image.open(SIGNED).save(p, quality=q) rec(f'JPEG re-encode at quality {q}', p) p = f'{OUT}/resized.jpg' Image.open(SIGNED).resize((1080, int(1080 * im.height / im.width))).save(p, quality=90) rec('resized to 1080 px wide', p) p = f'{OUT}/cropped.jpg' Image.open(SIGNED).crop((80, 80, im.width - 80, im.height - 80)).save(p, quality=90) rec('cropped by 80 px on each edge', p) p = f'{OUT}/rotated.jpg' Image.open(SIGNED).rotate(90, expand=True).save(p, quality=90) rec('rotated 90 degrees', p) # a screenshot is the same operation: decode the pixels, write a new file. # Doing both produced byte-identical output, so it is listed once. p = f'{OUT}/as.png'; Image.open(SIGNED).save(p) rec('converted to PNG, which is what a screenshot also produces', p) p = f'{OUT}/as.webp'; Image.open(SIGNED).save(p, quality=90); rec('converted to WebP', p) p = f'{OUT}/exif_stripped.jpg'; shutil.copyfile(SIGNED, p) sh(['exiftool', '-all=', '-overwrite_original', p]) rec('exiftool -all=, the usual way to strip metadata', p) p = f'{OUT}/im.jpg'; sh(['convert', SIGNED, '-quality', '90', p]) rec('ImageMagick convert', p) p = f'{OUT}/im_strip.jpg'; sh(['convert', SIGNED, '-strip', '-quality', '90', p]) rec('ImageMagick convert -strip', p) p = f'{OUT}/ff.jpg'; sh(['ffmpeg', '-y', '-i', SIGNED, '-q:v', '2', p]) rec('ffmpeg re-encode', p) return rows def versions(): import importlib.metadata as md out = [] try: out.append('c2pa-python ' + md.version('c2pa-python')) except Exception: pass try: out.append('Pillow ' + md.version('pillow')) except Exception: pass for name, cmd in [('exiftool', ['exiftool', '-ver']), ('ImageMagick', ['convert', '--version']), ('jpegtran', ['jpegtran', '-version']), ('ffmpeg', ['ffmpeg', '-version'])]: try: r = subprocess.run(cmd, capture_output=True, text=True) line = (r.stdout or r.stderr).splitlines()[0].strip() out.append(name + ' ' + line[:60]) except Exception: out.append(name + ' not found') return out if __name__ == '__main__': for v in versions(): print(v) print() make_cert() make_image() sign() print('original %d bytes, signed %d bytes, credential costs %d bytes (%.1f percent)' % ( os.path.getsize(ORIG), os.path.getsize(SIGNED), os.path.getsize(SIGNED) - os.path.getsize(ORIG), 100 * (os.path.getsize(SIGNED) / os.path.getsize(ORIG) - 1))) rows = run() w = max(len(r[0]) for r in rows) for name, st, size in rows: print(('%-' + str(w) + 's %-19s %9d') % (name, st, size)) counts = {} for _, st, _ in rows: counts[st] = counts.get(st, 0) + 1 print('\n' + ', '.join('%s: %d' % (k, v) for k, v in sorted(counts.items())))