mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-19 12:07:13 +02:00
Add rigorous SynthID research and evaluation harness
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user