返回资讯中心
外部精选
软件工程
#开发者实时流

Testing AI image watermarks: C2PA and SynthID

Testing AI image watermarks: C2PA and SynthID

Hacker News 最新coffeecoders2026/11/10 · 今天30 分钟阅读

以下正文同步自 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 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →