mirror of
https://github.com/aloshdenny/reverse-SynthID.git
synced 2026-07-07 08:17:52 +02:00
v3
This commit is contained in:
@@ -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
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user