Testing AI image watermarks: C2PA and SynthID
Testing AI image watermarks: C2PA and SynthID
以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。
Testing AI image watermarks: C2PA and SynthID
C2PA 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.
I decided to experiment with these to understand more.
c2patool ai-generated-images/pure-white-blank-canvas1.png --info
The important part here is "One manifest". Let's extract the manifest and search for interesting fields.
It is clear that this image has C2PA metadata.
Stripping is pretty easy with magick. Let's inspect again, after stripping.
{ "type": "c2pa", "outcome": "not_detected" }
Did we change the image, though? Let's decode these files into raw RGBA pixels and check the hash.
magick rectangle-original.png rgba:- | shasum -a 256 magick rectangle-stripped.png rgba:- | shasum -a 256
// Both output ef994f090d047a181138e41a0e44bdec7cc22e4982bda529eecc2240c21c3cb2
They both output the same hash, so the images are identical.
Interestingly enough, OpenAI does not seem to add SynthID in simple shapes like these. Let's generate something better.
I used the prompt "Draw a red apple, realistic studio photo, on a plain white background":
We could either use the web's version or just call the API.
I stripped the metadata and ran the check again.
{ "object": "content_provenance_check", "results": [ { "type": "c2pa", "generated_at": null, "issuer": null, "model": null, "outcome": "not_detected", "validation_state": "not_present" }, { "type": "SynthID", "generated_at": null, "model": null, "outcome": "detected" } ] }
This is expected, as removing metadata does not remove SynthID.
magick Pure-White-Canvas.png -gravity center -crop 512x512+0+0 +repage ai_crop_512.png
SynthID survives.
magick apple-stripped.png -quality 75 apple-q75.jpg
SynthID survives.
... pipe = StableDiffusionImg2ImgPipeline.from_pretrained( model_id, torch_dtype=torch.float16 if device == "mps" else torch.float32 ) prompt = "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" image = pipe( prompt=prompt, image=init_image, strength=0.25, guidance_scale=7.5 ).images[0] ...
That is, convert the original image to numbers (latents), add some noise, then denoise based on a prompt.
But 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.).
Guess I am back to analyzing pixels, and there is no better candidate for this than a nearly white image.
How about we compare this plain white image with a plain white image (that I can create locally)?
Let's analyze the AI image for spatial patterns or uniformities.
import numpy as np import matplotlib.pyplot as plt from PIL import Image
Compute a windowed, 0 mean 2D FFT spectrum
def fft_spectrum(channel): centered = channel - channel.mean(dtype=np.float64) # zero mean h, w = centered.shape window = np.outer( np.hanning(h), np.hanning(w), ) windowed = centered * window fft_raw = np.fft.fft2(windowed) fft_shifted = np.fft.fftshift(fft_raw) magnitude = np.log1p(np.abs(fft_shifted)) return magnitude
analyze spatial variation
load image, calculate mean RGB independently, subtract mean color from every pixel and compute FFT spectra
def analyze_zero_mean_residual( image_path, output_path="zero_mean_analysis.png", amp_factor=50, ): img = Image.open(image_path).convert("RGB") ai = np.array(img, dtype=np.float64) height, width, channels = ai.shape
print("--- Image ---") print(f"Path: {image_path}") print(f"Dimensions: {width} x {height}") print(f"Shape: {ai.shape}") print("--- Raw Pixel Statistics ---") print(f"Overall min: {ai.min():.0f}") print(f"Overall max: {ai.max():.0f}") print(f"Overall mean: {ai.mean(dtype=np.float64):.6f}")
unique_values = np.unique(ai.astype(np.uint8)) print(f"Unique channel values: {len(unique_values)}")
if len(unique_values) <= 30: print(f"Values: {unique_values}") else: print( f"Value range: " f"{unique_values[0]} ... {unique_values[-1]}" )
mean_rgb = ai.mean( axis=(0, 1), dtype=np.float64, )
print("--- Mean RGB ---") print(f"R = {mean_rgb[0]:.9f}") print(f"G = {mean_rgb[1]:.9f}") print(f"B = {mean_rgb[2]:.9f}")
channel_min = ai.min(axis=(0, 1)) channel_max = ai.max(axis=(0, 1))
print("--- Channel Ranges ---") print( f"R: {channel_min[0]:.0f} to {channel_max[0]:.0f}" ) print( f"G: {channel_min[1]:.0f} to {channel_max[1]:.0f}" ) print( f"B: {channel_min[2]:.0f} to {channel_max[2]:.0f}" )
if np.any(mean_rgb < channel_min) or np.any(mean_rgb > channel_max): raise RuntimeError( "Calculated RGB mean is outside the observed pixel range." f"Mean: {mean_rgb}" f"Min: {channel_min}" f"Max: {channel_max}" )
residual = ai - mean_rgb residual_channel_means = residual.mean( axis=(0, 1), dtype=np.float64, )
print("--- Residual ---") print(f"Min: {residual.min():.9f}") print(f"Max: {residual.max():.9f}") print( f"Overall mean: " f"{residual.mean(dtype=np.float64):.12f}" )
print("--- Residual Mean By Channel ---") print(f"R = {residual_channel_means[0]:.12f}") print(f"G = {residual_channel_means[1]:.12f}") print(f"B = {residual_channel_means[2]:.12f}")
if not np.allclose( residual_channel_means, 0.0, atol=1e-10, ): raise RuntimeError( "Residual was not centered correctly." f"Residual means: {residual_channel_means}" ) channel_names = ["Red", "Green", "Blue"]
print("--- Per-channel Residual Statistics ---")
for index, name in enumerate(channel_names): channel = residual[:, :, index]
print( f"{name:>5} | " f"mean={channel.mean(dtype=np.float64): .12f} " f"std={channel.std(dtype=np.float64): .9f} " f"min={channel.min(): .9f} " f"max={channel.max(): .9f}" )
Overall RMS residual
rms = np.sqrt( np.mean( residual ** 2, dtype=np.float64, ) ) print(f"Overall residual RMS: {rms:.9f}")
red_fft = fft_spectrum( residual[:, :, 0] ) green_fft = fft_spectrum( residual[:, :, 1] ) blue_fft = fft_spectrum( residual[:, :, 2] ) all_fft_values = np.concatenate([ red_fft.ravel(), green_fft.ravel(), blue_fft.ravel(), ]) fft_vmin = np.percentile( all_fft_values, 1.0, ) fft_vmax = np.percentile( all_fft_values, 99.5, )
print("--- Shared FFT Display Scale ---") print(f"vmin = {fft_vmin:.9f}") print(f"vmax = {fft_vmax:.9f}")
signed residual visualization
residual_vis = np.clip( 128.0 + residual * amp_factor, 0, 255, ).astype(np.uint8)
flat mean-color reference for visualization
flat_rgb_uint8 = np.clip( np.round(mean_rgb), 0, 255, ).astype(np.uint8) flat_vis = np.empty_like( ai, dtype=np.uint8, ) flat_vis[:] = flat_rgb_uint8
plot
fig, axes = plt.subplots( 2, 3, figsize=(18, 11), constrained_layout=True, )
Original image
axes[0, 0].set_title( "Original AI image" )
axes[0, 0].imshow(img) axes[0, 0].axis("off")
Mean-matched flat reference
axes[0, 1].set_title( "Mean-matched flat reference" f"Display RGB {tuple(flat_rgb_uint8.tolist())}" )
axes[0, 1].imshow(flat_vis) axes[0, 1].axis("off")
axes[0, 2].set_title( f"Signed residual ×{amp_factor}" "128 = zero difference" ) axes[0, 2].imshow( residual_vis ) axes[0, 2].axis("off")
fft_image = axes[1, 0].imshow( red_fft, cmap="magma", vmin=fft_vmin, vmax=fft_vmax, ) # red axes[1, 0].set_title( "FFT: red residual" ) axes[1, 0].axis("off") axes[1, 1].imshow( green_fft, cmap="magma", vmin=fft_vmin, vmax=fft_vmax, ) # green axes[1, 1].set_title( "FFT: green residual" ) axes[1, 1].axis("off") axes[1, 2].imshow( blue_fft, cmap="magma", vmin=fft_vmin, vmax=fft_vmax, ) axes[1, 2].set_title( "FFT: blue residual" )
axes[1, 2].axis("off")
Shared colorbar
fig.colorbar( fft_image, ax=axes[1, :], fraction=0.025, pad=0.02, label="log(1 + FFT magnitude)", )
All done!!!
plt.savefig( output_path, dpi=200, bbox_inches="tight", ) plt.close(fig) print(f"Saved: {output_path}")
if name == "main": analyze_zero_mean_residual( image_path="ai-generated-images/pure-white-blank-canvas1.png", output_path="zero_mean_analysis.png", amp_factor=50, )
Output:
There 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.
We 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.
random_control = np.empty_like(source, dtype=np.uint8) channel_names = ["Red", "Green", "Blue"] for i, channel_name in enumerate(channel_names): channel = source[:, :, i] unique_vals, counts = np.unique(channel, return_counts=True) probs = counts / counts.sum() random_channel = rng.choice( unique_vals, size=(h, w), p=probs, ).astype(np.uint8) random_control[:, :, i] = random_channel Image.fromarray(random_control).save(output_path)
Checking 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.
We will do one final spectral comparison here.
... for i, channel_name in enumerate(channel_names): ai_mag, ai_log, ai_power = fft_data( ai_residual[:, :, i] ) control_mag, control_log, control_power = fft_data( control_residual[:, :, i] ) ai_fft.append(ai_log) control_fft.append(control_log) ai_entropy = spectral_entropy(ai_power) control_entropy = spectral_entropy(control_power) ai_peak = peak_prominence(ai_power) control_peak = peak_prominence(control_power) corr = spectrum_correlation( ai_log, control_log, )
metrics.append({ "channel": channel_name, "ai_entropy": ai_entropy, "control_entropy": control_entropy, "ai_peak": ai_peak, "control_peak": control_peak, "correlation": corr, })
radial_ai.append( radial_power_spectrum(ai_power) ) radial_control.append( radial_power_spectrum(control_power) ) ...
This is an interesting result.
The AI image and control are identical but very different spectrally.
In these samples, SynthID images show much lower spectral entropy than their matched random controls.
I 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.
import numpy as np from PIL import Image
THRESHOLDS = [300, 500, 750, 1000, 1500, 2000, 2500, 3000]
def evaluate_threshold(arr, threshold, dc_radius=15): h, w, c = arr.shape reconstructed = np.zeros_like(arr)
y, x = np.ogrid[:h, :w] cy, cx = h // 2, w // 2 dc_mask = ((x - cx) ** 2 + (y - cy) ** 2) <= dc_radius ** 2
per_channel_affected = [] max_imag_residue = 0.0
for i in range(c): channel = arr[:, :, i]
fft_raw = np.fft.fft2(channel) fft_shifted = np.fft.fftshift(fft_raw)
magnitude = np.abs(fft_shifted) phase = np.angle(fft_shifted)
capped_mask = (magnitude > threshold) & (~dc_mask) affected_count = np.sum(capped_mask) affected_fraction = (affected_count / magnitude.size) * 100.0 per_channel_affected.append(affected_fraction)
magnitude[capped_mask] = threshold fft_capped = magnitude * np.exp(1j * phase)
inverse = np.fft.ifft2(np.fft.ifftshift(fft_capped))
max_imag_residue = max( max_imag_residue, np.max(np.abs(inverse.imag)) )
reconstructed[:, :, i] = inverse.real
overall_affected_fraction = float(np.mean(per_channel_affected))
Float-domain error
diff_float = reconstructed - arr rmse
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。