Add rigorous SynthID research and evaluation harness

This commit is contained in:
Victor Kuznetsov
2026-08-09 18:40:45 -07:00
parent f9beef365f
commit b011f0f962
36 changed files with 6307 additions and 34 deletions
+352
View File
@@ -0,0 +1,352 @@
"""Compare an exact-geometry phase carrier across color spaces.
Every model uses the same spatial-frequency candidates. Phases, expected
magnitudes, selected channels, and weights are learned independently from the
supplied positive images. The resulting scores are experimental evidence, not
a proprietary SynthID decoder or a certified production detector.
"""
from __future__ import annotations
import json
import logging
from dataclasses import asdict, dataclass
from pathlib import Path
import click
import cv2
import numpy as np
from PIL import Image
from synthid_phase_carrier import _leave_one_out_coherence
from synthid_v3_codebook_probe import load_v3_model
log = logging.getLogger(__name__)
COLOR_SPACES = ("rgb", "ycbcr", "ycocg", "opponent", "lab", "hsv")
CHANNEL_NAMES = {
"rgb": ("R", "G", "B"),
"ycbcr": ("Y", "Cb", "Cr"),
"ycocg": ("Y", "Co", "Cg"),
"opponent": ("L", "R-G", "R+G-2B"),
"lab": ("L*", "a*", "b*"),
"hsv": ("H", "S", "V"),
}
@dataclass(frozen=True)
class ColorPhaseModel:
"""Sparse phase carrier learned in one color space."""
color_space: str
height: int
width: int
rows: np.ndarray
columns: np.ndarray
channels: np.ndarray
phases: np.ndarray
weights: np.ndarray
expected_magnitudes: np.ndarray
@dataclass(frozen=True)
class ColorPhaseScore:
"""Phase-carrier evidence and channel contributions for one image."""
path: str
color_space: str
phase_score: float
active_weight_fraction: float
evidence_score: float
channel_evidence: tuple[float, float, float]
selected_peak_counts: tuple[int, int, int]
peak_count: int
def transform_color_space(rgb: np.ndarray, color_space: str) -> np.ndarray:
"""Transform float RGB values in [0, 255] into COLOR_SPACE."""
if rgb.ndim != 3 or rgb.shape[2] != 3:
raise ValueError("rgb must have shape (height, width, 3)")
pixels = np.asarray(rgb, dtype=np.float64)
red, green, blue = np.moveaxis(pixels, 2, 0)
if color_space == "rgb":
transformed = pixels
elif color_space == "ycbcr":
transformed = np.stack(
(
0.299 * red + 0.587 * green + 0.114 * blue,
128.0 - 0.168736 * red - 0.331264 * green + 0.5 * blue,
128.0 + 0.5 * red - 0.418688 * green - 0.081312 * blue,
),
axis=2,
)
elif color_space == "ycocg":
transformed = np.stack(
(
0.25 * red + 0.5 * green + 0.25 * blue,
0.5 * red - 0.5 * blue,
-0.25 * red + 0.5 * green - 0.25 * blue,
),
axis=2,
)
elif color_space == "opponent":
transformed = np.stack(
(
(red + green + blue) / np.sqrt(3.0),
(red - green) / np.sqrt(2.0),
(red + green - 2.0 * blue) / np.sqrt(6.0),
),
axis=2,
)
elif color_space == "lab":
transformed = cv2.cvtColor((pixels / 255.0).astype(np.float32), cv2.COLOR_RGB2LAB).astype(np.float64)
elif color_space == "hsv":
transformed = cv2.cvtColor((pixels / 255.0).astype(np.float32), cv2.COLOR_RGB2HSV).astype(np.float64)
else:
raise ValueError(f"unsupported color space: {color_space}")
if not np.all(np.isfinite(transformed)):
raise ValueError(f"{color_space} transform produced non-finite values")
return transformed
def candidate_bins_from_codebook(
codebook_path: Path,
*,
height: int,
width: int,
source_peak_count: int = 256,
) -> np.ndarray:
"""Expand the codebook's unique spatial coordinates over three channels."""
prior = load_v3_model(
codebook_path,
height=height,
width=width,
peak_count=source_peak_count,
)
spatial = np.unique(np.column_stack((prior.rows, prior.columns)), axis=0)
return np.asarray(
[(int(row), int(column), channel) for row, column in spatial for channel in range(3)],
dtype=np.int32,
)
def _load_rgb(path: Path, *, height: int, width: int) -> np.ndarray:
"""Load an exact-geometry image as float64 RGB."""
with Image.open(path) as image:
rgb = image.convert("RGB")
if rgb.size != (width, height):
raise ValueError(f"{path}: geometry {rgb.width}x{rgb.height} does not match {width}x{height}")
return np.asarray(rgb, dtype=np.float64)
def _extract_values(pixels: np.ndarray, bins: np.ndarray) -> np.ndarray:
"""Extract complex rFFT values at sparse BINS from three-channel PIXELS."""
values = np.empty(len(bins), dtype=np.complex128)
for channel in range(3):
positions = np.flatnonzero(bins[:, 2] == channel)
spectrum = np.fft.rfft2(pixels[:, :, channel])
values[positions] = spectrum[bins[positions, 0], bins[positions, 1]]
return values
def discover_model(
paths: list[Path],
*,
color_space: str,
candidate_bins: np.ndarray,
peak_count: int = 256,
) -> ColorPhaseModel:
"""Learn one color-space phase carrier from exact-geometry PATHS."""
if len(paths) < 3:
raise ValueError("at least three positive images are required")
if color_space not in COLOR_SPACES:
raise ValueError(f"unsupported color space: {color_space}")
with Image.open(paths[0]) as first:
width, height = first.size
bins = np.asarray(candidate_bins, dtype=np.int32)
if bins.ndim != 2 or bins.shape[1] != 3:
raise ValueError("candidate_bins must have shape (count, 3)")
if len(bins) < peak_count:
raise ValueError(f"only {len(bins)} candidate bins for {peak_count} peaks")
if (
np.any(bins[:, 0] < 0)
or np.any(bins[:, 0] >= height)
or np.any(bins[:, 1] <= 0)
or np.any(bins[:, 1] > width // 2)
or np.any(bins[:, 2] < 0)
or np.any(bins[:, 2] > 2)
):
raise ValueError("candidate_bins contain out-of-range coordinates")
image_values = np.empty((len(paths), len(bins)), dtype=np.complex128)
for index, path in enumerate(paths):
rgb = _load_rgb(path, height=height, width=width)
image_values[index] = _extract_values(transform_color_space(rgb, color_space), bins)
magnitudes = np.abs(image_values)
units = np.divide(image_values, magnitudes, out=np.zeros_like(image_values), where=magnitudes != 0.0)
unit_sum = np.sum(units, axis=0)
count = float(len(paths))
minimum_loo = np.ones(len(bins), dtype=np.float64)
for unit in units:
minimum_loo = np.minimum(minimum_loo, _leave_one_out_coherence(unit_sum, unit, count))
expected_magnitudes = np.mean(magnitudes, axis=0)
selection = np.power(minimum_loo, 4.0) * np.log1p(expected_magnitudes)
chosen = np.argpartition(selection, -peak_count)[-peak_count:]
chosen = chosen[np.argsort(selection[chosen])[::-1]]
raw_weights = selection[chosen]
if np.sum(raw_weights) <= 0.0:
raise ValueError("candidate bins have no usable phase consensus")
selected = bins[chosen]
return ColorPhaseModel(
color_space=color_space,
height=height,
width=width,
rows=selected[:, 0].astype(np.int32),
columns=selected[:, 1].astype(np.int32),
channels=selected[:, 2].astype(np.int8),
phases=np.angle(unit_sum[chosen] / count).astype(np.float64),
weights=(raw_weights / np.sum(raw_weights)).astype(np.float64),
expected_magnitudes=expected_magnitudes[chosen].astype(np.float64),
)
def score_image(path: Path, model: ColorPhaseModel) -> ColorPhaseScore:
"""Score PATH against MODEL and expose additive channel evidence."""
rgb = _load_rgb(path, height=model.height, width=model.width)
bins = np.column_stack((model.rows, model.columns, model.channels))
values = _extract_values(transform_color_space(rgb, model.color_space), bins)
magnitude_gate = np.minimum(np.abs(values) / (model.expected_magnitudes + 1e-12), 1.0)
contributions = model.weights * magnitude_gate * np.cos(np.angle(values) - model.phases)
active_weights = model.weights * magnitude_gate
active_weight = float(np.sum(active_weights))
evidence = float(np.sum(contributions))
phase_score = 0.0 if active_weight == 0.0 else evidence / active_weight
channel_evidence = tuple(float(np.sum(contributions[model.channels == channel])) for channel in range(3))
selected_peak_counts = tuple(int(np.sum(model.channels == channel)) for channel in range(3))
return ColorPhaseScore(
path=str(path),
color_space=model.color_space,
phase_score=phase_score,
active_weight_fraction=active_weight,
evidence_score=evidence,
channel_evidence=channel_evidence,
selected_peak_counts=selected_peak_counts,
peak_count=len(model.rows),
)
def save_model(path: Path, model: ColorPhaseModel) -> None:
"""Save MODEL as a pickle-free numeric NPZ."""
path.parent.mkdir(parents=True, exist_ok=True)
np.savez_compressed(
path,
format_version=np.asarray(1, dtype=np.int32),
color_space=np.asarray(model.color_space),
height=np.asarray(model.height, dtype=np.int32),
width=np.asarray(model.width, dtype=np.int32),
rows=model.rows.astype(np.int32),
columns=model.columns.astype(np.int32),
channels=model.channels.astype(np.int8),
phases=model.phases.astype(np.float32),
weights=model.weights.astype(np.float32),
expected_magnitudes=model.expected_magnitudes.astype(np.float64),
)
def load_model(path: Path) -> ColorPhaseModel:
"""Load and validate one color-space phase-carrier artifact."""
with np.load(path, allow_pickle=False) as artifact:
if int(artifact["format_version"]) != 1:
raise ValueError("unsupported color-phase model format version")
model = ColorPhaseModel(
color_space=str(artifact["color_space"]),
height=int(artifact["height"]),
width=int(artifact["width"]),
rows=np.asarray(artifact["rows"], dtype=np.int32),
columns=np.asarray(artifact["columns"], dtype=np.int32),
channels=np.asarray(artifact["channels"], dtype=np.int8),
phases=np.asarray(artifact["phases"], dtype=np.float64),
weights=np.asarray(artifact["weights"], dtype=np.float64),
expected_magnitudes=np.asarray(artifact["expected_magnitudes"], dtype=np.float64),
)
count = len(model.rows)
arrays = (model.columns, model.channels, model.phases, model.weights, model.expected_magnitudes)
if model.color_space not in COLOR_SPACES:
raise ValueError("invalid model color space")
if model.height < 64 or model.width < 64 or count == 0 or any(array.shape != (count,) for array in arrays):
raise ValueError("invalid color-phase model shapes")
if np.any(model.rows < 0) or np.any(model.rows >= model.height):
raise ValueError("invalid color-phase row indices")
if np.any(model.columns <= 0) or np.any(model.columns > model.width // 2):
raise ValueError("invalid color-phase column indices")
if np.any(model.channels < 0) or np.any(model.channels > 2):
raise ValueError("invalid color-phase channel indices")
if not np.isclose(np.sum(model.weights), 1.0, atol=1e-5) or np.any(model.weights < 0.0):
raise ValueError("invalid color-phase weights")
if np.any(model.expected_magnitudes < 0.0):
raise ValueError("invalid expected magnitudes")
return model
@click.group()
def main() -> None:
"""Discover and score phase carriers in multiple color spaces."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
@main.command()
@click.argument("codebook", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("positives", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--color-space", type=click.Choice(COLOR_SPACES), required=True)
@click.option("--source-peak-count", type=click.IntRange(min=1), default=256, show_default=True)
@click.option("--peak-count", type=click.IntRange(min=1), default=256, show_default=True)
@click.option("--model-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
def discover(
codebook: Path,
positives: tuple[Path, ...],
color_space: str,
source_peak_count: int,
peak_count: int,
model_out: Path,
) -> None:
"""Learn a color-space carrier from exact-geometry POSITIVES."""
with Image.open(positives[0]) as first:
width, height = first.size
candidates = candidate_bins_from_codebook(
codebook,
height=height,
width=width,
source_peak_count=source_peak_count,
)
model = discover_model(
list(positives),
color_space=color_space,
candidate_bins=candidates,
peak_count=peak_count,
)
save_model(model_out, model)
log.info("Wrote %s color-phase model with %s candidates: %s", color_space, len(candidates), model_out)
@main.command()
@click.argument("model_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--report-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
def score(model_path: Path, images: tuple[Path, ...], report_out: Path) -> None:
"""Score exact-geometry IMAGES with MODEL_PATH."""
model = load_model(model_path)
payload = {
"model": str(model_path),
"color_space": model.color_space,
"channel_names": CHANNEL_NAMES[model.color_space],
"height": model.height,
"width": model.width,
"peak_count": len(model.rows),
"scores": [asdict(score_image(image, model)) for image in images],
}
report_out.parent.mkdir(parents=True, exist_ok=True)
report_out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
log.info("Wrote %s color-phase score report: %s", model.color_space, report_out)
if __name__ == "__main__":
main()
+478
View File
@@ -0,0 +1,478 @@
"""Measure how well non-watermark confounds predict provider SynthID labels.
The experiment deliberately trains three provider-specific baselines:
``container``
File size, decoded geometry, aspect ratio, and format.
``thumbnail``
Container features plus a small RGB thumbnail that can learn generator and
content style.
``canonical``
Decoded, orientation-normalized RGB at a fixed geometry, with no container,
original-resolution, metadata, filename, or path features.
These are challenge baselines, not SynthID detectors. A candidate detector must
beat the canonical baseline on same-provider hard negatives and a temporal
holdout before its result can be attributed to a watermark-specific signal.
Usage:
uv run --extra pixels python scripts/synthid_confound_probe.py \
.local-eval/synthid/manifest.csv --target-provider google \
--report-out .local-eval/synthid/google-d1-confounds.json
"""
from __future__ import annotations
import csv
import json
import logging
from collections import Counter
from dataclasses import asdict, dataclass
from pathlib import Path
import click
import numpy as np
from PIL import Image, ImageOps
from synthid_research_manifest import artifact_sha256, audit_manifest, resolve_artifact_path
log = logging.getLogger(__name__)
FEATURE_FAMILIES = ("container", "thumbnail", "canonical")
FINAL_SPLITS = ("train", "validation", "test", "temporal")
THUMBNAIL_SIZE = 8
FORMAT_NAMES = ("png", "jpeg", "webp")
LOGISTIC_ITERATIONS = 2_000
LOGISTIC_LEARNING_RATE = 0.2
LOGISTIC_L2 = 0.01
@dataclass(frozen=True)
class Example:
"""One ordinary, provider-targeted manifest row eligible for D1."""
artifact_path: Path
artifact_sha256: str
group_id: str
split: str
label: int
negative_cohort: str | None
@dataclass(frozen=True)
class LogisticModel:
"""Standardization and regularized logistic-regression parameters."""
mean: np.ndarray
scale: np.ndarray
weights: np.ndarray
bias: float
@dataclass(frozen=True)
class Metrics:
"""Binary metrics at one validation-frozen threshold."""
count: int
positives: int
negatives: int
true_positives: int
false_positives: int
true_negatives: int
false_negatives: int
tpr: float | None
fpr: float | None
auc: float | None
def _safe_artifact_path(root: Path, value: str) -> Path:
"""Resolve a manifest-relative path without accepting traversal."""
candidate = resolve_artifact_path(root, value)
if candidate is None:
raise ValueError(f"unsafe artifact_path {value!r}")
return candidate
def _read_manifest(path: Path) -> list[dict[str, str]]:
"""Read manifest rows after the caller has run the canonical auditor."""
with path.open(newline="", encoding="utf-8") as stream:
return list(csv.DictReader(stream))
def load_examples(manifest: Path, target_provider: str) -> list[Example]:
"""Load ordinary final-label examples for one provider target.
Candidate, sham, and source-control rows are deliberately excluded. A
remover-generated negative must not certify the detector that created it,
and repeated controls must not receive extra sample weight.
"""
root = manifest.parent
examples: list[Example] = []
for row in _read_manifest(manifest):
if row.get("target_provider") != target_provider:
continue
if row.get("split") not in FINAL_SPLITS or row.get("oracle_role") != "ordinary":
continue
outcome = row.get("synthid_outcome")
if outcome not in {"detected", "not_detected"}:
continue
source_provider = row.get("source_provider", "")
if outcome == "detected" and source_provider != target_provider:
raise ValueError(
f"positive group {row.get('group_id')!r} targets {target_provider!r} "
f"but declares source_provider {source_provider!r}"
)
negative_cohort: str | None = None
if outcome == "not_detected":
if source_provider == target_provider:
negative_cohort = "same_provider"
elif source_provider in {"openai", "google", "other_ai"}:
negative_cohort = "other_ai"
else:
negative_cohort = "external"
examples.append(
Example(
artifact_path=_safe_artifact_path(root, row.get("artifact_path", "")),
artifact_sha256=row.get("artifact_sha256", ""),
group_id=row.get("group_id", ""),
split=row.get("split", ""),
label=1 if outcome == "detected" else 0,
negative_cohort=negative_cohort,
)
)
if not examples:
raise ValueError(f"manifest has no eligible ordinary rows for target_provider={target_provider!r}")
return examples
def _decoded_rgb(path: Path) -> Image.Image:
"""Return rendered RGB pixels with EXIF orientation applied once."""
with Image.open(path) as image:
return ImageOps.exif_transpose(image).convert("RGB")
def _thumbnail_features(image: Image.Image) -> np.ndarray:
"""Return a fixed-size RGB content fingerprint with no source geometry."""
thumbnail = image.resize((THUMBNAIL_SIZE, THUMBNAIL_SIZE), Image.Resampling.LANCZOS)
return np.asarray(thumbnail, dtype=np.float64).reshape(-1) / 255.0
def extract_feature_families(example: Example) -> dict[str, np.ndarray]:
"""Extract every confounded feature family with one image decode."""
with _decoded_rgb(example.artifact_path) as image:
width, height = image.size
thumbnail = _thumbnail_features(image)
file_size = example.artifact_path.stat().st_size
pixels = width * height
image_format = example.artifact_path.suffix.lower().lstrip(".")
if image_format == "jpg":
image_format = "jpeg"
container = np.asarray(
[
np.log1p(width),
np.log1p(height),
np.log1p(pixels),
np.log1p(file_size),
width / height,
file_size / pixels,
*(1.0 if image_format == name else 0.0 for name in FORMAT_NAMES),
],
dtype=np.float64,
)
return {
"container": container,
"thumbnail": np.concatenate((container, thumbnail)),
"canonical": thumbnail,
}
def extract_features(example: Example, family: str) -> np.ndarray:
"""Extract one deliberately confounded feature family."""
if family not in FEATURE_FAMILIES:
raise ValueError(f"unsupported feature family {family!r}")
return extract_feature_families(example)[family]
def feature_matrix(examples: list[Example], family: str) -> np.ndarray:
"""Extract a dense matrix in manifest order."""
return np.stack([extract_features(example, family) for example in examples])
def feature_matrices(examples: list[Example]) -> dict[str, np.ndarray]:
"""Extract all dense feature matrices while decoding each artifact once."""
rows = [extract_feature_families(example) for example in examples]
return {family: np.stack([row[family] for row in rows]) for family in FEATURE_FAMILIES}
def _balanced_sample_weights(labels: np.ndarray) -> np.ndarray:
"""Give each class equal total weight regardless of corpus imbalance."""
positives = int(np.sum(labels == 1))
negatives = int(np.sum(labels == 0))
if positives == 0 or negatives == 0:
raise ValueError("training requires at least one positive and one negative")
return np.where(labels == 1, 0.5 / positives, 0.5 / negatives)
def _sigmoid(values: np.ndarray) -> np.ndarray:
"""Evaluate a numerically stable logistic sigmoid."""
result = np.empty_like(values, dtype=np.float64)
positive = values >= 0
result[positive] = 1.0 / (1.0 + np.exp(-values[positive]))
exponent = np.exp(values[~positive])
result[~positive] = exponent / (1.0 + exponent)
return result
def fit_logistic(
features: np.ndarray,
labels: np.ndarray,
*,
iterations: int = LOGISTIC_ITERATIONS,
learning_rate: float = LOGISTIC_LEARNING_RATE,
l2: float = LOGISTIC_L2,
) -> LogisticModel:
"""Fit deterministic class-balanced L2 logistic regression."""
if features.ndim != 2 or labels.shape != (features.shape[0],):
raise ValueError("feature and label shapes are inconsistent")
if iterations < 1 or learning_rate <= 0.0 or l2 < 0.0:
raise ValueError("iterations and learning_rate must be positive; l2 must be nonnegative")
mean = np.mean(features, axis=0)
scale = np.std(features, axis=0)
scale = np.where(scale > 1e-12, scale, 1.0)
standardized = (features - mean) / scale
sample_weights = _balanced_sample_weights(labels)
weights = np.zeros(features.shape[1], dtype=np.float64)
bias = 0.0
for _ in range(iterations):
probabilities = _sigmoid(standardized @ weights + bias)
error = (probabilities - labels) * sample_weights
gradient = standardized.T @ error + l2 * weights
weights -= learning_rate * gradient
bias -= learning_rate * float(np.sum(error))
return LogisticModel(mean=mean, scale=scale, weights=weights, bias=bias)
def predict_scores(model: LogisticModel, features: np.ndarray) -> np.ndarray:
"""Return positive-class probabilities for FEATURES."""
standardized = (features - model.mean) / model.scale
return _sigmoid(standardized @ model.weights + model.bias)
def select_threshold(labels: np.ndarray, scores: np.ndarray, *, max_fpr: float) -> float:
"""Choose the validation threshold with maximum TPR under MAX_FPR."""
if labels.shape != scores.shape or labels.ndim != 1:
raise ValueError("validation labels and scores must be one-dimensional and aligned")
if not 0.0 <= max_fpr <= 1.0:
raise ValueError("max_fpr must be between zero and one")
if not np.any(labels == 1) or not np.any(labels == 0):
raise ValueError("threshold selection requires positive and negative validation examples")
candidates = [float(np.nextafter(np.max(scores), np.inf)), *sorted(set(map(float, scores)), reverse=True)]
best: tuple[float, float, float] | None = None
for threshold in candidates:
predicted = scores >= threshold
tpr = float(np.mean(predicted[labels == 1]))
fpr = float(np.mean(predicted[labels == 0]))
if fpr > max_fpr:
continue
candidate = (tpr, -fpr, threshold)
if best is None or candidate > best:
best = candidate
if best is None:
raise RuntimeError("threshold search found no feasible operating point")
return best[2]
def _auc(labels: np.ndarray, scores: np.ndarray) -> float | None:
"""Return tie-aware ROC AUC, or None when one class is absent."""
positive_count = int(np.sum(labels == 1))
negative_count = int(np.sum(labels == 0))
if positive_count == 0 or negative_count == 0:
return None
order = np.argsort(scores, kind="stable")
sorted_scores = scores[order]
ranks = np.empty(len(scores), dtype=np.float64)
start = 0
while start < len(scores):
end = start + 1
while end < len(scores) and sorted_scores[end] == sorted_scores[start]:
end += 1
ranks[order[start:end]] = (start + 1 + end) / 2.0
start = end
positive_rank_sum = float(np.sum(ranks[labels == 1]))
return (positive_rank_sum - positive_count * (positive_count + 1) / 2.0) / (positive_count * negative_count)
def calculate_metrics(labels: np.ndarray, scores: np.ndarray, threshold: float) -> Metrics:
"""Calculate confusion counts, rates, and AUC at THRESHOLD."""
predicted = scores >= threshold
positives = labels == 1
negatives = ~positives
true_positives = int(np.sum(predicted & positives))
false_positives = int(np.sum(predicted & negatives))
true_negatives = int(np.sum(~predicted & negatives))
false_negatives = int(np.sum(~predicted & positives))
positive_count = int(np.sum(positives))
negative_count = int(np.sum(negatives))
return Metrics(
count=len(labels),
positives=positive_count,
negatives=negative_count,
true_positives=true_positives,
false_positives=false_positives,
true_negatives=true_negatives,
false_negatives=false_negatives,
tpr=true_positives / positive_count if positive_count else None,
fpr=false_positives / negative_count if negative_count else None,
auc=_auc(labels, scores),
)
def _split_indices(examples: list[Example], split: str) -> np.ndarray:
"""Return integer indices for SPLIT."""
return np.asarray([index for index, example in enumerate(examples) if example.split == split], dtype=np.int64)
def _cohort_metrics(
examples: list[Example], scores: np.ndarray, threshold: float, split: str
) -> dict[str, dict[str, int | float | None]]:
"""Report negative-only false-positive rates by provenance cohort."""
result: dict[str, dict[str, int | float | None]] = {}
for cohort in ("same_provider", "other_ai", "external"):
indices = np.asarray(
[
index
for index, example in enumerate(examples)
if example.split == split and example.negative_cohort == cohort
],
dtype=np.int64,
)
if not len(indices):
result[cohort] = {"count": 0, "false_positives": 0, "fpr": None}
continue
cohort_scores = scores[indices]
false_positives = int(np.sum(cohort_scores >= threshold))
result[cohort] = {
"count": len(indices),
"false_positives": false_positives,
"fpr": false_positives / len(indices),
}
return result
def _require_split_classes(examples: list[Example], split: str) -> None:
"""Require both labels in the train, validation, and locked test splits."""
labels = {example.label for example in examples if example.split == split}
if labels != {0, 1}:
raise ValueError(f"split {split!r} must contain at least one ordinary positive and negative")
def run_experiment(
manifest: Path,
target_provider: str,
*,
max_fpr: float = 0.001,
verify_files: bool = True,
) -> dict[str, object]:
"""Audit MANIFEST, train all confound baselines, and return a JSON-safe report."""
errors = audit_manifest(manifest, verify_files=verify_files)
if errors:
preview = "; ".join(errors[:5])
raise ValueError(f"manifest audit failed with {len(errors)} error(s): {preview}")
examples = load_examples(manifest, target_provider)
for split in ("train", "validation", "test"):
_require_split_classes(examples, split)
labels = np.asarray([example.label for example in examples], dtype=np.int64)
split_counts = Counter(example.split for example in examples)
negative_counts = Counter(example.negative_cohort for example in examples if example.negative_cohort is not None)
train_indices = _split_indices(examples, "train")
validation_indices = _split_indices(examples, "validation")
report_families: dict[str, object] = {}
matrices = feature_matrices(examples)
for family in FEATURE_FAMILIES:
features = matrices[family]
model = fit_logistic(features[train_indices], labels[train_indices])
scores = predict_scores(model, features)
threshold = select_threshold(labels[validation_indices], scores[validation_indices], max_fpr=max_fpr)
split_metrics: dict[str, object] = {}
cohort_metrics: dict[str, object] = {}
for split in FINAL_SPLITS:
indices = _split_indices(examples, split)
if not len(indices):
split_metrics[split] = None
cohort_metrics[split] = {
cohort: {"count": 0, "false_positives": 0, "fpr": None}
for cohort in ("same_provider", "other_ai", "external")
}
continue
split_metrics[split] = asdict(calculate_metrics(labels[indices], scores[indices], threshold))
cohort_metrics[split] = _cohort_metrics(examples, scores, threshold, split)
report_families[family] = {
"feature_count": features.shape[1],
"threshold": threshold,
"metrics": split_metrics,
"negative_cohorts": cohort_metrics,
}
manifest_digest = artifact_sha256(manifest)
has_same_provider_test = any(
example.split == "test" and example.negative_cohort == "same_provider" for example in examples
)
has_temporal_both_classes = {example.label for example in examples if example.split == "temporal"} == {0, 1}
return {
"schema_version": 1,
"experiment": "synthid-d1-confounds",
"target_provider": target_provider,
"manifest_sha256": manifest_digest,
"max_validation_fpr": max_fpr,
"eligible_examples": len(examples),
"split_counts": dict(sorted(split_counts.items())),
"negative_cohort_counts": dict(sorted(negative_counts.items())),
"evidence_ready": has_same_provider_test and has_temporal_both_classes,
"evidence_missing": [
reason
for missing, reason in (
(not has_same_provider_test, "locked test has no same-provider hard negative"),
(not has_temporal_both_classes, "temporal split does not contain both labels"),
)
if missing
],
"feature_schema": {
"families": list(FEATURE_FAMILIES),
"thumbnail_size": THUMBNAIL_SIZE,
"format_names": list(FORMAT_NAMES),
},
"logistic_regression": {
"iterations": LOGISTIC_ITERATIONS,
"learning_rate": LOGISTIC_LEARNING_RATE,
"l2": LOGISTIC_L2,
"class_balancing": "equal total weight per class",
},
"families": report_families,
}
@click.command()
@click.argument("manifest", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--target-provider", required=True, type=click.Choice(["openai", "google"]))
@click.option("--report-out", required=True, type=click.Path(dir_okay=False, path_type=Path))
@click.option("--max-fpr", type=click.FloatRange(0.0, 1.0), default=0.001, show_default=True)
@click.option("--verify-files/--no-verify-files", default=True, show_default=True)
def main(manifest: Path, target_provider: str, report_out: Path, max_fpr: float, verify_files: bool) -> None:
"""Run D1 confound baselines from a provider-specific research MANIFEST."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
try:
report = run_experiment(manifest, target_provider, max_fpr=max_fpr, verify_files=verify_files)
except (OSError, ValueError) as exc:
raise click.ClickException(str(exc)) from exc
report_out.parent.mkdir(parents=True, exist_ok=True)
report_out.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
log.info("Wrote D1 confound report: %s", report_out)
if report["evidence_ready"]:
log.info("D1 report contains same-provider locked negatives and a two-class temporal holdout")
else:
log.warning("D1 report lacks required evidence: %s", "; ".join(report["evidence_missing"]))
if __name__ == "__main__":
main()
+259
View File
@@ -0,0 +1,259 @@
"""Discover a polarity-invariant spectral carrier from low-texture groups.
This research harness is intentionally separate from the shipping detector. It
uses repeated, independently generated low-texture images to find Fourier bins
whose phase is stable within a content group and whose phase axis is stable
across groups. Treat its output as a carrier hypothesis until it passes the
provider-oracle and hard-negative gates in the SynthID research plan.
Usage:
uv run --extra pixels python scripts/synthid_consensus_probe.py discover \
refs/black refs/white refs/red --limit 5 \
--model-out .local-eval/synthid/consensus.npz
uv run --extra pixels python scripts/synthid_consensus_probe.py score \
.local-eval/synthid/consensus.npz images/*.png
"""
from __future__ import annotations
import json
import logging
from dataclasses import asdict, dataclass
from pathlib import Path
import click
import numpy as np
from PIL import Image, ImageFilter
log = logging.getLogger(__name__)
IMAGE_SUFFIXES = {".jpeg", ".jpg", ".png", ".webp"}
@dataclass(frozen=True)
class ConsensusScore:
"""One image's alignment with a frozen carrier hypothesis."""
path: str
score: float
active_weight_fraction: float
peak_count: int
@dataclass(frozen=True)
class ConsensusModel:
"""A compact, pickle-free carrier hypothesis."""
size: int
peaks: np.ndarray
axial_phase: np.ndarray
weights: np.ndarray
expected_magnitude: np.ndarray
def _image_paths(directory: Path, limit: int | None) -> list[Path]:
"""Return a deterministic list of supported images in DIRECTORY."""
paths = sorted(path for path in directory.iterdir() if path.suffix.lower() in IMAGE_SUFFIXES)
if limit is not None:
paths = paths[:limit]
if not paths:
raise ValueError(f"no supported images in {directory}")
return paths
def _high_pass_rgb(path: Path, size: int, blur_radius: float) -> np.ndarray:
"""Decode PATH, canonicalize its geometry, and remove local image content."""
with Image.open(path) as source:
image = source.convert("RGB").resize((size, size), Image.Resampling.LANCZOS)
pixels = np.asarray(image, dtype=np.float64)
blurred = np.asarray(image.filter(ImageFilter.GaussianBlur(radius=blur_radius)), dtype=np.float64)
return pixels - blurred
def _spectrum(path: Path, size: int, blur_radius: float) -> np.ndarray:
"""Return a centered channel-wise spectrum for PATH."""
residual = _high_pass_rgb(path, size, blur_radius)
return np.fft.fftshift(np.fft.fft2(residual, axes=(0, 1)), axes=(0, 1))
def _group_statistics(paths: list[Path], size: int, blur_radius: float) -> tuple[np.ndarray, np.ndarray]:
"""Return phase coherence and mean magnitude for one reference group."""
spectra = [_spectrum(path, size, blur_radius) for path in paths]
units = [spectrum / (np.abs(spectrum) + 1e-12) for spectrum in spectra]
mean_unit = np.mean(units, axis=0)
coherence = np.abs(mean_unit)
mean_magnitude = np.mean([np.abs(spectrum) for spectrum in spectra], axis=0)
phase = np.angle(mean_unit)
return coherence * np.exp(1j * phase), mean_magnitude
def _valid_half_plane(size: int, min_radius: float, max_radius_fraction: float) -> np.ndarray:
"""Return the nonredundant Fourier region allowed for carrier selection."""
center = size // 2
yy, xx = np.ogrid[:size, :size]
dy = yy - center
dx = xx - center
radius = np.sqrt(np.square(dy) + np.square(dx))
half_plane = (dy > 0) | ((dy == 0) & (dx > 0))
off_axis = (dy != 0) & (dx != 0)
return half_plane & off_axis & (radius >= min_radius) & (radius <= size * max_radius_fraction)
def discover_model(
groups: list[list[Path]],
*,
size: int = 512,
blur_radius: float = 2.0,
peak_count: int = 256,
min_radius: float = 8.0,
max_radius_fraction: float = 0.4,
) -> ConsensusModel:
"""Discover a polarity-invariant carrier from independent image GROUPS."""
if len(groups) < 2:
raise ValueError("at least two reference groups are required")
if any(len(group) < 2 for group in groups):
raise ValueError("each reference group requires at least two images")
group_units: list[np.ndarray] = []
group_magnitudes: list[np.ndarray] = []
for paths in groups:
unit, magnitude = _group_statistics(paths, size, blur_radius)
group_units.append(unit)
group_magnitudes.append(magnitude)
stacked = np.stack(group_units, axis=0)
within_coherence = np.abs(stacked)
group_phase = np.angle(stacked)
axial_mean = np.mean(np.exp(2j * group_phase) * within_coherence, axis=0)
axial_coherence = np.abs(axial_mean) / (np.mean(within_coherence, axis=0) + 1e-12)
mean_within = np.mean(within_coherence, axis=0)
expected_magnitude = np.mean(group_magnitudes, axis=0)
magnitude_scale = np.median(expected_magnitude, axis=(0, 1), keepdims=True) + 1e-12
magnitude_score = np.log1p(expected_magnitude / magnitude_scale)
selection_score = np.square(mean_within) * np.square(axial_coherence) * magnitude_score
selection_score *= _valid_half_plane(size, min_radius, max_radius_fraction)[:, :, None]
candidate_count = int(np.count_nonzero(selection_score))
if candidate_count < peak_count:
raise ValueError(f"only {candidate_count} valid carrier candidates for {peak_count} peaks")
flat = selection_score.ravel()
indices = np.argpartition(flat, -peak_count)[-peak_count:]
indices = indices[np.argsort(flat[indices])[::-1]]
rows, columns, channels = np.unravel_index(indices, selection_score.shape)
peaks = np.column_stack((rows - size // 2, columns - size // 2, channels)).astype(np.int32)
axial_phase = 0.5 * np.angle(axial_mean[rows, columns, channels])
weights = selection_score[rows, columns, channels]
weights /= np.sum(weights)
magnitudes = expected_magnitude[rows, columns, channels]
return ConsensusModel(
size=size,
peaks=peaks,
axial_phase=axial_phase.astype(np.float64),
weights=weights.astype(np.float64),
expected_magnitude=magnitudes.astype(np.float64),
)
def score_image(path: Path, model: ConsensusModel, *, blur_radius: float = 2.0) -> ConsensusScore:
"""Score PATH against a frozen polarity-invariant carrier model."""
spectrum = _spectrum(path, model.size, blur_radius)
center = model.size // 2
rows = center + model.peaks[:, 0]
columns = center + model.peaks[:, 1]
channels = model.peaks[:, 2]
values = spectrum[rows, columns, channels]
phase_alignment = np.cos(2.0 * (np.angle(values) - model.axial_phase))
magnitude_gate = np.minimum(np.abs(values) / (model.expected_magnitude + 1e-12), 1.0)
active_weights = model.weights * magnitude_gate
active_weight = float(np.sum(active_weights))
score = 0.0 if active_weight == 0.0 else float(np.sum(active_weights * phase_alignment) / active_weight)
return ConsensusScore(
path=str(path),
score=score,
active_weight_fraction=active_weight,
peak_count=len(model.peaks),
)
def save_model(path: Path, model: ConsensusModel) -> None:
"""Save MODEL as a pickle-free NPZ artifact."""
path.parent.mkdir(parents=True, exist_ok=True)
np.savez_compressed(
path,
size=np.asarray(model.size, dtype=np.int32),
peaks=model.peaks.astype(np.int32),
axial_phase=model.axial_phase.astype(np.float32),
weights=model.weights.astype(np.float32),
expected_magnitude=model.expected_magnitude.astype(np.float32),
)
def load_model(path: Path) -> ConsensusModel:
"""Load and validate a pickle-free carrier model."""
with np.load(path, allow_pickle=False) as artifact:
model = ConsensusModel(
size=int(artifact["size"]),
peaks=np.asarray(artifact["peaks"], dtype=np.int32),
axial_phase=np.asarray(artifact["axial_phase"], dtype=np.float64),
weights=np.asarray(artifact["weights"], dtype=np.float64),
expected_magnitude=np.asarray(artifact["expected_magnitude"], dtype=np.float64),
)
count = len(model.peaks)
if model.peaks.ndim != 2 or model.peaks.shape[1] != 3:
raise ValueError("invalid peak shape")
if any(array.shape != (count,) for array in (model.axial_phase, model.weights, model.expected_magnitude)):
raise ValueError("model arrays do not match peak count")
if model.size < 64 or np.any(np.abs(model.peaks[:, :2]) >= model.size // 2):
raise ValueError("invalid canonical size or peak coordinates")
if np.any(model.peaks[:, 2] < 0) or np.any(model.peaks[:, 2] > 2):
raise ValueError("invalid channel index")
if not np.isclose(np.sum(model.weights), 1.0, atol=1e-5):
raise ValueError("model weights must sum to one")
return model
@click.group()
def main() -> None:
"""Run low-texture carrier discovery and scoring experiments."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
@main.command()
@click.argument("group_dirs", nargs=-1, required=True, type=click.Path(exists=True, file_okay=False, path_type=Path))
@click.option("--limit", type=click.IntRange(min=2))
@click.option("--size", type=click.IntRange(min=64), default=512, show_default=True)
@click.option("--peak-count", type=click.IntRange(min=1), default=256, show_default=True)
@click.option("--model-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
def discover(group_dirs: tuple[Path, ...], limit: int | None, size: int, peak_count: int, model_out: Path) -> None:
"""Discover a carrier from the images in each GROUP_DIRS directory."""
groups = [_image_paths(directory, limit) for directory in group_dirs]
model = discover_model(groups, size=size, peak_count=peak_count)
save_model(model_out, model)
log.info("Wrote consensus model: %s", model_out)
@main.command()
@click.argument("model_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--report-out", type=click.Path(dir_okay=False, path_type=Path))
def score(model_path: Path, images: tuple[Path, ...], report_out: Path | None) -> None:
"""Score IMAGES against MODEL_PATH."""
model = load_model(model_path)
payload = {
"model": str(model_path),
"scores": [asdict(score_image(image, model)) for image in images],
}
rendered = json.dumps(payload, indent=2) + "\n"
if report_out is None:
log.info("%s", rendered.rstrip())
return
report_out.parent.mkdir(parents=True, exist_ok=True)
report_out.write_text(rendered, encoding="utf-8")
log.info("Wrote score report: %s", report_out)
if __name__ == "__main__":
main()
+223
View File
@@ -0,0 +1,223 @@
"""Build pixel-only alternating-projection candidates for the ensemble detector.
The attack removes only the positive complex-spectrum projection onto the
learned RGB phases and HSV saturation/value phases. It preserves geometry and
does not invoke a generative model. Clearing the local research detector is not
proof that a provider oracle will clear SynthID.
"""
from __future__ import annotations
import json
import logging
from dataclasses import asdict
from pathlib import Path
from typing import TYPE_CHECKING
import click
import cv2
import numpy as np
from PIL import Image
from synthid_ensemble_detector import EnsembleConfig, detect_image, load_config, load_models
from synthid_pixel_attack import load_rgb, measure, norm_matched_noise
if TYPE_CHECKING:
from synthid_color_space_probe import ColorPhaseModel
log = logging.getLogger(__name__)
def _remove_positive_projection(
channel: np.ndarray,
*,
rows: np.ndarray,
columns: np.ndarray,
phases: np.ndarray,
strength: float,
) -> np.ndarray:
"""Remove STRENGTH of each positive phase projection from CHANNEL."""
if strength < 0.0 or strength > 1.0:
raise ValueError("strength must be between zero and one")
height, width = channel.shape
spectrum = np.fft.fft2(channel.astype(np.float64))
for row, column, phase in zip(rows, columns, phases, strict=True):
row_index = int(row)
column_index = int(column)
direction = np.exp(1j * float(phase))
value = spectrum[row_index, column_index]
projection = max(0.0, float(np.real(value * np.conj(direction))))
delta = strength * projection * direction
conjugate_row = (-row_index) % height
conjugate_column = (-column_index) % width
spectrum[row_index, column_index] -= delta
if (conjugate_row, conjugate_column) == (row_index, column_index):
spectrum[row_index, column_index] = complex(spectrum[row_index, column_index].real, 0.0)
else:
spectrum[conjugate_row, conjugate_column] -= np.conj(delta)
return np.fft.ifft2(spectrum).real
def _project_model_channels(
pixels: np.ndarray,
model: ColorPhaseModel,
*,
included_channels: frozenset[int],
strength: float,
) -> np.ndarray:
"""Apply positive-projection removal to selected MODEL channels."""
result = pixels.astype(np.float64, copy=True)
for channel in included_channels:
positions = np.flatnonzero(model.channels == channel)
if len(positions) == 0:
continue
result[:, :, channel] = _remove_positive_projection(
result[:, :, channel],
rows=model.rows[positions],
columns=model.columns[positions],
phases=model.phases[positions],
strength=strength,
)
return result
def alternating_projection(
pixels: np.ndarray,
rgb_model: ColorPhaseModel,
hsv_model: ColorPhaseModel,
*,
strength: float,
iterations: int,
) -> np.ndarray:
"""Alternate RGB and HSV S/V phase projections without regeneration."""
if iterations < 1:
raise ValueError("iterations must be positive")
expected_shape = (rgb_model.height, rgb_model.width, 3)
if pixels.shape != expected_shape or pixels.shape != (hsv_model.height, hsv_model.width, 3):
raise ValueError("pixel and model geometries do not match")
result = pixels.astype(np.float64)
for _ in range(iterations):
result = _project_model_channels(
result,
rgb_model,
included_channels=frozenset({0, 1, 2}),
strength=strength,
)
rgb_unit = np.clip(result / 255.0, 0.0, 1.0).astype(np.float32)
hsv = cv2.cvtColor(rgb_unit, cv2.COLOR_RGB2HSV).astype(np.float64)
hsv = _project_model_channels(
hsv,
hsv_model,
included_channels=frozenset({1, 2}),
strength=strength,
)
hsv[:, :, 0] = np.mod(hsv[:, :, 0], 360.0)
hsv[:, :, 1:] = np.clip(hsv[:, :, 1:], 0.0, 1.0)
result = cv2.cvtColor(hsv.astype(np.float32), cv2.COLOR_HSV2RGB).astype(np.float64) * 255.0
return np.clip(np.rint(result), 0, 255).astype(np.uint8)
def parse_positive_floats(value: str) -> tuple[float, ...]:
"""Parse strictly increasing strengths in the interval (0, 1]."""
try:
values = tuple(float(item.strip()) for item in value.split(","))
except ValueError as error:
raise click.BadParameter("strengths must be comma-separated numbers") from error
if not values or any(not np.isfinite(item) or item <= 0.0 or item > 1.0 for item in values):
raise click.BadParameter("strengths must be finite and in the interval (0, 1]")
if tuple(sorted(set(values))) != values:
raise click.BadParameter("strengths must be unique and strictly increasing")
return values
def parse_positive_integers(value: str) -> tuple[int, ...]:
"""Parse strictly increasing positive iteration counts."""
try:
values = tuple(int(item.strip()) for item in value.split(","))
except ValueError as error:
raise click.BadParameter("iterations must be comma-separated integers") from error
if not values or any(item < 1 for item in values):
raise click.BadParameter("iterations must be positive")
if tuple(sorted(set(values))) != values:
raise click.BadParameter("iterations must be unique and strictly increasing")
return values
def _load_source(path: Path, config: EnsembleConfig) -> np.ndarray:
"""Load an exact-geometry RGB source."""
pixels = load_rgb(path)
if pixels.shape != (config.height, config.width, 3):
raise ValueError("source geometry does not match detector config")
return pixels
@click.command()
@click.argument("config_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("output_dir", type=click.Path(file_okay=False, path_type=Path))
@click.option("--strengths", default="0.25,0.5,0.75,1", show_default=True)
@click.option("--iterations", default="1,2,4", show_default=True)
def main(config_path: Path, source: Path, output_dir: Path, strengths: str, iterations: str) -> None:
"""Write a frozen pixel-only alternating-projection batch for SOURCE."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
config = load_config(config_path)
rgb_model, hsv_model = load_models(config)
reference = _load_source(source, config)
strength_values = parse_positive_floats(strengths)
iteration_values = parse_positive_integers(iterations)
output_dir.mkdir(parents=True, exist_ok=True)
variants: list[dict[str, object]] = []
strongest = reference
for iteration_count in iteration_values:
for strength in strength_values:
pixels = alternating_projection(
reference,
rgb_model,
hsv_model,
strength=strength,
iterations=iteration_count,
)
strength_name = f"{strength:g}".replace(".", "p")
name = f"project-s{strength_name}-i{iteration_count}"
path = output_dir / f"{name}.png"
Image.fromarray(pixels, mode="RGB").save(path)
variants.append(
{
**asdict(measure(reference, pixels, name=name, path=path)),
**asdict(detect_image(path, config, rgb_model, hsv_model)),
"strength": strength,
"iterations": iteration_count,
}
)
strongest = pixels
sham = norm_matched_noise(reference, strongest, seed=20260823)
sham_path = output_dir / "sham-strongest-rms.png"
Image.fromarray(sham, mode="RGB").save(sham_path)
variants.append(
{
**asdict(measure(reference, sham, name="sham-strongest-rms", path=sham_path)),
**asdict(detect_image(sham_path, config, rgb_model, hsv_model)),
"strength": None,
"iterations": None,
}
)
report_path = output_dir / "report.json"
report_path.write_text(
json.dumps(
{
"source": str(source),
"config": str(config_path),
"variants": variants,
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
log.info("Wrote %d alternating-projection candidates: %s", len(variants), report_path)
if __name__ == "__main__":
main()
+188
View File
@@ -0,0 +1,188 @@
"""Run a positive-only exact-geometry SynthID research detector.
The detector requires independently frozen RGB and HSV phase models. It emits
``positive`` only when both branches and both support gates pass; every other
case is ``abstain``. It never claims that SynthID is absent.
"""
from __future__ import annotations
import json
import logging
from dataclasses import asdict, dataclass
from pathlib import Path
import click
from PIL import Image
from synthid_color_space_probe import ColorPhaseModel, ColorPhaseScore, load_model, score_image
from synthid_research_manifest import artifact_sha256
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class EnsembleConfig:
"""Frozen paths, hashes, geometry, and positive thresholds."""
width: int
height: int
rgb_model_path: Path
rgb_model_sha256: str
rgb_evidence_threshold: float
rgb_active_threshold: float
hsv_model_path: Path
hsv_model_sha256: str
hsv_sv_evidence_threshold: float
hsv_active_threshold: float
@dataclass(frozen=True)
class EnsembleVerdict:
"""Positive-only verdict with the evidence needed to audit it."""
path: str
verdict: str
reason: str
rgb_evidence: float | None
rgb_active_support: float | None
hsv_sv_evidence: float | None
hsv_active_support: float | None
def load_config(path: Path) -> EnsembleConfig:
"""Load a frozen epoch manifest and verify both model artifacts."""
payload = json.loads(path.read_text(encoding="utf-8"))
if payload.get("verdict_scope") != "positive-only exact-geometry research detector":
raise ValueError("config is not a positive-only exact-geometry detector")
rgb = payload["rgb_model"]
hsv = payload["hsv_model"]
geometry = payload["geometry"]
config = EnsembleConfig(
width=int(geometry["width"]),
height=int(geometry["height"]),
rgb_model_path=Path(rgb["path"]),
rgb_model_sha256=str(rgb["sha256"]),
rgb_evidence_threshold=float(rgb["evidence_threshold"]),
rgb_active_threshold=float(rgb["active_support_threshold"]),
hsv_model_path=Path(hsv["path"]),
hsv_model_sha256=str(hsv["sha256"]),
hsv_sv_evidence_threshold=float(hsv["sv_evidence_threshold"]),
hsv_active_threshold=float(hsv["active_support_threshold"]),
)
if config.width < 64 or config.height < 64:
raise ValueError("invalid detector geometry")
for model_path, expected_hash in (
(config.rgb_model_path, config.rgb_model_sha256),
(config.hsv_model_path, config.hsv_model_sha256),
):
if not model_path.is_file():
raise ValueError(f"model artifact does not exist: {model_path}")
if artifact_sha256(model_path) != expected_hash:
raise ValueError(f"model artifact hash mismatch: {model_path}")
return config
def load_models(config: EnsembleConfig) -> tuple[ColorPhaseModel, ColorPhaseModel]:
"""Load and cross-check the RGB and HSV models in CONFIG."""
rgb_model = load_model(config.rgb_model_path)
hsv_model = load_model(config.hsv_model_path)
if rgb_model.color_space != "rgb" or hsv_model.color_space != "hsv":
raise ValueError("detector requires one RGB model and one HSV model")
expected_geometry = (config.height, config.width)
if (rgb_model.height, rgb_model.width) != expected_geometry:
raise ValueError("RGB model geometry does not match config")
if (hsv_model.height, hsv_model.width) != expected_geometry:
raise ValueError("HSV model geometry does not match config")
return rgb_model, hsv_model
def classify_scores(
path: Path,
rgb_score: ColorPhaseScore,
hsv_score: ColorPhaseScore,
config: EnsembleConfig,
) -> EnsembleVerdict:
"""Apply CONFIG's positive-only rule to precomputed branch scores."""
hsv_sv_evidence = float(sum(hsv_score.channel_evidence[1:]))
rgb_support = rgb_score.active_weight_fraction >= config.rgb_active_threshold
hsv_support = hsv_score.active_weight_fraction >= config.hsv_active_threshold
rgb_pass = rgb_score.evidence_score >= config.rgb_evidence_threshold
hsv_pass = hsv_sv_evidence >= config.hsv_sv_evidence_threshold
if rgb_support and hsv_support and rgb_pass and hsv_pass:
verdict = "positive"
reason = "ensemble_pass"
elif not rgb_support or not hsv_support:
verdict = "abstain"
reason = "insufficient_support"
elif rgb_pass != hsv_pass:
verdict = "abstain"
reason = "branch_disagreement"
else:
verdict = "abstain"
reason = "below_positive_threshold"
return EnsembleVerdict(
path=str(path),
verdict=verdict,
reason=reason,
rgb_evidence=rgb_score.evidence_score,
rgb_active_support=rgb_score.active_weight_fraction,
hsv_sv_evidence=hsv_sv_evidence,
hsv_active_support=hsv_score.active_weight_fraction,
)
def detect_image(
path: Path,
config: EnsembleConfig,
rgb_model: ColorPhaseModel,
hsv_model: ColorPhaseModel,
) -> EnsembleVerdict:
"""Evaluate PATH or abstain when its geometry is unsupported."""
with Image.open(path) as image:
if image.size != (config.width, config.height):
return EnsembleVerdict(
path=str(path),
verdict="abstain",
reason="unsupported_geometry",
rgb_evidence=None,
rgb_active_support=None,
hsv_sv_evidence=None,
hsv_active_support=None,
)
return classify_scores(
path,
score_image(path, rgb_model),
score_image(path, hsv_model),
config,
)
@click.command()
@click.argument("config_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--report-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
def main(config_path: Path, images: tuple[Path, ...], report_out: Path) -> None:
"""Score IMAGES with the frozen positive-only detector CONFIG_PATH."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
config = load_config(config_path)
rgb_model, hsv_model = load_models(config)
verdicts = [detect_image(image, config, rgb_model, hsv_model) for image in images]
report_out.parent.mkdir(parents=True, exist_ok=True)
report_out.write_text(
json.dumps(
{
"config": str(config_path),
"positive_count": sum(verdict.verdict == "positive" for verdict in verdicts),
"abstain_count": sum(verdict.verdict == "abstain" for verdict in verdicts),
"verdicts": [asdict(verdict) for verdict in verdicts],
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
log.info("Wrote %d positive-only detector verdicts: %s", len(verdicts), report_out)
if __name__ == "__main__":
main()
+243
View File
@@ -0,0 +1,243 @@
"""Build non-generative spatial-fragmentation SynthID attack candidates.
The candidates combine deterministic smooth local warps with mild global
resampling, color changes, and codec round-trips. These are pixel transforms,
not semantic reconstruction or generative inpainting. Provider-oracle results
must be evaluated in a frozen batch with a source-positive control.
"""
from __future__ import annotations
import json
import logging
from dataclasses import asdict
from pathlib import Path
import click
import cv2
import numpy as np
from PIL import Image
from synthid_pixel_attack import (
jpeg_round_trip,
load_rgb,
measure,
norm_matched_noise,
resize_squeeze,
smooth_warp,
)
from synthid_v3_codebook_probe import load_v3_model, score_image
log = logging.getLogger(__name__)
def bounded_smooth_warp(
pixels: np.ndarray,
*,
max_displacement: float,
sigma: float,
seed: int,
) -> np.ndarray:
"""Apply a smooth warp whose per-axis displacement is absolutely bounded."""
if max_displacement < 0.0 or sigma <= 0.0:
raise ValueError("max_displacement must be nonnegative and sigma positive")
height, width = pixels.shape[:2]
rng = np.random.default_rng(seed)
fields: list[np.ndarray] = []
for _ in range(2):
noise = rng.normal(size=(height, width)).astype(np.float32)
field = cv2.GaussianBlur(noise, (0, 0), sigmaX=sigma, sigmaY=sigma)
maximum = float(np.max(np.abs(field)))
fields.append(np.zeros_like(field) if maximum == 0.0 else field * (max_displacement / maximum))
yy, xx = np.mgrid[:height, :width].astype(np.float32)
return cv2.remap(
pixels,
xx + fields[0],
yy + fields[1],
interpolation=cv2.INTER_LANCZOS4,
borderMode=cv2.BORDER_REFLECT_101,
)
def affine_combo(pixels: np.ndarray, *, rotation_degrees: float, zoom: float) -> np.ndarray:
"""Apply one centered rotation-and-zoom resampling operation."""
if zoom < 0.0:
raise ValueError("zoom must be nonnegative")
height, width = pixels.shape[:2]
matrix = cv2.getRotationMatrix2D(
center=((width - 1) / 2.0, (height - 1) / 2.0),
angle=rotation_degrees,
scale=1.0 + zoom,
)
return cv2.warpAffine(
pixels,
matrix,
(width, height),
flags=cv2.INTER_LANCZOS4,
borderMode=cv2.BORDER_REFLECT_101,
)
def color_nudge(
pixels: np.ndarray,
*,
brightness: float,
contrast: float,
saturation: float,
hue_degrees: float,
) -> np.ndarray:
"""Apply bounded global RGB contrast and HSV saturation/hue changes."""
rgb = pixels.astype(np.float32) / 255.0
rgb = np.clip((rgb - 0.5) * (1.0 + contrast) + 0.5 + brightness, 0.0, 1.0)
hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV)
hsv[:, :, 0] = np.mod(hsv[:, :, 0] + hue_degrees, 360.0)
hsv[:, :, 1] = np.clip(hsv[:, :, 1] * (1.0 + saturation), 0.0, 1.0)
result = cv2.cvtColor(hsv, cv2.COLOR_HSV2RGB)
return np.clip(np.rint(result * 255.0), 0, 255).astype(np.uint8)
def jpeg_chain(pixels: np.ndarray, qualities: tuple[int, ...]) -> np.ndarray:
"""Apply sequential JPEG round-trips at QUALITIES."""
result = pixels
for quality in qualities:
result = jpeg_round_trip(result, quality)
return result
def build_candidates(source: np.ndarray) -> dict[str, np.ndarray]:
"""Build the frozen spatial-fragmentation ladder for SOURCE."""
candidates: dict[str, np.ndarray] = {
"control": source.copy(),
"elastic-075": smooth_warp(source, amplitude=0.75, sigma=56.0, seed=20260812),
"elastic-125": smooth_warp(source, amplitude=1.25, sigma=52.0, seed=20260813),
"bounded-100": bounded_smooth_warp(
source,
max_displacement=1.0,
sigma=56.0,
seed=20260817,
),
"bounded-180": bounded_smooth_warp(
source,
max_displacement=1.8,
sigma=56.0,
seed=20260818,
),
"bounded-280": bounded_smooth_warp(
source,
max_displacement=2.8,
sigma=44.0,
seed=20260819,
),
}
balanced = smooth_warp(source, amplitude=0.75, sigma=56.0, seed=20260814)
balanced = affine_combo(balanced, rotation_degrees=0.2, zoom=0.004)
balanced = resize_squeeze(balanced, 0.94)
balanced = color_nudge(
balanced,
brightness=0.004,
contrast=0.006,
saturation=-0.005,
hue_degrees=0.15,
)
balanced = jpeg_chain(balanced, (94, 90))
candidates["fragment-balanced"] = balanced
strong = smooth_warp(source, amplitude=1.5, sigma=48.0, seed=20260815)
strong = affine_combo(strong, rotation_degrees=0.4, zoom=0.01)
strong = resize_squeeze(strong, 0.88)
strong = color_nudge(
strong,
brightness=0.008,
contrast=0.012,
saturation=-0.01,
hue_degrees=0.3,
)
strong = jpeg_chain(strong, (92, 88))
candidates["fragment-strong"] = strong
candidates["sham-strong-rms"] = norm_matched_noise(source, strong, seed=20260816)
bounded_balanced = bounded_smooth_warp(
source,
max_displacement=1.8,
sigma=56.0,
seed=20260820,
)
bounded_balanced = resize_squeeze(bounded_balanced, 0.98)
bounded_balanced = color_nudge(
bounded_balanced,
brightness=0.002,
contrast=0.003,
saturation=-0.003,
hue_degrees=0.1,
)
bounded_balanced = jpeg_chain(bounded_balanced, (96,))
candidates["bounded-fragment-balanced"] = bounded_balanced
bounded_strong = bounded_smooth_warp(
source,
max_displacement=2.8,
sigma=44.0,
seed=20260821,
)
bounded_strong = affine_combo(bounded_strong, rotation_degrees=0.2, zoom=0.004)
bounded_strong = resize_squeeze(bounded_strong, 0.94)
bounded_strong = color_nudge(
bounded_strong,
brightness=0.004,
contrast=0.006,
saturation=-0.005,
hue_degrees=0.15,
)
bounded_strong = jpeg_chain(bounded_strong, (94, 90))
candidates["bounded-fragment-strong"] = bounded_strong
candidates["sham-bounded-strong-rms"] = norm_matched_noise(source, bounded_strong, seed=20260822)
return candidates
@click.command()
@click.argument("codebook", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("output_dir", type=click.Path(file_okay=False, path_type=Path))
@click.option("--height", type=click.IntRange(min=64), required=True)
@click.option("--width", type=click.IntRange(min=64), required=True)
@click.option("--peak-count", type=click.IntRange(min=1), default=256, show_default=True)
def main(codebook: Path, source: Path, output_dir: Path, height: int, width: int, peak_count: int) -> None:
"""Write a frozen spatial-fragmentation batch for SOURCE."""
reference = load_rgb(source)
if reference.shape != (height, width, 3):
raise click.BadParameter("source geometry does not match --height and --width")
model = load_v3_model(codebook, height=height, width=width, peak_count=peak_count)
output_dir.mkdir(parents=True, exist_ok=True)
rows: list[dict[str, object]] = []
for name, pixels in build_candidates(reference).items():
path = output_dir / f"{name}.png"
Image.fromarray(pixels, mode="RGB").save(path)
rows.append(
{
**asdict(measure(reference, pixels, name=name, path=path)),
**asdict(score_image(path, model)),
}
)
report_path = output_dir / "report.json"
report_path.write_text(
json.dumps(
{
"source": str(source),
"codebook": str(codebook),
"height": height,
"width": width,
"peak_count": peak_count,
"variants": rows,
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
log.info("Wrote %d frozen fragmentation candidates: %s", len(rows), report_path)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(message)s")
main()
+140
View File
@@ -0,0 +1,140 @@
"""Build non-generative hybrid phase-projection and fragmentation candidates.
The matrix combines two independently measured mechanisms: sparse RGB/HSV
phase projection and spatially varying subpixel displacement. It is intended
for frozen provider-oracle batches with a visible-mark-removed source control.
"""
from __future__ import annotations
import json
import logging
from dataclasses import asdict
from pathlib import Path
from typing import TYPE_CHECKING
import click
from PIL import Image
from synthid_ensemble_attack import alternating_projection
from synthid_ensemble_detector import detect_image, load_config, load_models
from synthid_fragment_attack import bounded_smooth_warp, color_nudge, jpeg_chain
from synthid_pixel_attack import load_rgb, measure, norm_matched_noise, resize_squeeze, smooth_warp
if TYPE_CHECKING:
import numpy as np
from synthid_color_space_probe import ColorPhaseModel
log = logging.getLogger(__name__)
def build_candidates(
source: np.ndarray,
rgb_model: ColorPhaseModel,
hsv_model: ColorPhaseModel,
) -> dict[str, np.ndarray]:
"""Return a frozen mechanism matrix derived from SOURCE."""
projected_075 = alternating_projection(
source,
rgb_model,
hsv_model,
strength=0.75,
iterations=1,
)
projected_100 = alternating_projection(
source,
rgb_model,
hsv_model,
strength=1.0,
iterations=1,
)
bounded_100 = bounded_smooth_warp(
source,
max_displacement=1.0,
sigma=56.0,
seed=20260824,
)
projected_bounded_100 = bounded_smooth_warp(
projected_075,
max_displacement=1.0,
sigma=56.0,
seed=20260824,
)
bounded_polish = resize_squeeze(projected_bounded_100, 0.98)
bounded_polish = color_nudge(
bounded_polish,
brightness=0.002,
contrast=0.003,
saturation=-0.003,
hue_degrees=0.1,
)
bounded_polish = jpeg_chain(bounded_polish, (96,))
elastic_combo = smooth_warp(projected_100, amplitude=0.75, sigma=56.0, seed=20260825)
elastic_combo = resize_squeeze(elastic_combo, 0.98)
elastic_combo = jpeg_chain(elastic_combo, (96,))
return {
"projection-075": projected_075,
"bounded-100": bounded_100,
"projection-075-bounded-100": projected_bounded_100,
"projection-075-bounded-polish": bounded_polish,
"projection-100-elastic-075": elastic_combo,
}
@click.command()
@click.argument("config_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("output_dir", type=click.Path(file_okay=False, path_type=Path))
def main(config_path: Path, source: Path, output_dir: Path) -> None:
"""Write a frozen non-generative hybrid attack matrix for SOURCE."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
config = load_config(config_path)
rgb_model, hsv_model = load_models(config)
reference = load_rgb(source)
if reference.shape != (config.height, config.width, 3):
raise click.BadParameter("source geometry does not match detector config")
output_dir.mkdir(parents=True, exist_ok=True)
variants: list[dict[str, object]] = []
candidates = build_candidates(reference, rgb_model, hsv_model)
for name, pixels in candidates.items():
path = output_dir / f"{name}.png"
Image.fromarray(pixels, mode="RGB").save(path)
variants.append(
{
**asdict(measure(reference, pixels, name=name, path=path)),
**asdict(detect_image(path, config, rgb_model, hsv_model)),
}
)
selected = candidates["projection-075-bounded-100"]
sham = norm_matched_noise(reference, selected, seed=20260826)
sham_path = output_dir / "sham-projection-bounded-rms.png"
Image.fromarray(sham, mode="RGB").save(sham_path)
variants.append(
{
**asdict(measure(reference, sham, name="sham-projection-bounded-rms", path=sham_path)),
**asdict(detect_image(sham_path, config, rgb_model, hsv_model)),
}
)
report_path = output_dir / "report.json"
report_path.write_text(
json.dumps(
{
"source": str(source),
"config": str(config_path),
"variants": variants,
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
log.info("Wrote %d frozen hybrid candidates: %s", len(variants), report_path)
if __name__ == "__main__":
main()
+312
View File
@@ -0,0 +1,312 @@
"""Discover and evaluate an exact-geometry phase-carrier hypothesis.
The model is learned only from supplied images and stored as numeric arrays in
a pickle-free NPZ. It is a research baseline, not a proprietary SynthID
decoder. A valid detector claim still requires provider labels, same-provider
hard negatives, group-aware splits, and a locked operating point.
"""
from __future__ import annotations
import json
import logging
from dataclasses import asdict, dataclass
from pathlib import Path
import click
import numpy as np
from PIL import Image
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class PhaseCarrierModel:
"""Sparse exact-geometry phase carrier learned from positive images."""
height: int
width: int
rows: np.ndarray
columns: np.ndarray
channels: np.ndarray
phases: np.ndarray
weights: np.ndarray
expected_magnitudes: np.ndarray
@dataclass(frozen=True)
class PhaseCarrierScore:
"""Alignment of one exact-geometry image with a phase-carrier model."""
path: str
score: float
active_weight_fraction: float
peak_count: int
def _load_rgb(path: Path, *, height: int, width: int, canonicalize_geometry: bool = False) -> np.ndarray:
"""Load PATH as float64 RGB, optionally resizing to model geometry."""
with Image.open(path) as image:
rgb = image.convert("RGB")
if rgb.size != (width, height):
if not canonicalize_geometry:
raise ValueError(f"{path}: geometry {rgb.width}x{rgb.height} does not match {width}x{height}")
rgb = rgb.resize((width, height), Image.Resampling.LANCZOS)
return np.asarray(rgb, dtype=np.float64)
def _valid_frequency_mask(height: int, width: int, min_radius: float) -> np.ndarray:
"""Return eligible non-DC bins in an rFFT half-plane."""
rows = np.arange(height)
signed_rows = np.where(rows > height // 2, rows - height, rows)
columns = np.arange(width // 2 + 1)
radius = np.sqrt(np.square(signed_rows[:, None]) + np.square(columns[None, :]))
return (radius >= min_radius) & (columns[None, :] > 0)
def _leave_one_out_coherence(unit_sum: np.ndarray, held_out_unit: np.ndarray, count: float) -> np.ndarray:
"""Return phase coherence after removing HELD_OUT_UNIT from UNIT_SUM."""
if count <= 1.0:
raise ValueError("leave-one-out coherence requires at least two samples")
return np.abs((unit_sum - held_out_unit) / (count - 1.0))
def discover_model(
paths: list[Path],
*,
peak_count: int = 256,
min_radius: float = 15.0,
candidate_bins: np.ndarray | None = None,
) -> PhaseCarrierModel:
"""Learn a sparse phase-consensus model from exact-geometry PATHS."""
if len(paths) < 3:
raise ValueError("at least three positive images are required")
with Image.open(paths[0]) as first:
width, height = first.size
if min(height, width) < 64:
raise ValueError("images must be at least 64 pixels per side")
half_width = width // 2 + 1
unit_sum = np.zeros((height, half_width, 3), dtype=np.complex64)
magnitude_sum = np.zeros((height, half_width, 3), dtype=np.float64)
for path in paths:
pixels = _load_rgb(path, height=height, width=width)
for channel in range(3):
spectrum = np.fft.rfft2(pixels[:, :, channel])
magnitude = np.abs(spectrum)
unit_sum[:, :, channel] += np.divide(
spectrum,
magnitude,
out=np.zeros_like(spectrum),
where=magnitude != 0.0,
).astype(np.complex64)
magnitude_sum[:, :, channel] += magnitude
count = float(len(paths))
mean_unit = unit_sum / count
minimum_loo_coherence = np.ones((height, half_width, 3), dtype=np.float32)
for path in paths:
pixels = _load_rgb(path, height=height, width=width)
for channel in range(3):
spectrum = np.fft.rfft2(pixels[:, :, channel])
magnitude = np.abs(spectrum)
unit = np.divide(
spectrum,
magnitude,
out=np.zeros_like(spectrum),
where=magnitude != 0.0,
)
loo_coherence = _leave_one_out_coherence(unit_sum[:, :, channel], unit, count)
np.minimum(minimum_loo_coherence[:, :, channel], loo_coherence, out=minimum_loo_coherence[:, :, channel])
expected_magnitude = magnitude_sum / count
selection = np.power(minimum_loo_coherence.astype(np.float64), 4.0) * np.log1p(expected_magnitude)
selection *= _valid_frequency_mask(height, width, min_radius)[:, :, None]
if candidate_bins is None:
candidate_indices = np.flatnonzero(selection)
else:
bins = np.asarray(candidate_bins, dtype=np.int64)
if bins.ndim != 2 or bins.shape[1] != 3:
raise ValueError("candidate_bins must have shape (count, 3)")
if (
np.any(bins[:, 0] < 0)
or np.any(bins[:, 0] >= height)
or np.any(bins[:, 1] <= 0)
or np.any(bins[:, 1] > width // 2)
or np.any(bins[:, 2] < 0)
or np.any(bins[:, 2] > 2)
):
raise ValueError("candidate_bins contain out-of-range coordinates")
candidate_indices = np.unique(np.ravel_multi_index(bins.T, selection.shape))
candidate_indices = candidate_indices[selection.ravel()[candidate_indices] > 0.0]
candidate_count = len(candidate_indices)
if candidate_count < peak_count:
raise ValueError(f"only {candidate_count} eligible bins for {peak_count} peaks")
flat = selection.ravel()
candidate_scores = flat[candidate_indices]
chosen = np.argpartition(candidate_scores, -peak_count)[-peak_count:]
indices = candidate_indices[chosen]
indices = indices[np.argsort(flat[indices])[::-1]]
rows, columns, channels = np.unravel_index(indices, selection.shape)
raw_weights = selection[rows, columns, channels]
return PhaseCarrierModel(
height=height,
width=width,
rows=rows.astype(np.int32),
columns=columns.astype(np.int32),
channels=channels.astype(np.int8),
phases=np.angle(mean_unit[rows, columns, channels]).astype(np.float64),
weights=(raw_weights / np.sum(raw_weights)).astype(np.float64),
expected_magnitudes=expected_magnitude[rows, columns, channels].astype(np.float64),
)
def score_image(
path: Path,
model: PhaseCarrierModel,
*,
canonicalize_geometry: bool = False,
) -> PhaseCarrierScore:
"""Score PATH against MODEL, with optional geometry canonicalization."""
pixels = _load_rgb(
path,
height=model.height,
width=model.width,
canonicalize_geometry=canonicalize_geometry,
)
values = np.empty(len(model.rows), dtype=np.complex128)
for channel in range(3):
positions = np.flatnonzero(model.channels == channel)
if len(positions) == 0:
continue
spectrum = np.fft.rfft2(pixels[:, :, channel])
values[positions] = spectrum[model.rows[positions], model.columns[positions]]
magnitude_gate = np.minimum(np.abs(values) / (model.expected_magnitudes + 1e-12), 1.0)
active_weights = model.weights * magnitude_gate
active_weight = float(np.sum(active_weights))
score = (
0.0
if active_weight == 0.0
else float(np.sum(active_weights * np.cos(np.angle(values) - model.phases)) / active_weight)
)
return PhaseCarrierScore(
path=str(path),
score=score,
active_weight_fraction=active_weight,
peak_count=len(model.rows),
)
def save_model(path: Path, model: PhaseCarrierModel) -> None:
"""Save MODEL as a validated numeric NPZ artifact."""
path.parent.mkdir(parents=True, exist_ok=True)
np.savez_compressed(
path,
format_version=np.asarray(1, dtype=np.int32),
height=np.asarray(model.height, dtype=np.int32),
width=np.asarray(model.width, dtype=np.int32),
rows=model.rows.astype(np.int32),
columns=model.columns.astype(np.int32),
channels=model.channels.astype(np.int8),
phases=model.phases.astype(np.float32),
weights=model.weights.astype(np.float32),
expected_magnitudes=model.expected_magnitudes.astype(np.float64),
)
def load_model(path: Path) -> PhaseCarrierModel:
"""Load and validate a numeric phase-carrier artifact."""
with np.load(path, allow_pickle=False) as artifact:
if int(artifact["format_version"]) != 1:
raise ValueError("unsupported phase-carrier format version")
model = PhaseCarrierModel(
height=int(artifact["height"]),
width=int(artifact["width"]),
rows=np.asarray(artifact["rows"], dtype=np.int32),
columns=np.asarray(artifact["columns"], dtype=np.int32),
channels=np.asarray(artifact["channels"], dtype=np.int8),
phases=np.asarray(artifact["phases"], dtype=np.float64),
weights=np.asarray(artifact["weights"], dtype=np.float64),
expected_magnitudes=np.asarray(artifact["expected_magnitudes"], dtype=np.float64),
)
count = len(model.rows)
arrays = (model.columns, model.channels, model.phases, model.weights, model.expected_magnitudes)
if model.height < 64 or model.width < 64 or any(array.shape != (count,) for array in arrays):
raise ValueError("invalid phase-carrier model shapes")
if count == 0 or np.any(model.rows < 0) or np.any(model.rows >= model.height):
raise ValueError("invalid phase-carrier row indices")
if np.any(model.columns <= 0) or np.any(model.columns > model.width // 2):
raise ValueError("invalid phase-carrier column indices")
if np.any(model.channels < 0) or np.any(model.channels > 2):
raise ValueError("invalid phase-carrier channel indices")
if not np.isclose(np.sum(model.weights), 1.0, atol=1e-5) or np.any(model.weights < 0.0):
raise ValueError("invalid phase-carrier weights")
return model
@click.group()
def main() -> None:
"""Discover and evaluate an exact-geometry phase carrier."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
@main.command()
@click.argument("positives", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--peak-count", type=click.IntRange(min=1), default=256, show_default=True)
@click.option("--candidate-codebook", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--candidate-count", type=click.IntRange(min=1), default=16384, show_default=True)
@click.option("--model-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
def discover(
positives: tuple[Path, ...],
peak_count: int,
candidate_codebook: Path | None,
candidate_count: int,
model_out: Path,
) -> None:
"""Learn a phase carrier from exact-geometry POSITIVES."""
candidate_bins: np.ndarray | None = None
if candidate_codebook is not None:
from synthid_v3_codebook_probe import load_v3_model
with Image.open(positives[0]) as first:
width, height = first.size
prior = load_v3_model(
candidate_codebook,
height=height,
width=width,
peak_count=candidate_count,
)
candidate_bins = np.column_stack((prior.rows, prior.columns, prior.channels))
model = discover_model(list(positives), peak_count=peak_count, candidate_bins=candidate_bins)
save_model(model_out, model)
log.info("Wrote phase-carrier model: %s", model_out)
@main.command()
@click.argument("model_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--report-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
@click.option("--canonicalize-geometry", is_flag=True, help="Resize inputs to the model geometry before scoring.")
def score(
model_path: Path,
images: tuple[Path, ...],
report_out: Path,
canonicalize_geometry: bool,
) -> None:
"""Score IMAGES with MODEL_PATH."""
model = load_model(model_path)
payload = {
"model": str(model_path),
"height": model.height,
"width": model.width,
"peak_count": len(model.rows),
"canonicalize_geometry": canonicalize_geometry,
"scores": [asdict(score_image(image, model, canonicalize_geometry=canonicalize_geometry)) for image in images],
}
report_out.parent.mkdir(parents=True, exist_ok=True)
report_out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
log.info("Wrote phase-carrier score report: %s", report_out)
if __name__ == "__main__":
main()
+199
View File
@@ -0,0 +1,199 @@
"""Build deterministic, pixel-only SynthID attack candidates and controls.
The generated variants use quantization, resampling, and a smooth sub-pixel
warp. No generative model or image synthesis stage is involved. The command
also emits a norm-matched random-noise control so an oracle change cannot be
attributed to pixel distance alone.
This is a research harness. A candidate is successful only when the matching
provider oracle changes from detected to not detected while the crop-only
control remains detected.
"""
from __future__ import annotations
import json
import logging
import math
from dataclasses import asdict, dataclass
from pathlib import Path
import click
import cv2
import numpy as np
from invisible_quality_audit import _ssim
from PIL import Image
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class FidelityMeasurement:
"""Paired pixel metrics for one attack candidate."""
name: str
path: str
width: int
height: int
psnr_db: float
ssim: float
changed_pixel_fraction: float
residual_rms: float
residual_max: float
def load_rgb(path: Path) -> np.ndarray:
"""Load PATH as uint8 RGB pixels."""
with Image.open(path) as image:
return np.asarray(image.convert("RGB"), dtype=np.uint8)
def crop_visible_badge(pixels: np.ndarray, margin: int) -> np.ndarray:
"""Remove the bottom and right margins that contain the visible badge."""
height, width = pixels.shape[:2]
if margin < 0 or margin >= min(height, width):
raise ValueError("crop margin must be nonnegative and smaller than the image")
if margin == 0:
return pixels.copy()
return pixels[: height - margin, : width - margin].copy()
def quantize(pixels: np.ndarray, step: int) -> np.ndarray:
"""Round RGB samples to the nearest multiple of STEP."""
if step < 2 or step > 64:
raise ValueError("quantization step must be between 2 and 64")
values = np.rint(pixels.astype(np.float64) / step) * step
return np.clip(values, 0, 255).astype(np.uint8)
def smooth_warp(pixels: np.ndarray, *, amplitude: float, sigma: float, seed: int) -> np.ndarray:
"""Apply a deterministic smooth sub-pixel displacement field."""
if amplitude < 0.0 or sigma <= 0.0:
raise ValueError("warp amplitude must be nonnegative and sigma positive")
height, width = pixels.shape[:2]
rng = np.random.default_rng(seed)
fields: list[np.ndarray] = []
for _ in range(2):
noise = rng.normal(size=(height, width)).astype(np.float32)
field = cv2.GaussianBlur(noise, (0, 0), sigmaX=sigma, sigmaY=sigma)
field_std = float(np.std(field))
fields.append(np.zeros_like(field) if field_std == 0.0 else field * (amplitude / field_std))
yy, xx = np.mgrid[:height, :width].astype(np.float32)
return cv2.remap(
pixels,
xx + fields[0],
yy + fields[1],
interpolation=cv2.INTER_LANCZOS4,
borderMode=cv2.BORDER_REFLECT_101,
)
def resize_squeeze(pixels: np.ndarray, factor: float) -> np.ndarray:
"""Downsample and restore the original geometry without synthesis."""
if not 0.5 <= factor < 1.0:
raise ValueError("resize factor must be in [0.5, 1.0)")
height, width = pixels.shape[:2]
reduced = cv2.resize(
pixels,
(max(1, round(width * factor)), max(1, round(height * factor))),
interpolation=cv2.INTER_AREA,
)
return cv2.resize(reduced, (width, height), interpolation=cv2.INTER_LANCZOS4)
def jpeg_round_trip(pixels: np.ndarray, quality: int) -> np.ndarray:
"""Apply one in-memory JPEG encode/decode while returning RGB pixels."""
if quality < 1 or quality > 100:
raise ValueError("JPEG quality must be between 1 and 100")
success, encoded = cv2.imencode(
".jpg",
cv2.cvtColor(pixels, cv2.COLOR_RGB2BGR),
[cv2.IMWRITE_JPEG_QUALITY, quality],
)
if not success:
raise RuntimeError("JPEG encoding failed")
decoded = cv2.imdecode(encoded, cv2.IMREAD_COLOR)
if decoded is None:
raise RuntimeError("JPEG decoding failed")
return cv2.cvtColor(decoded, cv2.COLOR_BGR2RGB)
def norm_matched_noise(reference: np.ndarray, target: np.ndarray, *, seed: int) -> np.ndarray:
"""Return random RGB noise with approximately TARGET's residual RMS."""
target_residual = target.astype(np.float64) - reference.astype(np.float64)
target_rms = float(np.sqrt(np.mean(np.square(target_residual))))
rng = np.random.default_rng(seed)
noise = rng.normal(size=reference.shape)
noise *= target_rms / (float(np.sqrt(np.mean(np.square(noise)))) + 1e-12)
return np.clip(np.rint(reference.astype(np.float64) + noise), 0, 255).astype(np.uint8)
def measure(reference: np.ndarray, candidate: np.ndarray, *, name: str, path: Path) -> FidelityMeasurement:
"""Measure paired fidelity between equal-shaped RGB arrays."""
if reference.shape != candidate.shape:
raise ValueError("reference and candidate shapes differ")
residual = candidate.astype(np.float64) - reference.astype(np.float64)
mse = float(np.mean(np.square(residual)))
psnr = math.inf if mse == 0.0 else 20.0 * math.log10(255.0 / math.sqrt(mse))
reference_gray = cv2.cvtColor(reference, cv2.COLOR_RGB2GRAY)
candidate_gray = cv2.cvtColor(candidate, cv2.COLOR_RGB2GRAY)
return FidelityMeasurement(
name=name,
path=str(path),
width=int(reference.shape[1]),
height=int(reference.shape[0]),
psnr_db=psnr,
ssim=float(_ssim(reference_gray, candidate_gray)),
changed_pixel_fraction=float(np.mean(np.any(residual != 0.0, axis=2))),
residual_rms=float(math.sqrt(mse)),
residual_max=float(np.max(np.abs(residual))),
)
def build_candidates(source: np.ndarray) -> dict[str, np.ndarray]:
"""Build the preregistered attack batch from cropped SOURCE pixels."""
candidates: dict[str, np.ndarray] = {
"control-crop": source.copy(),
"quantize-2": quantize(source, 2),
"quantize-4": quantize(source, 4),
"quantize-8": quantize(source, 8),
"warp-035": smooth_warp(source, amplitude=0.35, sigma=48.0, seed=20260809),
}
combo = smooth_warp(source, amplitude=0.55, sigma=48.0, seed=20260810)
combo = resize_squeeze(combo, 0.96)
combo = quantize(combo, 4)
combo = jpeg_round_trip(combo, 96)
candidates["combo-mild"] = combo
candidates["sham-combo-rms"] = norm_matched_noise(source, combo, seed=20260811)
return candidates
@click.command()
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("output_dir", type=click.Path(file_okay=False, path_type=Path))
@click.option("--crop-margin", type=click.IntRange(min=0), default=160, show_default=True)
def main(source: Path, output_dir: Path, crop_margin: int) -> None:
"""Write a frozen pixel-only attack batch for SOURCE into OUTPUT_DIR."""
reference = crop_visible_badge(load_rgb(source), crop_margin)
output_dir.mkdir(parents=True, exist_ok=True)
measurements: list[FidelityMeasurement] = []
for name, pixels in build_candidates(reference).items():
path = output_dir / f"{name}.png"
Image.fromarray(pixels, mode="RGB").save(path)
measurements.append(measure(reference, pixels, name=name, path=path))
report_path = output_dir / "fidelity.json"
payload = {
"source": str(source),
"crop_margin": crop_margin,
"variants": [asdict(row) for row in measurements],
}
report_path.write_text(
json.dumps(payload, indent=2) + "\n",
encoding="utf-8",
)
log.info("Wrote %d candidates and fidelity report: %s", len(measurements), report_path)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(message)s")
main()
+170
View File
@@ -0,0 +1,170 @@
"""Inventory local SynthID research images without assigning evidence labels.
The inventory is deliberately weaker than the research manifest. It records
artifact hashes, decoded RGB hashes, geometry, format, and exact duplicates,
but contains no provider, SynthID outcome, oracle, or split fields. Promotion
from inventory to manifest therefore remains an explicit evidence decision.
Usage:
uv run python scripts/synthid_research_inventory.py \
--root .local-eval/synthid negatives google openai \
--inventory-out .local-eval/synthid/inventory.csv
"""
from __future__ import annotations
import csv
import logging
from collections import Counter
from dataclasses import asdict, dataclass
from pathlib import Path
import click
from synthid_research_manifest import artifact_sha256, decoded_image_fingerprint
log = logging.getLogger(__name__)
IMAGE_SUFFIXES = {".jpeg", ".jpg", ".png", ".webp"}
FIELDNAMES = (
"artifact_sha256",
"pixel_sha256",
"artifact_path",
"width",
"height",
"format",
"exact_pixel_group",
"artifact_duplicate_of",
"pixel_duplicate_of",
)
@dataclass(frozen=True)
class InventoryRow:
"""One decoded local image with exact-duplicate provenance."""
artifact_sha256: str
pixel_sha256: str
artifact_path: str
width: int
height: int
format: str
exact_pixel_group: str
artifact_duplicate_of: str
pixel_duplicate_of: str
def _inside_root(root: Path, path: Path) -> Path:
"""Resolve PATH and reject anything outside ROOT."""
resolved_root = root.resolve()
resolved = path.resolve()
try:
resolved.relative_to(resolved_root)
except ValueError as exc:
raise ValueError(f"source is outside inventory root: {path}") from exc
return resolved
def discover_images(root: Path, sources: tuple[Path, ...]) -> list[Path]:
"""Return supported images below explicit in-root sources in stable order."""
resolved_root = root.resolve()
discovered: set[Path] = set()
for source in sources:
candidate = source if source.is_absolute() else resolved_root / source
candidate = _inside_root(resolved_root, candidate)
if not candidate.exists():
raise ValueError(f"inventory source does not exist: {source}")
if candidate.is_file():
if candidate.suffix.lower() in IMAGE_SUFFIXES:
discovered.add(candidate)
continue
for path in candidate.rglob("*"):
if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES:
discovered.add(_inside_root(resolved_root, path))
return sorted(discovered, key=lambda path: path.relative_to(resolved_root).as_posix())
def build_inventory(root: Path, sources: tuple[Path, ...]) -> list[InventoryRow]:
"""Hash and decode all selected images without inferring any labels."""
resolved_root = root.resolve()
first_artifact: dict[str, str] = {}
first_pixels: dict[str, str] = {}
rows: list[InventoryRow] = []
for path in discover_images(resolved_root, sources):
relative = path.relative_to(resolved_root).as_posix()
artifact_digest = artifact_sha256(path)
pixel_digest, width, height, image_format = decoded_image_fingerprint(path)
rows.append(
InventoryRow(
artifact_sha256=artifact_digest,
pixel_sha256=pixel_digest,
artifact_path=relative,
width=width,
height=height,
format=image_format,
exact_pixel_group=f"pixel-{pixel_digest[:16]}",
artifact_duplicate_of=first_artifact.get(artifact_digest, ""),
pixel_duplicate_of=first_pixels.get(pixel_digest, ""),
)
)
first_artifact.setdefault(artifact_digest, relative)
first_pixels.setdefault(pixel_digest, relative)
return rows
def write_inventory(path: Path, rows: list[InventoryRow], *, replace: bool = False) -> None:
"""Write a complete inventory atomically enough to avoid partial decode results."""
if path.exists() and not replace:
raise FileExistsError(f"inventory already exists: {path}; pass --replace to overwrite it")
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.tmp")
try:
with temporary.open("w", newline="", encoding="utf-8") as stream:
writer = csv.DictWriter(stream, fieldnames=FIELDNAMES)
writer.writeheader()
writer.writerows(asdict(row) for row in rows)
temporary.replace(path)
finally:
if temporary.exists():
temporary.unlink()
def inventory_summary(rows: list[InventoryRow]) -> dict[str, object]:
"""Return aggregate coverage without exposing media paths."""
formats = Counter(row.format for row in rows)
geometries = Counter(f"{row.width}x{row.height}" for row in rows)
return {
"images": len(rows),
"unique_artifacts": len({row.artifact_sha256 for row in rows}),
"unique_pixels": len({row.pixel_sha256 for row in rows}),
"formats": dict(sorted(formats.items())),
"geometries": dict(sorted(geometries.items())),
}
@click.command()
@click.option("--root", required=True, type=click.Path(exists=True, file_okay=False, path_type=Path))
@click.argument("sources", nargs=-1, required=True, type=click.Path(path_type=Path))
@click.option("--inventory-out", required=True, type=click.Path(dir_okay=False, path_type=Path))
@click.option("--replace", is_flag=True, help="Replace an existing generated inventory.")
def main(root: Path, sources: tuple[Path, ...], inventory_out: Path, replace: bool) -> None:
"""Inventory image SOURCES below ROOT without assigning SynthID labels."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
try:
rows = build_inventory(root, sources)
if not rows:
raise ValueError("selected sources contain no supported images")
write_inventory(inventory_out, rows, replace=replace)
except (OSError, ValueError) as exc:
raise click.ClickException(str(exc)) from exc
summary = inventory_summary(rows)
log.info(
"Wrote inventory: %s images=%s unique_artifacts=%s unique_pixels=%s",
inventory_out,
summary["images"],
summary["unique_artifacts"],
summary["unique_pixels"],
)
if __name__ == "__main__":
main()
+347
View File
@@ -0,0 +1,347 @@
"""Audit a private SynthID research manifest before training or evaluation.
The research manifest is intentionally separate from ``data/synthid/manifest.csv``.
The latter records a small public regression corpus, while this schema tracks
private experiment lineage, provider-specific oracle evidence, and split groups.
Usage:
uv run python scripts/synthid_research_manifest.py MANIFEST.csv
uv run python scripts/synthid_research_manifest.py MANIFEST.csv --verify-files
"""
from __future__ import annotations
import csv
import hashlib
import logging
import re
from collections import defaultdict
from datetime import datetime
from pathlib import Path
import click
from PIL import Image
log = logging.getLogger(__name__)
FIELDNAMES = (
"artifact_sha256",
"pixel_sha256",
"artifact_path",
"parent_sha256",
"group_id",
"target_provider",
"source_provider",
"surface",
"model_epoch",
"generation_session",
"content_stratum",
"width",
"height",
"format",
"transform",
"split",
"c2pa_outcome",
"synthid_outcome",
"verified_via",
"evidence_reference",
"oracle_session",
"oracle_role",
"captured_at",
"oracle_checked_at",
"notes",
)
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
_TARGET_PROVIDERS = {"openai", "google"}
_SOURCE_PROVIDERS = {"openai", "google", "camera", "other_ai", "synthetic", "editor"}
_SPLITS = {"discovery", "train", "validation", "test", "temporal"}
_C2PA_OUTCOMES = {"detected", "not_detected", "invalid", "not_present", "not_checked"}
_SYNTHID_OUTCOMES = {"detected", "not_detected", "indeterminate", "refused", "not_checked"}
_VERIFIERS = {"openai-api", "openai-web", "gemini-app", "synthid-portal", "source-evidence", "none"}
_FORMATS = {"png", "jpeg", "webp"}
_ORACLE_ROLES = {"ordinary", "source_control", "candidate", "sham"}
_FINAL_SPLITS = {"train", "validation", "test", "temporal"}
_MATCHING_VERIFIERS = {
"openai": {"openai-api", "openai-web"},
"google": {"gemini-app", "synthid-portal"},
}
def _read_rows(path: Path) -> tuple[list[dict[str, str]], list[str]]:
"""Read a CSV and return rows plus header errors."""
with path.open(newline="", encoding="utf-8") as stream:
reader = csv.DictReader(stream)
actual = tuple(reader.fieldnames or ())
missing = [field for field in FIELDNAMES if field not in actual]
errors = [f"header: missing required field {field!r}" for field in missing]
return list(reader), errors
def _is_iso8601(value: str) -> bool:
"""Return whether VALUE is a timezone-aware ISO-8601 timestamp."""
if not value:
return False
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return False
return parsed.tzinfo is not None
def _file_sha256(path: Path) -> str:
"""Hash a file without loading it entirely into memory."""
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def decoded_image_fingerprint(path: Path) -> tuple[str, int, int, str]:
"""Return the decoded-RGB digest, geometry, and source format."""
with Image.open(path) as image:
image_format = (image.format or path.suffix.lstrip(".")).lower()
rgb = image.convert("RGB")
digest = hashlib.sha256(rgb.tobytes()).hexdigest()
return digest, rgb.width, rgb.height, "jpeg" if image_format == "jpg" else image_format
def _pixel_sha256(path: Path) -> tuple[str, int, int]:
"""Hash canonical decoded RGB pixels and return hash, width, and height."""
digest, width, height, _ = decoded_image_fingerprint(path)
return digest, width, height
def artifact_sha256(path: Path) -> str:
"""Return the artifact digest used by research manifests and inventories."""
return _file_sha256(path)
def pixel_fingerprint(path: Path) -> tuple[str, int, int]:
"""Return the decoded-RGB digest and geometry used by manifest verification."""
return _pixel_sha256(path)
def resolve_artifact_path(root: Path, value: str) -> Path | None:
"""Resolve a manifest-relative artifact path without allowing traversal."""
relative = Path(value)
if not value or relative.is_absolute() or ".." in relative.parts:
return None
candidate = (root / relative).resolve()
try:
candidate.relative_to(root.resolve())
except ValueError:
return None
return candidate
def _row_errors(row: dict[str, str], index: int) -> list[str]:
"""Validate one row without consulting other rows."""
prefix = f"row {index}"
errors: list[str] = []
artifact_sha = row.get("artifact_sha256", "")
pixel_sha = row.get("pixel_sha256", "")
parent_sha = row.get("parent_sha256", "")
if not _SHA256.fullmatch(artifact_sha):
errors.append(f"{prefix}: invalid artifact_sha256")
if not _SHA256.fullmatch(pixel_sha):
errors.append(f"{prefix}: invalid pixel_sha256")
if parent_sha and not _SHA256.fullmatch(parent_sha):
errors.append(f"{prefix}: invalid parent_sha256")
for field in ("group_id", "surface", "model_epoch", "generation_session", "content_stratum", "transform"):
if not row.get(field, "").strip():
errors.append(f"{prefix}: {field} must not be empty")
target = row.get("target_provider", "")
source = row.get("source_provider", "")
split = row.get("split", "")
c2pa = row.get("c2pa_outcome", "")
synthid = row.get("synthid_outcome", "")
verifier = row.get("verified_via", "")
oracle_role = row.get("oracle_role", "")
image_format = row.get("format", "").lower()
if target not in _TARGET_PROVIDERS:
errors.append(f"{prefix}: unsupported target_provider {target!r}")
if source not in _SOURCE_PROVIDERS:
errors.append(f"{prefix}: unsupported source_provider {source!r}")
if split not in _SPLITS:
errors.append(f"{prefix}: unsupported split {split!r}")
if c2pa not in _C2PA_OUTCOMES:
errors.append(f"{prefix}: unsupported c2pa_outcome {c2pa!r}")
if synthid not in _SYNTHID_OUTCOMES:
errors.append(f"{prefix}: unsupported synthid_outcome {synthid!r}")
if verifier not in _VERIFIERS:
errors.append(f"{prefix}: unsupported verified_via {verifier!r}")
if image_format not in _FORMATS:
errors.append(f"{prefix}: unsupported format {image_format!r}")
if oracle_role not in _ORACLE_ROLES:
errors.append(f"{prefix}: unsupported oracle_role {oracle_role!r}")
for dimension in ("width", "height"):
try:
if int(row.get(dimension, "")) <= 0:
raise ValueError
except ValueError:
errors.append(f"{prefix}: {dimension} must be a positive integer")
if not _is_iso8601(row.get("captured_at", "")):
errors.append(f"{prefix}: captured_at must be timezone-aware ISO-8601")
final_outcome = synthid in {"detected", "not_detected"}
if split in _FINAL_SPLITS and not final_outcome:
errors.append(f"{prefix}: split {split!r} requires a detected or not_detected SynthID outcome")
if final_outcome and not _is_iso8601(row.get("oracle_checked_at", "")):
errors.append(f"{prefix}: a final SynthID outcome requires oracle_checked_at")
matching = _MATCHING_VERIFIERS.get(target, set())
if synthid == "detected" and verifier not in matching:
errors.append(f"{prefix}: a detected {target!r} signal requires a matching provider verifier")
if synthid == "not_detected" and source == target and verifier not in matching:
errors.append(f"{prefix}: a same-provider negative requires a matching provider verifier")
if verifier in {"source-evidence", "none"} and synthid == "detected":
errors.append(f"{prefix}: {verifier!r} cannot establish a positive SynthID label")
if verifier == "source-evidence" and source == target:
errors.append(f"{prefix}: source-evidence cannot establish a same-provider negative")
if verifier == "source-evidence" and not row.get("evidence_reference", "").strip():
errors.append(f"{prefix}: source-evidence requires evidence_reference")
if verifier in matching and not row.get("oracle_session", "").strip():
errors.append(f"{prefix}: provider verification requires oracle_session")
if oracle_role == "source_control" and synthid != "detected":
errors.append(f"{prefix}: a source_control must have a detected SynthID outcome")
transform = row.get("transform", "")
if transform == "original" and parent_sha:
errors.append(f"{prefix}: an original must not have parent_sha256")
if transform != "original" and not parent_sha:
errors.append(f"{prefix}: a derivative requires parent_sha256")
return errors
def _lineage_errors(rows: list[dict[str, str]]) -> list[str]:
"""Validate uniqueness, parent links, group splits, and lineage cycles."""
errors: list[str] = []
by_sha: dict[str, dict[str, str]] = {}
group_splits: defaultdict[str, set[str]] = defaultdict(set)
pixel_groups: defaultdict[str, set[str]] = defaultdict(set)
for index, row in enumerate(rows, start=2):
artifact_sha = row.get("artifact_sha256", "")
if artifact_sha in by_sha:
errors.append(f"row {index}: duplicate artifact_sha256 {artifact_sha}")
else:
by_sha[artifact_sha] = row
group_splits[row.get("group_id", "")].add(row.get("split", ""))
pixel_groups[row.get("pixel_sha256", "")].add(row.get("group_id", ""))
for index, row in enumerate(rows, start=2):
parent_sha = row.get("parent_sha256", "")
if not parent_sha:
continue
parent = by_sha.get(parent_sha)
if parent is None:
errors.append(f"row {index}: parent_sha256 is not present in the manifest")
continue
if parent.get("group_id") != row.get("group_id"):
errors.append(f"row {index}: derivative and parent must share group_id")
if parent.get("target_provider") != row.get("target_provider"):
errors.append(f"row {index}: derivative and parent must share target_provider")
for group_id, splits in sorted(group_splits.items()):
if group_id and len(splits) > 1:
errors.append(f"group {group_id!r}: leaks across splits {sorted(splits)}")
for pixel_sha, groups in sorted(pixel_groups.items()):
if pixel_sha and len(groups) > 1:
errors.append(f"pixel_sha256 {pixel_sha}: appears in multiple groups {sorted(groups)}")
for artifact_sha in by_sha:
seen: set[str] = set()
current_sha = artifact_sha
while current_sha:
if current_sha in seen:
errors.append(f"artifact_sha256 {artifact_sha}: lineage cycle detected")
break
seen.add(current_sha)
current = by_sha.get(current_sha)
if current is None:
break
current_sha = current.get("parent_sha256", "")
return errors
def _oracle_session_errors(rows: list[dict[str, str]]) -> list[str]:
"""Require a healthy source control before accepting removal outcomes."""
errors: list[str] = []
positive_controls = {
(row.get("oracle_session", ""), row.get("group_id", ""), row.get("target_provider", ""))
for row in rows
if row.get("oracle_role") == "source_control" and row.get("synthid_outcome") == "detected"
}
for index, row in enumerate(rows, start=2):
if row.get("oracle_role") not in {"candidate", "sham"} or row.get("synthid_outcome") != "not_detected":
continue
key = (row.get("oracle_session", ""), row.get("group_id", ""), row.get("target_provider", ""))
if key not in positive_controls:
errors.append(
f"row {index}: a not_detected {row.get('oracle_role')} requires a detected "
"source_control in the same oracle session, group, and provider"
)
return errors
def audit_manifest(path: Path, *, verify_files: bool = False) -> list[str]:
"""Return all manifest errors, including optional on-disk hash checks."""
rows, errors = _read_rows(path)
if errors:
return errors
for index, row in enumerate(rows, start=2):
errors.extend(_row_errors(row, index))
errors.extend(_lineage_errors(rows))
errors.extend(_oracle_session_errors(rows))
if verify_files:
root = path.parent
for index, row in enumerate(rows, start=2):
artifact = resolve_artifact_path(root, row.get("artifact_path", ""))
if artifact is None:
errors.append(f"row {index}: artifact_path must be a safe manifest-relative path")
continue
if not artifact.is_file():
errors.append(f"row {index}: artifact_path does not exist: {row.get('artifact_path', '')}")
continue
if _file_sha256(artifact) != row.get("artifact_sha256"):
errors.append(f"row {index}: artifact_sha256 does not match the file")
try:
pixel_sha, width, height = _pixel_sha256(artifact)
except Exception as exc: # Pillow intentionally accepts many user-controlled formats.
errors.append(f"row {index}: could not decode artifact: {exc}")
continue
if pixel_sha != row.get("pixel_sha256"):
errors.append(f"row {index}: pixel_sha256 does not match decoded RGB pixels")
if str(width) != row.get("width") or str(height) != row.get("height"):
errors.append(f"row {index}: dimensions do not match decoded pixels")
return errors
@click.command()
@click.argument("manifest", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--verify-files", is_flag=True, help="Verify artifact bytes, decoded pixels, and dimensions.")
def main(manifest: Path, verify_files: bool) -> None:
"""Audit MANIFEST for evidence, lineage, and split integrity."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
errors = audit_manifest(manifest, verify_files=verify_files)
if errors:
for error in errors:
log.error("ERROR: %s", error)
raise click.ClickException(f"manifest audit failed with {len(errors)} error(s)")
log.info("Manifest audit passed: %s", manifest)
if __name__ == "__main__":
main()
+312
View File
@@ -0,0 +1,312 @@
"""Discover and score a shared spectral carrier from exact image pairs.
This is a research harness, not a production SynthID detector. Pair provenance
and oracle labels remain external evidence. The harness deliberately separates
template discovery from single-image scoring and stores arrays in NPZ without
pickle.
Usage:
uv run python scripts/synthid_spectral_probe.py discover \
--pair clean.png marked.png --pair clean2.png marked2.png \
--template-out .local-eval/synthid/template.npz \
--report-out .local-eval/synthid/pair-report.json
uv run python scripts/synthid_spectral_probe.py score \
.local-eval/synthid/template.npz image.png other.png
"""
from __future__ import annotations
import json
import logging
from dataclasses import asdict, dataclass
from itertools import combinations
from pathlib import Path
import click
import numpy as np
from PIL import Image, ImageFilter
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class PairMeasurement:
"""Pixel-domain measurements for one exact clean/marked pair."""
clean: str
marked: str
width: int
height: int
psnr_db: float
changed_pixel_fraction: float
difference_min: float
difference_max: float
channel_mean: tuple[float, float, float]
channel_std: tuple[float, float, float]
@dataclass(frozen=True)
class ImageScore:
"""Single-image phase alignment against a discovered template."""
path: str
phase_mean: float
phase_weighted: float
channel_phase_weighted: tuple[float, float, float]
top_two_channel_phase_12: float
peak_count: int
def load_rgb(path: Path) -> np.ndarray:
"""Load PATH as float64 RGB pixels."""
with Image.open(path) as image:
return np.asarray(image.convert("RGB"), dtype=np.float64)
def _resize_float(channel: np.ndarray, size: int) -> np.ndarray:
"""Resize one floating-point channel without quantizing the residual."""
image = Image.fromarray(channel.astype(np.float32), mode="F")
return np.asarray(image.resize((size, size), Image.Resampling.BILINEAR), dtype=np.float64)
def pair_residual(clean: Path, marked: Path, *, size: int = 512) -> tuple[np.ndarray, PairMeasurement]:
"""Return a canonical RGB residual and measurements for an exact pair."""
clean_rgb = load_rgb(clean)
marked_rgb = load_rgb(marked)
if clean_rgb.shape != marked_rgb.shape:
raise ValueError(f"pair shapes differ: {clean_rgb.shape} != {marked_rgb.shape}")
difference = marked_rgb - clean_rgb
mse = float(np.mean(np.square(difference)))
psnr = float("inf") if mse == 0.0 else float(20.0 * np.log10(255.0 / np.sqrt(mse)))
residual = np.stack([_resize_float(difference[:, :, channel], size) for channel in range(3)], axis=2)
measurement = PairMeasurement(
clean=str(clean),
marked=str(marked),
width=int(clean_rgb.shape[1]),
height=int(clean_rgb.shape[0]),
psnr_db=psnr,
changed_pixel_fraction=float(np.mean(np.any(difference != 0.0, axis=2))),
difference_min=float(np.min(difference)),
difference_max=float(np.max(difference)),
channel_mean=tuple(float(value) for value in np.mean(difference, axis=(0, 1))),
channel_std=tuple(float(value) for value in np.std(difference, axis=(0, 1))),
)
return residual, measurement
def normalized_channels(residual: np.ndarray) -> np.ndarray:
"""Zero-center and unit-normalize each residual channel."""
centered = residual - np.mean(residual, axis=(0, 1), keepdims=True)
norms = np.linalg.norm(centered, axis=(0, 1), keepdims=True)
return np.divide(centered, norms, out=np.zeros_like(centered), where=norms != 0.0)
def channel_ncc(first: np.ndarray, second: np.ndarray) -> tuple[float, float, float]:
"""Return per-channel normalized cross-correlation."""
first_norm = normalized_channels(first)
second_norm = normalized_channels(second)
values = np.sum(first_norm * second_norm, axis=(0, 1))
return tuple(float(value) for value in values)
def build_template(residuals: list[np.ndarray]) -> np.ndarray:
"""Average canonical residuals after per-channel normalization."""
if not residuals:
raise ValueError("at least one residual is required")
shape = residuals[0].shape
if any(residual.shape != shape for residual in residuals):
raise ValueError("all canonical residuals must have the same shape")
return np.mean([normalized_channels(residual) for residual in residuals], axis=0)
def _template_fft(template: np.ndarray) -> np.ndarray:
"""Return a centered two-dimensional FFT for each RGB channel."""
return np.fft.fftshift(np.fft.fft2(template, axes=(0, 1)), axes=(0, 1))
def select_peaks(
template: np.ndarray,
*,
count: int = 64,
min_radius: float = 8.0,
max_radius_fraction: float = 0.35,
min_distance: float = 3.0,
) -> np.ndarray:
"""Select separated high-energy carrier bins from one Fourier half-plane."""
if count <= 0:
raise ValueError("count must be positive")
height, width, channels = template.shape
if height != width or channels != 3:
raise ValueError("template must be a square RGB array")
center = height // 2
spectrum = _template_fft(template)
magnitude = np.linalg.norm(spectrum, axis=2)
yy, xx = np.ogrid[:height, :width]
radius = np.sqrt(np.square(yy - center) + np.square(xx - center))
valid = (radius >= min_radius) & (radius <= height * max_radius_fraction)
candidates = np.flatnonzero(valid)
order = candidates[np.argsort(magnitude.ravel()[candidates])[::-1]]
selected: list[tuple[int, int]] = []
for flat_index in order:
row, column = np.unravel_index(flat_index, magnitude.shape)
dy, dx = int(row - center), int(column - center)
if dy < 0 or (dy == 0 and dx < 0):
continue
if any((dy - old_dy) ** 2 + (dx - old_dx) ** 2 < min_distance**2 for old_dy, old_dx in selected):
continue
selected.append((dy, dx))
if len(selected) == count:
break
if len(selected) != count:
raise ValueError(f"could select only {len(selected)} of {count} peaks")
return np.asarray(selected, dtype=np.int32)
def _high_pass_rgb(path: Path, size: int, blur_radius: float) -> np.ndarray:
"""Decode, resize, and subtract a small Gaussian blur from RGB pixels."""
with Image.open(path) as source:
image = source.convert("RGB").resize((size, size), Image.Resampling.LANCZOS)
pixels = np.asarray(image, dtype=np.float64)
blurred = np.asarray(image.filter(ImageFilter.GaussianBlur(radius=blur_radius)), dtype=np.float64)
return pixels - blurred
def score_image(path: Path, template: np.ndarray, peaks: np.ndarray, *, blur_radius: float = 2.0) -> ImageScore:
"""Score one image by phase alignment at discovered carrier bins."""
size = int(template.shape[0])
image_fft = np.fft.fftshift(np.fft.fft2(_high_pass_rgb(path, size, blur_radius), axes=(0, 1)), axes=(0, 1))
template_fft = _template_fft(template)
center = size // 2
phase_values: list[np.ndarray] = []
weights: list[np.ndarray] = []
for dy, dx in peaks:
image_value = image_fft[center + int(dy), center + int(dx)]
template_value = template_fft[center + int(dy), center + int(dx)]
phase = np.real(image_value * np.conj(template_value)) / (np.abs(image_value) * np.abs(template_value) + 1e-12)
phase_values.append(phase)
weights.append(np.abs(template_value))
phases = np.asarray(phase_values)
carrier_weights = np.asarray(weights)
channel_weighted = np.sum(phases * carrier_weights, axis=0) / np.sum(carrier_weights, axis=0)
consensus_count = min(12, len(peaks))
consensus_channels = np.sum(phases[:consensus_count] * carrier_weights[:consensus_count], axis=0) / np.sum(
carrier_weights[:consensus_count], axis=0
)
top_two_channel_phase_12 = float(np.mean(np.sort(consensus_channels)[-2:]))
return ImageScore(
path=str(path),
phase_mean=float(np.mean(phases)),
phase_weighted=float(np.sum(phases * carrier_weights) / np.sum(carrier_weights)),
channel_phase_weighted=tuple(float(value) for value in channel_weighted),
top_two_channel_phase_12=top_two_channel_phase_12,
peak_count=len(peaks),
)
def save_template(path: Path, template: np.ndarray, peaks: np.ndarray) -> None:
"""Store a template in a pickle-free compressed NPZ artifact."""
path.parent.mkdir(parents=True, exist_ok=True)
np.savez_compressed(path, template=template.astype(np.float32), peaks=peaks.astype(np.int32))
def load_template(path: Path) -> tuple[np.ndarray, np.ndarray]:
"""Load a template artifact without enabling pickle."""
with np.load(path, allow_pickle=False) as artifact:
template = np.asarray(artifact["template"], dtype=np.float64)
peaks = np.asarray(artifact["peaks"], dtype=np.int32)
if template.ndim != 3 or template.shape[2] != 3 or template.shape[0] != template.shape[1]:
raise ValueError("invalid template shape")
if peaks.ndim != 2 or peaks.shape[1] != 2:
raise ValueError("invalid peak shape")
return template, peaks
def discovery_report(
residuals: list[np.ndarray], measurements: list[PairMeasurement], peaks: np.ndarray
) -> dict[str, object]:
"""Build a JSON-safe report with pair statistics and cross-pair NCC."""
pairwise = [
{
"first": measurements[first].marked,
"second": measurements[second].marked,
"channel_ncc": channel_ncc(residuals[first], residuals[second]),
}
for first, second in combinations(range(len(residuals)), 2)
]
return {
"pair_count": len(measurements),
"pairs": [asdict(measurement) for measurement in measurements],
"pairwise": pairwise,
"peaks": peaks.tolist(),
}
@click.group()
def main() -> None:
"""Discover and score an experimental shared spectral carrier."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
@main.command()
@click.option(
"--pair",
"pairs",
type=(
click.Path(exists=True, dir_okay=False, path_type=Path),
click.Path(exists=True, dir_okay=False, path_type=Path),
),
multiple=True,
required=True,
help="Exact CLEAN MARKED pair; repeat for multiple pairs.",
)
@click.option("--size", type=click.IntRange(min=64), default=512, show_default=True)
@click.option("--peak-count", type=click.IntRange(min=1), default=64, show_default=True)
@click.option("--template-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
@click.option("--report-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
def discover(
pairs: tuple[tuple[Path, Path], ...],
size: int,
peak_count: int,
template_out: Path,
report_out: Path,
) -> None:
"""Build a template and report from exact CLEAN MARKED pairs."""
residuals: list[np.ndarray] = []
measurements: list[PairMeasurement] = []
for clean, marked in pairs:
residual, measurement = pair_residual(clean, marked, size=size)
residuals.append(residual)
measurements.append(measurement)
template = build_template(residuals)
peaks = select_peaks(template, count=peak_count)
save_template(template_out, template, peaks)
report = discovery_report(residuals, measurements, peaks)
report_out.parent.mkdir(parents=True, exist_ok=True)
report_out.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
log.info("Wrote template: %s", template_out)
log.info("Wrote report: %s", report_out)
@main.command()
@click.argument("template_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--report-out", type=click.Path(dir_okay=False, path_type=Path))
def score(template_path: Path, images: tuple[Path, ...], report_out: Path | None) -> None:
"""Score IMAGES against TEMPLATE_PATH."""
template, peaks = load_template(template_path)
scores = [asdict(score_image(image, template, peaks)) for image in images]
payload = {"template": str(template_path), "scores": scores}
rendered = json.dumps(payload, indent=2) + "\n"
if report_out is None:
log.info("%s", rendered.rstrip())
return
report_out.parent.mkdir(parents=True, exist_ok=True)
report_out.write_text(rendered, encoding="utf-8")
log.info("Wrote score report: %s", report_out)
if __name__ == "__main__":
main()
+166
View File
@@ -0,0 +1,166 @@
"""Estimate and subtract a periodic SynthID residual tile without regeneration.
At 1536x2816, the dominant carrier bins lie on an FFT lattice spaced by 96
rows and 88 columns, corresponding to a 16x32 spatial tile. Folding a
high-pass residual modulo that tile averages over 8448 repetitions and
suppresses non-periodic image content.
"""
from __future__ import annotations
import json
import logging
from dataclasses import asdict
from pathlib import Path
import click
import cv2
import numpy as np
from PIL import Image
from synthid_ensemble_detector import detect_image, load_config, load_models
from synthid_pixel_attack import load_rgb, measure
log = logging.getLogger(__name__)
def fold_residual_template(
pixels: np.ndarray,
*,
tile_height: int,
tile_width: int,
denoise_sigma: float,
) -> np.ndarray:
"""Estimate a zero-mean periodic residual template by modulo folding."""
if pixels.ndim != 3 or pixels.shape[2] != 3:
raise ValueError("pixels must have shape (height, width, 3)")
if tile_height < 1 or tile_width < 1 or denoise_sigma <= 0.0:
raise ValueError("tile dimensions and denoise sigma must be positive")
height, width = pixels.shape[:2]
if height % tile_height != 0 or width % tile_width != 0:
raise ValueError("image geometry must be divisible by the tile geometry")
source = pixels.astype(np.float64)
denoised = cv2.GaussianBlur(
source,
(0, 0),
sigmaX=denoise_sigma,
sigmaY=denoise_sigma,
borderType=cv2.BORDER_REFLECT_101,
)
residual = source - denoised
repeats_y = height // tile_height
repeats_x = width // tile_width
folded = residual.reshape(repeats_y, tile_height, repeats_x, tile_width, 3).mean(axis=(0, 2))
return folded - np.mean(folded, axis=(0, 1), keepdims=True)
def subtract_tiled_template(pixels: np.ndarray, template: np.ndarray, *, strength: float) -> np.ndarray:
"""Subtract STRENGTH times TEMPLATE repeated over PIXELS."""
if strength < 0.0:
raise ValueError("strength must be nonnegative")
height, width = pixels.shape[:2]
tile_height, tile_width = template.shape[:2]
if template.shape[2:] != (3,) or height % tile_height != 0 or width % tile_width != 0:
raise ValueError("template does not tile the pixel geometry")
repeated = np.tile(template, (height // tile_height, width // tile_width, 1))
result = pixels.astype(np.float64) - strength * repeated
return np.clip(np.rint(result), 0, 255).astype(np.uint8)
def parse_positive_floats(value: str, *, option_name: str) -> tuple[float, ...]:
"""Parse a strictly increasing comma-separated positive-float sweep."""
try:
values = tuple(float(item.strip()) for item in value.split(","))
except ValueError as error:
raise click.BadParameter(f"{option_name} must be comma-separated numbers") from error
if not values or any(not np.isfinite(item) or item <= 0.0 for item in values):
raise click.BadParameter(f"{option_name} must be finite and positive")
if tuple(sorted(set(values))) != values:
raise click.BadParameter(f"{option_name} must be unique and strictly increasing")
return values
@click.command()
@click.argument("config_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("output_dir", type=click.Path(file_okay=False, path_type=Path))
@click.option("--tile-height", type=click.IntRange(min=1), default=16, show_default=True)
@click.option("--tile-width", type=click.IntRange(min=1), default=32, show_default=True)
@click.option("--denoise-sigmas", default="0.6,1,1.5", show_default=True)
@click.option("--strengths", default="0.5,1,1.5,2", show_default=True)
def main(
config_path: Path,
source: Path,
output_dir: Path,
tile_height: int,
tile_width: int,
denoise_sigmas: str,
strengths: str,
) -> None:
"""Write a frozen periodic-tile subtraction sweep for SOURCE."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
config = load_config(config_path)
rgb_model, hsv_model = load_models(config)
reference = load_rgb(source)
if reference.shape != (config.height, config.width, 3):
raise click.BadParameter("source geometry does not match detector config")
sigma_values = parse_positive_floats(denoise_sigmas, option_name="denoise sigmas")
strength_values = parse_positive_floats(strengths, option_name="strengths")
output_dir.mkdir(parents=True, exist_ok=True)
variants: list[dict[str, object]] = []
templates: list[dict[str, object]] = []
for sigma in sigma_values:
template = fold_residual_template(
reference,
tile_height=tile_height,
tile_width=tile_width,
denoise_sigma=sigma,
)
sigma_name = f"{sigma:g}".replace(".", "p")
templates.append(
{
"denoise_sigma": sigma,
"template_rms": float(np.sqrt(np.mean(np.square(template)))),
"template_max_abs": float(np.max(np.abs(template))),
}
)
shifted = np.roll(template, shift=(1, 1), axis=(0, 1))
for strength in strength_values:
strength_name = f"{strength:g}".replace(".", "p")
for control_name, selected_template in (("aligned", template), ("shifted", shifted)):
name = f"tile-{control_name}-sigma{sigma_name}-s{strength_name}"
pixels = subtract_tiled_template(reference, selected_template, strength=strength)
path = output_dir / f"{name}.png"
Image.fromarray(pixels, mode="RGB").save(path)
variants.append(
{
**asdict(measure(reference, pixels, name=name, path=path)),
**asdict(detect_image(path, config, rgb_model, hsv_model)),
"denoise_sigma": sigma,
"strength": strength,
"template_alignment": control_name,
}
)
report_path = output_dir / "report.json"
report_path.write_text(
json.dumps(
{
"source": str(source),
"config": str(config_path),
"tile_height": tile_height,
"tile_width": tile_width,
"repeat_count": (config.height // tile_height) * (config.width // tile_width),
"templates": templates,
"variants": variants,
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
log.info("Wrote %d periodic-tile candidates: %s", len(variants), report_path)
if __name__ == "__main__":
main()
+148
View File
@@ -0,0 +1,148 @@
"""Build pixel-only V3 carrier-subtraction candidates and matched controls.
The command uses a frozen numeric frequency profile as a local research
surrogate. It subtracts a sparse Hermitian spectrum, preserves image geometry,
and never invokes a generative model. A lower local score is not evidence that
the provider's SynthID verifier will change its decision.
"""
from __future__ import annotations
import json
import logging
from dataclasses import asdict
from pathlib import Path
import click
import numpy as np
from PIL import Image
from synthid_pixel_attack import load_rgb, measure, norm_matched_noise
from synthid_v3_codebook_probe import V3CarrierModel, load_v3_model, score_image
log = logging.getLogger(__name__)
def load_exact_rgb(path: Path, model: V3CarrierModel) -> np.ndarray:
"""Load PATH as RGB and reject geometry that differs from MODEL."""
pixels = load_rgb(path)
if pixels.shape != (model.height, model.width, 3):
height, width = pixels.shape[:2]
raise ValueError(f"image geometry {width}x{height} does not match profile {model.width}x{model.height}")
return pixels
def subtract_carrier(pixels: np.ndarray, model: V3CarrierModel, *, strength: float) -> np.ndarray:
"""Subtract STRENGTH times MODEL's sparse complex carrier from PIXELS."""
if strength < 0.0:
raise ValueError("strength must be nonnegative")
expected_shape = (model.height, model.width, 3)
if pixels.shape != expected_shape:
raise ValueError(f"pixel shape {pixels.shape} does not match {expected_shape}")
result = np.empty_like(pixels, dtype=np.float64)
for channel in range(3):
spectrum = np.fft.fft2(pixels[:, :, channel].astype(np.float64))
positions = np.flatnonzero(model.channels == channel)
deltas: dict[tuple[int, int], complex] = {}
for position in positions:
row = int(model.rows[position])
column = int(model.columns[position])
delta = strength * model.expected_magnitudes[position] * np.exp(1j * model.phases[position])
key = (row, column)
conjugate_key = ((-row) % model.height, (-column) % model.width)
deltas[key] = deltas.get(key, 0.0j) + delta
if conjugate_key == key:
deltas[key] = complex(deltas[key].real, 0.0)
else:
deltas[conjugate_key] = deltas.get(conjugate_key, 0.0j) + np.conj(delta)
for (row, column), delta in deltas.items():
spectrum[row, column] -= delta
result[:, :, channel] = np.fft.ifft2(spectrum).real
return np.clip(np.rint(result), 0, 255).astype(np.uint8)
def parse_strengths(value: str) -> tuple[float, ...]:
"""Parse a comma-separated, strictly increasing nonnegative sweep."""
try:
strengths = tuple(float(item.strip()) for item in value.split(","))
except ValueError as error:
raise click.BadParameter("strengths must be comma-separated numbers") from error
if not strengths or any(not np.isfinite(item) or item < 0.0 for item in strengths):
raise click.BadParameter("strengths must be finite and nonnegative")
if tuple(sorted(set(strengths))) != strengths:
raise click.BadParameter("strengths must be unique and strictly increasing")
return strengths
@click.command()
@click.argument("codebook", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("output_dir", type=click.Path(file_okay=False, path_type=Path))
@click.option("--height", type=click.IntRange(min=64), required=True)
@click.option("--width", type=click.IntRange(min=64), required=True)
@click.option("--peak-count", type=click.IntRange(min=1), default=256, show_default=True)
@click.option("--strengths", default="0.25,0.5,1,1.5,2,4", show_default=True)
def main(
codebook: Path,
source: Path,
output_dir: Path,
height: int,
width: int,
peak_count: int,
strengths: str,
) -> None:
"""Write a frozen analytical carrier-subtraction batch for SOURCE."""
model = load_v3_model(codebook, height=height, width=width, peak_count=peak_count)
reference = load_exact_rgb(source, model)
sweep = parse_strengths(strengths)
output_dir.mkdir(parents=True, exist_ok=True)
variants: list[dict[str, object]] = []
strongest = reference
for strength in sweep:
pixels = subtract_carrier(reference, model, strength=strength)
name = f"subtract-{strength:g}".replace(".", "p")
path = output_dir / f"{name}.png"
Image.fromarray(pixels, mode="RGB").save(path)
variants.append(
{
**asdict(measure(reference, pixels, name=name, path=path)),
**asdict(score_image(path, model)),
"strength": strength,
}
)
strongest = pixels
sham = norm_matched_noise(reference, strongest, seed=20260809)
sham_path = output_dir / "sham-strongest-rms.png"
Image.fromarray(sham, mode="RGB").save(sham_path)
variants.append(
{
**asdict(measure(reference, sham, name="sham-strongest-rms", path=sham_path)),
**asdict(score_image(sham_path, model)),
"strength": None,
}
)
report_path = output_dir / "report.json"
report_path.write_text(
json.dumps(
{
"source": str(source),
"codebook": str(codebook),
"height": height,
"width": width,
"peak_count": peak_count,
"variants": variants,
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
log.info("Wrote %d frozen carrier candidates: %s", len(variants), report_path)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(message)s")
main()
+172
View File
@@ -0,0 +1,172 @@
"""Independently evaluate a numeric reverse-SynthID V3 NPZ codebook.
The loader accepts only the documented numeric format-v2 arrays and disables
pickle. It does not import or execute third-party code. Scores are exploratory:
the external reference provenance and labels still require independent oracle
validation before this can support a SynthID detector claim.
"""
from __future__ import annotations
import json
import logging
from dataclasses import asdict, dataclass
from pathlib import Path
import click
import numpy as np
from PIL import Image
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class V3CarrierModel:
"""Selected numeric bins from one exact-resolution V3 profile."""
height: int
width: int
rows: np.ndarray
columns: np.ndarray
channels: np.ndarray
phases: np.ndarray
weights: np.ndarray
expected_magnitudes: np.ndarray
@dataclass(frozen=True)
class V3Score:
"""Phase-alignment scores for one image."""
path: str
phase_score: float
axial_phase_score: float
active_weight_fraction: float
peak_count: int
def _load_sparse_channel(artifact: np.lib.npyio.NpzFile, prefix: str, channel: int) -> tuple[np.ndarray, ...]:
"""Load one sparse channel without reconstructing full image-sized arrays."""
indices = np.asarray(artifact[f"{prefix}idx_{channel}"], dtype=np.uint32)
magnitudes = np.exp2(np.asarray(artifact[f"{prefix}mag_{channel}"], dtype=np.float64)) - 1.0
phases = np.asarray(artifact[f"{prefix}phase_{channel}"], dtype=np.float64)
coherence = np.asarray(artifact[f"{prefix}cons_{channel}"], dtype=np.float64) / 255.0
if not (indices.shape == magnitudes.shape == phases.shape == coherence.shape):
raise ValueError("sparse profile arrays have inconsistent shapes")
return indices, magnitudes, phases, coherence
def load_v3_model(
path: Path,
*,
height: int,
width: int,
peak_count: int = 256,
min_radius: float = 15.0,
) -> V3CarrierModel:
"""Load top phase-consistent bins from a numeric V3 codebook profile."""
prefix = f"{height}x{width}/"
half_width = width // 2 + 1
candidates: list[tuple[float, int, int, int, float, float]] = []
with np.load(path, allow_pickle=False) as artifact:
if int(artifact["format_version"]) != 2:
raise ValueError("only numeric V3 format version 2 is supported")
if not bool(int(artifact[f"{prefix}sparse"])):
raise ValueError("only sparse profiles are supported by this audit loader")
for channel in range(3):
indices, magnitudes, phases, coherence = _load_sparse_channel(artifact, prefix, channel)
rows, columns = np.unravel_index(indices, (height, half_width))
signed_rows = np.where(rows > height // 2, rows - height, rows)
radius = np.sqrt(np.square(signed_rows) + np.square(columns))
valid = (radius >= min_radius) & (columns > 0)
selection = np.square(coherence) * np.log1p(magnitudes)
for index in np.flatnonzero(valid):
candidates.append(
(
float(selection[index]),
int(rows[index]),
int(columns[index]),
channel,
float(phases[index]),
float(magnitudes[index]),
)
)
if len(candidates) < peak_count:
raise ValueError(f"profile exposes only {len(candidates)} eligible bins")
selected = sorted(candidates, reverse=True)[:peak_count]
raw_weights = np.asarray([item[0] for item in selected], dtype=np.float64)
return V3CarrierModel(
height=height,
width=width,
rows=np.asarray([item[1] for item in selected], dtype=np.int32),
columns=np.asarray([item[2] for item in selected], dtype=np.int32),
channels=np.asarray([item[3] for item in selected], dtype=np.int8),
phases=np.asarray([item[4] for item in selected], dtype=np.float64),
weights=raw_weights / np.sum(raw_weights),
expected_magnitudes=np.asarray([item[5] for item in selected], dtype=np.float64),
)
def _load_profile_rgb(path: Path, model: V3CarrierModel) -> np.ndarray:
"""Load PATH and resize only when it does not match the profile geometry."""
with Image.open(path) as source:
image = source.convert("RGB")
if image.size != (model.width, model.height):
image = image.resize((model.width, model.height), Image.Resampling.LANCZOS)
return np.asarray(image, dtype=np.float64)
def score_image(path: Path, model: V3CarrierModel) -> V3Score:
"""Score PATH against selected V3 phase bins."""
pixels = _load_profile_rgb(path, model)
values = np.empty(len(model.rows), dtype=np.complex128)
for channel in range(3):
positions = np.flatnonzero(model.channels == channel)
if len(positions) == 0:
continue
spectrum = np.fft.fft2(pixels[:, :, channel])
values[positions] = spectrum[model.rows[positions], model.columns[positions]]
phase_difference = np.angle(values) - model.phases
magnitude_gate = np.minimum(np.abs(values) / (model.expected_magnitudes + 1e-12), 1.0)
active_weights = model.weights * magnitude_gate
active_weight = float(np.sum(active_weights))
if active_weight == 0.0:
phase_score = 0.0
axial_score = 0.0
else:
phase_score = float(np.sum(active_weights * np.cos(phase_difference)) / active_weight)
axial_score = float(np.sum(active_weights * np.cos(2.0 * phase_difference)) / active_weight)
return V3Score(
path=str(path),
phase_score=phase_score,
axial_phase_score=axial_score,
active_weight_fraction=active_weight,
peak_count=len(model.rows),
)
@click.command()
@click.argument("codebook", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--height", type=click.IntRange(min=64), required=True)
@click.option("--width", type=click.IntRange(min=64), required=True)
@click.option("--peak-count", type=click.IntRange(min=1), default=256, show_default=True)
@click.option("--report-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
def main(codebook: Path, images: tuple[Path, ...], height: int, width: int, peak_count: int, report_out: Path) -> None:
"""Score IMAGES against one exact-resolution profile from CODEBOOK."""
model = load_v3_model(codebook, height=height, width=width, peak_count=peak_count)
payload = {
"codebook": str(codebook),
"height": height,
"width": width,
"peak_count": peak_count,
"scores": [asdict(score_image(image, model)) for image in images],
}
report_out.parent.mkdir(parents=True, exist_ok=True)
report_out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
log.info("Wrote V3 score report: %s", report_out)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(message)s")
main()