This commit is contained in:
Alosh Denny
2026-02-15 18:04:20 +05:30
parent ad79ba532f
commit 25483c159a
22 changed files with 3341 additions and 296 deletions
+465
View File
@@ -0,0 +1,465 @@
"""
SynthID Watermark Extraction Benchmark Suite
Comprehensive benchmarking for watermark extraction and removal:
1. Detection accuracy across image types
2. Removal quality (PSNR, SSIM)
3. Re-detection test (verify watermark is removed)
4. Performance metrics
Usage:
python benchmark_extraction.py --input-dir /path/to/images --codebook codebook.pkl
"""
import os
import sys
import json
import time
import argparse
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass, asdict
from pathlib import Path
import numpy as np
import cv2
from collections import defaultdict
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from robust_extractor import RobustSynthIDExtractor, DetectionResult
from watermark_remover import WatermarkRemover, RemovalResult
@dataclass
class BenchmarkResults:
"""Results from benchmarking run."""
n_images: int
detection_rate: float
avg_confidence: float
avg_correlation: float
avg_phase_match: float
removal_success_rate: float
avg_psnr: float
avg_ssim: float
avg_confidence_drop: float
re_detection_rate: float
total_time: float
avg_time_per_image: float
details: Dict
class BenchmarkSuite:
"""
Comprehensive benchmark suite for SynthID extraction and removal.
"""
def __init__(
self,
codebook_path: Optional[str] = None,
verbose: bool = True
):
"""
Initialize benchmark suite.
Args:
codebook_path: Path to pre-extracted codebook
verbose: Print progress during benchmarking
"""
self.verbose = verbose
self.extractor = RobustSynthIDExtractor()
self.remover = None
if codebook_path and os.path.exists(codebook_path):
self.extractor.load_codebook(codebook_path)
self.remover = WatermarkRemover(extractor=self.extractor)
def log(self, message: str):
"""Print message if verbose."""
if self.verbose:
print(message)
def load_images(
self,
image_dir: str,
sample_size: Optional[int] = None,
extensions: set = {'.png', '.jpg', '.jpeg', '.webp'}
) -> List[Tuple[str, np.ndarray]]:
"""Load images from directory."""
images = []
for fname in sorted(os.listdir(image_dir)):
if os.path.splitext(fname)[1].lower() in extensions:
path = os.path.join(image_dir, fname)
img = cv2.imread(path)
if img is not None:
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
images.append((path, img_rgb))
if sample_size and len(images) >= sample_size:
break
return images
def benchmark_detection(
self,
images: List[Tuple[str, np.ndarray]]
) -> Dict:
"""
Benchmark detection accuracy.
Returns:
Dict with detection metrics
"""
self.log(f"\n{'='*60}")
self.log("DETECTION BENCHMARK")
self.log(f"{'='*60}")
results = []
start_time = time.time()
for i, (path, img) in enumerate(images):
try:
result = self.extractor.detect_array(img)
results.append({
'path': path,
'is_watermarked': result.is_watermarked,
'confidence': result.confidence,
'correlation': result.correlation,
'phase_match': result.phase_match,
'structure_ratio': result.structure_ratio,
'carrier_strength': result.carrier_strength,
})
except Exception as e:
self.log(f" Error processing {path}: {e}")
results.append({
'path': path,
'error': str(e)
})
if (i + 1) % 10 == 0:
self.log(f" Processed {i+1}/{len(images)} images...")
elapsed = time.time() - start_time
# Compute statistics
valid_results = [r for r in results if 'error' not in r]
detected = [r for r in valid_results if r['is_watermarked']]
detection_rate = len(detected) / len(valid_results) if valid_results else 0
avg_confidence = np.mean([r['confidence'] for r in valid_results]) if valid_results else 0
avg_correlation = np.mean([r['correlation'] for r in valid_results]) if valid_results else 0
avg_phase_match = np.mean([r['phase_match'] for r in valid_results]) if valid_results else 0
self.log(f"\n Detection Rate: {detection_rate:.1%}")
self.log(f" Avg Confidence: {avg_confidence:.4f}")
self.log(f" Avg Correlation: {avg_correlation:.4f}")
self.log(f" Avg Phase Match: {avg_phase_match:.4f}")
self.log(f" Time: {elapsed:.2f}s ({elapsed/len(images):.3f}s per image)")
return {
'n_images': len(images),
'n_valid': len(valid_results),
'n_detected': len(detected),
'detection_rate': detection_rate,
'avg_confidence': avg_confidence,
'avg_correlation': avg_correlation,
'avg_phase_match': avg_phase_match,
'elapsed_seconds': elapsed,
'results': results
}
def benchmark_removal(
self,
images: List[Tuple[str, np.ndarray]],
output_dir: Optional[str] = None,
save_samples: int = 5
) -> Dict:
"""
Benchmark removal quality.
Args:
images: List of (path, image) tuples
output_dir: Directory to save sample cleaned images
save_samples: Number of sample images to save
Returns:
Dict with removal metrics
"""
if self.remover is None:
return {'error': 'No remover initialized (need codebook)'}
self.log(f"\n{'='*60}")
self.log("REMOVAL BENCHMARK")
self.log(f"{'='*60}")
results = []
start_time = time.time()
if output_dir:
os.makedirs(output_dir, exist_ok=True)
for i, (path, img) in enumerate(images):
try:
result = self.remover.remove(img, verify=True)
entry = {
'path': path,
'success': result.success,
'psnr': result.psnr,
'ssim': result.ssim,
'removal_confidence': result.removal_confidence,
'original_watermarked': result.original_detection.is_watermarked,
'original_confidence': result.original_detection.confidence,
'cleaned_watermarked': result.cleaned_detection.is_watermarked if result.cleaned_detection else None,
'cleaned_confidence': result.cleaned_detection.confidence if result.cleaned_detection else None,
}
results.append(entry)
# Save sample outputs
if output_dir and i < save_samples:
fname = os.path.basename(path)
out_path = os.path.join(output_dir, f"cleaned_{fname}")
cv2.imwrite(out_path, cv2.cvtColor(result.cleaned_image, cv2.COLOR_RGB2BGR))
except Exception as e:
self.log(f" Error processing {path}: {e}")
results.append({
'path': path,
'error': str(e)
})
if (i + 1) % 10 == 0:
self.log(f" Processed {i+1}/{len(images)} images...")
elapsed = time.time() - start_time
# Compute statistics
valid_results = [r for r in results if 'error' not in r]
successful = [r for r in valid_results if r['success']]
re_detected = [r for r in valid_results if r.get('cleaned_watermarked', True)]
removal_success_rate = len(successful) / len(valid_results) if valid_results else 0
avg_psnr = np.mean([r['psnr'] for r in valid_results]) if valid_results else 0
avg_ssim = np.mean([r['ssim'] for r in valid_results]) if valid_results else 0
# Confidence drop
conf_drops = []
for r in valid_results:
if r.get('cleaned_confidence') is not None:
drop = r['original_confidence'] - r['cleaned_confidence']
conf_drops.append(drop)
avg_conf_drop = np.mean(conf_drops) if conf_drops else 0
re_detection_rate = len(re_detected) / len(valid_results) if valid_results else 0
self.log(f"\n Removal Success Rate: {removal_success_rate:.1%}")
self.log(f" Avg PSNR: {avg_psnr:.2f} dB")
self.log(f" Avg SSIM: {avg_ssim:.4f}")
self.log(f" Avg Confidence Drop: {avg_conf_drop:.4f}")
self.log(f" Re-detection Rate: {re_detection_rate:.1%}")
self.log(f" Time: {elapsed:.2f}s ({elapsed/len(images):.3f}s per image)")
return {
'n_images': len(images),
'n_valid': len(valid_results),
'n_successful': len(successful),
'removal_success_rate': removal_success_rate,
'avg_psnr': avg_psnr,
'avg_ssim': avg_ssim,
'avg_confidence_drop': avg_conf_drop,
're_detection_rate': re_detection_rate,
'elapsed_seconds': elapsed,
'results': results
}
def run_full_benchmark(
self,
image_dir: str,
sample_size: Optional[int] = None,
output_dir: Optional[str] = None,
save_report: Optional[str] = None
) -> BenchmarkResults:
"""
Run complete benchmark suite.
Args:
image_dir: Directory containing watermarked images
sample_size: Max images to test (None for all)
output_dir: Directory to save cleaned samples
save_report: Path to save JSON report
Returns:
BenchmarkResults
"""
self.log(f"\n{'='*60}")
self.log("SYNTHID EXTRACTION BENCHMARK SUITE")
self.log(f"{'='*60}")
self.log(f"Image directory: {image_dir}")
self.log(f"Sample size: {sample_size or 'all'}")
# Load images
self.log("\nLoading images...")
images = self.load_images(image_dir, sample_size)
self.log(f"Loaded {len(images)} images")
if not images:
raise ValueError("No images found in directory")
# Run benchmarks
total_start = time.time()
detection_results = self.benchmark_detection(images)
removal_results = self.benchmark_removal(images, output_dir)
total_time = time.time() - total_start
# Compile results
results = BenchmarkResults(
n_images=len(images),
detection_rate=detection_results['detection_rate'],
avg_confidence=detection_results['avg_confidence'],
avg_correlation=detection_results['avg_correlation'],
avg_phase_match=detection_results['avg_phase_match'],
removal_success_rate=removal_results.get('removal_success_rate', 0),
avg_psnr=removal_results.get('avg_psnr', 0),
avg_ssim=removal_results.get('avg_ssim', 0),
avg_confidence_drop=removal_results.get('avg_confidence_drop', 0),
re_detection_rate=removal_results.get('re_detection_rate', 0),
total_time=total_time,
avg_time_per_image=total_time / len(images),
details={
'detection': detection_results,
'removal': removal_results
}
)
# Print summary
self.log(f"\n{'='*60}")
self.log("BENCHMARK SUMMARY")
self.log(f"{'='*60}")
self.log(f"Images Tested: {results.n_images}")
self.log(f"")
self.log(f"Detection:")
self.log(f" Rate: {results.detection_rate:.1%}")
self.log(f" Confidence: {results.avg_confidence:.4f}")
self.log(f"")
self.log(f"Removal:")
self.log(f" Success Rate: {results.removal_success_rate:.1%}")
self.log(f" PSNR: {results.avg_psnr:.2f} dB")
self.log(f" SSIM: {results.avg_ssim:.4f}")
self.log(f" Re-detection: {results.re_detection_rate:.1%}")
self.log(f"")
self.log(f"Performance:")
self.log(f" Total Time: {results.total_time:.2f}s")
self.log(f" Per Image: {results.avg_time_per_image:.3f}s")
self.log(f"{'='*60}")
# Save report
if save_report:
report = asdict(results)
# Remove large result arrays for JSON
if 'details' in report:
for key in ['detection', 'removal']:
if key in report['details']:
report['details'][key].pop('results', None)
with open(save_report, 'w') as f:
json.dump(report, f, indent=2)
self.log(f"\nReport saved to: {save_report}")
return results
def compare_with_original(
image_dir: str,
original_codebook: str,
robust_codebook: str,
sample_size: int = 50
):
"""
Compare original vs robust extractor performance.
"""
print("\n" + "=" * 60)
print("COMPARISON: Original vs Robust Extractor")
print("=" * 60)
# Original extractor (using same interface)
from synthid_codebook_extractor import detect_synthid
# Robust extractor
robust = RobustSynthIDExtractor()
robust.load_codebook(robust_codebook)
# Load images
extensions = {'.png', '.jpg', '.jpeg', '.webp'}
images = []
for fname in sorted(os.listdir(image_dir)):
if os.path.splitext(fname)[1].lower() in extensions:
path = os.path.join(image_dir, fname)
images.append(path)
if len(images) >= sample_size:
break
print(f"Testing on {len(images)} images...")
# Compare
original_detected = 0
robust_detected = 0
for path in images:
# Original
try:
orig_result = detect_synthid(path, original_codebook)
if orig_result['is_watermarked']:
original_detected += 1
except:
pass
# Robust
try:
robust_result = robust.detect(path)
if robust_result.is_watermarked:
robust_detected += 1
except:
pass
print(f"\nResults:")
print(f" Original Extractor: {original_detected}/{len(images)} ({100*original_detected/len(images):.1f}%)")
print(f" Robust Extractor: {robust_detected}/{len(images)} ({100*robust_detected/len(images):.1f}%)")
print(f" Improvement: {robust_detected - original_detected} more detected")
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='SynthID Extraction Benchmark Suite')
parser.add_argument('--input-dir', type=str, required=True,
help='Directory with watermarked images')
parser.add_argument('--codebook', type=str, required=True,
help='Path to codebook file')
parser.add_argument('--sample-size', type=int, default=None,
help='Number of images to test (default: all)')
parser.add_argument('--output-dir', type=str, default=None,
help='Directory to save cleaned samples')
parser.add_argument('--output-report', type=str, default='benchmark_results.json',
help='Path to save JSON report')
parser.add_argument('--quiet', action='store_true',
help='Reduce output verbosity')
args = parser.parse_args()
suite = BenchmarkSuite(
codebook_path=args.codebook,
verbose=not args.quiet
)
results = suite.run_full_benchmark(
image_dir=args.input_dir,
sample_size=args.sample_size,
output_dir=args.output_dir,
save_report=args.output_report
)
File diff suppressed because it is too large Load Diff
+628
View File
@@ -0,0 +1,628 @@
"""
SynthID Watermark Remover — Signature-Based Approach
Uses watermark signatures extracted from pure black/white Gemini images
to perform targeted watermark subtraction, combined with JPEG compression
for maximum effectiveness.
Key findings from analysis:
- Pure black images reveal the exact watermark as pixel values > 0
- 24/25 black images share the same pattern (r=0.74), indicating a fixed key
- JPEG Q50 + Signature subtraction gives 15-19% phase reduction at 34-38dB PSNR
- The watermark is content-adaptive, but has a fixed structural component
"""
import os
import sys
import io
import json
import numpy as np
import cv2
from PIL import Image
from scipy.ndimage import zoom
from dataclasses import dataclass, field
from typing import Optional, Dict, Tuple
@dataclass
class RemovalResult:
"""Result of watermark removal."""
success: bool
cleaned_image: np.ndarray
psnr: float
ssim: float
detection_before: Optional[Dict] = None
detection_after: Optional[Dict] = None
method: str = ''
details: Dict = field(default_factory=dict)
class WatermarkRemover:
"""
SynthID watermark remover using extracted signatures.
Approach:
1. Load pre-extracted watermark signature from pure black/white Gemini images
2. Resize signature to match target image
3. Subtract signature from image (disrupts fixed watermark component)
4. Apply JPEG compression (disrupts remaining adaptive component)
"""
def __init__(
self,
signature_dir: str = None,
extractor=None
):
"""
Args:
signature_dir: Path to directory containing signature .npy files
extractor: RobustSynthIDExtractor instance for verification
"""
self.extractor = extractor
self.signature = None
self.white_signature = None
self.meta = None
if signature_dir:
self.load_signature(signature_dir)
def load_signature(self, signature_dir: str):
"""Load watermark signature from pre-extracted files."""
black_path = os.path.join(signature_dir, 'synthid_black_signature.npy')
white_path = os.path.join(signature_dir, 'synthid_white_signature.npy')
meta_path = os.path.join(signature_dir, 'signature_meta.json')
if os.path.exists(black_path):
self.signature = np.load(black_path)
if os.path.exists(white_path):
self.white_signature = np.load(white_path)
if os.path.exists(meta_path):
with open(meta_path) as f:
self.meta = json.load(f)
def extract_signature_from_images(
self,
black_dir: str = None,
white_dir: str = None,
output_dir: str = None
):
"""
Extract watermark signature directly from pure black/white Gemini images.
On a pure black image, every pixel > 0 IS the watermark.
On a pure white image, every pixel < 255 IS the watermark.
"""
import glob
if black_dir:
black_files = sorted(glob.glob(os.path.join(black_dir, '*.png')))
print(f"Found {len(black_files)} black images")
# Load all and cluster by correlation to find main group
all_wms = []
for f in black_files:
img = cv2.imread(f)
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
all_wms.append(img_rgb.astype(np.float32))
# Simple clustering: find the majority group
n = len(all_wms)
if n > 2:
# Check pairwise correlation of flattened binary masks
binary_wms = [(wm > 0).astype(np.float32).ravel() for wm in all_wms]
corr_matrix = np.zeros((n, n))
for i in range(n):
for j in range(i+1, n):
c = np.corrcoef(binary_wms[i], binary_wms[j])[0, 1]
corr_matrix[i, j] = c
corr_matrix[j, i] = c
# Find largest group with r > 0.5
groups = []
visited = set()
for i in range(n):
if i in visited:
continue
group = [i]
for j in range(i+1, n):
if j not in visited and corr_matrix[i, j] > 0.5:
group.append(j)
for g in group:
visited.add(g)
groups.append(group)
# Use the largest group
main_group = max(groups, key=len)
print(f"Main group: {len(main_group)} images (excluded {n - len(main_group)} outliers)")
else:
main_group = list(range(n))
self.signature = np.mean([all_wms[i] for i in main_group], axis=0)
print(f"Signature shape: {self.signature.shape}")
if white_dir:
white_files = sorted(glob.glob(os.path.join(white_dir, '*.png')))
print(f"Found {len(white_files)} white images")
white_wms = []
for f in white_files:
img = cv2.imread(f)
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
white_wms.append(255.0 - img_rgb.astype(np.float32))
self.white_signature = np.mean(white_wms, axis=0)
# Save if output directory specified
if output_dir:
os.makedirs(output_dir, exist_ok=True)
if self.signature is not None:
np.save(os.path.join(output_dir, 'synthid_black_signature.npy'), self.signature)
if self.white_signature is not None:
np.save(os.path.join(output_dir, 'synthid_white_signature.npy'), self.white_signature)
meta = {
'black_shape': list(self.signature.shape) if self.signature is not None else None,
'white_shape': list(self.white_signature.shape) if self.white_signature is not None else None,
'recommended_subtraction_scale': 1.0,
'recommended_jpeg_quality': 50,
}
with open(os.path.join(output_dir, 'signature_meta.json'), 'w') as f:
json.dump(meta, f, indent=2)
print(f"Saved to {output_dir}")
def _resize_signature(self, target_h: int, target_w: int) -> np.ndarray:
"""Resize signature to match target image dimensions."""
if self.signature is None:
raise ValueError("No signature loaded. Call load_signature() first.")
sig_h, sig_w = self.signature.shape[:2]
if sig_h == target_h and sig_w == target_w:
return self.signature
scale_y = target_h / sig_h
scale_x = target_w / sig_w
return zoom(self.signature, (scale_y, scale_x, 1), order=1)
@staticmethod
def _jpeg_compress(image: np.ndarray, quality: int = 50) -> np.ndarray:
"""Apply JPEG compression/decompression."""
img_uint8 = np.clip(image, 0, 255).astype(np.uint8)
pil_img = Image.fromarray(img_uint8, mode='RGB')
buf = io.BytesIO()
pil_img.save(buf, format='JPEG', quality=quality)
buf.seek(0)
return np.array(Image.open(buf)).astype(np.float32)
@staticmethod
def compute_psnr(original: np.ndarray, modified: np.ndarray) -> float:
"""Compute Peak Signal-to-Noise Ratio."""
mse = np.mean((original.astype(float) - modified.astype(float)) ** 2)
if mse == 0:
return float('inf')
return float(10 * np.log10(255.0 ** 2 / mse))
@staticmethod
def compute_ssim(original: np.ndarray, modified: np.ndarray) -> float:
"""Compute simplified SSIM."""
from scipy import ndimage
C1, C2 = (0.01 * 255) ** 2, (0.03 * 255) ** 2
orig_f = original.astype(np.float64)
mod_f = modified.astype(np.float64)
mu1 = ndimage.uniform_filter(orig_f, size=11)
mu2 = ndimage.uniform_filter(mod_f, size=11)
sigma1_sq = ndimage.uniform_filter(orig_f ** 2, size=11) - mu1 ** 2
sigma2_sq = ndimage.uniform_filter(mod_f ** 2, size=11) - mu2 ** 2
sigma12 = ndimage.uniform_filter(orig_f * mod_f, size=11) - mu1 * mu2
ssim_map = ((2 * mu1 * mu2 + C1) * (2 * sigma12 + C2)) / \
((mu1 ** 2 + mu2 ** 2 + C1) * (sigma1_sq + sigma2_sq + C2))
return float(np.mean(ssim_map))
def remove(
self,
image: np.ndarray,
mode: str = 'balanced',
verify: bool = True,
strength: str = 'aggressive'
) -> RemovalResult:
"""
Remove SynthID watermark from image.
Args:
image: Input image (RGB, uint8)
mode: 'light', 'balanced', 'aggressive', 'maximum', or 'combined_worst'
verify: Whether to verify removal with detection
Returns:
RemovalResult with cleaned image and metrics
"""
# V2 combined worst-case mode — delegates to bypass_v2 pipeline
if mode == 'combined_worst':
return self._remove_combined_worst(image, verify=verify, strength=strength)
img_f = image.astype(np.float32)
h, w = img_f.shape[:2]
# Get mode parameters
params = self._get_mode_params(mode)
# Resize signature
resized_sig = self._resize_signature(h, w)
# Initial detection
detection_before = None
if verify and self.extractor is not None:
result = self.extractor.detect_array(image)
detection_before = {
'is_watermarked': result.is_watermarked,
'confidence': result.confidence,
'phase_match': result.phase_match
}
# Apply removal pipeline
current = img_f.copy()
method_parts = []
# Step 1: JPEG compression (if first)
if params.get('jpeg_first', False):
current = self._jpeg_compress(current, quality=params['jpeg_quality'])
method_parts.append(f"JPEG_Q{params['jpeg_quality']}")
# Step 2: Signature subtraction
if params['subtract_scale'] > 0:
current = current - resized_sig * params['subtract_scale']
current = np.clip(current, 0, 255)
method_parts.append(f"Sub_{params['subtract_scale']}x")
# Step 3: JPEG compression (if after subtraction)
if params.get('jpeg_after', False):
current = self._jpeg_compress(current, quality=params['jpeg_quality'])
method_parts.append(f"JPEG_Q{params['jpeg_quality']}")
# Step 4: Additional JPEG passes
for _ in range(params.get('extra_jpeg_passes', 0)):
q = params.get('extra_jpeg_quality', 60)
current = self._jpeg_compress(current, quality=q)
method_parts.append(f"JPEG_Q{q}")
# Final cleanup
cleaned = np.clip(current, 0, 255).astype(np.uint8)
# Quality metrics
psnr = self.compute_psnr(image, cleaned)
ssim = self.compute_ssim(image, cleaned)
# Final detection
detection_after = None
if verify and self.extractor is not None:
result = self.extractor.detect_array(cleaned)
detection_after = {
'is_watermarked': result.is_watermarked,
'confidence': result.confidence,
'phase_match': result.phase_match
}
# Determine success
success = psnr > 28
if detection_before and detection_after:
phase_drop = detection_before['phase_match'] - detection_after['phase_match']
success = success and (phase_drop > 0.05 or not detection_after['is_watermarked'])
method = ' + '.join(method_parts)
return RemovalResult(
success=success,
cleaned_image=cleaned,
psnr=psnr,
ssim=ssim,
detection_before=detection_before,
detection_after=detection_after,
method=method,
details={'mode': mode, 'params': params}
)
def _remove_combined_worst(
self,
image: np.ndarray,
verify: bool = True,
strength: str = 'aggressive'
) -> RemovalResult:
"""
Combined worst-case removal using bypass_v2 pipeline.
This is the v2 approach that stacks transforms from multiple
categories (spatial, quality, noise, color, overlay) to exploit
SynthID's weakness against combined transforms.
"""
from synthid_bypass import SynthIDBypass
bypass = SynthIDBypass(extractor=self.extractor)
result = bypass.bypass_v2(image, strength=strength, verify=verify)
return RemovalResult(
success=result.success,
cleaned_image=result.cleaned_image,
psnr=result.psnr,
ssim=result.ssim,
detection_before=result.detection_before,
detection_after=result.detection_after,
method=f'combined_worst_{strength}',
details={
'mode': 'combined_worst',
'strength': strength,
'stages': result.stages_applied,
'v2_details': result.details
}
)
def _get_mode_params(self, mode: str) -> Dict:
"""Get parameters for each removal mode."""
if mode == 'light':
return {
'subtract_scale': 0.5,
'jpeg_first': False,
'jpeg_after': True,
'jpeg_quality': 65,
'extra_jpeg_passes': 0,
}
elif mode == 'aggressive':
return {
'subtract_scale': 2.0,
'jpeg_first': True,
'jpeg_after': True,
'jpeg_quality': 50,
'extra_jpeg_passes': 0,
}
elif mode == 'maximum':
return {
'subtract_scale': 5.0,
'jpeg_first': True,
'jpeg_after': True,
'jpeg_quality': 50,
'extra_jpeg_passes': 1,
'extra_jpeg_quality': 55,
}
else: # balanced (default)
return {
'subtract_scale': 1.0,
'jpeg_first': True,
'jpeg_after': False,
'jpeg_quality': 50,
'extra_jpeg_passes': 0,
}
def remove_file(
self,
input_path: str,
output_path: str,
mode: str = 'balanced',
verify: bool = True,
strength: str = 'aggressive'
) -> RemovalResult:
"""Remove watermark from image file and save result."""
img = cv2.imread(input_path)
if img is None:
raise ValueError(f"Could not load image: {input_path}")
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
result = self.remove(img_rgb, mode=mode, verify=verify, strength=strength)
os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True)
cv2.imwrite(output_path, cv2.cvtColor(result.cleaned_image, cv2.COLOR_RGB2BGR))
return result
def batch_remove(
self,
input_dir: str,
output_dir: str,
mode: str = 'balanced',
verify: bool = True,
limit: int = None,
strength: str = 'aggressive'
):
"""Remove watermark from all images in a directory."""
import glob
os.makedirs(output_dir, exist_ok=True)
extensions = ['*.png', '*.jpg', '*.jpeg', '*.webp']
files = []
for ext in extensions:
files.extend(glob.glob(os.path.join(input_dir, ext)))
files = sorted(files)
if limit:
files = files[:limit]
print(f"Processing {len(files)} images in {mode} mode")
if mode == 'combined_worst':
print(f"Strength: {strength}")
print("=" * 70)
results = []
for i, f in enumerate(files):
basename = os.path.basename(f)
output_path = os.path.join(output_dir, basename)
try:
result = self.remove_file(f, output_path, mode=mode, verify=verify, strength=strength)
results.append(result)
if verify and result.detection_before and result.detection_after:
before = result.detection_before['phase_match']
after = result.detection_after['phase_match']
drop = (before - after) / before * 100
det_before = '' if result.detection_before['is_watermarked'] else ''
det_after = '' if result.detection_after['is_watermarked'] else ''
print(f" [{i+1}/{len(files)}] {basename:20s} | {det_before}{det_after} | "
f"phase: {before:.3f}{after:.3f} ({drop:+5.1f}%) | PSNR: {result.psnr:.1f}dB")
else:
print(f" [{i+1}/{len(files)}] {basename:20s} | PSNR: {result.psnr:.1f}dB")
except Exception as e:
print(f" [{i+1}/{len(files)}] {basename:20s} | ERROR: {e}")
# Summary
if results and verify:
drops = []
successes = 0
for r in results:
if r.detection_before and r.detection_after:
before = r.detection_before['phase_match']
after = r.detection_after['phase_match']
drops.append((before - after) / before * 100)
if not r.detection_after['is_watermarked']:
successes += 1
print("=" * 70)
print(f"Results: {len(results)} images processed")
if drops:
print(f" Average phase drop: {np.mean(drops):.1f}%")
print(f" Best phase drop: {max(drops):.1f}%")
print(f" Undetected: {successes}/{len(results)}")
print(f" Average PSNR: {np.mean([r.psnr for r in results]):.1f}dB")
return results
# ================================================================
# CLI INTERFACE
# ================================================================
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(
description='SynthID Watermark Remover (Signature-Based)',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Remove watermark from a single image
python watermark_remover.py remove input.png output.png --signature artifacts/signature/
# Batch remove from directory
python watermark_remover.py batch /path/to/images/ /path/to/output/ --signature artifacts/signature/
# Extract signature from pure images
python watermark_remover.py extract --black assets/black/gemini/ --white assets/white/gemini/ -o artifacts/signature/
"""
)
subparsers = parser.add_subparsers(dest='command', help='Command')
# Remove command
remove_parser = subparsers.add_parser('remove', help='Remove watermark from image')
remove_parser.add_argument('input', help='Input image path')
remove_parser.add_argument('output', help='Output image path')
remove_parser.add_argument('--signature', '-s', default='artifacts/signature/',
help='Path to signature directory')
remove_parser.add_argument('--mode', '-m', default='balanced',
choices=['light', 'balanced', 'aggressive', 'maximum', 'combined_worst'],
help='Removal mode')
remove_parser.add_argument('--strength', default='aggressive',
choices=['moderate', 'aggressive', 'maximum'],
help='Strength for combined_worst mode')
remove_parser.add_argument('--codebook', '-c', default=None,
help='Codebook path for verification')
remove_parser.add_argument('--no-verify', action='store_true',
help='Skip verification')
# Batch command
batch_parser = subparsers.add_parser('batch', help='Batch remove watermarks')
batch_parser.add_argument('input_dir', help='Input directory')
batch_parser.add_argument('output_dir', help='Output directory')
batch_parser.add_argument('--signature', '-s', default='artifacts/signature/')
batch_parser.add_argument('--mode', '-m', default='balanced',
choices=['light', 'balanced', 'aggressive', 'maximum', 'combined_worst'])
batch_parser.add_argument('--strength', default='aggressive',
choices=['moderate', 'aggressive', 'maximum'])
batch_parser.add_argument('--codebook', '-c', default=None)
batch_parser.add_argument('--no-verify', action='store_true')
batch_parser.add_argument('--limit', '-n', type=int, default=None)
# Extract command
extract_parser = subparsers.add_parser('extract', help='Extract signature from pure images')
extract_parser.add_argument('--black', help='Directory of pure black Gemini images')
extract_parser.add_argument('--white', help='Directory of pure white Gemini images')
extract_parser.add_argument('-o', '--output', default='artifacts/signature/',
help='Output directory for signature')
args = parser.parse_args()
if args.command is None:
parser.print_help()
sys.exit(1)
if args.command == 'extract':
remover = WatermarkRemover()
remover.extract_signature_from_images(
black_dir=args.black,
white_dir=args.white,
output_dir=args.output
)
else:
# Load extractor for verification
extractor = None
codebook = getattr(args, 'codebook', None)
no_verify = getattr(args, 'no_verify', False)
if codebook and not no_verify:
try:
from robust_extractor import RobustSynthIDExtractor
extractor = RobustSynthIDExtractor()
extractor.load_codebook(codebook)
except Exception as e:
print(f"Warning: Could not load extractor: {e}")
sig_dir = args.signature
remover = WatermarkRemover(signature_dir=sig_dir, extractor=extractor)
strength = getattr(args, 'strength', 'aggressive')
if args.command == 'remove':
result = remover.remove_file(
args.input, args.output,
mode=args.mode, verify=not no_verify,
strength=strength
)
print("\n" + "=" * 60)
print("WATERMARK REMOVAL RESULTS")
print("=" * 60)
print(f" Mode: {args.mode}")
if args.mode == 'combined_worst':
print(f" Strength: {strength}")
print(f" Method: {result.method}")
print(f" Success: {result.success}")
print(f" PSNR: {result.psnr:.2f} dB")
print(f" SSIM: {result.ssim:.4f}")
if result.detection_before:
print(f"\n Before:")
print(f" Watermarked: {result.detection_before['is_watermarked']}")
print(f" Phase Match: {result.detection_before['phase_match']:.4f}")
if result.detection_after:
print(f"\n After:")
print(f" Watermarked: {result.detection_after['is_watermarked']}")
print(f" Phase Match: {result.detection_after['phase_match']:.4f}")
if result.detection_before:
drop = result.detection_before['phase_match'] - result.detection_after['phase_match']
pct = 100 * drop / result.detection_before['phase_match']
print(f"\n Phase Drop: {drop:.4f} ({pct:.1f}%)")
print("=" * 60)
print(f"Saved to: {args.output}")
elif args.command == 'batch':
remover.batch_remove(
args.input_dir, args.output_dir,
mode=args.mode, verify=not no_verify,
limit=args.limit,
strength=strength
)