{"slug": "testing-ai-image-watermarks-c2pa-and-synthid", "title": "Testing AI image watermarks: C2PA and SynthID", "summary": "An independent experiment found that OpenAI's C2PA Content Credentials can be removed from an AI-generated image by stripping its metadata with ImageMagick, while Google's SynthID pixel watermark survived metadata stripping, a 512x512 center crop and JPEG re-encoding at quality 75. The tester confirmed the C2PA removal was lossless by decoding both the original and stripped files to raw RGBA and matching SHA-256 hash ef994f090d047a181138e41a0e44bdec7cc22e4982bda529eecc2240c21c3cb2, and noted OpenAI did not embed SynthID in a simple generated rectangle.", "body_md": "## Testing AI image watermarks: C2PA and SynthID\n\nC2PA and SynthID are two technologies used to identify AI-generated images. C2PA (Content Credentials) is pretty simple, as it just adds signed metadata to a file. SynthID, on the other hand, is a different beast. It adds a watermark directly into the pixels of images.\n\nI decided to experiment with these to understand more.\n\n### Test 1: Plain rectangle\n\nPrompt:\n\nLet's inspect the image:\n\n```\nc2patool ai-generated-images/pure-white-blank-canvas1.png --info\n```\n\nThe important part here is \"One manifest\". Let's extract the manifest and search for interesting fields.\n\nIt is clear that this image has C2PA metadata.\n\nStripping is pretty easy with magick. Let's inspect again, after stripping.\n\nAt this point, the metadata is gone.\n\n```\n</>javascript\n{\n  \"type\": \"c2pa\",\n  \"outcome\": \"not_detected\"\n}\n```\n\nDid we change the image, though? Let's decode these files into raw RGBA pixels and check the hash.\n\n```\n</>javascript\nmagick rectangle-original.png rgba:- | shasum -a 256\nmagick rectangle-stripped.png rgba:- | shasum -a 256\n\n// Both output ef994f090d047a181138e41a0e44bdec7cc22e4982bda529eecc2240c21c3cb2\n```\n\nThey both output the same hash, so the images are identical.\n\nInterestingly enough, OpenAI does not seem to add SynthID in simple shapes like these. Let's generate something better.\n\n### Test 2: Apple\n\nI used the prompt \"Draw a red apple, realistic studio photo, on a plain white background\":\n\nLet's check for provenance signals.\n\nWe could either use the [web's version](https://openai.com/research/verify/) or just call the API.\n\nI stripped the metadata and ran the check again.\n\n```\n</>javascript\n{\n  \"object\": \"content_provenance_check\",\n  \"results\": [\n    {\n      \"type\": \"c2pa\",\n      \"generated_at\": null,\n      \"issuer\": null,\n      \"model\": null,\n      \"outcome\": \"not_detected\",\n      \"validation_state\": \"not_present\"\n    },\n    {\n      \"type\": \"SynthID\",\n      \"generated_at\": null,\n      \"model\": null,\n      \"outcome\": \"detected\"\n    }\n  ]\n}\n```\n\nThis is expected, as removing metadata does not remove SynthID.\n\nWhat happens with the crop?\n\n```\nmagick Pure-White-Canvas.png -gravity center -crop 512x512+0+0 +repage ai_crop_512.png\n```\n\nSynthID survives.\n\nJPEG conversion?\n\n```\nmagick apple-stripped.png -quality 75 apple-q75.jpg\n```\n\nSynthID survives.\n\nMaybe a diffusion attack, then?\n\n```\n...\npipe = StableDiffusionImg2ImgPipeline.from_pretrained(\n        model_id, \n        torch_dtype=torch.float16 if device == \"mps\" else torch.float32\n    )\nprompt = \"A high-quality, detailed, photorealistic macro photograph of a single glossy red apple with a brown stem, natural yellow streaks, isolated on a plain white background with a soft shadow\"\n    image = pipe(\n        prompt=prompt, \n        image=init_image, \n        strength=0.25, \n        guidance_scale=7.5\n    ).images[0]\n  ...\n```\n\nThat is, convert the original image to numbers (latents), add some noise, then denoise based on a prompt.\n\nBut this is cheating. I am essentially regenerating the image (from the original), not removing SynthID. This type of diffusion reconstruction will not exactly preserve the original image (texture, color, edges, lighting, etc.).\n\nGuess I am back to analyzing pixels, and there is no better candidate for this than a nearly white image.\n\n### Test 3: Plain white background\n\nPrompt:\n\nHow about we compare this plain white image with a plain white image (that I can create locally)?\n\nLet's analyze the AI image for spatial patterns or uniformities.\n\n``` python\nimport numpy as np\nimport matplotlib.pyplot as plt\nfrom PIL import Image\n\n# Compute a windowed, 0 mean 2D FFT spectrum\ndef fft_spectrum(channel):\n    centered = channel - channel.mean(dtype=np.float64) # zero mean\n    h, w = centered.shape\n    window = np.outer(\n        np.hanning(h),\n        np.hanning(w),\n    )\n    windowed = centered * window\n    fft_raw = np.fft.fft2(windowed)\n    fft_shifted = np.fft.fftshift(fft_raw)\n    magnitude = np.log1p(np.abs(fft_shifted))\n    return magnitude\n\n# analyze spatial variation\n# load image, calculate mean RGB independently, subtract mean color from every pixel and compute FFT spectra\ndef analyze_zero_mean_residual(\n    image_path,\n    output_path=\"zero_mean_analysis.png\",\n    amp_factor=50,\n):\n    img = Image.open(image_path).convert(\"RGB\")\n    ai = np.array(img, dtype=np.float64)\n    height, width, channels = ai.shape\n\n    print(\"--- Image ---\")\n    print(f\"Path: {image_path}\")\n    print(f\"Dimensions: {width} x {height}\")\n    print(f\"Shape: {ai.shape}\")\n    print(\"--- Raw Pixel Statistics ---\")\n    print(f\"Overall min:  {ai.min():.0f}\")\n    print(f\"Overall max:  {ai.max():.0f}\")\n    print(f\"Overall mean: {ai.mean(dtype=np.float64):.6f}\")\n\n    unique_values = np.unique(ai.astype(np.uint8))\n    print(f\"Unique channel values: {len(unique_values)}\")\n\n    if len(unique_values) <= 30:\n        print(f\"Values: {unique_values}\")\n    else:\n        print(\n            f\"Value range: \"\n            f\"{unique_values[0]} ... {unique_values[-1]}\"\n        )\n\n    mean_rgb = ai.mean(\n        axis=(0, 1),\n        dtype=np.float64,\n    )\n\n    print(\"--- Mean RGB ---\")\n    print(f\"R = {mean_rgb[0]:.9f}\")\n    print(f\"G = {mean_rgb[1]:.9f}\")\n    print(f\"B = {mean_rgb[2]:.9f}\")\n\n    channel_min = ai.min(axis=(0, 1))\n    channel_max = ai.max(axis=(0, 1))\n\n    print(\"--- Channel Ranges ---\")\n    print(\n        f\"R: {channel_min[0]:.0f} to {channel_max[0]:.0f}\"\n    )\n    print(\n        f\"G: {channel_min[1]:.0f} to {channel_max[1]:.0f}\"\n    )\n    print(\n        f\"B: {channel_min[2]:.0f} to {channel_max[2]:.0f}\"\n    )\n\n    if np.any(mean_rgb < channel_min) or np.any(mean_rgb > channel_max):\n        raise RuntimeError(\n            \"Calculated RGB mean is outside the observed pixel range.\"\n            f\"Mean: {mean_rgb}\"\n            f\"Min:  {channel_min}\"\n            f\"Max:  {channel_max}\"\n        )\n\n    residual = ai - mean_rgb\n    residual_channel_means = residual.mean(\n        axis=(0, 1),\n        dtype=np.float64,\n    )\n\n    print(\"--- Residual ---\")\n    print(f\"Min:  {residual.min():.9f}\")\n    print(f\"Max:  {residual.max():.9f}\")\n    print(\n        f\"Overall mean: \"\n        f\"{residual.mean(dtype=np.float64):.12f}\"\n    )\n\n    print(\"--- Residual Mean By Channel ---\")\n    print(f\"R = {residual_channel_means[0]:.12f}\")\n    print(f\"G = {residual_channel_means[1]:.12f}\")\n    print(f\"B = {residual_channel_means[2]:.12f}\")\n\n    if not np.allclose(\n        residual_channel_means,\n        0.0,\n        atol=1e-10,\n    ):\n        raise RuntimeError(\n            \"Residual was not centered correctly.\"\n            f\"Residual means: {residual_channel_means}\"\n        )\n    channel_names = [\"Red\", \"Green\", \"Blue\"]\n\n    print(\"--- Per-channel Residual Statistics ---\")\n\n    for index, name in enumerate(channel_names):\n        channel = residual[:, :, index]\n\n        print(\n            f\"{name:>5} | \"\n            f\"mean={channel.mean(dtype=np.float64): .12f} \"\n            f\"std={channel.std(dtype=np.float64): .9f} \"\n            f\"min={channel.min(): .9f} \"\n            f\"max={channel.max(): .9f}\"\n        )\n\n    # Overall RMS residual\n    rms = np.sqrt(\n        np.mean(\n            residual ** 2,\n            dtype=np.float64,\n        )\n    )\n    print(f\"Overall residual RMS: {rms:.9f}\")\n\n    red_fft = fft_spectrum( residual[:, :, 0] )\n    green_fft = fft_spectrum( residual[:, :, 1] )\n    blue_fft = fft_spectrum( residual[:, :, 2] )\n    all_fft_values = np.concatenate([ red_fft.ravel(), green_fft.ravel(), blue_fft.ravel(), ])\n    fft_vmin = np.percentile( all_fft_values, 1.0, )\n    fft_vmax = np.percentile( all_fft_values, 99.5, )\n\n    print(\"--- Shared FFT Display Scale ---\")\n    print(f\"vmin = {fft_vmin:.9f}\")\n    print(f\"vmax = {fft_vmax:.9f}\")\n\n    # signed residual visualization\n    residual_vis = np.clip( 128.0 + residual * amp_factor, 0, 255, ).astype(np.uint8)\n\n    # flat mean-color reference for visualization\n    flat_rgb_uint8 = np.clip( np.round(mean_rgb), 0, 255, ).astype(np.uint8)\n    flat_vis = np.empty_like( ai, dtype=np.uint8, )\n    flat_vis[:] = flat_rgb_uint8\n\n    # plot\n    fig, axes = plt.subplots( 2, 3, figsize=(18, 11), constrained_layout=True, )\n\n    # Original image\n    axes[0, 0].set_title( \"Original AI image\" )\n\n    axes[0, 0].imshow(img)\n    axes[0, 0].axis(\"off\")\n\n    # Mean-matched flat reference\n    axes[0, 1].set_title( \"Mean-matched flat reference\" f\"Display RGB {tuple(flat_rgb_uint8.tolist())}\" )\n\n    axes[0, 1].imshow(flat_vis)\n    axes[0, 1].axis(\"off\")\n\n    axes[0, 2].set_title( f\"Signed residual ×{amp_factor}\" \"128 = zero difference\" )\n    axes[0, 2].imshow( residual_vis )\n    axes[0, 2].axis(\"off\")\n\n    fft_image = axes[1, 0].imshow( red_fft, cmap=\"magma\", vmin=fft_vmin, vmax=fft_vmax, ) # red\n    axes[1, 0].set_title( \"FFT: red residual\" )\n    axes[1, 0].axis(\"off\")\n    axes[1, 1].imshow( green_fft, cmap=\"magma\", vmin=fft_vmin, vmax=fft_vmax, ) # green\n    axes[1, 1].set_title( \"FFT: green residual\" )\n    axes[1, 1].axis(\"off\")\n    axes[1, 2].imshow( blue_fft, cmap=\"magma\", vmin=fft_vmin, vmax=fft_vmax, )\n    axes[1, 2].set_title( \"FFT: blue residual\" )\n\n    axes[1, 2].axis(\"off\")\n\n    # Shared colorbar\n    fig.colorbar( fft_image, ax=axes[1, :], fraction=0.025, pad=0.02, label=\"log(1 + FFT magnitude)\", )\n\n    # All done!!!\n    plt.savefig( output_path, dpi=200, bbox_inches=\"tight\", )\n    plt.close(fig)\n    print(f\"Saved: {output_path}\")\n\nif __name__ == \"__main__\":\n    analyze_zero_mean_residual(\n        image_path=\"ai-generated-images/pure-white-blank-canvas1.png\",\n        output_path=\"zero_mean_analysis.png\",\n        amp_factor=50,\n    )\n```\n\n**Output:**\n\nThere is a lot of data to unpack, but we are mostly interested in mean RGB values and the residual standard deviation. Green seems to have a lot more residual energy, but we can't conclude that it is carrying the watermark.\n\nWe can use Pillow to generate a-like image. We will get the exact value distribution per channel and generate a random image with the same histogram.\n\n```\nrandom_control = np.empty_like(source, dtype=np.uint8)\nchannel_names = [\"Red\", \"Green\", \"Blue\"]\nfor i, channel_name in enumerate(channel_names):\n    channel = source[:, :, i]\n    unique_vals, counts = np.unique(channel, return_counts=True)\n    probs = counts / counts.sum()\n    random_channel = rng.choice( unique_vals, size=(h, w), p=probs, ).astype(np.uint8) random_control[:, :, i] = random_channel\nImage.fromarray(random_control).save(output_path)\n```\n\nChecking this newly generated image through the provenance API shows \"SynthID not detected\". So, just having the matching RGB distributions is not enough. The AI image contains spatial information that matters.\n\nWe will do one final spectral comparison here.\n\n```\n...\nfor i, channel_name in enumerate(channel_names):\n    ai_mag, ai_log, ai_power = fft_data( ai_residual[:, :, i] )\n    control_mag, control_log, control_power = fft_data( control_residual[:, :, i] )\n    ai_fft.append(ai_log)\n    control_fft.append(control_log)\n    ai_entropy = spectral_entropy(ai_power)\n    control_entropy = spectral_entropy(control_power)\n    ai_peak = peak_prominence(ai_power)\n    control_peak = peak_prominence(control_power)\n    corr = spectrum_correlation( ai_log, control_log, )\n\n    metrics.append({\n        \"channel\": channel_name,\n        \"ai_entropy\": ai_entropy,\n        \"control_entropy\": control_entropy,\n        \"ai_peak\": ai_peak,\n        \"control_peak\": control_peak,\n        \"correlation\": corr,\n    })\n\n    radial_ai.append( radial_power_spectrum(ai_power) )\n    radial_control.append( radial_power_spectrum(control_power) )\n...\n```\n\nThis is an interesting result.\n\nThe AI image and control are identical but very different spectrally.\n\n- **Spectral entropy:**\n AI image: ~0.889-0.900\nControl image: ~0.970\n- **Peak prominence:**\n AI image: ~716-1078\nControl image: ~19-25\n- **Spectrum correlation:**\nZero\n\nRunning it a few more times showed:\n\n| Metric | Run 1 | Run 2 | Run 3 | Controls | \n|---|---|---|---|---|\n| Red entropy | 0.9000 | 0.9011 | 0.8895 | ~0.9704 | \n| Green entropy | 0.8893 | 0.8818 | 0.8702 | ~0.9704 | \n| Blue entropy | 0.8970 | 0.8896 | 0.8817 | ~0.9704 | \n| Red peak | 715.7 | 554.5 | 485.2 | ~21-25 | \n| Green peak | 1077.8 | 884.5 | 633.6 | ~19-21 | \n| Blue peak | 777.5 | 718.3 | 811.3 | ~19-24 | \n\nIn these samples, SynthID images show much lower spectral entropy than their matched random controls.\n\nI am now curious to see how much shaving off or limiting high-frequency spikes affects the image's appearance. We can convert each color channel into the frequency domain (via a 2D Fast Fourier Transform) and shift the zero-frequency component to the center. Then we iterate over some magnitude thresholds, cap the ones that exceed and reconstruct the image back.\n\n``` python\nimport numpy as np\nfrom PIL import Image\n\nTHRESHOLDS = [300, 500, 750, 1000, 1500, 2000, 2500, 3000]\n\ndef evaluate_threshold(arr, threshold, dc_radius=15):\n    h, w, c = arr.shape\n    reconstructed = np.zeros_like(arr)\n\n    y, x = np.ogrid[:h, :w]\n    cy, cx = h // 2, w // 2\n    dc_mask = ((x - cx) ** 2 + (y - cy) ** 2) <= dc_radius ** 2\n\n    per_channel_affected = []\n    max_imag_residue = 0.0\n\n    for i in range(c):\n        channel = arr[:, :, i]\n\n        fft_raw = np.fft.fft2(channel)\n        fft_shifted = np.fft.fftshift(fft_raw)\n\n        magnitude = np.abs(fft_shifted)\n        phase = np.angle(fft_shifted)\n\n        capped_mask = (magnitude > threshold) & (~dc_mask)\n        affected_count = np.sum(capped_mask)\n        affected_fraction = (affected_count / magnitude.size) * 100.0\n        per_channel_affected.append(affected_fraction)\n\n        magnitude[capped_mask] = threshold\n        fft_capped = magnitude * np.exp(1j * phase)\n\n        inverse = np.fft.ifft2(np.fft.ifftshift(fft_capped))\n\n        max_imag_residue = max(\n            max_imag_residue,\n            np.max(np.abs(inverse.imag))\n        )\n\n        reconstructed[:, :, i] = inverse.real\n\n    overall_affected_fraction = float(np.mean(per_channel_affected))\n\n    # Float-domain error\n    diff_float = reconstructed - arr\n    rmse_float = np.sqrt(np.mean(diff_float ** 2))\n    mae_float = np.mean(np.abs(diff_float))\n    max_abs_float = np.max(np.abs(diff_float))\n\n    # 8-bit / saved-image error\n    arr_8bit = np.clip(np.round(arr), 0, 255).astype(np.uint8)\n    recon_8bit = np.clip(np.round(reconstructed), 0, 255).astype(np.uint8)\n\n    diff_8bit = arr_8bit.astype(np.float64) - recon_8bit.astype(np.float64)\n    rmse_8bit = np.sqrt(np.mean(diff_8bit ** 2))\n\n    pixel_changed_mask = np.any(arr_8bit != recon_8bit, axis=2)\n    pixels_changed_pct = (np.sum(pixel_changed_mask) / (h * w)) * 100.0\n\n    subpixels_changed_pct = (\n        np.sum(arr_8bit != recon_8bit) / arr_8bit.size\n    ) * 100.0\n\n    return {\n        \"threshold\": threshold,\n        \"affected_overall_pct\": overall_affected_fraction,\n        \"affected_red_pct\": per_channel_affected[0],\n        \"affected_green_pct\": per_channel_affected[1],\n        \"affected_blue_pct\": per_channel_affected[2],\n        \"rmse_float\": rmse_float,\n        \"mae_float\": mae_float,\n        \"max_abs_float\": max_abs_float,\n        \"rmse_8bit\": rmse_8bit,\n        \"pixels_changed_pct\": pixels_changed_pct,\n        \"subpixels_changed_pct\": subpixels_changed_pct,\n        \"max_imag_residue\": max_imag_residue,\n    }\n\ndef run_sweep(input_path, dc_radius=15):\n    print(\"--- Running Threshold Sweep ---\")\n    print(f\"Image: {input_path}\")\n    print(f\"DC protection radius: {dc_radius}\")\n\n    img = Image.open(input_path).convert(\"RGB\")\n    arr = np.array(img, dtype=np.float64)\n\n    print(\n        f\"{'Threshold':<10} | \"\n        f\"{'FFT Avg':<9} | \"\n        f\"{'R%':<7} | \"\n        f\"{'G%':<7} | \"\n        f\"{'B%':<7} | \"\n        f\"{'Float RMSE':<11} | \"\n        f\"{'8-bit RMSE':<11} | \"\n        f\"{'RGB Px':<9} | \"\n        f\"{'Subpx':<9} | \"\n        f\"{'Max Imag'}\"\n    )\n    print(\"-\" * 120)\n\n    for t in THRESHOLDS:\n        result = evaluate_threshold(arr, t, dc_radius=dc_radius)\n\n        print(\n            f\"{result['threshold']:<10} | \"\n            f\"{result['affected_overall_pct']:>7.2f}% | \"\n            f\"{result['affected_red_pct']:>6.2f}% | \"\n            f\"{result['affected_green_pct']:>6.2f}% | \"\n            f\"{result['affected_blue_pct']:>6.2f}% | \"\n            f\"{result['rmse_float']:>10.4f} | \"\n            f\"{result['rmse_8bit']:>10.4f} | \"\n            f\"{result['pixels_changed_pct']:>7.2f}% | \"\n            f\"{result['subpixels_changed_pct']:>7.2f}% | \"\n            f\"{result['max_imag_residue']:.2e}\"\n        )\n\nif __name__ == \"__main__\":\n    run_sweep(\n        \"ai-generated-images/pure-white-blank-canvas1.png\",\n        dc_radius=15,\n    )\n```\n\nThis result ruled out my naive but tempting shortcut.\n\nAt 1500, I am changing about 5% of FFT bins, but only ~0.5% of RGB pixels survive quantization. But at 2000, I am changing about 2.6% of the FFT bins, but the 8-bit image is unchanged. This probably means threshold sweeps aren't a good way to target recurring peaks.\n\nThe strong FFT peaks are interesting, but blindly capping them was not a good way to isolate whatever the detector was seeing.\n\n### Comparing frequencies\n\nFinally, I took the FFT area that were very strong in the AI image and treated them as candidates. I can now compare them with similar non candidate frequencies.\n\nFirst is Frequency radius. I start from the center (FFT), and compare the radius of AI (candidate) and control frequencies. The result was:\n\n- Median radius difference: 0.73 bins\n- 95th percentile: 2.67 bins\n\nSo they were pretty close in frequency location.\n\nThen I matched FFT magnitude. For every candidate, I looked for a control frequency within 0.95x and 1.05x (FTT) magnitude. The result was that the median control magnitude was about 95.6% of the candidate magnitude.\n\nAfter matching radius and magnitude, I got RMSE of candidate: 0.105326 and RMSE of control: 0.100709, i.e. just 4.6% difference.\n\nIn other words, once I matched both sides properly, most of that difference disappeared.\n\n### Final words\n\nI started this experiment thinking the watermark was simple stuff. But the data decided to consume my evening. The takeaway is that the AI-generated images contained small pixel variations, and they do not behave like random noise. When I compared them with random controls, the AI-generated ones showed lower spectral entropy and near-zero spectrum correlations with our controls. And my attempt to suppress the high-energy FFT coefficients didn't turn out the way I hoped.\n\nSo I didn't find the SynthID frequency (yet). But there was clearly some spectral structure hiding inside a plain white image.", "url": "https://wpnews.pro/news/testing-ai-image-watermarks-c2pa-and-synthid", "canonical_source": "https://nabraj.com/blog/testing-ai-watermarks", "published_at": "2026-11-10 17:12:13+00:00", "updated_at": "2026-11-10 17:25:57.596037+00:00", "lang": "en", "topics": ["artificial-intelligence", "generative-ai", "ai-safety", "computer-vision"], "entities": ["C2PA", "SynthID", "OpenAI", "Google", "ImageMagick", "StableDiffusionImg2ImgPipeline"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/testing-ai-image-watermarks-c2pa-and-synthid", "markdown": "https://wpnews.pro/news/testing-ai-image-watermarks-c2pa-and-synthid.md", "text": "https://wpnews.pro/news/testing-ai-image-watermarks-c2pa-and-synthid.txt", "jsonld": "https://wpnews.pro/news/testing-ai-image-watermarks-c2pa-and-synthid.jsonld"}}