Spaces:
Running
Running
Release: display score + provenance fix + heatmap spotlight + web bundle with policy pages (a-eye f1f73c2edcd4)
Browse files- .gitattributes +0 -10
- Dockerfile +2 -0
- crop_geometry.py +121 -0
- heatmap_nextgen.py +55 -2
- lattice_features.py +237 -0
- main_hybrid.py +129 -10
- provenance_gate.py +9 -2
- renderer_trace.py +269 -0
- schemas.py +8 -1
- score_display.py +100 -0
- tests/fixtures/display_regression_golden.json +505 -0
- tests/test_display_score.py +364 -0
- tests/test_fft_nyquist_parity.py +147 -0
- tests/test_heatmap_spotlight.py +169 -0
- tests/test_lattice_service.py +284 -0
- tests/test_patch_adapter_merge.py +269 -0
- tests/test_renderer_trace.py +213 -0
- webapp/_expo/static/js/web/{entry-c72d53cd4e22507b95184fe0159c61c3.js → entry-73d73bf1e3d88c923bfb3f7b7cae3866.js} +0 -0
- webapp/assets/assets/logo.9add05631c93e895ff3b380bf07ae0b0.png +0 -3
- webapp/assets/assets/onboarding/p2_ai.0b15766d558e195bc5587b85c0417bff.webp +0 -0
- webapp/assets/assets/onboarding/p2_real.15019b6a906480c5cbafa43036b7531f.webp +0 -0
- webapp/assets/assets/onboarding/p3_messenger.6fd165b9e4ba0ce3ef707e5f6722006a.jpg +0 -0
- webapp/assets/assets/onboarding/p3_original.91ab13644283dc95349469ea82650133.webp +0 -0
- webapp/assets/assets/onboarding/p3_rephoto.1e35d25745130d74193c6b89fe94e651.webp +0 -0
- webapp/assets/assets/onboarding/p3_rephoto_bg.1f99f9dd1101bf90539c1fbdf765d72c.webp +0 -0
- webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/AntDesign.3f78af31cca60105799838a1a7a59fbd.ttf +0 -3
- webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Entypo.31b5ffea3daddc69dd01a1f3d6cf63c5.ttf +0 -0
- webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/EvilIcons.140c53a7643ea949007aa9a282153849.ttf +0 -0
- webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Feather.ca4b48e04dc1ce10bfbddb262c8b835f.ttf +0 -0
- webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome.b06871f281fee6b241d60582ae9369b9.ttf +0 -3
- webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome5_Brands.3b89dd103490708d19a95adcae52210e.ttf +0 -3
- webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome5_Regular.1f77739ca9ff2188b539c36f30ffa2be.ttf +0 -0
- webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome5_Solid.605ed7926cf39a2ad5ec2d1f9d391d3d.ttf +0 -3
- webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome6_Brands.56c8d80832e37783f12c05db7c8849e2.ttf +0 -3
- webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome6_Regular.370dd5af19f8364907b6e2c41f45dbbf.ttf +0 -0
- webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome6_Solid.adec7d6f310bc577f05e8fe06a5daccf.ttf +0 -3
- webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Fontisto.b49ae8ab2dbccb02c4d11caaacf09eab.ttf +0 -3
- webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Foundation.e20945d7c929279ef7a6f1db184a4470.ttf +0 -0
- webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/MaterialCommunityIcons.6e435534bd35da5fef04168860a9b8fa.ttf +0 -3
- webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/MaterialIcons.4e85bc9ebe07e0340c9c4fc2f6c38908.ttf +0 -3
- webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Octicons.871378c6eab492a3e689a9385dc45a12.ttf +0 -0
- webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/SimpleLineIcons.d2285965fe34b05465047401b8595dd0.ttf +0 -0
- webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Zocial.1681f34aaca71b8dfb70756bca331eb2.ttf +0 -0
- webapp/index.html +6 -1
- webapp/legal.css +151 -0
- webapp/privacy-en.html +216 -0
- webapp/privacy.html +221 -0
- webapp/support.html +98 -0
- zero_shot_v4.py +53 -4
- zero_shot_v6.py +31 -6
.gitattributes
CHANGED
|
@@ -1,12 +1,2 @@
|
|
| 1 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
webapp/assets/assets/logo.9add05631c93e895ff3b380bf07ae0b0.png filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/AntDesign.3f78af31cca60105799838a1a7a59fbd.ttf filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome.b06871f281fee6b241d60582ae9369b9.ttf filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome5_Brands.3b89dd103490708d19a95adcae52210e.ttf filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome5_Solid.605ed7926cf39a2ad5ec2d1f9d391d3d.ttf filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome6_Brands.56c8d80832e37783f12c05db7c8849e2.ttf filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome6_Solid.adec7d6f310bc577f05e8fe06a5daccf.ttf filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Fontisto.b49ae8ab2dbccb02c4d11caaacf09eab.ttf filter=lfs diff=lfs merge=lfs -text
|
| 10 |
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Ionicons.b4eb097d35f44ed943676fd56f6bdc51.ttf filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/MaterialCommunityIcons.6e435534bd35da5fef04168860a9b8fa.ttf filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/MaterialIcons.4e85bc9ebe07e0340c9c4fc2f6c38908.ttf filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Ionicons.b4eb097d35f44ed943676fd56f6bdc51.ttf filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
Dockerfile
CHANGED
|
@@ -35,9 +35,11 @@ USER appuser
|
|
| 35 |
# authenticated HTTPS request instead. Check any change here with `python tools/live_check.py --mode hub`.
|
| 36 |
# Guard defaults target the free CPU tier (2 vCPU / 16 GB): one inference at a time,
|
| 37 |
# 2 intra-op threads; see guards.py / SECURITY.md for every AEYE_* knob.
|
|
|
|
| 38 |
ENV AEYE_DEVICE=cpu \
|
| 39 |
AEYE_TORCH_THREADS=2 \
|
| 40 |
AEYE_MAX_CONCURRENCY=1 \
|
|
|
|
| 41 |
TRANSFORMERS_OFFLINE=1
|
| 42 |
|
| 43 |
# S6: where the weights come from when models/*.pt are not in this (public) repo. None of this is secret - the
|
|
|
|
| 35 |
# authenticated HTTPS request instead. Check any change here with `python tools/live_check.py --mode hub`.
|
| 36 |
# Guard defaults target the free CPU tier (2 vCPU / 16 GB): one inference at a time,
|
| 37 |
# 2 intra-op threads; see guards.py / SECURITY.md for every AEYE_* knob.
|
| 38 |
+
# AEYE_HEATMAP_STYLE: spotlight (app request 2026-09-24) | jet; a Space variable `jet` rolls the look back.
|
| 39 |
ENV AEYE_DEVICE=cpu \
|
| 40 |
AEYE_TORCH_THREADS=2 \
|
| 41 |
AEYE_MAX_CONCURRENCY=1 \
|
| 42 |
+
AEYE_HEATMAP_STYLE=spotlight \
|
| 43 |
TRANSFORMERS_OFFLINE=1
|
| 44 |
|
| 45 |
# S6: where the weights come from when models/*.pt are not in this (public) repo. None of this is secret - the
|
crop_geometry.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Crop geometry plane (GPT round 3, 2026-09-26): WHERE each native-resolution v6 crop sits in its image.
|
| 2 |
+
|
| 3 |
+
Why: the ChatGPT-renderer lattice (model.lattice_features, src/models/lattice_features.py) is a period-2 pattern whose
|
| 4 |
+
frequency (0.5 * n0 / n cycles/px, n0 = 16 * floor(n / 16)) and phase are fixed in IMAGE coordinates. A statistic computed
|
| 5 |
+
in a crop's own coordinates cannot see it (wf_r3 measure: crop-anchored phase .01-.16 TPR@1% vs .88-1.00 in image
|
| 6 |
+
coordinates), so the model needs, per crop, its offset in the image, the image size and whether the view was mirrored.
|
| 7 |
+
|
| 8 |
+
Transport: the geometry travels INSIDE the crops tensor as a fourth channel ("geometry plane"), so every loader, collate,
|
| 9 |
+
pair batch, `.to(device)` and evaluator that already moves `crops` around carries it without a code change and it can
|
| 10 |
+
never be paired with the wrong crop:
|
| 11 |
+
crops [..., 4, S, S]: channels 0-2 = the CLIP-normalised pixels (unchanged)
|
| 12 |
+
channel 3 = zeros except row 0, columns 0..6 = (MAGIC, x0, y0, width, height, hflip, vflip)
|
| 13 |
+
x0, y0 top-left of the crop in the image's own pixel frame (negative inside pad_to's reflect padding)
|
| 14 |
+
width, height the image the crop was cut from, AFTER every pipeline op that changes pixels geometrically (the
|
| 15 |
+
post-`pre` image in training, the delivered / decoded image in evaluation and in the service) and
|
| 16 |
+
BEFORE the pad_to padding
|
| 17 |
+
hflip, vflip 1 when the crop's pixels are mirrored relative to the image (training's per-view RandomHorizontalFlip);
|
| 18 |
+
column j of a mirrored crop is image column x0 + (S - 1 - j)
|
| 19 |
+
Only a model built with lattice_features reads the plane (ZeroShotV6Detector splits it off before any other branch); a
|
| 20 |
+
model without it rejects a 4-channel crop tensor (the CLIP de-normalisation cannot broadcast), and a lattice model rejects
|
| 21 |
+
crops WITHOUT the plane (split_geometry raises) - a statistic silently computed in crop coordinates is the failure mode
|
| 22 |
+
this plane exists to prevent. The magic value also fails when the tensor was cast below float32 or mirrored as a tensor
|
| 23 |
+
(the plane's row 0 would move) after it was built.
|
| 24 |
+
|
| 25 |
+
Byte-identical copies: 4_model_training/src/data/crop_geometry.py and 1_deployed_service/crop_geometry.py
|
| 26 |
+
(1_deployed_service/tests/test_lattice_service.py pins it). torch only - no torchvision / PIL / package imports, so the
|
| 27 |
+
DataLoader workers and the service import it for free.
|
| 28 |
+
"""
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import torch
|
| 32 |
+
|
| 33 |
+
GEOM_MAGIC = 4099.0 # exactly representable in float32; 4096 in bfloat16 -> a lossy cast is caught
|
| 34 |
+
GEOM_FIELDS = ("x0", "y0", "width", "height", "hflip", "vflip")
|
| 35 |
+
PIXEL_CHANNELS = 3
|
| 36 |
+
PLANE_CHANNELS = PIXEL_CHANNELS + 1
|
| 37 |
+
_ROW = 1 + len(GEOM_FIELDS) # magic + fields, stored in row 0 of the plane
|
| 38 |
+
_MAX_COORD = 1 << 23 # integers up to 2**24 are exact in float32; keep a margin
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def geometry_row(x0: int, y0: int, width: int, height: int, hflip: bool = False, vflip: bool = False) -> list[float]:
|
| 42 |
+
"""One crop's geometry as the plane stores it (floats of exact integers)."""
|
| 43 |
+
row = [float(int(x0)), float(int(y0)), float(int(width)), float(int(height)), float(bool(hflip)), float(bool(vflip))]
|
| 44 |
+
if row[2] < 1 or row[3] < 1:
|
| 45 |
+
raise ValueError(f"crop geometry: image size {row[2]:g}x{row[3]:g} must be >= 1")
|
| 46 |
+
if max(abs(v) for v in row) >= _MAX_COORD:
|
| 47 |
+
raise ValueError(f"crop geometry: {row} out of the exactly representable range")
|
| 48 |
+
return row
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def has_geometry(crops: torch.Tensor) -> bool:
|
| 52 |
+
return torch.is_tensor(crops) and crops.dim() >= 3 and crops.shape[-3] == PLANE_CHANNELS
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def attach_geometry(pixels: torch.Tensor, geom) -> torch.Tensor:
|
| 56 |
+
"""pixels [..., 3, S, S] + geometry [..., 6] (tensor or nested lists of geometry_row values) -> crops [..., 4, S, S].
|
| 57 |
+
The pixel channels are copied bit for bit; the plane is float32 whatever the pixel dtype (float32 in every caller)."""
|
| 58 |
+
if pixels.dim() < 3 or pixels.shape[-3] != PIXEL_CHANNELS:
|
| 59 |
+
raise ValueError(f"attach_geometry: expected [..., 3, S, S] pixels, got {tuple(pixels.shape)}")
|
| 60 |
+
h, w = int(pixels.shape[-2]), int(pixels.shape[-1])
|
| 61 |
+
if w < _ROW:
|
| 62 |
+
raise ValueError(f"attach_geometry: crops narrower than {_ROW} px cannot hold the geometry row")
|
| 63 |
+
g = torch.as_tensor(geom, dtype=torch.float64)
|
| 64 |
+
lead = tuple(pixels.shape[:-3])
|
| 65 |
+
if tuple(g.shape) != lead + (len(GEOM_FIELDS),):
|
| 66 |
+
raise ValueError(f"attach_geometry: geometry shape {tuple(g.shape)} != {lead + (len(GEOM_FIELDS),)}")
|
| 67 |
+
_validate(g)
|
| 68 |
+
plane = torch.zeros(lead + (1, h, w), dtype=torch.float32, device=pixels.device)
|
| 69 |
+
plane[..., 0, 0, 0] = GEOM_MAGIC
|
| 70 |
+
plane[..., 0, 0, 1:_ROW] = g.to(device=pixels.device, dtype=torch.float32)
|
| 71 |
+
return torch.cat([pixels, plane.to(pixels.dtype)], dim=-3)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def own_frame_geometry(pixels: torch.Tensor) -> torch.Tensor:
|
| 75 |
+
"""Geometry [..., 6] (float64, on the pixels' device) for crops that ARE their whole image: x0 = y0 = 0, size = the crop
|
| 76 |
+
size, not mirrored. Used for the global view that v6 feeds as a single crop when no native crops are given (aux
|
| 77 |
+
batches); that view is a resize, so no renderer lattice survives in it anyway."""
|
| 78 |
+
lead = tuple(pixels.shape[:-3])
|
| 79 |
+
g = torch.zeros(lead + (len(GEOM_FIELDS),), dtype=torch.float64, device=pixels.device)
|
| 80 |
+
g[..., 2] = float(pixels.shape[-1])
|
| 81 |
+
g[..., 3] = float(pixels.shape[-2])
|
| 82 |
+
return g
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def split_geometry(crops: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
| 86 |
+
"""crops [..., 4, S, S] -> (pixels [..., 3, S, S] (a view, bit-identical), geometry [..., 6] float64).
|
| 87 |
+
Raises ValueError when the plane is missing or damaged."""
|
| 88 |
+
if not has_geometry(crops):
|
| 89 |
+
shape = tuple(crops.shape) if torch.is_tensor(crops) else type(crops).__name__
|
| 90 |
+
raise ValueError(
|
| 91 |
+
f"model.lattice_features needs the crop geometry plane (crops [..., 4, S, S], src/data/crop_geometry.py) but got "
|
| 92 |
+
f"{shape}: a loader / evaluator / service path that cuts native crops must attach it (MultiViewDataset("
|
| 93 |
+
f"crop_geometry=True), eval_sealed, main_hybrid._native_crops) - the lattice statistic is only defined in image "
|
| 94 |
+
f"coordinates")
|
| 95 |
+
if crops.shape[-1] < _ROW:
|
| 96 |
+
raise ValueError(f"crop geometry plane: crops narrower than {_ROW} px")
|
| 97 |
+
plane = crops[..., PIXEL_CHANNELS, :, :]
|
| 98 |
+
meta = plane[..., 0, :_ROW].to(torch.float64)
|
| 99 |
+
if not bool((meta[..., 0] == GEOM_MAGIC).all()):
|
| 100 |
+
raise ValueError("crop geometry plane: magic value missing - the plane was damaged after it was built (a cast below "
|
| 101 |
+
"float32, a tensor-level flip / crop / resize of the crops, or a 4-channel tensor that is not a plane)")
|
| 102 |
+
if bool(plane[..., 0, _ROW:].ne(0).any()) or bool(plane[..., 1:, :].ne(0).any()):
|
| 103 |
+
raise ValueError("crop geometry plane: non-zero values outside the geometry row (not a plane, or it was mixed with "
|
| 104 |
+
"pixel data)")
|
| 105 |
+
g = meta[..., 1:]
|
| 106 |
+
_validate(g)
|
| 107 |
+
return crops[..., :PIXEL_CHANNELS, :, :], g
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _validate(g: torch.Tensor) -> None:
|
| 111 |
+
if g.numel() == 0:
|
| 112 |
+
return
|
| 113 |
+
if not bool(torch.isfinite(g).all()) or not bool((g == torch.round(g)).all()):
|
| 114 |
+
raise ValueError("crop geometry: offsets / sizes / flips must be finite integers")
|
| 115 |
+
if bool((g[..., 2] < 1).any()) or bool((g[..., 3] < 1).any()):
|
| 116 |
+
raise ValueError("crop geometry: image width / height must be >= 1")
|
| 117 |
+
flips = g[..., 4:6]
|
| 118 |
+
if not bool(((flips == 0) | (flips == 1)).all()):
|
| 119 |
+
raise ValueError("crop geometry: hflip / vflip must be 0 or 1")
|
| 120 |
+
if bool((g.abs() >= _MAX_COORD).any()):
|
| 121 |
+
raise ValueError("crop geometry: value out of the exactly representable range")
|
heatmap_nextgen.py
CHANGED
|
@@ -7,13 +7,17 @@ The heatmap is the PatchGuard patch-probability map, refined for localization:
|
|
| 7 |
(kills scattered noise so only the suspected insert lights up),
|
| 8 |
* intensity-gated by the calibrated image confidence, so weak/uncertain maps
|
| 9 |
stay faint instead of painting the photo with noise.
|
| 10 |
-
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
import numpy as np
|
| 15 |
from PIL import Image
|
| 16 |
-
from scipy.ndimage import gaussian_filter, label, uniform_filter
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
def jet(values: np.ndarray) -> np.ndarray:
|
|
@@ -96,3 +100,52 @@ def overlay(
|
|
| 96 |
alpha = max_alpha * c * (norm ** gamma)
|
| 97 |
blended = rgb * (1.0 - alpha[..., None]) + jet(big) * alpha[..., None]
|
| 98 |
return Image.fromarray((np.clip(blended, 0.0, 1.0) * 255).astype(np.uint8))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
(kills scattered noise so only the suspected insert lights up),
|
| 8 |
* intensity-gated by the calibrated image confidence, so weak/uncertain maps
|
| 9 |
stay faint instead of painting the photo with noise.
|
| 10 |
+
Two overlay styles share that map: `overlay` (jet colours painted on the photo) and `spotlight` (no colour: the
|
| 11 |
+
suspect region stays the original photo, the rest is darkened, one white edge line) - AEYE_HEATMAP_STYLE picks.
|
| 12 |
+
Pure numpy / PIL / scipy.
|
| 13 |
"""
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
import numpy as np
|
| 17 |
from PIL import Image
|
| 18 |
+
from scipy.ndimage import binary_dilation, binary_erosion, gaussian_filter, label, uniform_filter
|
| 19 |
+
|
| 20 |
+
HEATMAP_STYLES = ("spotlight", "jet")
|
| 21 |
|
| 22 |
|
| 23 |
def jet(values: np.ndarray) -> np.ndarray:
|
|
|
|
| 100 |
alpha = max_alpha * c * (norm ** gamma)
|
| 101 |
blended = rgb * (1.0 - alpha[..., None]) + jet(big) * alpha[..., None]
|
| 102 |
return Image.fromarray((np.clip(blended, 0.0, 1.0) * 255).astype(np.uint8))
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def spotlight(
|
| 106 |
+
image: Image.Image,
|
| 107 |
+
loc: np.ndarray,
|
| 108 |
+
blanket: bool = False,
|
| 109 |
+
dim: float = 0.5,
|
| 110 |
+
ramp: tuple[float, float] = (0.35, 0.5),
|
| 111 |
+
line_px: int = 2,
|
| 112 |
+
line_alpha: float = 0.9,
|
| 113 |
+
shadow_px: int = 1,
|
| 114 |
+
shadow_alpha: float = 0.25,
|
| 115 |
+
ref_side: int = 768,
|
| 116 |
+
) -> Image.Image:
|
| 117 |
+
"""Spotlight on the photo (app request 2026-09-24, owner-approved; _collab/REQUEST_server_heatmap_spotlight.md).
|
| 118 |
+
|
| 119 |
+
No new colour: the jet overlay painted green and yellow over photos the app calls AI, and in the app green means
|
| 120 |
+
'real' and amber 'uncertain'. Same focus map as overlay() (one strongest blob), normalised so its peak is 1:
|
| 121 |
+
* norm >= ramp[1]: the original pixels, untouched;
|
| 122 |
+
* norm <= ramp[0]: brightness x `dim` (a plain multiply, so hue and saturation stay);
|
| 123 |
+
* in between: a linear ramp, so the edge of the dark area is soft;
|
| 124 |
+
* the contour norm = ramp[1]: a white line of `line_px` px (at a `ref_side` px display, scaled with the image;
|
| 125 |
+
half outside, half inside the region) at `line_alpha`, with a `shadow_px` black rim at `shadow_alpha` just
|
| 126 |
+
inside it so the line still shows on a bright photo.
|
| 127 |
+
`blanket=True` or a map with no blob returns the photo unchanged: there is no region to point at."""
|
| 128 |
+
if blanket:
|
| 129 |
+
return image.convert("RGB").copy()
|
| 130 |
+
rgb = np.asarray(image.convert("RGB"), np.float32) / 255.0
|
| 131 |
+
h, w = rgb.shape[:2]
|
| 132 |
+
big = _big(_focus(loc), (w, h))
|
| 133 |
+
peak = float(big.max())
|
| 134 |
+
if peak < 1e-6:
|
| 135 |
+
return image.convert("RGB").copy()
|
| 136 |
+
norm = big / peak
|
| 137 |
+
lo, hi = ramp
|
| 138 |
+
gain = dim + (1.0 - dim) * np.clip((norm - lo) / (hi - lo), 0.0, 1.0)
|
| 139 |
+
out = rgb * gain[..., None]
|
| 140 |
+
inside = norm >= hi
|
| 141 |
+
scale = max(1, int(round(max(w, h) / float(ref_side))))
|
| 142 |
+
line_w, shadow_w = line_px * scale, shadow_px * scale
|
| 143 |
+
out_half = line_w // 2
|
| 144 |
+
grown = binary_dilation(inside, iterations=out_half) if out_half else inside
|
| 145 |
+
# border_value=1: a region that touches the image edge gets no line along the edge of the picture
|
| 146 |
+
core = binary_erosion(inside, iterations=line_w - out_half, border_value=1)
|
| 147 |
+
line = grown & ~core
|
| 148 |
+
shadow = core & ~binary_erosion(core, iterations=shadow_w, border_value=1)
|
| 149 |
+
out[shadow] *= 1.0 - shadow_alpha
|
| 150 |
+
out[line] = out[line] * (1.0 - line_alpha) + line_alpha
|
| 151 |
+
return Image.fromarray(np.rint(np.clip(out, 0.0, 1.0) * 255.0).astype(np.uint8))
|
lattice_features.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ChatGPT-renderer lattice statistics as per-crop v6 artifact features (GPT round 3, 2026-09-26; model.lattice_features).
|
| 2 |
+
|
| 3 |
+
What (wf_r3 measurement, owner TRAIN images only): pristine ChatGPT-app outputs carry a deterministic period-2 "lattice".
|
| 4 |
+
Each axis of length n is rendered on a grid n0 = 16 * floor(n / 16) and then slightly UP-sampled to n, so the lattice
|
| 5 |
+
sits at 0.5 * n0 / n cycles/px (334/334 train axes within 0.0002 c/px) with a phase fixed at the image origin (circular
|
| 6 |
+
mean +0.3 / +0.2 deg, resultant length 1.000). Its 8x8 template T_REF (19 train images of 16-multiple size, leave-one-out
|
| 7 |
+
cosine 0.995) puts 96% of its energy on the axis Nyquist terms and 2.7% on the (1/4, -1/4) anti-diagonal pair, a period-4
|
| 8 |
+
term a Bayer demosaic does not make. The current recipes (fft_nyquist included) did not learn it: the radial FFT ring
|
| 9 |
+
dilutes a 1-4 bin line to ~1e-4 of its ring mean, and the phase only exists in IMAGE coordinates, which random crops and
|
| 10 |
+
flips hide from the network.
|
| 11 |
+
|
| 12 |
+
Statistics (per 224 crop, on the RAW 0-1 crop pixels the model receives - after every pipeline op, before any resize -,
|
| 13 |
+
in the image's own coordinates given by the crop geometry plane, src/data/crop_geometry.py):
|
| 14 |
+
luma Y = 255 * (0.299 R + 0.587 G + 0.114 B); residual r = Y - median3x3(Y) (edge-replicate border = scipy 'reflect').
|
| 15 |
+
x_in(x) = (x + 0.5) * n0(W) / W - 0.5 for image column x (and the same for rows with H).
|
| 16 |
+
nyq_x_img signed z of the column sums of r projected on cos(pi * x_in + PHI0[0]); noise = mean over the 8 lower-frequency
|
| 17 |
+
controls pi * x_in * (1 + eps), eps = -0.015 - 0.01 k, of the quadrature energy / 2.
|
| 18 |
+
nyq_y_img the same for the row sums and PHI0[1].
|
| 19 |
+
nyq_min_img min(nyq_x_img, nyq_y_img): the lattice with the reference phase on BOTH axes (camera demosaic lattices
|
| 20 |
+
often have the origin phase on one axis only). Recommendation #1 (crop_nyqmin_img: 1.00 / .97 / .88 TPR@1% FPR
|
| 21 |
+
on owner held-out full scenes, pristine / q95 / q85; .00 on social and messenger_x2 - the resize removes it).
|
| 22 |
+
d4_img signed z of T_REF restricted to its (1/4, -1/4) pair under the same resample model: sum r cos(psi + THETA_D4),
|
| 23 |
+
psi = (pi / 2) (x_in - y_in); noise = RMS of the same sum at the 8 frequency-scaled controls. Recommendation #2,
|
| 24 |
+
the camera gate (per crop; the measured whole-image version gates Dresden better - 0.7% vs 4.9% - but the
|
| 25 |
+
model only sees crops).
|
| 26 |
+
Each selected z is passed as clamp(log1p(max(z, 0)), 0, clip) (`sides: positive`, the default) or, for an ablation only,
|
| 27 |
+
clamp(sign(z) * log1p(|z|), -clip, clip) (`sides: signed`). Why one-sided (wf_r3 review, 2026-09-26): the renderer is never
|
| 28 |
+
on the negative side (owner / gpt-image-1.5: 0% of images with mean z < -3), but the NEGATIVE side is where camera demosaic
|
| 29 |
+
lattices (owner iPhone cal: 32% of crops z < -4 at orig, 29% after JPEG q85; Dresden 28%) and SD-family / GAN decoder
|
| 30 |
+
lattices (sd15, wukong, vqdm, firefly, ...) sit. Through the exact 95 train loader on a uniform v7.2i train sample, crops
|
| 31 |
+
with z < -4 are 0.85% of real vs 4.3% of fake crops (likelihood ratio 5.1; 7.1 on the pristine branch): a signed feature
|
| 32 |
+
hands the head "strong anti-phase lattice = fake", which lands on a third of the protected owner-iPhone photos. The positive
|
| 33 |
+
part keeps everything the measurement used (every TPR@FPR statistic thresholds the positive side).
|
| 34 |
+
The reference is three fixed constants from owner
|
| 35 |
+
train: PHI0, T_REF (-> THETA_D4) and the n0 rule. Identical code for both classes; no parameters (nothing to train, nothing
|
| 36 |
+
in a checkpoint); computed under no_grad in float32 (pixels) / float64 (projections: immune to TF32 matmuls and autocast).
|
| 37 |
+
|
| 38 |
+
Byte-identical copies: 4_model_training/src/models/lattice_features.py and 1_deployed_service/lattice_features.py
|
| 39 |
+
(1_deployed_service/tests/test_lattice_service.py pins it; the import below works in both layouts).
|
| 40 |
+
"""
|
| 41 |
+
from __future__ import annotations
|
| 42 |
+
|
| 43 |
+
import math
|
| 44 |
+
|
| 45 |
+
import torch
|
| 46 |
+
import torch.nn as nn
|
| 47 |
+
import torch.nn.functional as F
|
| 48 |
+
|
| 49 |
+
try: # training package layout (src.models -> src.data)
|
| 50 |
+
from ..data.crop_geometry import (GEOM_FIELDS, attach_geometry, geometry_row, has_geometry, # noqa: F401
|
| 51 |
+
own_frame_geometry, split_geometry)
|
| 52 |
+
except ImportError: # service: flat modules
|
| 53 |
+
from crop_geometry import (GEOM_FIELDS, attach_geometry, geometry_row, has_geometry, # noqa: F401
|
| 54 |
+
own_frame_geometry, split_geometry)
|
| 55 |
+
|
| 56 |
+
# ---- reference (wf_r3 measure/ref.json, sha256 648bd485f9121afc197c3950bd927f2404408761c0eea4ca679c7a7535c56818,
|
| 57 |
+
# learned from the 167 owner TRAIN images; held-out and public packs were never used to fit it) ------------------
|
| 58 |
+
PHI0 = (0.005224707587257661, 0.003667792028783213) # radians, x / y, at the image origin
|
| 59 |
+
T_REF = ( # 8x8 zero-mean lattice template, rows = y mod 8, cols = x mod 8
|
| 60 |
+
(0.17269160274461814, 0.016624810023089433, 0.18758460622056203, -0.027523961101165936, 0.14382515421100395, 0.010407459922983272, 0.22656217773376325, -0.04924261285800926),
|
| 61 |
+
(4.117843771012356e-05, -0.20775218375052468, 0.018273811741025364, -0.14531792563384296, -0.007125371135598881, -0.19250900379480274, 0.011853639378828587, -0.15534194826915765),
|
| 62 |
+
(0.21871752243500459, -0.041426545193588266, 0.15513920077363727, 0.00981277178155932, 0.18862573156031762, -0.02502035730420058, 0.16482270033206348, -0.006749445513922211),
|
| 63 |
+
(0.02603878721645228, -0.13637760279564196, -0.02078179072234321, -0.1671030130249308, 0.02701412394689342, -0.13616309110290123, 0.009209601740110585, -0.18093331853537442),
|
| 64 |
+
(0.14200447990246007, -0.018297584224412827, 0.18252467048327578, -0.033059550544393856, 0.13405059346250559, 0.006831903648010557, 0.1963587313220746, -0.052682036739672894),
|
| 65 |
+
(0.01117447391168055, -0.20319086173560147, 0.008779578605276629, -0.13356437311264927, -0.014971437140206703, -0.178110895003855, 0.03181750290914937, -0.1503561100517138),
|
| 66 |
+
(0.22419278170968648, -0.037280988420779794, 0.1560292920928076, 0.0016715735350392035, 0.20854080743872597, -0.03821453011186845, 0.1570347879658586, 0.009063247694749367),
|
| 67 |
+
(0.031853372606417844, -0.15298436141156935, -0.02837693990464353, -0.18054482745022435, -0.005528327933958054, -0.1497548713314285, -0.009564785696936069, -0.20332203945750849),
|
| 68 |
+
)
|
| 69 |
+
RENDER_GRID = 16 # n0 = RENDER_GRID * floor(n / RENDER_GRID)
|
| 70 |
+
CTRL_EPS = tuple(-0.015 - 0.01 * k for k in range(8)) # lower-frequency controls (both statistics)
|
| 71 |
+
Z_EPS = 1e-12 # as the measurement (0-255 luma scale)
|
| 72 |
+
LUMA = (0.299, 0.587, 0.114)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _theta_d4() -> float:
|
| 76 |
+
"""Phase of the (l, k) = (-2, +2) DFT coefficient of T_REF (numpy fft2 convention: sum T exp(-2 pi i (l y + k x) / 8)
|
| 77 |
+
= sum T exp(-i pi/2 (x - y))). Pure python: the same float on every machine."""
|
| 78 |
+
re = sum(T_REF[y][x] * math.cos(-0.5 * math.pi * (x - y)) for y in range(8) for x in range(8))
|
| 79 |
+
im = sum(T_REF[y][x] * math.sin(-0.5 * math.pi * (x - y)) for y in range(8) for x in range(8))
|
| 80 |
+
return math.atan2(im, re)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
THETA_D4 = _theta_d4() # -2.5207698601217063
|
| 84 |
+
|
| 85 |
+
# ---- config contract ------------------------------------------------------------------------------------------------
|
| 86 |
+
LATTICE_KEYS = ("stats", "per_crop", "clip", "sides")
|
| 87 |
+
LATTICE_STATS = ("nyq_min_img", "d4_img", "nyq_x_img", "nyq_y_img")
|
| 88 |
+
LATTICE_SIDES = ("positive", "signed")
|
| 89 |
+
DEFAULT_STATS = ("nyq_min_img", "d4_img")
|
| 90 |
+
DEFAULT_CLIP = 4.0 # |z| up to e^4 - 1 = 53.6 (per-crop z: owner pristine p99 ~22 / 63)
|
| 91 |
+
DEFAULT_SIDES = "positive" # the renderer phase only (see the module docstring)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def parse_lattice_features(spec) -> dict | None:
|
| 95 |
+
"""Validated spec {stats: tuple, per_crop: True, clip: float, sides: 'positive' | 'signed'}, or None when the features
|
| 96 |
+
are off (absent / None / False / 'none' / {} / {stats: []}). Unknown keys, unknown or duplicated stats, per_crop: false,
|
| 97 |
+
clip <= 0 and an unknown `sides` are errors: a misspelt key must not train a silent copy of the recipe without the
|
| 98 |
+
features."""
|
| 99 |
+
if spec is None or spec is False:
|
| 100 |
+
return None
|
| 101 |
+
if isinstance(spec, str):
|
| 102 |
+
if spec.strip().lower() in ("", "none", "off"):
|
| 103 |
+
return None
|
| 104 |
+
raise ValueError(f"model.lattice_features must be a mapping {{stats, per_crop, clip}}, got {spec!r}")
|
| 105 |
+
if not isinstance(spec, dict):
|
| 106 |
+
raise ValueError(f"model.lattice_features must be a mapping, got {type(spec).__name__}")
|
| 107 |
+
unknown = sorted(set(spec) - set(LATTICE_KEYS))
|
| 108 |
+
if unknown:
|
| 109 |
+
raise ValueError(f"model.lattice_features: unknown key(s) {unknown} (allowed: {list(LATTICE_KEYS)})")
|
| 110 |
+
if not spec:
|
| 111 |
+
return None
|
| 112 |
+
stats = spec.get("stats", DEFAULT_STATS)
|
| 113 |
+
if isinstance(stats, str) or not isinstance(stats, (list, tuple)):
|
| 114 |
+
raise ValueError(f"model.lattice_features.stats must be a list, got {stats!r}")
|
| 115 |
+
stats = tuple(str(s) for s in stats)
|
| 116 |
+
if not stats:
|
| 117 |
+
return None
|
| 118 |
+
bad = [s for s in stats if s not in LATTICE_STATS]
|
| 119 |
+
if bad or len(set(stats)) != len(stats):
|
| 120 |
+
raise ValueError(f"model.lattice_features.stats {list(stats)}: unknown {bad} or duplicated (allowed: {list(LATTICE_STATS)})")
|
| 121 |
+
per_crop = spec.get("per_crop", True)
|
| 122 |
+
if per_crop is not True:
|
| 123 |
+
raise ValueError("model.lattice_features.per_crop must be true: the model only sees crops (an image-level statistic "
|
| 124 |
+
"would have to be computed by every loader / evaluator / the service outside the model)")
|
| 125 |
+
clip = float(spec.get("clip", DEFAULT_CLIP))
|
| 126 |
+
if not (clip > 0 and math.isfinite(clip)):
|
| 127 |
+
raise ValueError(f"model.lattice_features.clip must be a positive number, got {spec.get('clip')!r}")
|
| 128 |
+
sides = spec.get("sides", DEFAULT_SIDES)
|
| 129 |
+
if not isinstance(sides, str) or sides.strip().lower() not in LATTICE_SIDES:
|
| 130 |
+
raise ValueError(f"model.lattice_features.sides must be one of {list(LATTICE_SIDES)}, got {sides!r}")
|
| 131 |
+
return {"stats": stats, "per_crop": True, "clip": clip, "sides": sides.strip().lower()}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def render_grid(n: torch.Tensor) -> torch.Tensor:
|
| 135 |
+
"""n0 = max(16, 16 * floor(n / 16)) for integer-valued float64 sizes."""
|
| 136 |
+
return torch.clamp(torch.floor(n / RENDER_GRID) * RENDER_GRID, min=float(RENDER_GRID))
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def median3x3(y: torch.Tensor) -> torch.Tensor:
|
| 140 |
+
"""3x3 median of [N, H, W] with an edge-replicate border (= scipy.ndimage.median_filter(size=3, mode='reflect')).
|
| 141 |
+
Exact order statistic via the 19-exchange median-of-9 network (elementwise min / max only: deterministic, no sort)."""
|
| 142 |
+
h, w = y.shape[-2:]
|
| 143 |
+
p = F.pad(y.unsqueeze(1), (1, 1, 1, 1), mode="replicate").squeeze(1)
|
| 144 |
+
v = [p[:, i:i + h, j:j + w] for i in range(3) for j in range(3)]
|
| 145 |
+
for a, b in ((1, 2), (4, 5), (7, 8), (0, 1), (3, 4), (6, 7), (1, 2), (4, 5), (7, 8), (0, 3), (5, 8), (4, 7), (3, 6),
|
| 146 |
+
(1, 4), (2, 5), (4, 7), (4, 2), (6, 4), (4, 2)):
|
| 147 |
+
v[a], v[b] = torch.minimum(v[a], v[b]), torch.maximum(v[a], v[b])
|
| 148 |
+
return v[4]
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _image_coords(start: torch.Tensor, flip: torch.Tensor, length: int, n_full: torch.Tensor) -> torch.Tensor:
|
| 152 |
+
"""[N, length] generation-grid coordinate x_in of the crop's pixel columns (or rows), float64."""
|
| 153 |
+
j = torch.arange(length, dtype=torch.float64, device=start.device).unsqueeze(0)
|
| 154 |
+
pos = torch.where(flip.unsqueeze(1) > 0.5, (length - 1) - j, j) + start.unsqueeze(1) # image column of crop column j
|
| 155 |
+
s = (render_grid(n_full) / n_full).unsqueeze(1)
|
| 156 |
+
return (pos + 0.5) * s - 0.5
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
class LatticeFeatures(nn.Module):
|
| 160 |
+
"""raw 0-1 crops [N, 3, H, W] + geometry [N, 6] (crop_geometry fields) -> [N, len(stats)] float32 features.
|
| 161 |
+
No parameters and no buffers: nothing is added to a state dict."""
|
| 162 |
+
|
| 163 |
+
CHUNK = 64 # crops per pass: bounds the transient memory of the median network (~20 MB per crop-chunk of 224^2)
|
| 164 |
+
|
| 165 |
+
def __init__(self, spec: dict) -> None:
|
| 166 |
+
super().__init__()
|
| 167 |
+
spec = parse_lattice_features(spec) # idempotent on an already parsed spec
|
| 168 |
+
if spec is None:
|
| 169 |
+
raise ValueError("LatticeFeatures needs an enabled lattice_features spec")
|
| 170 |
+
self.stats = tuple(spec["stats"])
|
| 171 |
+
self.clip = float(spec["clip"])
|
| 172 |
+
self.sides = str(spec["sides"])
|
| 173 |
+
self.out_dim = len(self.stats)
|
| 174 |
+
|
| 175 |
+
def extra_repr(self) -> str:
|
| 176 |
+
return f"stats={list(self.stats)}, clip={self.clip:g}, sides={self.sides}"
|
| 177 |
+
|
| 178 |
+
@staticmethod
|
| 179 |
+
def transform(z: torch.Tensor, clip: float, sides: str) -> torch.Tensor:
|
| 180 |
+
"""positive: clamp(log1p(max(z, 0)), 0, clip) - an anti-phase lattice reads as 'no lattice';
|
| 181 |
+
signed: clamp(sign(z) * log1p(|z|), -clip, clip)."""
|
| 182 |
+
if sides == "positive":
|
| 183 |
+
return torch.clamp(torch.log1p(torch.clamp(z, min=0.0)), 0.0, clip)
|
| 184 |
+
if sides == "signed":
|
| 185 |
+
return torch.clamp(torch.sign(z) * torch.log1p(z.abs()), -clip, clip)
|
| 186 |
+
raise ValueError(f"lattice transform: unknown sides {sides!r}")
|
| 187 |
+
|
| 188 |
+
def forward(self, raw: torch.Tensor, geom: torch.Tensor) -> torch.Tensor:
|
| 189 |
+
z = self.z_scores(raw, geom)
|
| 190 |
+
out = torch.stack([self.transform(z[s], self.clip, self.sides) for s in self.stats], dim=1)
|
| 191 |
+
return out.to(torch.float32)
|
| 192 |
+
|
| 193 |
+
@torch.no_grad()
|
| 194 |
+
def z_scores(self, raw: torch.Tensor, geom: torch.Tensor) -> dict[str, torch.Tensor]:
|
| 195 |
+
"""{stat: [N] float64 signed z} for every statistic (the features before the log / clip transform)."""
|
| 196 |
+
if raw.dim() != 4 or raw.shape[1] != 3:
|
| 197 |
+
raise ValueError(f"LatticeFeatures: expected raw crops [N, 3, H, W], got {tuple(raw.shape)}")
|
| 198 |
+
n = raw.shape[0]
|
| 199 |
+
geom = torch.as_tensor(geom).to(device=raw.device, dtype=torch.float64).reshape(n, len(GEOM_FIELDS))
|
| 200 |
+
dev_type = raw.device.type if raw.device.type in ("cuda", "cpu") else "cpu"
|
| 201 |
+
parts: dict[str, list] = {k: [] for k in LATTICE_STATS}
|
| 202 |
+
with torch.autocast(device_type=dev_type, enabled=False):
|
| 203 |
+
for i in range(0, n, self.CHUNK):
|
| 204 |
+
zs = self._z_chunk(raw[i:i + self.CHUNK].detach().float(), geom[i:i + self.CHUNK])
|
| 205 |
+
for k in LATTICE_STATS:
|
| 206 |
+
parts[k].append(zs[k])
|
| 207 |
+
return {k: torch.cat(v) for k, v in parts.items()}
|
| 208 |
+
|
| 209 |
+
@staticmethod
|
| 210 |
+
def _z_chunk(raw: torch.Tensor, geom: torch.Tensor) -> dict[str, torch.Tensor]:
|
| 211 |
+
n, _, h, w = raw.shape
|
| 212 |
+
y = (raw[:, 0] * LUMA[0] + raw[:, 1] * LUMA[1] + raw[:, 2] * LUMA[2]) * 255.0
|
| 213 |
+
r = (y - median3x3(y)).to(torch.float64) # [n, h, w]
|
| 214 |
+
x0, y0, wimg, himg, hf, vf = geom.unbind(dim=1)
|
| 215 |
+
xin = _image_coords(x0, hf, w, wimg) # [n, w]
|
| 216 |
+
yin = _image_coords(y0, vf, h, himg) # [n, h]
|
| 217 |
+
eps = torch.tensor(CTRL_EPS, dtype=torch.float64, device=raw.device) # [8]
|
| 218 |
+
|
| 219 |
+
def axis_z(prof: torch.Tensor, xi: torch.Tensor, phi: float) -> torch.Tensor:
|
| 220 |
+
sc = (prof * torch.cos(math.pi * xi + phi)).sum(dim=1)
|
| 221 |
+
a = math.pi * xi.unsqueeze(1) * (1.0 + eps).view(1, -1, 1) # [n, 8, len]
|
| 222 |
+
pc = (prof.unsqueeze(1) * torch.cos(a)).sum(dim=2)
|
| 223 |
+
ps = (prof.unsqueeze(1) * torch.sin(a)).sum(dim=2)
|
| 224 |
+
noise = ((pc * pc + ps * ps) / 2.0).mean(dim=1) + Z_EPS
|
| 225 |
+
return sc / torch.sqrt(noise)
|
| 226 |
+
|
| 227 |
+
zx = axis_z(r.sum(dim=1), xin, PHI0[0]) # column sums
|
| 228 |
+
zy = axis_z(r.sum(dim=2), yin, PHI0[1]) # row sums
|
| 229 |
+
|
| 230 |
+
scales = torch.cat([torch.zeros(1, dtype=torch.float64, device=raw.device), eps]) # [9]: 0 = the reference
|
| 231 |
+
a = 0.5 * math.pi * xin.unsqueeze(2) * (1.0 + scales).view(1, 1, -1) + THETA_D4 # [n, w, 9]
|
| 232 |
+
b = 0.5 * math.pi * yin.unsqueeze(2) * (1.0 + scales).view(1, 1, -1) # [n, h, 9]
|
| 233 |
+
# sum_{y,x} r cos(A(x) - B(y)) = sum_y [cos B (r @ cos A) + sin B (r @ sin A)] (float64 bmm: no TF32)
|
| 234 |
+
s = (torch.cos(b) * torch.bmm(r, torch.cos(a)) + torch.sin(b) * torch.bmm(r, torch.sin(a))).sum(dim=1) # [n, 9]
|
| 235 |
+
noise = torch.sqrt((s[:, 1:] ** 2).mean(dim=1)) + Z_EPS
|
| 236 |
+
zd4 = s[:, 0] / noise
|
| 237 |
+
return {"nyq_min_img": torch.minimum(zx, zy), "d4_img": zd4, "nyq_x_img": zx, "nyq_y_img": zy}
|
main_hybrid.py
CHANGED
|
@@ -56,8 +56,10 @@ import fusion
|
|
| 56 |
import guards
|
| 57 |
import multiview
|
| 58 |
import provenance_gate
|
|
|
|
| 59 |
import synthid_gate
|
| 60 |
-
from heatmap_nextgen import overlay as render_overlay, pure_heatmap, residual_var
|
|
|
|
| 61 |
from patchguard import PATCHGUARD_ARCH, PatchGuardDetector
|
| 62 |
from schemas import AnalyzeResponse, HealthResponse, VersionResponse
|
| 63 |
from zero_shot_v4 import CLIP_MEAN, CLIP_STD, ZeroShotV4Detector
|
|
@@ -232,6 +234,20 @@ def fusion_decision(band: str, source: str, fused: float | None, model) -> tuple
|
|
| 232 |
|
| 233 |
|
| 234 |
FUSION_MODE = parse_fusion_mode(os.getenv("AEYE_FUSION", "auto")) # auto | on | off
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
FUSION_PATH = os.getenv("AEYE_FUSION_PATH", os.path.join(os.path.dirname(os.path.abspath(__file__)), "fusion",
|
| 236 |
"fusion_v7.2.json"))
|
| 237 |
|
|
@@ -429,9 +445,71 @@ def _resolve_weights(path: str) -> str:
|
|
| 429 |
|
| 430 |
|
| 431 |
_V5_KEYS = ("clip_layers", "anchor_layer", "clip_adapter", "adapter_rank", "adapter_targets", "adapter_layers")
|
| 432 |
-
|
|
|
|
|
|
|
| 433 |
_V6_KEYS = ("patch_backbone", "patch_dim", "n_crops_train", "n_crops_eval", "crop_pool", "crop_head",
|
| 434 |
-
"semantic_dropout_p", "artifact_dropout_p", "attribution_classes"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
|
| 436 |
|
| 437 |
def _source_head_width(state) -> int | None:
|
|
@@ -486,6 +564,7 @@ def _verdict_kwargs_from_checkpoint(ck) -> tuple[str, dict]:
|
|
| 486 |
for k in _V6_KEYS:
|
| 487 |
if k in mcfg:
|
| 488 |
kwargs[k] = mcfg[k]
|
|
|
|
| 489 |
return "v6", kwargs
|
| 490 |
return "v5", kwargs
|
| 491 |
|
|
@@ -520,6 +599,21 @@ def _load_state_strict(model: torch.nn.Module, state: dict, label: str) -> int:
|
|
| 520 |
return len(state)
|
| 521 |
|
| 522 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
def _read_threshold(path: str, default: float) -> float:
|
| 524 |
try:
|
| 525 |
ck = torch.load(path, map_location="cpu", weights_only=True) # never unpickle arbitrary objects
|
|
@@ -633,23 +727,36 @@ class HybridDetector:
|
|
| 633 |
model = ZeroShotV4Detector(**kwargs)
|
| 634 |
state = ck.get("model_trainable_state_dict", ck.get("model_state_dict", ck))
|
| 635 |
n = _load_state_strict(model, state, f"verdict({arch})")
|
|
|
|
|
|
|
|
|
|
| 636 |
# needed by the fusion gate (v6 only) and by the band binding (AEYE_BANDS_FOR); hashing ~30 MB of tensors is
|
| 637 |
# not free, so only then
|
| 638 |
wanted = (arch == "v6" and FUSION_MODE != "off") or bool(BANDS_FOR)
|
| 639 |
self.verdict_fingerprint = fusion.state_fingerprint(state) if wanted else None
|
| 640 |
_log(f"[verdict] arch={arch} clip_layers={kwargs.get('clip_layers', kwargs.get('clip_layer'))} "
|
| 641 |
f"adapter={kwargs.get('clip_adapter', 'none')} tensors={n} "
|
| 642 |
-
f"
|
|
|
|
| 643 |
return model.to(self.device).eval()
|
| 644 |
|
| 645 |
def _native_crops(self, pil: Image.Image) -> torch.Tensor:
|
| 646 |
"""[1, K, 3, 224, 224] windows cut at the image's own resolution — the second half of a v6 input.
|
| 647 |
-
Same grid and same padding as dataset_v7/eval_sealed.py (see multiview.py).
|
|
|
|
|
|
|
|
|
|
| 648 |
padded = multiview.pad_to(pil, INPUT_SIZE)
|
| 649 |
w, h = padded.size
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 653 |
|
| 654 |
@torch.no_grad()
|
| 655 |
def _dense_patch(self, pil: Image.Image) -> np.ndarray:
|
|
@@ -811,7 +918,8 @@ class HybridDetector:
|
|
| 811 |
# confidence dimming (product preference: heatmap should never look faint).
|
| 812 |
conf = 1.0
|
| 813 |
heatmap_b64 = _png_b64(pure_heatmap(loc, display.size, conf, blanket=False))
|
| 814 |
-
overlay_b64 = _png_b64(
|
|
|
|
| 815 |
flat = np.sort(patch.ravel())[::-1]
|
| 816 |
k = max(1, int(round(flat.size * 0.08)))
|
| 817 |
structure_score = float(flat[:k].mean())
|
|
@@ -833,6 +941,13 @@ class HybridDetector:
|
|
| 833 |
if c2pa.get("present") or signals:
|
| 834 |
provenance_status = "unverified" # string scan only; no signature / trust-chain validation
|
| 835 |
taxonomy = "EDITED" if (source == "patchguard_rescue" or partial_edit_suspect) else verdict
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 836 |
return {
|
| 837 |
"score": score,
|
| 838 |
"verdict": verdict,
|
|
@@ -840,6 +955,7 @@ class HybridDetector:
|
|
| 840 |
"band_thresholds": {"t5": t5, "t1": t1},
|
| 841 |
"heatmap_b64": heatmap_b64,
|
| 842 |
"overlay_b64": overlay_b64,
|
|
|
|
| 843 |
"model_version": self.model_version + ("+provenance" if source == "provenance" else "") + ("+rescue" if rescued else ""),
|
| 844 |
"elapsed_ms": int((time.perf_counter() - started) * 1000),
|
| 845 |
"structure_score": structure_score,
|
|
@@ -862,6 +978,8 @@ class HybridDetector:
|
|
| 862 |
"max_band": self.fusion.max_band}
|
| 863 |
# the detailed reason (paths, fingerprints) stays in the start-up log, not in every response
|
| 864 |
if fused is not None else {"enabled": False, "status": "disabled"}),
|
|
|
|
|
|
|
| 865 |
}
|
| 866 |
|
| 867 |
|
|
@@ -889,7 +1007,7 @@ def startup_banner(s: "guards.Settings", threshold: float, arch: str, crops: int
|
|
| 889 |
the pixel-bomb patch had split that field in two, and since every unit test runs the app WITHOUT the
|
| 890 |
lifespan, `uvicorn main_hybrid:app` died at "Application startup" while the suite stayed green."""
|
| 891 |
return (f"[hybrid] ready: verdict={arch}" + (f" crops={crops}" if crops else "") + f" thr={threshold} "
|
| 892 |
-
f"heatmap=dense | guards: max_upload={s.max_upload_bytes}B "
|
| 893 |
f"max_pixels jpeg={s.max_pixels_jpeg}/other={s.max_pixels_other} decoded<={s.max_decoded_pixels} "
|
| 894 |
f"concurrency={s.max_concurrency}/queue={s.max_queue} wait={s.queue_timeout_s}s "
|
| 895 |
f"anon={s.anon_per_minute}/min,{s.anon_per_day}/day keys={len(s.api_keys)} "
|
|
@@ -1078,6 +1196,7 @@ async def predict(request: Request, image: UploadFile = File(...)) -> dict[str,
|
|
| 1078 |
"reason": result.get("reason"),
|
| 1079 |
"heatmap_b64": result.get("heatmap_b64"),
|
| 1080 |
"overlay_b64": result.get("overlay_b64"),
|
|
|
|
| 1081 |
}
|
| 1082 |
|
| 1083 |
|
|
|
|
| 56 |
import guards
|
| 57 |
import multiview
|
| 58 |
import provenance_gate
|
| 59 |
+
import score_display
|
| 60 |
import synthid_gate
|
| 61 |
+
from heatmap_nextgen import HEATMAP_STYLES, overlay as render_overlay, pure_heatmap, residual_var
|
| 62 |
+
from heatmap_nextgen import spotlight as render_spotlight
|
| 63 |
from patchguard import PATCHGUARD_ARCH, PatchGuardDetector
|
| 64 |
from schemas import AnalyzeResponse, HealthResponse, VersionResponse
|
| 65 |
from zero_shot_v4 import CLIP_MEAN, CLIP_STD, ZeroShotV4Detector
|
|
|
|
| 234 |
|
| 235 |
|
| 236 |
FUSION_MODE = parse_fusion_mode(os.getenv("AEYE_FUSION", "auto")) # auto | on | off
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def parse_heatmap_style(value: str | None) -> str:
|
| 240 |
+
"""AEYE_HEATMAP_STYLE: spotlight (default since the 2026-09-24 app request) | jet (the old colour overlay).
|
| 241 |
+
Rolling the look back is this one variable. An unknown value is logged and served as spotlight rather than
|
| 242 |
+
stopping the start-up: it only changes how the overlay looks, never a verdict."""
|
| 243 |
+
style = (value or "").strip().lower() or "spotlight"
|
| 244 |
+
if style not in HEATMAP_STYLES:
|
| 245 |
+
print(f"[hybrid] AEYE_HEATMAP_STYLE={value!r} is not one of {HEATMAP_STYLES} - serving spotlight", flush=True)
|
| 246 |
+
return "spotlight"
|
| 247 |
+
return style
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
HEATMAP_STYLE = parse_heatmap_style(os.getenv("AEYE_HEATMAP_STYLE"))
|
| 251 |
FUSION_PATH = os.getenv("AEYE_FUSION_PATH", os.path.join(os.path.dirname(os.path.abspath(__file__)), "fusion",
|
| 252 |
"fusion_v7.2.json"))
|
| 253 |
|
|
|
|
| 445 |
|
| 446 |
|
| 447 |
_V5_KEYS = ("clip_layers", "anchor_layer", "clip_adapter", "adapter_rank", "adapter_targets", "adapter_layers")
|
| 448 |
+
# fft_nyquist (C3, 2026-09-24): a checkpoint trained with the Nyquist ring must be rebuilt with it; absent = historic rings
|
| 449 |
+
_V4_KEYS = ("clip_backbone", "clip_layer", "semantic_dim", "forensic_dim", "frequency_dim", "fft_bins", "image_size", "num_classes",
|
| 450 |
+
"fft_nyquist")
|
| 451 |
_V6_KEYS = ("patch_backbone", "patch_dim", "n_crops_train", "n_crops_eval", "crop_pool", "crop_head",
|
| 452 |
+
"semantic_dropout_p", "artifact_dropout_p", "attribution_classes",
|
| 453 |
+
# GPT round 3 (2026-09-26): per-crop renderer-lattice statistics; ZeroShotV6Detector refuses an unknown stat and
|
| 454 |
+
# _native_crops then attaches the crop geometry plane (crop_geometry.py) the statistics are defined on
|
| 455 |
+
"lattice_features")
|
| 456 |
+
# patch_adapter (R2C, 2026-09-24) is deliberately NOT a constructor key here: a verdict trained with an Effort adapter on
|
| 457 |
+
# its DINOv2 linears is served MERGED. The export (tools/merge_patch_adapter.py; tools/package_release.py does it) folds
|
| 458 |
+
# W_p + U_k diag(s_k) V_k into plain weights stored under the frozen backbone's own names, the service builds the plain
|
| 459 |
+
# v6 and its ordinary strict loader puts them into the frozen DINOv2 - no adapter code, no SVD at start-up, the CPU cost
|
| 460 |
+
# of a frozen-DINOv2 model. _check_patch_adapter proves the stored tensors are exactly the ones the config announces.
|
| 461 |
+
# Target -> module path in a Dinov2Layer: a copy of the training table (src/models/zero_shot_v6.PATCH_ADAPTER_TARGETS);
|
| 462 |
+
# tests/test_patch_adapter_merge.py pins that the two agree.
|
| 463 |
+
_PATCH_ADAPTER_TARGETS = {
|
| 464 |
+
"query": "attention.attention.query",
|
| 465 |
+
"key": "attention.attention.key",
|
| 466 |
+
"value": "attention.attention.value",
|
| 467 |
+
"dense": "attention.output.dense",
|
| 468 |
+
"fc1": "mlp.fc1",
|
| 469 |
+
"fc2": "mlp.fc2",
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def _patch_adapter_spec(mcfg) -> dict | None:
|
| 474 |
+
"""The checkpoint's MERGED patch adapter spec, None without one. An unmerged adapter is refused (fail closed): the
|
| 475 |
+
service has no adapter code path, and the factors alone would be dropped as unknown tensors anyway."""
|
| 476 |
+
pa = mcfg.get("patch_adapter") if isinstance(mcfg, dict) else None
|
| 477 |
+
if pa is None or pa is False or (isinstance(pa, str) and pa.strip().lower() in ("", "none", "off")):
|
| 478 |
+
return None
|
| 479 |
+
if not isinstance(pa, dict):
|
| 480 |
+
raise ValueError(f"verdict checkpoint: model.patch_adapter {pa!r} is not a mapping")
|
| 481 |
+
if str(pa.get("kind", "none")).strip().lower() in ("", "none", "off"):
|
| 482 |
+
return None
|
| 483 |
+
if not pa.get("merged"):
|
| 484 |
+
raise ValueError("verdict checkpoint carries an UNMERGED DINOv2 patch adapter (model.patch_adapter): this service "
|
| 485 |
+
"serves merged weights only - export it with tools/merge_patch_adapter.py (package_release does)")
|
| 486 |
+
targets = pa.get("targets")
|
| 487 |
+
if not targets or isinstance(targets, str) or any(t not in _PATCH_ADAPTER_TARGETS for t in targets):
|
| 488 |
+
raise ValueError(f"verdict checkpoint: model.patch_adapter.targets {targets!r} not understood by this service build")
|
| 489 |
+
return pa
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def _check_patch_adapter(model: torch.nn.Module, mcfg, state: dict, label: str) -> int:
|
| 493 |
+
"""The DINOv2 tensors a checkpoint stores must be EXACTLY the merged adapter weights its config announces (every
|
| 494 |
+
targeted linear of every layer, nothing else) - otherwise a half-merged export, or merged weights with the config
|
| 495 |
+
flag lost, would serve a silently different backbone. Returns the number of merged tensors (0 without an adapter)."""
|
| 496 |
+
spec = _patch_adapter_spec(mcfg)
|
| 497 |
+
patch_keys = sorted(k for k in state if k.startswith("patch."))
|
| 498 |
+
if spec is None:
|
| 499 |
+
if patch_keys:
|
| 500 |
+
raise RuntimeError(f"{label}: checkpoint carries {len(patch_keys)} DINOv2 tensor(s) {patch_keys[:3]} but its config "
|
| 501 |
+
f"has no patch_adapter - refusing to serve an undeclared backbone change")
|
| 502 |
+
return 0
|
| 503 |
+
layers = getattr(getattr(getattr(model, "patch", None), "encoder", None), "layer", None)
|
| 504 |
+
if layers is None:
|
| 505 |
+
raise RuntimeError(f"{label}: model.patch_adapter is set but the model has no DINOv2 patch backbone")
|
| 506 |
+
want = sorted(f"patch.encoder.layer.{i}.{_PATCH_ADAPTER_TARGETS[t]}.weight"
|
| 507 |
+
for i in range(len(layers)) for t in spec["targets"])
|
| 508 |
+
if patch_keys != want:
|
| 509 |
+
missing, extra = sorted(set(want) - set(patch_keys)), sorted(set(patch_keys) - set(want))
|
| 510 |
+
raise RuntimeError(f"{label}: merged patch adapter does not match its config ({list(spec['targets'])} x {len(layers)} "
|
| 511 |
+
f"layers): {len(missing)} missing {missing[:3]}, {len(extra)} unexpected {extra[:3]}")
|
| 512 |
+
return len(want)
|
| 513 |
|
| 514 |
|
| 515 |
def _source_head_width(state) -> int | None:
|
|
|
|
| 564 |
for k in _V6_KEYS:
|
| 565 |
if k in mcfg:
|
| 566 |
kwargs[k] = mcfg[k]
|
| 567 |
+
_patch_adapter_spec(mcfg) # R2C: an unmerged DINOv2 adapter is refused here, before anything is built
|
| 568 |
return "v6", kwargs
|
| 569 |
return "v5", kwargs
|
| 570 |
|
|
|
|
| 599 |
return len(state)
|
| 600 |
|
| 601 |
|
| 602 |
+
def _check_fft_rings(model: torch.nn.Module, label: str) -> None:
|
| 603 |
+
"""The radial-FFT rings are a PERSISTENT buffer, so a checkpoint carries its own copy and loading it overrides the
|
| 604 |
+
rings the constructor built. Prove that what was loaded is exactly the geometry the checkpoint's config describes
|
| 605 |
+
(fft_nyquist on or off): a Nyquist-trained checkpoint served through a config without the flag - or the reverse -
|
| 606 |
+
would otherwise either compute silently different features or silently match only by accident. Every pre-C3
|
| 607 |
+
checkpoint (best49, best63, best69, best69_loc) stores exactly the historic rings (checked 2026-09-24)."""
|
| 608 |
+
fb = getattr(model, "frequency_branch", None)
|
| 609 |
+
if fb is None or not hasattr(fb, "masks"):
|
| 610 |
+
return
|
| 611 |
+
want = fb._make_radial_masks(fb.image_size, fb.bins, nyquist=bool(getattr(fb, "nyquist", False)))
|
| 612 |
+
if not torch.equal(fb.masks.detach().cpu(), want):
|
| 613 |
+
raise RuntimeError(f"{label}: frequency_branch.masks do not match the rings of fft_nyquist="
|
| 614 |
+
f"{bool(getattr(fb, 'nyquist', False))} - checkpoint and config disagree")
|
| 615 |
+
|
| 616 |
+
|
| 617 |
def _read_threshold(path: str, default: float) -> float:
|
| 618 |
try:
|
| 619 |
ck = torch.load(path, map_location="cpu", weights_only=True) # never unpickle arbitrary objects
|
|
|
|
| 727 |
model = ZeroShotV4Detector(**kwargs)
|
| 728 |
state = ck.get("model_trainable_state_dict", ck.get("model_state_dict", ck))
|
| 729 |
n = _load_state_strict(model, state, f"verdict({arch})")
|
| 730 |
+
_check_fft_rings(model, f"verdict({arch})")
|
| 731 |
+
mcfg = ((ck.get("config") or {}).get("model") or {}) if isinstance(ck, dict) else {}
|
| 732 |
+
n_patch = _check_patch_adapter(model, mcfg, state, f"verdict({arch})") if arch == "v6" else 0
|
| 733 |
# needed by the fusion gate (v6 only) and by the band binding (AEYE_BANDS_FOR); hashing ~30 MB of tensors is
|
| 734 |
# not free, so only then
|
| 735 |
wanted = (arch == "v6" and FUSION_MODE != "off") or bool(BANDS_FOR)
|
| 736 |
self.verdict_fingerprint = fusion.state_fingerprint(state) if wanted else None
|
| 737 |
_log(f"[verdict] arch={arch} clip_layers={kwargs.get('clip_layers', kwargs.get('clip_layer'))} "
|
| 738 |
f"adapter={kwargs.get('clip_adapter', 'none')} tensors={n} "
|
| 739 |
+
+ (f"patch_adapter=merged({n_patch} DINOv2 weights) " if n_patch else "")
|
| 740 |
+
+ f"crops={self.verdict_crops} decode<={self.verdict_decode_box or 'guard default'}")
|
| 741 |
return model.to(self.device).eval()
|
| 742 |
|
| 743 |
def _native_crops(self, pil: Image.Image) -> torch.Tensor:
|
| 744 |
"""[1, K, 3, 224, 224] windows cut at the image's own resolution — the second half of a v6 input.
|
| 745 |
+
Same grid and same padding as dataset_v7/eval_sealed.py (see multiview.py). A verdict with lattice_features gets
|
| 746 |
+
[1, K, 4, 224, 224]: + the geometry plane (crop_geometry.py) = each window's offset in the decoded image (before
|
| 747 |
+
the padding) and that image's size, exactly as eval_sealed.RecordSet(crop_geometry=True) attaches it."""
|
| 748 |
+
w0, h0 = pil.size
|
| 749 |
padded = multiview.pad_to(pil, INPUT_SIZE)
|
| 750 |
w, h = padded.size
|
| 751 |
+
boxes = multiview.grid_boxes(w, h, INPUT_SIZE, self.verdict_crops)
|
| 752 |
+
crops = torch.stack([self.transform_crop(padded.crop((cx, cy, cx + INPUT_SIZE, cy + INPUT_SIZE))) for cx, cy in boxes])
|
| 753 |
+
if getattr(self.verdict, "lattice_spec", None) is not None:
|
| 754 |
+
import crop_geometry
|
| 755 |
+
|
| 756 |
+
ox, oy = max(0, INPUT_SIZE - w0) // 2, max(0, INPUT_SIZE - h0) // 2
|
| 757 |
+
crops = crop_geometry.attach_geometry(
|
| 758 |
+
crops, [crop_geometry.geometry_row(cx - ox, cy - oy, w0, h0) for cx, cy in boxes])
|
| 759 |
+
return crops.unsqueeze(0).to(self.device)
|
| 760 |
|
| 761 |
@torch.no_grad()
|
| 762 |
def _dense_patch(self, pil: Image.Image) -> np.ndarray:
|
|
|
|
| 918 |
# confidence dimming (product preference: heatmap should never look faint).
|
| 919 |
conf = 1.0
|
| 920 |
heatmap_b64 = _png_b64(pure_heatmap(loc, display.size, conf, blanket=False))
|
| 921 |
+
overlay_b64 = _png_b64(render_spotlight(display, loc) if HEATMAP_STYLE == "spotlight"
|
| 922 |
+
else render_overlay(display, loc, conf, blanket=False))
|
| 923 |
flat = np.sort(patch.ravel())[::-1]
|
| 924 |
k = max(1, int(round(flat.size * 0.08)))
|
| 925 |
structure_score = float(flat[:k].mean())
|
|
|
|
| 941 |
if c2pa.get("present") or signals:
|
| 942 |
provenance_status = "unverified" # string scan only; no signature / trust-chain validation
|
| 943 |
taxonomy = "EDITED" if (source == "patchguard_rescue" or partial_edit_suspect) else verdict
|
| 944 |
+
# additive display fields (score_display.py, owner 2026-09-28): the pixel score stretched onto the FINAL
|
| 945 |
+
# band's range (REAL 0-10, UNCERTAIN 40-60, AI 90-100); nothing above reads them back
|
| 946 |
+
display_band = score_display.final_band(verdict, verdict_band, source)
|
| 947 |
+
# a filename export pattern alone is weak evidence: shown as the pixel score inside the AI range, not 100
|
| 948 |
+
filename_only = bool(filename_signals) and not c2pa.get("present") and set(signals) <= set(filename_signals)
|
| 949 |
+
display_score = score_display.display_score(score49, display_band, source, t5, t1, self.threshold,
|
| 950 |
+
filename_only=filename_only)
|
| 951 |
return {
|
| 952 |
"score": score,
|
| 953 |
"verdict": verdict,
|
|
|
|
| 955 |
"band_thresholds": {"t5": t5, "t1": t1},
|
| 956 |
"heatmap_b64": heatmap_b64,
|
| 957 |
"overlay_b64": overlay_b64,
|
| 958 |
+
"heatmap_style": HEATMAP_STYLE,
|
| 959 |
"model_version": self.model_version + ("+provenance" if source == "provenance" else "") + ("+rescue" if rescued else ""),
|
| 960 |
"elapsed_ms": int((time.perf_counter() - started) * 1000),
|
| 961 |
"structure_score": structure_score,
|
|
|
|
| 978 |
"max_band": self.fusion.max_band}
|
| 979 |
# the detailed reason (paths, fingerprints) stays in the start-up log, not in every response
|
| 980 |
if fused is not None else {"enabled": False, "status": "disabled"}),
|
| 981 |
+
"display_score": display_score,
|
| 982 |
+
"display_band": display_band,
|
| 983 |
}
|
| 984 |
|
| 985 |
|
|
|
|
| 1007 |
the pixel-bomb patch had split that field in two, and since every unit test runs the app WITHOUT the
|
| 1008 |
lifespan, `uvicorn main_hybrid:app` died at "Application startup" while the suite stayed green."""
|
| 1009 |
return (f"[hybrid] ready: verdict={arch}" + (f" crops={crops}" if crops else "") + f" thr={threshold} "
|
| 1010 |
+
f"heatmap=dense/{HEATMAP_STYLE} | guards: max_upload={s.max_upload_bytes}B "
|
| 1011 |
f"max_pixels jpeg={s.max_pixels_jpeg}/other={s.max_pixels_other} decoded<={s.max_decoded_pixels} "
|
| 1012 |
f"concurrency={s.max_concurrency}/queue={s.max_queue} wait={s.queue_timeout_s}s "
|
| 1013 |
f"anon={s.anon_per_minute}/min,{s.anon_per_day}/day keys={len(s.api_keys)} "
|
|
|
|
| 1196 |
"reason": result.get("reason"),
|
| 1197 |
"heatmap_b64": result.get("heatmap_b64"),
|
| 1198 |
"overlay_b64": result.get("overlay_b64"),
|
| 1199 |
+
"heatmap_style": result.get("heatmap_style"),
|
| 1200 |
}
|
| 1201 |
|
| 1202 |
|
provenance_gate.py
CHANGED
|
@@ -40,7 +40,9 @@ BARE_C2PA_IS_AI = os.getenv("AEYE_BARE_C2PA_IS_AI", "0") == "1"
|
|
| 40 |
# sources in captions and filenames.
|
| 41 |
SIGNATURES: dict[str, tuple[bytes, ...]] = {
|
| 42 |
"OpenAI": (b"openai", b"dall-e", b"dall\xc2\xb7e", b"dalle", b"gpt-image", b"chatgpt"),
|
| 43 |
-
|
|
|
|
|
|
|
| 44 |
"StableDiffusion/Comfy": (b"stable diffusion", b"stable-diffusion", b"sd-webui", b"automatic1111",
|
| 45 |
b"comfyui", b"negative prompt", b"stability ai", b"stability.ai"),
|
| 46 |
"Midjourney": (b"midjourney", b"nijijourney", b"niji journey", b"niji\xc2\xb7journey", b"--niji"),
|
|
@@ -65,6 +67,11 @@ C2PA_AI_GENERATORS = (b"openai", b"dall-e", b"chatgpt", b"gpt-image", b"firefly"
|
|
| 65 |
b"imagen", b"gemini", b"microsoft designer", b"bing image creator", b"ideogram",
|
| 66 |
b"leonardo", b"black forest labs", b"flux.1", b"grok", b"emu image")
|
| 67 |
PROVENANCE_REGIONS = ("APP11/JUMBF", "caBX", "C2PA", "APP1/XMP", "APP1/XMP-ext", "XMP")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
_EXIF_TAGS = {v: k for k, v in ExifTags.TAGS.items()}
|
| 70 |
|
|
@@ -211,7 +218,7 @@ def scan_metadata(raw: bytes) -> dict:
|
|
| 211 |
capture = False # wrote the manifest is not proof the pixels were generated)
|
| 212 |
for label, data in regions:
|
| 213 |
low = data.lower()
|
| 214 |
-
for sig_label, toks in SIGNATURES.items():
|
| 215 |
for tok in _find_tokens(low, toks):
|
| 216 |
bucket = matches.setdefault(sig_label, [])
|
| 217 |
if tok not in bucket:
|
|
|
|
| 40 |
# sources in captions and filenames.
|
| 41 |
SIGNATURES: dict[str, tuple[bytes, ...]] = {
|
| 42 |
"OpenAI": (b"openai", b"dall-e", b"dall\xc2\xb7e", b"dalle", b"gpt-image", b"chatgpt"),
|
| 43 |
+
# b"cai:" alone was dropped 2026-09-29: it matched by chance inside the APP6 tuning blob of Motorola Moto Z2 Play
|
| 44 |
+
# camera originals (4 Dresden/Forchheim real photos -> "Adobe/Firefly" -> forced AI); the namespace declaration stays
|
| 45 |
+
"Adobe/Firefly": (b"adobe firefly", b"firefly image", b"firefly", b"xmlns:cai="),
|
| 46 |
"StableDiffusion/Comfy": (b"stable diffusion", b"stable-diffusion", b"sd-webui", b"automatic1111",
|
| 47 |
b"comfyui", b"negative prompt", b"stability ai", b"stability.ai"),
|
| 48 |
"Midjourney": (b"midjourney", b"nijijourney", b"niji journey", b"niji\xc2\xb7journey", b"--niji"),
|
|
|
|
| 67 |
b"imagen", b"gemini", b"microsoft designer", b"bing image creator", b"ideogram",
|
| 68 |
b"leonardo", b"black forest labs", b"flux.1", b"grok", b"emu image")
|
| 69 |
PROVENANCE_REGIONS = ("APP11/JUMBF", "caBX", "C2PA", "APP1/XMP", "APP1/XMP-ext", "XMP")
|
| 70 |
+
# JPEG APPn segments that carry vendor BINARY data (camera tuning / maker blobs, Adobe DCT info), never generator text:
|
| 71 |
+
# generator-signature tokens are not searched there, because long runs of bytes that happen to be printable occur in such
|
| 72 |
+
# blobs and a short token then matches by chance (2026-09-29, Moto Z2 Play APP6: "CAI:" -> false "Adobe/Firefly").
|
| 73 |
+
# APP1 (EXIF / XMP), APP11 (JUMBF / C2PA), APP13 (Photoshop IRB / IPTC text) and COM are still searched.
|
| 74 |
+
BINARY_APP_LABELS = frozenset(f"APP{n}" for n in (0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 14, 15))
|
| 75 |
|
| 76 |
_EXIF_TAGS = {v: k for k, v in ExifTags.TAGS.items()}
|
| 77 |
|
|
|
|
| 218 |
capture = False # wrote the manifest is not proof the pixels were generated)
|
| 219 |
for label, data in regions:
|
| 220 |
low = data.lower()
|
| 221 |
+
for sig_label, toks in (() if label in BINARY_APP_LABELS else SIGNATURES.items()):
|
| 222 |
for tok in _find_tokens(low, toks):
|
| 223 |
bucket = matches.setdefault(sig_label, [])
|
| 224 |
if tok not in bucket:
|
renderer_trace.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Size-gated ChatGPT-renderer trace: the 'renderer_trace' verdict basis (GPT round 4, _collab/prereg_gpt_round4.json 'trace').
|
| 2 |
+
|
| 3 |
+
Byte-identical copies: tools/renderer_trace.py and 1_deployed_service/renderer_trace.py
|
| 4 |
+
(1_deployed_service/tests/test_renderer_trace.py pins both). numpy + scipy + PIL only: no torch, no import from tools/ or
|
| 5 |
+
from 4_model_training, so the service copy stands alone.
|
| 6 |
+
|
| 7 |
+
What it measures (frozen before any round-4 data, prereg 'trace.statistic'; constants NOT refit):
|
| 8 |
+
s = min( mean_k nyq_min_img_k / 1.08 , mean_k d4_img_k / 4.42 )
|
| 9 |
+
over the 9 native 224-px crops grid_boxes(W, H, 224, 9) of the decoded image (an image smaller than 224 on a side is first
|
| 10 |
+
padded by pad_to exactly as wf_r3 did, and the crop geometry is then taken in the PADDED frame, as wf_r3 did).
|
| 11 |
+
nyq_min_img = min(z_x, z_y) of the signed Nyquist matched filter in IMAGE coordinates (renderer resample model
|
| 12 |
+
x_in = (x + 0.5) * n0 / n - 0.5 with n0 = 16 * floor(n / 16), fixed reference phase PHI0), z-scored by 8
|
| 13 |
+
lower-frequency controls (= wf_r3 measure/supp.py 'crop_nyqmin_img')
|
| 14 |
+
d4_img = the signed z of the owner-TRAIN template T_REF restricted to its (1/4, -1/4) pair, same resample model,
|
| 15 |
+
z-scored by 8 frequency-scaled controls (= wf_r3 measure/d4all.py 'crop_d4_img')
|
| 16 |
+
1.08 and 4.42 are the reals' pooled p99 of the two statistics (wf_r3 final_tables.md).
|
| 17 |
+
|
| 18 |
+
Why this is a PORT of the wf_r3 numpy code and not LatticeFeatures.z_scores (lattice_features.py sha16 c5970908a11b32ff):
|
| 19 |
+
the prereg asks for |dz| < 1e-4 against the wf_r3 jsonl values. LatticeFeatures computes luma as (u8 / 255 * w) * 255 in
|
| 20 |
+
float32 and reproduces nyq_min_img to ~1e-5 but d4_img only to 1.1e-4 (owner_edit_train IMG pristine, 81 crop-variants
|
| 21 |
+
checked, 2026-09-28), so the rule's fallback applies: latlib.py (wf_r3/measure, sha256 70fb858c...) is ported verbatim
|
| 22 |
+
below (grid_boxes, pad_to, luma, resid, n0_of, x_in, nyq_mf, template_coeffs, tmpl_mf, camguard.restricted). PHI0 and
|
| 23 |
+
T_REF are the ref.json values (sha256 648bd485...), identical to lattice_features.PHI0 / T_REF.
|
| 24 |
+
|
| 25 |
+
Gate (prereg 'trace.gate'): decoded long side <= 2048 px after EXIF transpose. Above it the statistic is not computed and
|
| 26 |
+
the trace never fires. Decoding (decode()) is the evaluator's / service's rule: JPEG/MPO DCT draft to a 2048 box
|
| 27 |
+
(eval_sealed.RecordSet with multi-view, guards.native_draft_target), EXIF transpose, RGB.
|
| 28 |
+
One-sided: fires iff s > t (t = t* from tools/trace_calib.py). The trace can only ADD an AI verdict.
|
| 29 |
+
v2 (_collab/prereg_gpt_round4_trace_v2.json): an optional lower bound, gate_min = 1024 -> the gate is 1024 <= long side
|
| 30 |
+
<= 2048. gate_min = 0 (the default) is v1, bit for bit; the statistic and its fingerprint do not change.
|
| 31 |
+
|
| 32 |
+
python renderer_trace.py <image> [<image> ...] [--t 0.2] [--gate-min 1024] per-image JSON (s, means, long side, gate, fires)
|
| 33 |
+
"""
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
import hashlib
|
| 37 |
+
import io
|
| 38 |
+
import json
|
| 39 |
+
import math
|
| 40 |
+
import sys
|
| 41 |
+
|
| 42 |
+
import numpy as np
|
| 43 |
+
from PIL import Image, ImageOps
|
| 44 |
+
from scipy import ndimage
|
| 45 |
+
|
| 46 |
+
TRACE_VERSION = "renderer_trace/1: min(mean9 crop_nyqmin_img / 1.08, mean9 crop_d4_img / 4.42), gate long side <= 2048"
|
| 47 |
+
SCALE_NYQ = 1.08 # reals' pooled p99 of mean_k nyq_min_img (wf_r3 final_tables.md)
|
| 48 |
+
SCALE_D4 = 4.42 # reals' pooled p99 of mean_k d4_img
|
| 49 |
+
GATE_LONG_SIDE = 2048 # decoded long side (after EXIF transpose) above which the trace is not computed
|
| 50 |
+
CROP = 224
|
| 51 |
+
N_CROPS = 9
|
| 52 |
+
DECODE_BOX = 2048 # JPEG DCT draft box of the evaluator / the v6 service
|
| 53 |
+
|
| 54 |
+
# ---- reference (wf_r3 measure/ref.json, sha256 648bd485f9121afc197c3950bd927f2404408761c0eea4ca679c7a7535c56818) ----
|
| 55 |
+
PHI0 = (0.005224707587257661, 0.003667792028783213) # radians, x / y, at the image origin
|
| 56 |
+
T_REF = ( # 8x8 zero-mean lattice template, rows = y mod 8, cols = x mod 8
|
| 57 |
+
(0.17269160274461814, 0.016624810023089433, 0.18758460622056203, -0.027523961101165936, 0.14382515421100395, 0.010407459922983272, 0.22656217773376325, -0.04924261285800926),
|
| 58 |
+
(4.117843771012356e-05, -0.20775218375052468, 0.018273811741025364, -0.14531792563384296, -0.007125371135598881, -0.19250900379480274, 0.011853639378828587, -0.15534194826915765),
|
| 59 |
+
(0.21871752243500459, -0.041426545193588266, 0.15513920077363727, 0.00981277178155932, 0.18862573156031762, -0.02502035730420058, 0.16482270033206348, -0.006749445513922211),
|
| 60 |
+
(0.02603878721645228, -0.13637760279564196, -0.02078179072234321, -0.1671030130249308, 0.02701412394689342, -0.13616309110290123, 0.009209601740110585, -0.18093331853537442),
|
| 61 |
+
(0.14200447990246007, -0.018297584224412827, 0.18252467048327578, -0.033059550544393856, 0.13405059346250559, 0.006831903648010557, 0.1963587313220746, -0.052682036739672894),
|
| 62 |
+
(0.01117447391168055, -0.20319086173560147, 0.008779578605276629, -0.13356437311264927, -0.014971437140206703, -0.178110895003855, 0.03181750290914937, -0.1503561100517138),
|
| 63 |
+
(0.22419278170968648, -0.037280988420779794, 0.1560292920928076, 0.0016715735350392035, 0.20854080743872597, -0.03821453011186845, 0.1570347879658586, 0.009063247694749367),
|
| 64 |
+
(0.031853372606417844, -0.15298436141156935, -0.02837693990464353, -0.18054482745022435, -0.005528327933958054, -0.1497548713314285, -0.009564785696936069, -0.20332203945750849),
|
| 65 |
+
)
|
| 66 |
+
CTRL_EPS = np.array([-0.015 - 0.01 * k for k in range(8)]) # lower-frequency controls for the matched filters
|
| 67 |
+
KSET = np.arange(-4, 5)
|
| 68 |
+
KW = np.where(np.abs(KSET) == 4, 0.5, 1.0)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ---------------------------------------------------------------- v6 eval geometry (latlib copy of multiview.py)
|
| 72 |
+
def grid_boxes(w: int, h: int, size: int, k: int) -> list[tuple[int, int]]:
|
| 73 |
+
k = max(1, int(k))
|
| 74 |
+
cols = int(round(math.sqrt(k * max(w, 1) / max(h, 1))))
|
| 75 |
+
cols = max(1, min(k, cols))
|
| 76 |
+
rows = max(1, int(math.ceil(k / cols)))
|
| 77 |
+
cols = max(1, min(cols, max(1, w - size + 1)))
|
| 78 |
+
rows = max(1, min(rows, max(1, h - size + 1)))
|
| 79 |
+
xs = [int(round(i * (w - size) / (cols - 1))) if cols > 1 else (w - size) // 2 for i in range(cols)]
|
| 80 |
+
ys = [int(round(j * (h - size) / (rows - 1))) if rows > 1 else (h - size) // 2 for j in range(rows)]
|
| 81 |
+
boxes: list[tuple[int, int]] = []
|
| 82 |
+
for y in ys:
|
| 83 |
+
for x in xs:
|
| 84 |
+
if (x, y) not in boxes:
|
| 85 |
+
boxes.append((x, y))
|
| 86 |
+
if len(boxes) > k:
|
| 87 |
+
idx = np.linspace(0, len(boxes) - 1, k).round().astype(int)
|
| 88 |
+
boxes = [boxes[i] for i in idx]
|
| 89 |
+
while len(boxes) < k:
|
| 90 |
+
boxes.append(boxes[len(boxes) % max(1, len(boxes))])
|
| 91 |
+
return boxes
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def pad_to(arr: np.ndarray, size: int) -> np.ndarray:
|
| 95 |
+
h, w = arr.shape[:2]
|
| 96 |
+
if w >= size and h >= size:
|
| 97 |
+
return arr
|
| 98 |
+
pw, ph = max(0, size - w), max(0, size - h)
|
| 99 |
+
pads = ((ph // 2, ph - ph // 2), (pw // 2, pw - pw // 2)) + ((0, 0),) * (arr.ndim - 2)
|
| 100 |
+
mode = "reflect" if (ph < h and pw < w) else "edge"
|
| 101 |
+
return np.pad(arr, pads, mode=mode)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ---------------------------------------------------------------- basics (latlib verbatim)
|
| 105 |
+
def luma(rgb_u8: np.ndarray) -> np.ndarray:
|
| 106 |
+
a = rgb_u8.astype(np.float32)
|
| 107 |
+
return a[..., 0] * 0.299 + a[..., 1] * 0.587 + a[..., 2] * 0.114
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def resid(Y: np.ndarray) -> np.ndarray:
|
| 111 |
+
return Y - ndimage.median_filter(Y, size=3, mode="reflect")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def n0_of(n: int) -> int:
|
| 115 |
+
return max(16, 16 * (n // 16))
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def x_in(n_full: int, start: int, length: int, model: bool = True) -> np.ndarray:
|
| 119 |
+
"""generation-grid coordinate of output pixels start..start+length-1 of an axis of full length n_full."""
|
| 120 |
+
x = np.arange(start, start + length, dtype=np.float64)
|
| 121 |
+
if not model:
|
| 122 |
+
return x - start
|
| 123 |
+
s = n0_of(n_full) / float(n_full)
|
| 124 |
+
return (x + 0.5) * s - 0.5
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ---------------------------------------------------------------- matched filters (latlib verbatim)
|
| 128 |
+
def nyq_mf(r: np.ndarray, W_full: int, H_full: int, x0: int = 0, y0: int = 0, phi=(0.0, 0.0), model: bool = True):
|
| 129 |
+
"""signed z per axis + quadrature magnitude, predicted Nyquist field of the renderer model."""
|
| 130 |
+
H, W = r.shape
|
| 131 |
+
res = {}
|
| 132 |
+
for ax, (n_full, start, prof, ph0) in {"x": (W_full, x0, r.sum(axis=0, dtype=np.float64), phi[0]),
|
| 133 |
+
"y": (H_full, y0, r.sum(axis=1, dtype=np.float64), phi[1])}.items():
|
| 134 |
+
xi = x_in(n_full, start, len(prof), model)
|
| 135 |
+
arg = np.pi * xi + ph0
|
| 136 |
+
sc, ss = float(prof @ np.cos(arg)), float(prof @ np.sin(arg))
|
| 137 |
+
nz = []
|
| 138 |
+
for e in CTRL_EPS:
|
| 139 |
+
a2 = np.pi * xi * (1.0 + e)
|
| 140 |
+
nz.append(((prof @ np.cos(a2)) ** 2 + (prof @ np.sin(a2)) ** 2) / 2.0)
|
| 141 |
+
noise = float(np.mean(nz)) + 1e-12
|
| 142 |
+
res[ax] = (sc / math.sqrt(noise), math.sqrt((sc * sc + ss * ss) / noise), math.degrees(math.atan2(-ss, sc)))
|
| 143 |
+
return res
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def template_coeffs(T: np.ndarray) -> np.ndarray:
|
| 147 |
+
"""8x8 template -> 9x9 weighted Fourier coefficients on KSET x KSET (rows = l (y), cols = k (x)); DC removed."""
|
| 148 |
+
C8 = np.fft.fft2(T) / 64.0 # C8[l,k]: T(y,x) = sum C8[l,k] exp(2 pi i (l y + k x)/8)
|
| 149 |
+
C = np.zeros((9, 9), complex)
|
| 150 |
+
for a, l in enumerate(KSET):
|
| 151 |
+
for b, k in enumerate(KSET):
|
| 152 |
+
C[a, b] = C8[l % 8, k % 8] * KW[a] * KW[b]
|
| 153 |
+
C[4, 4] = 0.0
|
| 154 |
+
return C
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def tmpl_mf(r: np.ndarray, C: np.ndarray, W_full: int, H_full: int, x0: int = 0, y0: int = 0, model: bool = True):
|
| 158 |
+
"""signed z of <r, P_ref> and phase-agnostic ratio; controls = frequency-scaled templates (x_in * (1+eps))."""
|
| 159 |
+
H, W = r.shape
|
| 160 |
+
xi = x_in(W_full, x0, W, model)
|
| 161 |
+
yi = x_in(H_full, y0, H, model)
|
| 162 |
+
scales = np.concatenate([[0.0], CTRL_EPS])
|
| 163 |
+
# U: W x (9*S) complex, V: H x (9*S)
|
| 164 |
+
U = np.exp(2j * np.pi * np.outer(xi, KSET)[:, None, :] * (1.0 + scales)[None, :, None] / 8.0).reshape(W, -1)
|
| 165 |
+
V = np.exp(2j * np.pi * np.outer(yi, KSET)[:, None, :] * (1.0 + scales)[None, :, None] / 8.0) # H x S x 9
|
| 166 |
+
rr = r.astype(np.float64)
|
| 167 |
+
RU = rr @ U.real + 1j * (rr @ U.imag) # H x (S*9)
|
| 168 |
+
RU = RU.reshape(H, len(scales), 9)
|
| 169 |
+
S_vals, A_vals = [], []
|
| 170 |
+
for s in range(len(scales)):
|
| 171 |
+
R = V[:, s, :].T @ RU[:, s, :] # 9(l) x 9(k): sum_y v_l(y) sum_x r u_k(x)
|
| 172 |
+
S_vals.append(float(np.real(np.sum(C * R))))
|
| 173 |
+
A_vals.append(float(np.sum(np.abs(C) * np.abs(R))))
|
| 174 |
+
S_vals, A_vals = np.array(S_vals), np.array(A_vals)
|
| 175 |
+
noise = math.sqrt(np.mean(S_vals[1:] ** 2)) + 1e-12
|
| 176 |
+
return float(S_vals[0] / noise), float(A_vals[0] / (A_vals[1:].mean() + 1e-12))
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _restricted(keep) -> np.ndarray:
|
| 180 |
+
"""wf_r3 camguard.restricted: T_REF keeping only the listed (l, k) DFT terms, as 9x9 template coefficients."""
|
| 181 |
+
C8 = np.fft.fft2(np.array(T_REF))
|
| 182 |
+
M = np.zeros_like(C8)
|
| 183 |
+
for (l, k) in keep:
|
| 184 |
+
M[l % 8, k % 8] = C8[l % 8, k % 8]
|
| 185 |
+
return template_coeffs(np.real(np.fft.ifft2(M)))
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
C_D4 = _restricted([(-2, 2), (2, -2)]) # the (1/4, -1/4) anti-diagonal pair
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# ---------------------------------------------------------------- decode / statistic
|
| 192 |
+
def decode(src) -> Image.Image:
|
| 193 |
+
"""Pixels as the evaluator and the v6 service decode an upload: JPEG/MPO DCT draft to a 2048 box (PIL picks the largest
|
| 194 |
+
scale s in 8/4/2/1 with s <= min(w // 2048, h // 2048), so files below 4096 px on a side decode at full size), EXIF
|
| 195 |
+
transpose, RGB. `src` = path or bytes."""
|
| 196 |
+
im = Image.open(io.BytesIO(src) if isinstance(src, (bytes, bytearray)) else src)
|
| 197 |
+
if im.format in ("JPEG", "MPO"):
|
| 198 |
+
im.draft("RGB", (DECODE_BOX, DECODE_BOX))
|
| 199 |
+
return ImageOps.exif_transpose(im).convert("RGB")
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def crop_z(img: Image.Image) -> tuple[list[float], list[float]]:
|
| 203 |
+
"""Per-crop (nyq_min_img, d4_img) z over the 9 grid crops of the (padded) image, image coordinates of the padded frame."""
|
| 204 |
+
arr = np.asarray(img.convert("RGB") if img.mode != "RGB" else img)
|
| 205 |
+
H, W = arr.shape[:2]
|
| 206 |
+
if W < CROP or H < CROP:
|
| 207 |
+
arr = pad_to(arr, CROP)
|
| 208 |
+
H, W = arr.shape[:2]
|
| 209 |
+
nyq, d4 = [], []
|
| 210 |
+
for (x0, y0) in grid_boxes(W, H, CROP, N_CROPS):
|
| 211 |
+
r = resid(luma(arr[y0:y0 + CROP, x0:x0 + CROP])).astype(np.float32)
|
| 212 |
+
m = nyq_mf(r, W, H, x0, y0, phi=PHI0, model=True)
|
| 213 |
+
nyq.append(min(m["x"][0], m["y"][0]))
|
| 214 |
+
d4.append(tmpl_mf(r, C_D4, W, H, x0, y0)[0])
|
| 215 |
+
return nyq, d4
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def outside_gate(long_side: int, gate_min: int = 0) -> bool:
|
| 219 |
+
"""True when the trace is not computed: long side > 2048, or (v2, _collab/prereg_gpt_round4_trace_v2.json) long side
|
| 220 |
+
< gate_min. gate_min = 0 is the v1 gate."""
|
| 221 |
+
return long_side > GATE_LONG_SIDE or long_side < int(gate_min)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def trace_stat(img: Image.Image, force: bool = False, gate_min: int = 0) -> dict:
|
| 225 |
+
"""{s, nyq_min_img_mean, d4_img_mean, long_side, gated}. gated=True: long side outside [gate_min, 2048] (v1: gate_min
|
| 226 |
+
= 0, only > 2048), the statistic is not computed (s = None) and the trace cannot fire; force=True computes it anyway
|
| 227 |
+
(diagnostics / reproduction tests only)."""
|
| 228 |
+
long_side = int(max(img.size))
|
| 229 |
+
gated = outside_gate(long_side, gate_min)
|
| 230 |
+
if gated and not force:
|
| 231 |
+
return {"s": None, "nyq_min_img_mean": None, "d4_img_mean": None, "long_side": long_side, "gated": True}
|
| 232 |
+
nyq, d4 = crop_z(img)
|
| 233 |
+
a, d = float(np.mean(nyq)), float(np.mean(d4))
|
| 234 |
+
return {"s": float(min(a / SCALE_NYQ, d / SCALE_D4)), "nyq_min_img_mean": a, "d4_img_mean": d,
|
| 235 |
+
"long_side": long_side, "gated": gated}
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def fires(img: Image.Image, t: float, gate_min: int = 0) -> bool:
|
| 239 |
+
"""The renderer_trace basis: inside the gate and s > t (strict, one-sided). v2: gate_min = 1024."""
|
| 240 |
+
st = trace_stat(img, gate_min=gate_min)
|
| 241 |
+
return (not st["gated"]) and st["s"] is not None and st["s"] > float(t)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def statistic_fingerprint() -> str:
|
| 245 |
+
"""sha256[:16] of everything that defines the statistic and the gate (for AEYE_TRACE_JSON / reports)."""
|
| 246 |
+
spec = {"version": TRACE_VERSION, "scale_nyq": SCALE_NYQ, "scale_d4": SCALE_D4, "gate_long_side": GATE_LONG_SIDE,
|
| 247 |
+
"crop": CROP, "n_crops": N_CROPS, "decode_box": DECODE_BOX, "phi0": list(PHI0), "t_ref": [list(r) for r in T_REF],
|
| 248 |
+
"ctrl_eps": [float(e) for e in CTRL_EPS]}
|
| 249 |
+
return hashlib.sha256(json.dumps(spec, sort_keys=True).encode("utf-8")).hexdigest()[:16]
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def main(argv: list[str]) -> int:
|
| 253 |
+
import argparse
|
| 254 |
+
ap = argparse.ArgumentParser(description="renderer trace statistic of image files (decoded as the service decodes)")
|
| 255 |
+
ap.add_argument("images", nargs="+")
|
| 256 |
+
ap.add_argument("--t", type=float, default=None, help="threshold t*: also print whether the trace fires")
|
| 257 |
+
ap.add_argument("--force", action="store_true", help="compute above the gate too (diagnostics)")
|
| 258 |
+
ap.add_argument("--gate-min", type=int, default=0, help="v2 lower gate bound on the long side (0 = v1)")
|
| 259 |
+
a = ap.parse_args(argv)
|
| 260 |
+
for p in a.images:
|
| 261 |
+
st = trace_stat(decode(p), force=a.force, gate_min=a.gate_min)
|
| 262 |
+
if a.t is not None:
|
| 263 |
+
st["fires"] = (not st["gated"]) and st["s"] is not None and st["s"] > a.t
|
| 264 |
+
print(json.dumps({"path": p, **st}))
|
| 265 |
+
return 0
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
if __name__ == "__main__":
|
| 269 |
+
raise SystemExit(main(sys.argv[1:]))
|
schemas.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from typing import Any
|
| 2 |
|
| 3 |
from pydantic import BaseModel, Field
|
| 4 |
|
|
@@ -32,6 +32,13 @@ class AnalyzeResponse(BaseModel):
|
|
| 32 |
# classifier + localiser fusion (fusion.py). Declared here because FastAPI drops every undeclared key.
|
| 33 |
partial_edit_suspect: bool | None = None # the fusion found a likely local edit the classifier alone did not call AI
|
| 34 |
fusion: dict[str, Any] | None = None # {enabled, score, threshold, hit, calibration, max_band} or {enabled, status}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
class VersionResponse(BaseModel):
|
|
|
|
| 1 |
+
from typing import Any, Literal
|
| 2 |
|
| 3 |
from pydantic import BaseModel, Field
|
| 4 |
|
|
|
|
| 32 |
# classifier + localiser fusion (fusion.py). Declared here because FastAPI drops every undeclared key.
|
| 33 |
partial_edit_suspect: bool | None = None # the fusion found a likely local edit the classifier alone did not call AI
|
| 34 |
fusion: dict[str, Any] | None = None # {enabled, score, threshold, hit, calibration, max_band} or {enabled, status}
|
| 35 |
+
# what the app prints (score_display.py): whole percent inside the final band's range - real 0-10,
|
| 36 |
+
# uncertain 40-60, ai 90-100 - and that band. Additive: every field above is unchanged.
|
| 37 |
+
display_score: int | None = Field(default=None, ge=0, le=100)
|
| 38 |
+
display_band: str | None = None # real | uncertain | ai
|
| 39 |
+
# how overlay_b64 was drawn (AEYE_HEATMAP_STYLE): the app picks its caption and overlay opacity from this, so an
|
| 40 |
+
# app build and a Space deploy no longer have to land on the same day. Absent (older servers) = jet.
|
| 41 |
+
heatmap_style: Literal["jet", "spotlight"] | None = None
|
| 42 |
|
| 43 |
|
| 44 |
class VersionResponse(BaseModel):
|
score_display.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The number the app shows: the pixel model's score mapped onto the calibrated verdict bands (owner decision 2026-09-28).
|
| 2 |
+
|
| 3 |
+
"Real photos read 0-10 %, AI images 90-100 %, the ambiguous ones 40-60 %, and within each range the score still differs
|
| 4 |
+
point by point." The raw model score cannot be shown as it is: RC1's real photos sit at 0.2-0.4 (0.39 median after a
|
| 5 |
+
messenger re-share) while its UNCERTAIN edge t5 is 0.84 and its AI edge t1 is 0.97, so "AI일 확률 39%" on a photo the
|
| 6 |
+
service calls REAL contradicted the verdict, and a fixed cut on the raw score would turn re-shared real photos red.
|
| 7 |
+
Instead each calibrated band is stretched linearly onto its own display range:
|
| 8 |
+
|
| 9 |
+
s < t5 -> REAL_MAX * s / t5 (0 .. 10)
|
| 10 |
+
t5 <= s < t1 -> UNC_MIN + (UNC_MAX - UNC_MIN) * (s - t5) / (t1 - t5) (40 .. 60)
|
| 11 |
+
s >= t1 -> AI_MIN + (100 - AI_MIN) * (s - t1) / (1 - t1) (90 .. 100)
|
| 12 |
+
|
| 13 |
+
rounded half up to an integer, then CLAMPED into the range of the FINAL band the service answered with — the band a
|
| 14 |
+
provenance record, a rescue, the real guard or the fusion decided, not only the pixel score's own band. So a REAL-band
|
| 15 |
+
score the fusion lifted to UNCERTAIN ('local_edit') shows 40, a rescued AI shows at least 90, and an AI-declaring
|
| 16 |
+
provenance record shows 100. Without calibrated bands (legacy v4 checkpoints, AEYE_BAND_* unset: t5 == t1 == thr) the
|
| 17 |
+
decision threshold is the single line: s < thr -> 0..10, s >= thr -> 90..100, and 40..60 is used only when the final
|
| 18 |
+
band itself is UNCERTAIN.
|
| 19 |
+
|
| 20 |
+
On screen (owner 2026-09-29) the number is called "AI 판별 점수" in points (점, out of 100) - a score, not a probability - and
|
| 21 |
+
the internal ranges are not shown to users; the fields and values here are unchanged by that naming decision.
|
| 22 |
+
|
| 23 |
+
Pure and dependency-free so tools/probe_service.py and the tests can import it without loading a model. It is an
|
| 24 |
+
additive display field: verdicts, bands, thresholds and bases are computed elsewhere and never read back from here.
|
| 25 |
+
"""
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import math
|
| 29 |
+
|
| 30 |
+
# display ranges, in whole percent — change them here and nowhere else
|
| 31 |
+
REAL_MAX = 10
|
| 32 |
+
UNC_MIN = 40
|
| 33 |
+
UNC_MAX = 60
|
| 34 |
+
AI_MIN = 90
|
| 35 |
+
|
| 36 |
+
BANDS = ("real", "uncertain", "ai")
|
| 37 |
+
RANGES = {"real": (0, REAL_MAX), "uncertain": (UNC_MIN, UNC_MAX), "ai": (AI_MIN, 100)}
|
| 38 |
+
|
| 39 |
+
# bases whose AI verdict rests on a record in the file, not on the pixels: shown as 100
|
| 40 |
+
PROVENANCE_BASES = frozenset({"provenance"})
|
| 41 |
+
# bases for which a verdict_band of UNCERTAIN is the answer the user gets (same rule as the app's lib/verdict.ts):
|
| 42 |
+
# a provenance hit, a rescue or the real guard decided the binary verdict instead
|
| 43 |
+
UNCERTAIN_BASES = frozenset({"model", "local_edit"})
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def final_band(verdict: str | None, verdict_band: str | None, basis: str | None) -> str:
|
| 47 |
+
"""'real' | 'uncertain' | 'ai': the band the service's answer falls in after every override.
|
| 48 |
+
|
| 49 |
+
verdict_band is the pixel score's band (possibly lifted to UNCERTAIN by the fusion); an override (provenance,
|
| 50 |
+
PatchGuard/SigLIP rescue, real guard) decides through the binary verdict and wins over it."""
|
| 51 |
+
if str(verdict_band or "").upper() == "UNCERTAIN" and (basis or "model") in UNCERTAIN_BASES:
|
| 52 |
+
return "uncertain"
|
| 53 |
+
return "ai" if str(verdict or "").upper() == "AI" else "real"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _unit(x: float) -> float:
|
| 57 |
+
return min(1.0, max(0.0, x))
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def mapped_score(model_score: float, t5: float | None, t1: float | None, thr: float | None) -> float:
|
| 61 |
+
"""The unrounded, unclamped piecewise-linear display value (0..100) of a pixel score."""
|
| 62 |
+
s = _unit(float(model_score))
|
| 63 |
+
if t5 is not None and t1 is not None and 0.0 < t5 < t1 < 1.0:
|
| 64 |
+
if s < t5:
|
| 65 |
+
return REAL_MAX * s / t5
|
| 66 |
+
if s < t1:
|
| 67 |
+
return UNC_MIN + (UNC_MAX - UNC_MIN) * (s - t5) / (t1 - t5)
|
| 68 |
+
return AI_MIN + (100 - AI_MIN) * (s - t1) / (1.0 - t1)
|
| 69 |
+
# no uncertain band: the decision threshold is the only line (t1 == thr whenever bands are unset)
|
| 70 |
+
line = thr if thr is not None else t1
|
| 71 |
+
if line is None or not 0.0 < line < 1.0:
|
| 72 |
+
return 100.0 * s
|
| 73 |
+
if s < line:
|
| 74 |
+
return REAL_MAX * s / line
|
| 75 |
+
return AI_MIN + (100 - AI_MIN) * (s - line) / (1.0 - line)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def display_score(model_score: float, band: str, basis: str | None, t5: float | None, t1: float | None,
|
| 79 |
+
thr: float | None, filename_only: bool = False) -> int:
|
| 80 |
+
"""Whole-percent display score (0..100) that always lies inside `band`'s display range.
|
| 81 |
+
|
| 82 |
+
model_score: the pixel model's own P(AI) (the response's `model_score`, before any override).
|
| 83 |
+
band: the final band ('real' | 'uncertain' | 'ai', case-insensitive; see final_band()).
|
| 84 |
+
basis: the response's `basis`; an AI verdict resting on provenance shows 100.
|
| 85 |
+
t5, t1: the band edges the service used (`band_thresholds`); thr: the decision threshold (`threshold`).
|
| 86 |
+
filename_only: the provenance evidence is only a generator-export filename pattern (weak): the AI verdict stands but
|
| 87 |
+
the number is the pixel score mapped into the AI range (so at least AI_MIN), not 100."""
|
| 88 |
+
key = str(band).lower()
|
| 89 |
+
if key not in RANGES:
|
| 90 |
+
raise ValueError(f"unknown band {band!r}; expected one of {BANDS}")
|
| 91 |
+
lo, hi = RANGES[key]
|
| 92 |
+
if key == "ai" and basis in PROVENANCE_BASES and not filename_only:
|
| 93 |
+
return 100
|
| 94 |
+
try:
|
| 95 |
+
raw = mapped_score(model_score, t5, t1, thr)
|
| 96 |
+
except (TypeError, ValueError):
|
| 97 |
+
raw = float("nan")
|
| 98 |
+
if not math.isfinite(raw):
|
| 99 |
+
return lo
|
| 100 |
+
return int(min(hi, max(lo, math.floor(raw + 0.5))))
|
tests/fixtures/display_regression_golden.json
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"note": "analyze() of main_hybrid.py at e8463904 (before display_score), RC1 pair, CPU, 2 threads",
|
| 3 |
+
"rows": [
|
| 4 |
+
{
|
| 5 |
+
"tag": "real_galaxy",
|
| 6 |
+
"path": "data_v7/owner_phone_s20/s20_camera_cal/20180716_155445.jpg",
|
| 7 |
+
"filename": "20180716_155445.jpg",
|
| 8 |
+
"strip": false,
|
| 9 |
+
"out": {
|
| 10 |
+
"score": 0.19636252522468567,
|
| 11 |
+
"verdict": "REAL",
|
| 12 |
+
"verdict_band": "REAL",
|
| 13 |
+
"band_thresholds": {
|
| 14 |
+
"t5": 0.842605,
|
| 15 |
+
"t1": 0.973393
|
| 16 |
+
},
|
| 17 |
+
"heatmap_b64": "sha256:10591806514ed3ecc015cee6455137a87eab51c904e7f7409c1a3eb3f1a1f764",
|
| 18 |
+
"overlay_b64": "sha256:10591806514ed3ecc015cee6455137a87eab51c904e7f7409c1a3eb3f1a1f764",
|
| 19 |
+
"model_version": "best(v6)+best",
|
| 20 |
+
"structure_score": 0.19636252522468567,
|
| 21 |
+
"context_score": 0.19636252522468567,
|
| 22 |
+
"detail_score": 0.19636252522468567,
|
| 23 |
+
"threshold": 0.973393,
|
| 24 |
+
"reason": "model 49 verdict - real, heatmap suppressed",
|
| 25 |
+
"source": "model",
|
| 26 |
+
"basis": "model",
|
| 27 |
+
"model_score": 0.19636252522468567,
|
| 28 |
+
"model_verdict": "REAL",
|
| 29 |
+
"taxonomy": "REAL",
|
| 30 |
+
"provenance_status": "none",
|
| 31 |
+
"provenance_class": null,
|
| 32 |
+
"provenance": {
|
| 33 |
+
"signals": [],
|
| 34 |
+
"details": {
|
| 35 |
+
"regions": [
|
| 36 |
+
"APP1/Exif"
|
| 37 |
+
],
|
| 38 |
+
"matched": {},
|
| 39 |
+
"c2pa": {
|
| 40 |
+
"present": false,
|
| 41 |
+
"class": null,
|
| 42 |
+
"markers": [],
|
| 43 |
+
"generator_hints": [],
|
| 44 |
+
"capture": false,
|
| 45 |
+
"status": "unverified"
|
| 46 |
+
},
|
| 47 |
+
"format": "JPEG",
|
| 48 |
+
"exif": {
|
| 49 |
+
"Make": "samsung",
|
| 50 |
+
"Model": "SM-J510L",
|
| 51 |
+
"Software": "J510LKLU1APE2"
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
},
|
| 55 |
+
"ensemble": {
|
| 56 |
+
"model49_score": 0.19636252522468567,
|
| 57 |
+
"siglip53_score": 0.0,
|
| 58 |
+
"patchguard_score": 0.4258074164390564,
|
| 59 |
+
"patch_peak": 0.2928650975227356,
|
| 60 |
+
"rescue_threshold": 0.72
|
| 61 |
+
},
|
| 62 |
+
"partial_edit_suspect": false,
|
| 63 |
+
"fusion": {
|
| 64 |
+
"enabled": true,
|
| 65 |
+
"score": -2.2946649981325047,
|
| 66 |
+
"threshold": 2.3288784941598424,
|
| 67 |
+
"hit": false,
|
| 68 |
+
"calibration": "traffic",
|
| 69 |
+
"max_band": "UNCERTAIN"
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"tag": "real_galaxy_filename_hint",
|
| 75 |
+
"path": "data_v7/owner_phone_s20/s20_camera_cal/20180716_155445.jpg",
|
| 76 |
+
"filename": "ChatGPT Image 2026.jpg",
|
| 77 |
+
"strip": false,
|
| 78 |
+
"out": {
|
| 79 |
+
"score": 0.99,
|
| 80 |
+
"verdict": "AI",
|
| 81 |
+
"verdict_band": "REAL",
|
| 82 |
+
"band_thresholds": {
|
| 83 |
+
"t5": 0.842605,
|
| 84 |
+
"t1": 0.973393
|
| 85 |
+
},
|
| 86 |
+
"heatmap_b64": "sha256:4f4e06c7122c7b17e86b04a5df4eebed1297496f6fd2e8219db5253a04172765",
|
| 87 |
+
"overlay_b64": "sha256:54cc92969a310043e5de6919b040b2e397ed4bd27cbd2027ed9c0a3a2439c1fd",
|
| 88 |
+
"model_version": "best(v6)+best+provenance",
|
| 89 |
+
"structure_score": 0.21456266939640045,
|
| 90 |
+
"context_score": 0.99,
|
| 91 |
+
"detail_score": 0.2928650975227356,
|
| 92 |
+
"threshold": 0.973393,
|
| 93 |
+
"reason": "AI provenance found (Filename:AI-export, OpenAI)",
|
| 94 |
+
"source": "provenance",
|
| 95 |
+
"basis": "provenance",
|
| 96 |
+
"model_score": 0.19636252522468567,
|
| 97 |
+
"model_verdict": "REAL",
|
| 98 |
+
"taxonomy": "AI",
|
| 99 |
+
"provenance_status": "unverified",
|
| 100 |
+
"provenance_class": null,
|
| 101 |
+
"provenance": {
|
| 102 |
+
"signals": [
|
| 103 |
+
"Filename:AI-export",
|
| 104 |
+
"OpenAI"
|
| 105 |
+
],
|
| 106 |
+
"details": {
|
| 107 |
+
"regions": [
|
| 108 |
+
"APP1/Exif"
|
| 109 |
+
],
|
| 110 |
+
"matched": {},
|
| 111 |
+
"c2pa": {
|
| 112 |
+
"present": false,
|
| 113 |
+
"class": null,
|
| 114 |
+
"markers": [],
|
| 115 |
+
"generator_hints": [],
|
| 116 |
+
"capture": false,
|
| 117 |
+
"status": "unverified"
|
| 118 |
+
},
|
| 119 |
+
"format": "JPEG",
|
| 120 |
+
"exif": {
|
| 121 |
+
"Make": "samsung",
|
| 122 |
+
"Model": "SM-J510L",
|
| 123 |
+
"Software": "J510LKLU1APE2"
|
| 124 |
+
},
|
| 125 |
+
"filename_hint": "ChatGPT Image 2026.jpg"
|
| 126 |
+
}
|
| 127 |
+
},
|
| 128 |
+
"ensemble": {
|
| 129 |
+
"model49_score": 0.19636252522468567,
|
| 130 |
+
"siglip53_score": 0.0,
|
| 131 |
+
"patchguard_score": 0.4258074164390564,
|
| 132 |
+
"patch_peak": 0.2928650975227356,
|
| 133 |
+
"rescue_threshold": 0.72
|
| 134 |
+
},
|
| 135 |
+
"partial_edit_suspect": false,
|
| 136 |
+
"fusion": {
|
| 137 |
+
"enabled": true,
|
| 138 |
+
"score": -2.2946649981325047,
|
| 139 |
+
"threshold": 2.3288784941598424,
|
| 140 |
+
"hit": false,
|
| 141 |
+
"calibration": "traffic",
|
| 142 |
+
"max_band": "UNCERTAIN"
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
},
|
| 146 |
+
{
|
| 147 |
+
"tag": "gpt25_c2pa",
|
| 148 |
+
"path": "data_v7/user_gpt25/new_full/new_full_001_greenhouse_bench.png",
|
| 149 |
+
"filename": "img_0001.png",
|
| 150 |
+
"strip": false,
|
| 151 |
+
"out": {
|
| 152 |
+
"score": 0.99,
|
| 153 |
+
"verdict": "AI",
|
| 154 |
+
"verdict_band": "REAL",
|
| 155 |
+
"band_thresholds": {
|
| 156 |
+
"t5": 0.842605,
|
| 157 |
+
"t1": 0.973393
|
| 158 |
+
},
|
| 159 |
+
"heatmap_b64": "sha256:a6a0fb1d3d1bf220254862ff8867c7ac63643c8d580d91fa5f39a268b5008d8f",
|
| 160 |
+
"overlay_b64": "sha256:626f3e178875cad06aff8ffcca95891a8af9a4454162406789b7cd4cfddef051",
|
| 161 |
+
"model_version": "best(v6)+best+provenance",
|
| 162 |
+
"structure_score": 0.3269358277320862,
|
| 163 |
+
"context_score": 0.99,
|
| 164 |
+
"detail_score": 0.41130611300468445,
|
| 165 |
+
"threshold": 0.973393,
|
| 166 |
+
"reason": "AI provenance found (AIGC-generic, C2PA:AI, OpenAI)",
|
| 167 |
+
"source": "provenance",
|
| 168 |
+
"basis": "provenance",
|
| 169 |
+
"model_score": 0.2907660901546478,
|
| 170 |
+
"model_verdict": "REAL",
|
| 171 |
+
"taxonomy": "AI",
|
| 172 |
+
"provenance_status": "unverified",
|
| 173 |
+
"provenance_class": "ai",
|
| 174 |
+
"provenance": {
|
| 175 |
+
"signals": [
|
| 176 |
+
"AIGC-generic",
|
| 177 |
+
"C2PA:AI",
|
| 178 |
+
"OpenAI"
|
| 179 |
+
],
|
| 180 |
+
"details": {
|
| 181 |
+
"regions": [
|
| 182 |
+
"IHDR",
|
| 183 |
+
"caBX"
|
| 184 |
+
],
|
| 185 |
+
"matched": {
|
| 186 |
+
"OpenAI": [
|
| 187 |
+
"openai",
|
| 188 |
+
"gpt-image",
|
| 189 |
+
"chatgpt"
|
| 190 |
+
],
|
| 191 |
+
"AIGC-generic": [
|
| 192 |
+
"trainedalgorithmicmedia"
|
| 193 |
+
]
|
| 194 |
+
},
|
| 195 |
+
"c2pa": {
|
| 196 |
+
"present": true,
|
| 197 |
+
"class": "ai",
|
| 198 |
+
"markers": [
|
| 199 |
+
"trainedalgorithmicmedia"
|
| 200 |
+
],
|
| 201 |
+
"generator_hints": [
|
| 202 |
+
"chatgpt",
|
| 203 |
+
"gpt-image",
|
| 204 |
+
"openai"
|
| 205 |
+
],
|
| 206 |
+
"capture": false,
|
| 207 |
+
"status": "unverified"
|
| 208 |
+
},
|
| 209 |
+
"format": "PNG"
|
| 210 |
+
}
|
| 211 |
+
},
|
| 212 |
+
"ensemble": {
|
| 213 |
+
"model49_score": 0.2907660901546478,
|
| 214 |
+
"siglip53_score": 0.0,
|
| 215 |
+
"patchguard_score": 0.5787836909294128,
|
| 216 |
+
"patch_peak": 0.41130611300468445,
|
| 217 |
+
"rescue_threshold": 0.72
|
| 218 |
+
},
|
| 219 |
+
"partial_edit_suspect": false,
|
| 220 |
+
"fusion": {
|
| 221 |
+
"enabled": true,
|
| 222 |
+
"score": -1.6157930262798303,
|
| 223 |
+
"threshold": 2.3288784941598424,
|
| 224 |
+
"hit": false,
|
| 225 |
+
"calibration": "traffic",
|
| 226 |
+
"max_band": "UNCERTAIN"
|
| 227 |
+
}
|
| 228 |
+
}
|
| 229 |
+
},
|
| 230 |
+
{
|
| 231 |
+
"tag": "receipt_c2pa",
|
| 232 |
+
"path": "D:/hf_cache/hub/datasets--Scam-AI--gpt4o-receipt/snapshots/ba43bd74c4a3ab69df803941eb0a2d52a8e4aac0/receipt_0001.png",
|
| 233 |
+
"filename": "img_0002.png",
|
| 234 |
+
"strip": false,
|
| 235 |
+
"out": {
|
| 236 |
+
"score": 0.9928823709487915,
|
| 237 |
+
"verdict": "AI",
|
| 238 |
+
"verdict_band": "AI",
|
| 239 |
+
"band_thresholds": {
|
| 240 |
+
"t5": 0.842605,
|
| 241 |
+
"t1": 0.973393
|
| 242 |
+
},
|
| 243 |
+
"heatmap_b64": "sha256:385b3770270944c91a470bfaa2844f5821ffa3d49478126cf46ee8738baa597f",
|
| 244 |
+
"overlay_b64": "sha256:58ab96ebf41925b440204a2427d5acc1b6b83a7b687301d684680803b1d14a29",
|
| 245 |
+
"model_version": "best(v6)+best+provenance",
|
| 246 |
+
"structure_score": 0.9992738962173462,
|
| 247 |
+
"context_score": 0.9928823709487915,
|
| 248 |
+
"detail_score": 0.999677300453186,
|
| 249 |
+
"threshold": 0.973393,
|
| 250 |
+
"reason": "AI provenance found (AIGC-generic, C2PA:AI, OpenAI); pixel model also positive",
|
| 251 |
+
"source": "provenance",
|
| 252 |
+
"basis": "provenance",
|
| 253 |
+
"model_score": 0.9928823709487915,
|
| 254 |
+
"model_verdict": "AI",
|
| 255 |
+
"taxonomy": "AI",
|
| 256 |
+
"provenance_status": "unverified",
|
| 257 |
+
"provenance_class": "ai",
|
| 258 |
+
"provenance": {
|
| 259 |
+
"signals": [
|
| 260 |
+
"AIGC-generic",
|
| 261 |
+
"C2PA:AI",
|
| 262 |
+
"OpenAI"
|
| 263 |
+
],
|
| 264 |
+
"details": {
|
| 265 |
+
"regions": [
|
| 266 |
+
"IHDR",
|
| 267 |
+
"caBX"
|
| 268 |
+
],
|
| 269 |
+
"matched": {
|
| 270 |
+
"OpenAI": [
|
| 271 |
+
"openai",
|
| 272 |
+
"chatgpt"
|
| 273 |
+
],
|
| 274 |
+
"AIGC-generic": [
|
| 275 |
+
"trainedalgorithmicmedia"
|
| 276 |
+
]
|
| 277 |
+
},
|
| 278 |
+
"c2pa": {
|
| 279 |
+
"present": true,
|
| 280 |
+
"class": "ai",
|
| 281 |
+
"markers": [
|
| 282 |
+
"trainedalgorithmicmedia"
|
| 283 |
+
],
|
| 284 |
+
"generator_hints": [
|
| 285 |
+
"chatgpt",
|
| 286 |
+
"openai"
|
| 287 |
+
],
|
| 288 |
+
"capture": false,
|
| 289 |
+
"status": "unverified"
|
| 290 |
+
},
|
| 291 |
+
"format": "PNG"
|
| 292 |
+
}
|
| 293 |
+
},
|
| 294 |
+
"ensemble": {
|
| 295 |
+
"model49_score": 0.9928823709487915,
|
| 296 |
+
"siglip53_score": 0.0,
|
| 297 |
+
"patchguard_score": 0.9748627543449402,
|
| 298 |
+
"patch_peak": 0.999677300453186,
|
| 299 |
+
"rescue_threshold": 0.72
|
| 300 |
+
},
|
| 301 |
+
"partial_edit_suspect": false,
|
| 302 |
+
"fusion": {
|
| 303 |
+
"enabled": true,
|
| 304 |
+
"score": 4.492686495995523,
|
| 305 |
+
"threshold": 2.3288784941598424,
|
| 306 |
+
"hit": true,
|
| 307 |
+
"calibration": "traffic",
|
| 308 |
+
"max_band": "UNCERTAIN"
|
| 309 |
+
}
|
| 310 |
+
}
|
| 311 |
+
},
|
| 312 |
+
{
|
| 313 |
+
"tag": "receipt_pixels_only",
|
| 314 |
+
"path": "D:/hf_cache/hub/datasets--Scam-AI--gpt4o-receipt/snapshots/ba43bd74c4a3ab69df803941eb0a2d52a8e4aac0/receipt_0001.png",
|
| 315 |
+
"filename": "img_0003.png",
|
| 316 |
+
"strip": true,
|
| 317 |
+
"out": {
|
| 318 |
+
"score": 0.9928823709487915,
|
| 319 |
+
"verdict": "AI",
|
| 320 |
+
"verdict_band": "AI",
|
| 321 |
+
"band_thresholds": {
|
| 322 |
+
"t5": 0.842605,
|
| 323 |
+
"t1": 0.973393
|
| 324 |
+
},
|
| 325 |
+
"heatmap_b64": "sha256:385b3770270944c91a470bfaa2844f5821ffa3d49478126cf46ee8738baa597f",
|
| 326 |
+
"overlay_b64": "sha256:58ab96ebf41925b440204a2427d5acc1b6b83a7b687301d684680803b1d14a29",
|
| 327 |
+
"model_version": "best(v6)+best",
|
| 328 |
+
"structure_score": 0.9992738962173462,
|
| 329 |
+
"context_score": 0.9928823709487915,
|
| 330 |
+
"detail_score": 0.999677300453186,
|
| 331 |
+
"threshold": 0.973393,
|
| 332 |
+
"reason": "model 49 verdict",
|
| 333 |
+
"source": "model",
|
| 334 |
+
"basis": "model",
|
| 335 |
+
"model_score": 0.9928823709487915,
|
| 336 |
+
"model_verdict": "AI",
|
| 337 |
+
"taxonomy": "AI",
|
| 338 |
+
"provenance_status": "none",
|
| 339 |
+
"provenance_class": null,
|
| 340 |
+
"provenance": {
|
| 341 |
+
"signals": [],
|
| 342 |
+
"details": {
|
| 343 |
+
"regions": [
|
| 344 |
+
"IHDR"
|
| 345 |
+
],
|
| 346 |
+
"matched": {},
|
| 347 |
+
"c2pa": {
|
| 348 |
+
"present": false,
|
| 349 |
+
"class": null,
|
| 350 |
+
"markers": [],
|
| 351 |
+
"generator_hints": [],
|
| 352 |
+
"capture": false,
|
| 353 |
+
"status": "unverified"
|
| 354 |
+
},
|
| 355 |
+
"format": "PNG"
|
| 356 |
+
}
|
| 357 |
+
},
|
| 358 |
+
"ensemble": {
|
| 359 |
+
"model49_score": 0.9928823709487915,
|
| 360 |
+
"siglip53_score": 0.0,
|
| 361 |
+
"patchguard_score": 0.9748627543449402,
|
| 362 |
+
"patch_peak": 0.999677300453186,
|
| 363 |
+
"rescue_threshold": 0.72
|
| 364 |
+
},
|
| 365 |
+
"partial_edit_suspect": false,
|
| 366 |
+
"fusion": {
|
| 367 |
+
"enabled": true,
|
| 368 |
+
"score": 4.492686495995523,
|
| 369 |
+
"threshold": 2.3288784941598424,
|
| 370 |
+
"hit": true,
|
| 371 |
+
"calibration": "traffic",
|
| 372 |
+
"max_band": "UNCERTAIN"
|
| 373 |
+
}
|
| 374 |
+
}
|
| 375 |
+
},
|
| 376 |
+
{
|
| 377 |
+
"tag": "cord_uncertain",
|
| 378 |
+
"path": "data_v7/probe/cord_v2/test_0084.png",
|
| 379 |
+
"filename": "img_0004.png",
|
| 380 |
+
"strip": false,
|
| 381 |
+
"out": {
|
| 382 |
+
"score": 0.8605962991714478,
|
| 383 |
+
"verdict": "REAL",
|
| 384 |
+
"verdict_band": "UNCERTAIN",
|
| 385 |
+
"band_thresholds": {
|
| 386 |
+
"t5": 0.842605,
|
| 387 |
+
"t1": 0.973393
|
| 388 |
+
},
|
| 389 |
+
"heatmap_b64": "sha256:657d71db8b8a9a2faf13fc0dee82abddfa588efb320923dc8a50f83b4e467b10",
|
| 390 |
+
"overlay_b64": "sha256:657d71db8b8a9a2faf13fc0dee82abddfa588efb320923dc8a50f83b4e467b10",
|
| 391 |
+
"model_version": "best(v6)+best",
|
| 392 |
+
"structure_score": 0.8605962991714478,
|
| 393 |
+
"context_score": 0.8605962991714478,
|
| 394 |
+
"detail_score": 0.8605962991714478,
|
| 395 |
+
"threshold": 0.973393,
|
| 396 |
+
"reason": "model 49 verdict - real, heatmap suppressed",
|
| 397 |
+
"source": "model",
|
| 398 |
+
"basis": "model",
|
| 399 |
+
"model_score": 0.8605962991714478,
|
| 400 |
+
"model_verdict": "REAL",
|
| 401 |
+
"taxonomy": "REAL",
|
| 402 |
+
"provenance_status": "none",
|
| 403 |
+
"provenance_class": null,
|
| 404 |
+
"provenance": {
|
| 405 |
+
"signals": [],
|
| 406 |
+
"details": {
|
| 407 |
+
"regions": [
|
| 408 |
+
"IHDR"
|
| 409 |
+
],
|
| 410 |
+
"matched": {},
|
| 411 |
+
"c2pa": {
|
| 412 |
+
"present": false,
|
| 413 |
+
"class": null,
|
| 414 |
+
"markers": [],
|
| 415 |
+
"generator_hints": [],
|
| 416 |
+
"capture": false,
|
| 417 |
+
"status": "unverified"
|
| 418 |
+
},
|
| 419 |
+
"format": "PNG"
|
| 420 |
+
}
|
| 421 |
+
},
|
| 422 |
+
"ensemble": {
|
| 423 |
+
"model49_score": 0.8605962991714478,
|
| 424 |
+
"siglip53_score": 0.0,
|
| 425 |
+
"patchguard_score": 0.8612217307090759,
|
| 426 |
+
"patch_peak": 0.9601179957389832,
|
| 427 |
+
"rescue_threshold": 0.72
|
| 428 |
+
},
|
| 429 |
+
"partial_edit_suspect": false,
|
| 430 |
+
"fusion": {
|
| 431 |
+
"enabled": true,
|
| 432 |
+
"score": 5.078632616781172,
|
| 433 |
+
"threshold": 2.3288784941598424,
|
| 434 |
+
"hit": true,
|
| 435 |
+
"calibration": "traffic",
|
| 436 |
+
"max_band": "UNCERTAIN"
|
| 437 |
+
}
|
| 438 |
+
}
|
| 439 |
+
},
|
| 440 |
+
{
|
| 441 |
+
"tag": "cord_local_edit",
|
| 442 |
+
"path": "data_v7/probe/cord_v2/test_0096.png",
|
| 443 |
+
"filename": "img_0005.png",
|
| 444 |
+
"strip": false,
|
| 445 |
+
"out": {
|
| 446 |
+
"score": 0.4749445915222168,
|
| 447 |
+
"verdict": "REAL",
|
| 448 |
+
"verdict_band": "UNCERTAIN",
|
| 449 |
+
"band_thresholds": {
|
| 450 |
+
"t5": 0.842605,
|
| 451 |
+
"t1": 0.973393
|
| 452 |
+
},
|
| 453 |
+
"heatmap_b64": "sha256:1694e0113b8422c598309eed858384f9d3c279b5b1f92de0f7afbe4d11ea7d68",
|
| 454 |
+
"overlay_b64": "sha256:d1e13529346d881a53f4ba47728a486896c7fed4a2ddab4a0afe33701f40e691",
|
| 455 |
+
"model_version": "best(v6)+best",
|
| 456 |
+
"structure_score": 0.898382306098938,
|
| 457 |
+
"context_score": 0.4749445915222168,
|
| 458 |
+
"detail_score": 0.9623211622238159,
|
| 459 |
+
"threshold": 0.973393,
|
| 460 |
+
"reason": "classifier + localiser fusion: possible local edit",
|
| 461 |
+
"source": "local_edit",
|
| 462 |
+
"basis": "local_edit",
|
| 463 |
+
"model_score": 0.4749445915222168,
|
| 464 |
+
"model_verdict": "REAL",
|
| 465 |
+
"taxonomy": "EDITED",
|
| 466 |
+
"provenance_status": "none",
|
| 467 |
+
"provenance_class": null,
|
| 468 |
+
"provenance": {
|
| 469 |
+
"signals": [],
|
| 470 |
+
"details": {
|
| 471 |
+
"regions": [
|
| 472 |
+
"IHDR"
|
| 473 |
+
],
|
| 474 |
+
"matched": {},
|
| 475 |
+
"c2pa": {
|
| 476 |
+
"present": false,
|
| 477 |
+
"class": null,
|
| 478 |
+
"markers": [],
|
| 479 |
+
"generator_hints": [],
|
| 480 |
+
"capture": false,
|
| 481 |
+
"status": "unverified"
|
| 482 |
+
},
|
| 483 |
+
"format": "PNG"
|
| 484 |
+
}
|
| 485 |
+
},
|
| 486 |
+
"ensemble": {
|
| 487 |
+
"model49_score": 0.4749445915222168,
|
| 488 |
+
"siglip53_score": 0.0,
|
| 489 |
+
"patchguard_score": 0.8260679841041565,
|
| 490 |
+
"patch_peak": 0.9623211622238159,
|
| 491 |
+
"rescue_threshold": 0.72
|
| 492 |
+
},
|
| 493 |
+
"partial_edit_suspect": true,
|
| 494 |
+
"fusion": {
|
| 495 |
+
"enabled": true,
|
| 496 |
+
"score": 2.743159158373138,
|
| 497 |
+
"threshold": 2.3288784941598424,
|
| 498 |
+
"hit": true,
|
| 499 |
+
"calibration": "traffic",
|
| 500 |
+
"max_band": "UNCERTAIN"
|
| 501 |
+
}
|
| 502 |
+
}
|
| 503 |
+
}
|
| 504 |
+
]
|
| 505 |
+
}
|
tests/test_display_score.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""display_score / display_band (score_display.py): the owner's 0-10 / 40-60 / 90-100 display (2026-09-28).
|
| 2 |
+
|
| 3 |
+
Fast tests pin the mapping (edges, monotonicity, clamping for every basis x band, the legacy no-band path, the schema).
|
| 4 |
+
The slow test replays analyze() on real images with the RC1 pair and proves every pre-existing response field is
|
| 5 |
+
bit-identical to the output recorded from main_hybrid.py BEFORE the display fields existed
|
| 6 |
+
(fixtures/display_regression_golden.json, recorded at e8463904 on CPU with 2 torch threads). It runs with
|
| 7 |
+
AEYE_HEATMAP_STYLE=jet (the RC1 overlay); a second slow test proves the spotlight changes overlay_b64 and nothing else.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
import hashlib
|
| 13 |
+
import io
|
| 14 |
+
import json
|
| 15 |
+
import math
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
|
| 19 |
+
import pytest
|
| 20 |
+
|
| 21 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 22 |
+
REPO = os.path.dirname(ROOT)
|
| 23 |
+
sys.path.insert(0, ROOT)
|
| 24 |
+
|
| 25 |
+
import score_display as sd # noqa: E402
|
| 26 |
+
from score_display import AI_MIN, REAL_MAX, UNC_MAX, UNC_MIN, display_score, final_band, mapped_score # noqa: E402
|
| 27 |
+
|
| 28 |
+
# RC1 (Dockerfile): traffic-calibrated bands bound to 69_v23f_effort_v7.2; the decision threshold IS t1
|
| 29 |
+
T5, T1 = 0.842605, 0.973393
|
| 30 |
+
LEGACY_THR = 0.4625 # best49-era default: no bands, t5 == t1 == thr
|
| 31 |
+
BASES = ("model", "local_edit", "provenance", "patchguard_rescue", "siglip_rescue", "real_guard")
|
| 32 |
+
RANGE = {"real": (0, REAL_MAX), "uncertain": (UNC_MIN, UNC_MAX), "ai": (AI_MIN, 100)}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _below(x: float) -> float:
|
| 36 |
+
return math.nextafter(x, 0.0)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _grid(n: int = 4001) -> list[float]:
|
| 40 |
+
pts = {i / (n - 1) for i in range(n)}
|
| 41 |
+
for e in (T5, T1, LEGACY_THR):
|
| 42 |
+
pts.update({e, _below(e), math.nextafter(e, 1.0), e - 1e-4, e + 1e-4})
|
| 43 |
+
return sorted(p for p in pts if 0.0 <= p <= 1.0)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _pixel_band(s: float, t5: float, t1: float) -> str:
|
| 47 |
+
import main_hybrid as mh
|
| 48 |
+
return mh.band_for(s, t5, t1).lower()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# --------------------------------------------------------------------------- the three ranges and their edges
|
| 52 |
+
def test_range_constants_are_the_owner_decision():
|
| 53 |
+
assert (REAL_MAX, UNC_MIN, UNC_MAX, AI_MIN) == (10, 40, 60, 90)
|
| 54 |
+
assert sd.RANGES == RANGE
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@pytest.mark.parametrize("s, band, want", [
|
| 58 |
+
(0.0, "real", 0),
|
| 59 |
+
(T5 / 2, "real", 5),
|
| 60 |
+
(_below(T5), "real", 10), # just below t5: top of REAL, never 40
|
| 61 |
+
(T5, "uncertain", 40), # t5 exactly: bottom of UNCERTAIN
|
| 62 |
+
(math.nextafter(T5, 1.0), "uncertain", 40),
|
| 63 |
+
((T5 + T1) / 2, "uncertain", 50),
|
| 64 |
+
(_below(T1), "uncertain", 60), # just below t1: top of UNCERTAIN, never 90
|
| 65 |
+
(T1, "ai", 90), # t1 exactly: bottom of AI
|
| 66 |
+
(math.nextafter(T1, 1.0), "ai", 90),
|
| 67 |
+
((T1 + 1.0) / 2, "ai", 95),
|
| 68 |
+
(1.0, "ai", 100),
|
| 69 |
+
])
|
| 70 |
+
def test_edges_of_the_three_ranges(s, band, want):
|
| 71 |
+
assert _pixel_band(s, T5, T1) == band
|
| 72 |
+
assert display_score(s, band, "model", T5, T1, T1) == want
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_mapped_score_is_continuous_inside_each_band_and_jumps_only_at_the_edges():
|
| 76 |
+
assert mapped_score(_below(T5), T5, T1, T1) == pytest.approx(REAL_MAX, abs=1e-9)
|
| 77 |
+
assert mapped_score(T5, T5, T1, T1) == pytest.approx(UNC_MIN)
|
| 78 |
+
assert mapped_score(_below(T1), T5, T1, T1) == pytest.approx(UNC_MAX, abs=1e-9)
|
| 79 |
+
assert mapped_score(T1, T5, T1, T1) == pytest.approx(AI_MIN)
|
| 80 |
+
assert mapped_score(1.0, T5, T1, T1) == pytest.approx(100)
|
| 81 |
+
assert mapped_score(-0.5, T5, T1, T1) == 0 and mapped_score(1.5, T5, T1, T1) == 100 # out-of-range input
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_the_galaxy_median_real_photo_reads_low():
|
| 85 |
+
# measured RC1 medians on 700 owner Galaxy photos: 0.24 originals, 0.39 after a messenger re-share
|
| 86 |
+
assert display_score(0.24, "real", "model", T5, T1, T1) == 3
|
| 87 |
+
assert display_score(0.39, "real", "model", T5, T1, T1) == 5
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# --------------------------------------------------------------------------- monotonic, and rounding never crosses an edge
|
| 91 |
+
@pytest.mark.parametrize("t5, t1, thr", [(T5, T1, T1), (LEGACY_THR, LEGACY_THR, LEGACY_THR), (None, None, LEGACY_THR)])
|
| 92 |
+
def test_monotonic_non_decreasing_and_consistent_with_the_service_band(t5, t1, thr):
|
| 93 |
+
e5, e1 = (t5, t1) if t5 is not None else (thr, thr)
|
| 94 |
+
prev = {}
|
| 95 |
+
for s in _grid():
|
| 96 |
+
band = _pixel_band(s, e5, e1)
|
| 97 |
+
v = display_score(s, band, "model", t5, t1, thr)
|
| 98 |
+
lo, hi = RANGE[band]
|
| 99 |
+
assert lo <= v <= hi, (s, band, v)
|
| 100 |
+
# the pixel score's own band never needs the clamp: rounding alone stays inside the range
|
| 101 |
+
assert v == math.floor(mapped_score(s, t5, t1, thr) + 0.5), (s, band, v)
|
| 102 |
+
if band in prev:
|
| 103 |
+
assert v >= prev[band], (s, band, v, prev[band])
|
| 104 |
+
prev[band] = v
|
| 105 |
+
# and across bands the display order follows the score order
|
| 106 |
+
assert display_score(0.0, "real", "model", t5, t1, thr) <= display_score(1.0, "ai", "model", t5, t1, thr)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_one_point_steps_inside_each_band():
|
| 110 |
+
seen = {b: set() for b in RANGE}
|
| 111 |
+
for s in _grid(20001):
|
| 112 |
+
b = _pixel_band(s, T5, T1)
|
| 113 |
+
seen[b].add(display_score(s, b, "model", T5, T1, T1))
|
| 114 |
+
assert seen == {b: set(range(lo, hi + 1)) for b, (lo, hi) in RANGE.items()}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# --------------------------------------------------------------------------- clamping for every basis x band
|
| 118 |
+
@pytest.mark.parametrize("basis", BASES)
|
| 119 |
+
@pytest.mark.parametrize("band", list(RANGE))
|
| 120 |
+
@pytest.mark.parametrize("t5, t1, thr", [(T5, T1, T1), (None, None, LEGACY_THR), (LEGACY_THR, LEGACY_THR, LEGACY_THR)])
|
| 121 |
+
def test_every_basis_band_combination_lands_in_its_range(basis, band, t5, t1, thr):
|
| 122 |
+
lo, hi = RANGE[band]
|
| 123 |
+
for s in _grid(401):
|
| 124 |
+
v = display_score(s, band, basis, t5, t1, thr)
|
| 125 |
+
assert isinstance(v, int) and lo <= v <= hi, (basis, band, s, v)
|
| 126 |
+
if basis == "provenance" and band == "ai":
|
| 127 |
+
assert v == 100
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def test_named_override_cases():
|
| 131 |
+
# the fusion lifted a REAL-band photo to UNCERTAIN ('local_edit'): bottom of the uncertain range
|
| 132 |
+
assert display_score(0.47, "uncertain", "local_edit", T5, T1, T1) == 40
|
| 133 |
+
# an AI-declaring provenance record: 100 whatever the pixels say
|
| 134 |
+
assert display_score(0.19, "ai", "provenance", T5, T1, T1) == 100
|
| 135 |
+
assert display_score(0.90, "ai", "provenance", T5, T1, T1) == 100
|
| 136 |
+
# a rescue that called AI on a REAL-band pixel score: bottom of AI
|
| 137 |
+
assert display_score(0.30, "ai", "patchguard_rescue", LEGACY_THR, LEGACY_THR, LEGACY_THR) == 90
|
| 138 |
+
# the real guard that called REAL on an AI-band pixel score: top of REAL
|
| 139 |
+
assert display_score(0.55, "real", "real_guard", LEGACY_THR, LEGACY_THR, LEGACY_THR) == 10
|
| 140 |
+
# an UNCERTAIN-band pixel score a provenance marker decided: AI 100
|
| 141 |
+
assert display_score(0.90, final_band("AI", "UNCERTAIN", "provenance"), "provenance", T5, T1, T1) == 100
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@pytest.mark.parametrize("verdict, verdict_band, basis, want", [
|
| 145 |
+
("REAL", "REAL", "model", "real"),
|
| 146 |
+
("REAL", "UNCERTAIN", "model", "uncertain"),
|
| 147 |
+
("AI", "AI", "model", "ai"),
|
| 148 |
+
("REAL", "UNCERTAIN", "local_edit", "uncertain"),
|
| 149 |
+
("AI", "REAL", "provenance", "ai"),
|
| 150 |
+
("AI", "UNCERTAIN", "provenance", "ai"),
|
| 151 |
+
("AI", "AI", "provenance", "ai"),
|
| 152 |
+
("AI", "REAL", "patchguard_rescue", "ai"),
|
| 153 |
+
("AI", "UNCERTAIN", "siglip_rescue", "ai"),
|
| 154 |
+
("REAL", "AI", "real_guard", "real"),
|
| 155 |
+
("REAL", "UNCERTAIN", "real_guard", "real"),
|
| 156 |
+
("REAL", None, None, "real"),
|
| 157 |
+
("AI", None, None, "ai"),
|
| 158 |
+
])
|
| 159 |
+
def test_final_band_follows_the_override_rules(verdict, verdict_band, basis, want):
|
| 160 |
+
assert final_band(verdict, verdict_band, basis) == want
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def test_band_names_are_case_insensitive_and_unknown_bands_fail():
|
| 164 |
+
assert display_score(0.5, "REAL", "model", T5, T1, T1) == display_score(0.5, "real", "model", T5, T1, T1)
|
| 165 |
+
with pytest.raises(ValueError):
|
| 166 |
+
display_score(0.5, "maybe", "model", T5, T1, T1)
|
| 167 |
+
assert display_score(float("nan"), "uncertain", "model", T5, T1, T1) == UNC_MIN
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# --------------------------------------------------------------------------- legacy: no calibrated bands
|
| 171 |
+
def test_legacy_single_line_at_the_decision_threshold():
|
| 172 |
+
for t5, t1 in ((None, None), (LEGACY_THR, LEGACY_THR)):
|
| 173 |
+
assert display_score(0.0, "real", "model", t5, t1, LEGACY_THR) == 0
|
| 174 |
+
assert display_score(_below(LEGACY_THR), "real", "model", t5, t1, LEGACY_THR) == 10
|
| 175 |
+
assert display_score(LEGACY_THR, "ai", "model", t5, t1, LEGACY_THR) == 90
|
| 176 |
+
assert display_score(1.0, "ai", "model", t5, t1, LEGACY_THR) == 100
|
| 177 |
+
assert mapped_score(LEGACY_THR / 2, t5, t1, LEGACY_THR) == pytest.approx(5)
|
| 178 |
+
# the uncertain range only through the final band (a fusion lift), then clamped into 40..60
|
| 179 |
+
assert display_score(0.1, "uncertain", "local_edit", t5, t1, LEGACY_THR) == 40
|
| 180 |
+
assert display_score(0.9, "uncertain", "model", t5, t1, LEGACY_THR) == 60
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# --------------------------------------------------------------------------- the fields reach the client
|
| 184 |
+
def test_schema_declares_the_display_fields():
|
| 185 |
+
from schemas import AnalyzeResponse
|
| 186 |
+
base = {"score": 0.1, "verdict": "REAL", "heatmap_b64": "", "overlay_b64": "", "model_version": "x", "elapsed_ms": 1}
|
| 187 |
+
assert AnalyzeResponse(**base).display_score is None # legacy dicts still validate
|
| 188 |
+
r = AnalyzeResponse(**base, display_score=7, display_band="real")
|
| 189 |
+
assert (r.display_score, r.display_band) == (7, "real")
|
| 190 |
+
with pytest.raises(Exception):
|
| 191 |
+
AnalyzeResponse(**base, display_score=101, display_band="ai")
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def test_display_fields_survive_the_api_response():
|
| 195 |
+
import httpx
|
| 196 |
+
from PIL import Image
|
| 197 |
+
import guards
|
| 198 |
+
import main_hybrid as mh
|
| 199 |
+
|
| 200 |
+
class Stub:
|
| 201 |
+
model_version = "stub"
|
| 202 |
+
|
| 203 |
+
def analyze(self, raw: bytes, filename: str | None = None) -> dict:
|
| 204 |
+
return {"score": 0.3, "verdict": "REAL", "heatmap_b64": "", "overlay_b64": "", "model_version": "stub",
|
| 205 |
+
"elapsed_ms": 1, "threshold": T1, "source": "model", "basis": "model", "model_score": 0.3,
|
| 206 |
+
"verdict_band": "REAL", "band_thresholds": {"t5": T5, "t1": T1},
|
| 207 |
+
"display_score": display_score(0.3, "real", "model", T5, T1, T1), "display_band": "real"}
|
| 208 |
+
|
| 209 |
+
buf = io.BytesIO()
|
| 210 |
+
Image.new("RGB", (64, 64), (90, 120, 150)).save(buf, "PNG")
|
| 211 |
+
old = (guards.SETTINGS, mh._detector)
|
| 212 |
+
guards.configure(guards.Settings(anon_per_minute=1000, anon_per_day=100000, max_concurrency=1, max_queue=4,
|
| 213 |
+
queue_timeout_s=1.0, trusted_proxy_hops=1))
|
| 214 |
+
mh._detector = Stub()
|
| 215 |
+
|
| 216 |
+
async def go():
|
| 217 |
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=mh.app), base_url="http://test") as c:
|
| 218 |
+
return await c.post("/api/analyze", files={"image": ("x.png", buf.getvalue(), "image/png")})
|
| 219 |
+
try:
|
| 220 |
+
body = asyncio.run(go()).json()
|
| 221 |
+
finally:
|
| 222 |
+
guards.configure(old[0])
|
| 223 |
+
mh._detector = old[1]
|
| 224 |
+
assert body["display_score"] == 4 and body["display_band"] == "real"
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
# --------------------------------------------------------------------------- regression: pre-existing fields unchanged
|
| 228 |
+
GOLDEN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures", "display_regression_golden.json")
|
| 229 |
+
EXP = os.path.join(REPO, "4_model_training", "experiments")
|
| 230 |
+
CHAMPION = os.path.join(EXP, "69_v23f_effort_v7.2", "checkpoints", "best.pt")
|
| 231 |
+
LOC70 = os.path.join(EXP, "70_v24_dense_v7loc_v7.2", "checkpoints", "best.pt")
|
| 232 |
+
DISPLAY_KEYS = {"display_score", "display_band", "heatmap_style"} # added after the golden was recorded
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _canon(out: dict) -> dict:
|
| 236 |
+
"""What the golden stores: every key but the wall clock, image payloads as digests."""
|
| 237 |
+
c = {}
|
| 238 |
+
for k, v in out.items():
|
| 239 |
+
if k == "elapsed_ms":
|
| 240 |
+
continue
|
| 241 |
+
c[k] = "sha256:" + hashlib.sha256(v.encode("ascii")).hexdigest() if k.endswith("_b64") else v
|
| 242 |
+
return c
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _load(path: str, strip: bool) -> bytes:
|
| 246 |
+
raw = open(os.path.join(REPO, path), "rb").read()
|
| 247 |
+
if not strip:
|
| 248 |
+
return raw
|
| 249 |
+
from PIL import Image
|
| 250 |
+
buf = io.BytesIO()
|
| 251 |
+
Image.open(io.BytesIO(raw)).save(buf, format="PNG") # lossless: same pixels, no metadata
|
| 252 |
+
return buf.getvalue()
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
@pytest.fixture(scope="module")
|
| 256 |
+
def rc1_detector():
|
| 257 |
+
missing = [p for p in (CHAMPION, LOC70, GOLDEN) if not os.path.exists(p)]
|
| 258 |
+
if missing:
|
| 259 |
+
pytest.skip(f"not on this machine: {missing[:2]}")
|
| 260 |
+
import torch
|
| 261 |
+
import main_hybrid as mh
|
| 262 |
+
threads = torch.get_num_threads()
|
| 263 |
+
torch.set_num_threads(2) # as recorded (and as AEYE_TORCH_THREADS in production)
|
| 264 |
+
mp = pytest.MonkeyPatch()
|
| 265 |
+
for name, value in (("VERDICT_PATH", CHAMPION), ("HEATMAP_PATH", LOC70), ("DEVICE", "cpu"), ("FUSION_MODE", "on"),
|
| 266 |
+
("BAND_T5", T5), ("BAND_T1", T1), ("BANDS_FOR", "3509c0e7b189bda5"),
|
| 267 |
+
("HEATMAP_STYLE", "jet")): # the golden overlays are RC1's jet overlays
|
| 268 |
+
mp.setattr(mh, name, value)
|
| 269 |
+
try:
|
| 270 |
+
yield mh.HybridDetector()
|
| 271 |
+
finally:
|
| 272 |
+
mp.undo()
|
| 273 |
+
torch.set_num_threads(threads)
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
@pytest.mark.slow
|
| 277 |
+
def test_every_pre_existing_field_is_bit_identical(rc1_detector):
|
| 278 |
+
golden = json.load(open(GOLDEN, encoding="utf-8"))["rows"]
|
| 279 |
+
checked, bases = 0, set()
|
| 280 |
+
for row in golden:
|
| 281 |
+
if not os.path.exists(os.path.join(REPO, row["path"])):
|
| 282 |
+
continue
|
| 283 |
+
out = rc1_detector.analyze(_load(row["path"], row["strip"]), row["filename"])
|
| 284 |
+
assert set(out) == set(row["out"]) | {"elapsed_ms"} | DISPLAY_KEYS, row["tag"]
|
| 285 |
+
before = row["out"]
|
| 286 |
+
after = {k: v for k, v in _canon(out).items() if k not in DISPLAY_KEYS}
|
| 287 |
+
assert json.dumps(after, sort_keys=True) == json.dumps(before, sort_keys=True), row["tag"]
|
| 288 |
+
band = final_band(out["verdict"], out["verdict_band"], out["basis"])
|
| 289 |
+
assert out["display_band"] == band
|
| 290 |
+
lo, hi = RANGE[band]
|
| 291 |
+
assert isinstance(out["display_score"], int) and lo <= out["display_score"] <= hi, row["tag"]
|
| 292 |
+
# filename-only provenance (a generator-export name, no C2PA): the AI verdict stands, the number is not 100
|
| 293 |
+
import provenance_gate
|
| 294 |
+
fn_sig = set(provenance_gate.check_filename(row["filename"]))
|
| 295 |
+
sigs = set((out.get("provenance") or {}).get("signals") or [])
|
| 296 |
+
c2pa_present = bool((((out.get("provenance") or {}).get("details") or {}).get("c2pa") or {}).get("present"))
|
| 297 |
+
filename_only = bool(fn_sig) and not c2pa_present and sigs <= fn_sig
|
| 298 |
+
assert out["display_score"] == display_score(out["model_score"], band, out["basis"],
|
| 299 |
+
out["band_thresholds"]["t5"], out["band_thresholds"]["t1"],
|
| 300 |
+
out["threshold"], filename_only=filename_only)
|
| 301 |
+
checked += 1
|
| 302 |
+
bases.add((out["basis"], band))
|
| 303 |
+
if checked == 0:
|
| 304 |
+
pytest.skip("no golden fixture image on this machine")
|
| 305 |
+
if checked == len(golden):
|
| 306 |
+
# the recorded set covers every basis RC1 can answer with
|
| 307 |
+
assert {("model", "real"), ("model", "uncertain"), ("model", "ai"), ("local_edit", "uncertain"),
|
| 308 |
+
("provenance", "ai")} <= bases
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
@pytest.mark.slow
|
| 312 |
+
def test_spotlight_changes_the_overlay_and_nothing_else(rc1_detector):
|
| 313 |
+
"""App request 2026-09-24: with AEYE_HEATMAP_STYLE=spotlight every field but overlay_b64 / heatmap_style is what the
|
| 314 |
+
jet run returns; the overlay differs exactly where a heatmap is drawn (AI verdict or a partial-edit suspect)."""
|
| 315 |
+
import main_hybrid as mh
|
| 316 |
+
golden = json.load(open(GOLDEN, encoding="utf-8"))["rows"]
|
| 317 |
+
drawn = 0
|
| 318 |
+
for row in golden:
|
| 319 |
+
if not os.path.exists(os.path.join(REPO, row["path"])):
|
| 320 |
+
continue
|
| 321 |
+
raw = _load(row["path"], row["strip"])
|
| 322 |
+
out = {}
|
| 323 |
+
for style in ("jet", "spotlight"):
|
| 324 |
+
mp = pytest.MonkeyPatch()
|
| 325 |
+
mp.setattr(mh, "HEATMAP_STYLE", style)
|
| 326 |
+
try:
|
| 327 |
+
out[style] = rc1_detector.analyze(raw, row["filename"])
|
| 328 |
+
finally:
|
| 329 |
+
mp.undo()
|
| 330 |
+
jet, spot = (_canon(out[s]) for s in ("jet", "spotlight"))
|
| 331 |
+
assert (jet["heatmap_style"], spot["heatmap_style"]) == ("jet", "spotlight")
|
| 332 |
+
same = lambda c: {k: v for k, v in c.items() if k not in ("overlay_b64", "heatmap_style")} # noqa: E731
|
| 333 |
+
assert json.dumps(same(jet), sort_keys=True) == json.dumps(same(spot), sort_keys=True), row["tag"]
|
| 334 |
+
heat = out["jet"]["verdict"] == "AI" or bool(out["jet"].get("partial_edit_suspect"))
|
| 335 |
+
assert (jet["overlay_b64"] != spot["overlay_b64"]) == heat, row["tag"]
|
| 336 |
+
drawn += heat
|
| 337 |
+
if drawn == 0:
|
| 338 |
+
pytest.skip("no golden image with a drawn heatmap on this machine")
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def test_filename_only_provenance_is_not_100():
|
| 342 |
+
"""Owner-review 2026-09-28: a generator-export filename alone is weak evidence - the AI verdict stands, the number is
|
| 343 |
+
the pixel score mapped into the AI range (never below AI_MIN), while a C2PA / metadata record still shows 100."""
|
| 344 |
+
import score_display as sd
|
| 345 |
+
t5, t1 = 0.842605, 0.973393
|
| 346 |
+
assert sd.display_score(0.36, "ai", "provenance", t5, t1, t1) == 100
|
| 347 |
+
assert sd.display_score(0.36, "ai", "provenance", t5, t1, t1, filename_only=True) == sd.AI_MIN
|
| 348 |
+
v = sd.display_score(0.99, "ai", "provenance", t5, t1, t1, filename_only=True)
|
| 349 |
+
assert sd.AI_MIN <= v < 100
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def test_vendor_binary_app_segments_do_not_carry_generator_signals():
|
| 353 |
+
"""2026-09-29: a Motorola Moto Z2 Play camera original has an APP6 tuning blob whose printable bytes contained 'CAI:'
|
| 354 |
+
-> the old gate reported 'Adobe/Firefly' and forced AI. Generator tokens are no longer searched in vendor binary APPn
|
| 355 |
+
segments, and the bare 'cai:' token is gone; XMP and C2PA are still searched."""
|
| 356 |
+
import provenance_gate as pg
|
| 357 |
+
def seg(marker, payload):
|
| 358 |
+
return bytes([0xFF, marker]) + (len(payload) + 2).to_bytes(2, "big") + payload
|
| 359 |
+
blob = b"x" * 40 + b"ADOBE FIREFLY CAI:OO6=?SHIJZ9=68ILRcNbEVF@9PJ<=>^ULO" + b"y" * 40
|
| 360 |
+
jpg = b"\xff\xd8" + seg(0xE6, blob) + b"\xff\xda\x00\x02" + b"\x00" * 16 + b"\xff\xd9"
|
| 361 |
+
assert pg.check_bytes(jpg)["signals"] == []
|
| 362 |
+
xmp = b"http://ns.adobe.com/xap/1.0/\x00<x:xmpmeta><rdf:Description xmlns:cai=\"http://ns.adobe.com/cai/1.0/\"/></x:xmpmeta>"
|
| 363 |
+
jpg2 = b"\xff\xd8" + seg(0xE1, xmp) + b"\xff\xda\x00\x02" + b"\x00" * 16 + b"\xff\xd9"
|
| 364 |
+
assert "Adobe/Firefly" in pg.check_bytes(jpg2)["signals"]
|
tests/test_fft_nyquist_parity.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""fft_nyquist (C3, 2026-09-24) in the service. Run: .venv\\Scripts\\python -m pytest 1_deployed_service/tests/test_fft_nyquist_parity.py -q
|
| 2 |
+
|
| 3 |
+
The radial-FFT branch historically clamped the radius to 1.0 and required r < 1 for its last ring, so the exact-Nyquist
|
| 4 |
+
row/column and the spectrum corners fell in no ring. `fft_nyquist=True` makes the last ring [edges[-2], sqrt 2]. These
|
| 5 |
+
tests pin what the service must do with it:
|
| 6 |
+
* without the flag the rings are the historic ones, bit for bit (every served checkpoint stores exactly those);
|
| 7 |
+
* the service's RadialFFTBranch is the training class, textually and numerically (masks and forward);
|
| 8 |
+
* a checkpoint's config flag reaches the model (_verdict_kwargs_from_checkpoint), and a checkpoint whose stored rings
|
| 9 |
+
disagree with its config is a start-up error (_load_state_strict / _check_fft_rings), never a silent switch.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import inspect
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
|
| 17 |
+
import pytest
|
| 18 |
+
import torch
|
| 19 |
+
import torch.nn as nn
|
| 20 |
+
|
| 21 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 22 |
+
REPO = os.path.dirname(ROOT)
|
| 23 |
+
sys.path.insert(0, ROOT)
|
| 24 |
+
|
| 25 |
+
import main_hybrid as mh # noqa: E402
|
| 26 |
+
from zero_shot_v4 import RadialFFTBranch # noqa: E402
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def historic_masks(size: int, bins: int) -> torch.Tensor:
|
| 30 |
+
"""The pre-C3 _make_radial_masks, verbatim."""
|
| 31 |
+
axis = torch.linspace(-1.0, 1.0, size)
|
| 32 |
+
yy, xx = torch.meshgrid(axis, axis, indexing="ij")
|
| 33 |
+
rr = torch.sqrt(xx.square() + yy.square()).clamp(max=1.0)
|
| 34 |
+
edges = torch.linspace(0.0, 1.0, bins + 1)
|
| 35 |
+
masks = []
|
| 36 |
+
for idx in range(bins):
|
| 37 |
+
mask = ((rr >= edges[idx]) & (rr < edges[idx + 1])).float()
|
| 38 |
+
denom = mask.sum().clamp_min(1.0)
|
| 39 |
+
masks.append(mask / denom)
|
| 40 |
+
return torch.stack(masks, dim=0)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _radius(size: int) -> torch.Tensor:
|
| 44 |
+
axis = torch.linspace(-1.0, 1.0, size)
|
| 45 |
+
yy, xx = torch.meshgrid(axis, axis, indexing="ij")
|
| 46 |
+
return torch.sqrt(xx.square() + yy.square())
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _training_branch_cls():
|
| 50 |
+
training = os.path.join(REPO, "4_model_training")
|
| 51 |
+
if not os.path.isdir(training):
|
| 52 |
+
pytest.skip("4_model_training not present (service image): nothing to compare against")
|
| 53 |
+
if training not in sys.path:
|
| 54 |
+
sys.path.insert(0, training)
|
| 55 |
+
from src.models.zero_shot_v4 import RadialFFTBranch as TrainingBranch
|
| 56 |
+
return TrainingBranch
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class _Holder(nn.Module):
|
| 60 |
+
"""Just enough model for _load_state_strict / _check_fft_rings: a `frequency_branch` like v4/v5/v6 have."""
|
| 61 |
+
|
| 62 |
+
def __init__(self, nyquist: bool) -> None:
|
| 63 |
+
super().__init__()
|
| 64 |
+
self.frequency_branch = RadialFFTBranch(image_size=224, bins=48, out_dim=192, nyquist=nyquist)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_default_rings_are_the_historic_rings_bit_for_bit():
|
| 68 |
+
assert torch.equal(RadialFFTBranch._make_radial_masks(224, 48), historic_masks(224, 48))
|
| 69 |
+
assert torch.equal(RadialFFTBranch(image_size=224, bins=48).masks, historic_masks(224, 48))
|
| 70 |
+
assert torch.equal(RadialFFTBranch._make_radial_masks(64, 16, nyquist=False), historic_masks(64, 16))
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_nyquist_rings_cover_the_whole_spectrum_and_only_move_the_last_ring():
|
| 74 |
+
old, new = historic_masks(224, 48), RadialFFTBranch._make_radial_masks(224, 48, nyquist=True)
|
| 75 |
+
assert torch.equal(new[:-1], old[:-1]), "rings 0..46 must not move (warm starts depend on it)"
|
| 76 |
+
rr = _radius(224)
|
| 77 |
+
last = new[-1] > 0
|
| 78 |
+
assert torch.equal(last, rr >= torch.linspace(0.0, 1.0, 49)[-2])
|
| 79 |
+
support = (new > 0).sum(dim=0)
|
| 80 |
+
assert int(support.min()) == 1 and int(support.max()) == 1, "every frequency in exactly one ring"
|
| 81 |
+
dropped_before = int(((old > 0).sum(dim=0) == 0).sum())
|
| 82 |
+
assert dropped_before == int((rr >= 1.0).sum()) > 0 # what the historic rings never saw (Nyquist row/col + corners)
|
| 83 |
+
assert bool(last[0].all()) and bool(last[:, 0].all()), "the exact-Nyquist row and column (fftshift index 0) are in"
|
| 84 |
+
for ring in new:
|
| 85 |
+
assert torch.isclose(ring.sum(), torch.tensor(1.0), atol=1e-5)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_service_ring_class_is_the_training_class():
|
| 89 |
+
TrainingBranch = _training_branch_cls()
|
| 90 |
+
assert inspect.getsource(RadialFFTBranch) == inspect.getsource(TrainingBranch)
|
| 91 |
+
torch.manual_seed(0)
|
| 92 |
+
x = torch.rand(3, 3, 224, 224)
|
| 93 |
+
for nyquist in (False, True):
|
| 94 |
+
svc = RadialFFTBranch(image_size=224, bins=48, out_dim=192, nyquist=nyquist).eval()
|
| 95 |
+
trn = TrainingBranch(image_size=224, bins=48, out_dim=192, nyquist=nyquist).eval()
|
| 96 |
+
trn.load_state_dict(svc.state_dict())
|
| 97 |
+
assert torch.equal(svc.masks, trn.masks)
|
| 98 |
+
with torch.no_grad():
|
| 99 |
+
assert torch.equal(svc(x), trn(x))
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_nyquist_changes_the_features_only_through_the_last_ring():
|
| 103 |
+
torch.manual_seed(1)
|
| 104 |
+
old = RadialFFTBranch(image_size=224, bins=48, nyquist=False).eval()
|
| 105 |
+
new = RadialFFTBranch(image_size=224, bins=48, nyquist=True).eval()
|
| 106 |
+
new.load_state_dict(old.state_dict()) # warm start: the nyquist branch keeps its own rings
|
| 107 |
+
assert new.ignored_checkpoint_masks and not torch.equal(new.masks, old.masks)
|
| 108 |
+
x = torch.rand(2, 3, 224, 224)
|
| 109 |
+
with torch.no_grad():
|
| 110 |
+
assert not torch.equal(old(x), new(x))
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_verdict_kwargs_carry_the_flag():
|
| 114 |
+
base = {"type": "zero_shot_v6", "clip_backbone": "clip-vit-l-14", "clip_layers": [13], "fft_bins": 48}
|
| 115 |
+
_, kw = mh._verdict_kwargs_from_checkpoint({"config": {"model": dict(base, fft_nyquist=True)}, "model_trainable_state_dict": {}})
|
| 116 |
+
assert kw["fft_nyquist"] is True
|
| 117 |
+
_, kw = mh._verdict_kwargs_from_checkpoint({"config": {"model": dict(base)}, "model_trainable_state_dict": {}})
|
| 118 |
+
assert "fft_nyquist" not in kw # absent = the constructor default = historic rings
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_a_nyquist_model_refuses_clamped_rings_from_a_checkpoint():
|
| 122 |
+
legacy_state = _Holder(nyquist=False).state_dict()
|
| 123 |
+
with pytest.raises(RuntimeError, match="did not survive loading"):
|
| 124 |
+
mh._load_state_strict(_Holder(nyquist=True), legacy_state, "verdict")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_rings_that_disagree_with_the_config_are_a_startup_error():
|
| 128 |
+
model = _Holder(nyquist=False)
|
| 129 |
+
mh._load_state_strict(model, _Holder(nyquist=True).state_dict(), "verdict") # historic loading takes the buffer
|
| 130 |
+
with pytest.raises(RuntimeError, match="checkpoint and config disagree"):
|
| 131 |
+
mh._check_fft_rings(model, "verdict")
|
| 132 |
+
ok = _Holder(nyquist=True)
|
| 133 |
+
mh._load_state_strict(ok, _Holder(nyquist=True).state_dict(), "verdict")
|
| 134 |
+
mh._check_fft_rings(ok, "verdict")
|
| 135 |
+
mh._check_fft_rings(nn.Linear(2, 2), "no fft branch") # models without the branch are not checked
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@pytest.mark.slow
|
| 139 |
+
@pytest.mark.parametrize("name", ["best49.pt", "best63.pt", "best69.pt", "best69_loc.pt"])
|
| 140 |
+
def test_shipped_checkpoints_store_the_historic_rings(name):
|
| 141 |
+
path = os.path.join(ROOT, "models", name)
|
| 142 |
+
if not os.path.exists(path):
|
| 143 |
+
pytest.skip(f"{name} not present")
|
| 144 |
+
ck = torch.load(path, map_location="cpu", weights_only=True)
|
| 145 |
+
state = ck.get("model_trainable_state_dict", ck.get("model_state_dict", ck))
|
| 146 |
+
assert not (ck.get("config") or {}).get("model", {}).get("fft_nyquist")
|
| 147 |
+
assert torch.equal(state["frequency_branch.masks"], historic_masks(224, 48))
|
tests/test_heatmap_spotlight.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Heatmap spotlight (app request 2026-09-24, owner-approved; _collab/REQUEST_server_heatmap_spotlight.md).
|
| 2 |
+
|
| 3 |
+
The jet overlay painted green / yellow over photos the app calls AI, and in the app green means 'real' and amber
|
| 4 |
+
'uncertain'. The spotlight adds no colour: the suspect region stays the original photo, the rest is darkened to 50 %,
|
| 5 |
+
one white line on the edge. AEYE_HEATMAP_STYLE picks the style and every response says which one it got
|
| 6 |
+
(heatmap_style), so the app can choose its caption without an app build and a deploy landing on the same day.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import io
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import pytest
|
| 17 |
+
from PIL import Image
|
| 18 |
+
|
| 19 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 20 |
+
sys.path.insert(0, ROOT)
|
| 21 |
+
|
| 22 |
+
import heatmap_nextgen as hn # noqa: E402
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _photo(w: int = 768, h: int = 512) -> Image.Image:
|
| 26 |
+
"""A smooth colour photo stand-in (no pure black / white, so dimming and the white line are measurable)."""
|
| 27 |
+
y, x = np.mgrid[0:h, 0:w].astype(np.float32)
|
| 28 |
+
r = 60 + 150 * x / w
|
| 29 |
+
g = 70 + 120 * y / h
|
| 30 |
+
b = 90 + 80 * (1 - x / w)
|
| 31 |
+
return Image.fromarray(np.stack([r, g, b], -1).astype(np.uint8))
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _blob_map(grid: int = 24, cx: int = 16, cy: int = 8, radius: float = 3.5) -> np.ndarray:
|
| 35 |
+
y, x = np.mgrid[0:grid, 0:grid].astype(np.float32)
|
| 36 |
+
return np.exp(-((x - cx) ** 2 + (y - cy) ** 2) / (2 * radius ** 2)).astype(np.float32)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _norm(loc: np.ndarray, size: tuple[int, int]) -> np.ndarray:
|
| 40 |
+
big = hn._big(hn._focus(loc), size)
|
| 41 |
+
return big / big.max()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_region_is_the_original_photo_and_the_rest_is_half_as_bright():
|
| 45 |
+
img, loc = _photo(), _blob_map()
|
| 46 |
+
out = np.asarray(hn.spotlight(img, loc), np.int32)
|
| 47 |
+
src = np.asarray(img, np.int32)
|
| 48 |
+
norm = _norm(loc, img.size)
|
| 49 |
+
deep_inside = norm >= 0.8 # far from the edge line and its shadow
|
| 50 |
+
far_outside = norm <= 0.2
|
| 51 |
+
assert deep_inside.sum() > 500 and far_outside.sum() > 100_000
|
| 52 |
+
assert np.array_equal(out[deep_inside], src[deep_inside]) # untouched, pixel for pixel
|
| 53 |
+
half = np.rint(src[far_outside] * 0.5)
|
| 54 |
+
assert np.abs(out[far_outside] - half).max() <= 1 # 50 % brightness
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_dimming_keeps_hue_and_adds_no_colour():
|
| 58 |
+
img, loc = _photo(), _blob_map()
|
| 59 |
+
out = np.asarray(hn.spotlight(img, loc), np.float32)
|
| 60 |
+
src = np.asarray(img, np.float32)
|
| 61 |
+
norm = _norm(loc, img.size)
|
| 62 |
+
ramp = (norm > 0.36) & (norm < 0.45) # inside the soft ramp, away from the line
|
| 63 |
+
for mask in (norm <= 0.3, ramp):
|
| 64 |
+
assert mask.sum() > 50
|
| 65 |
+
ratio = out[mask] / src[mask]
|
| 66 |
+
assert np.abs(ratio - ratio.mean(axis=1, keepdims=True)).max() < 0.02 # same factor on R, G and B
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_edge_line_is_white_with_a_dark_rim_inside():
|
| 70 |
+
from scipy.ndimage import binary_dilation, binary_erosion
|
| 71 |
+
img, loc = _photo(), _blob_map()
|
| 72 |
+
out = np.asarray(hn.spotlight(img, loc), np.int32)
|
| 73 |
+
src = np.asarray(img, np.int32)
|
| 74 |
+
inside = _norm(loc, img.size) >= 0.5
|
| 75 |
+
line = binary_dilation(inside, iterations=1) & ~binary_erosion(inside, iterations=1, border_value=1)
|
| 76 |
+
assert line.sum() > 100
|
| 77 |
+
assert out[line].min() >= int(0.9 * 255) # 0.9 white over any photo: at least 229
|
| 78 |
+
shadow = binary_erosion(inside, iterations=1, border_value=1) & ~binary_erosion(inside, iterations=2, border_value=1)
|
| 79 |
+
assert np.abs(out[shadow] - np.rint(src[shadow] * 0.75)).max() <= 1
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_line_scales_with_the_display_size():
|
| 83 |
+
loc = _blob_map()
|
| 84 |
+
|
| 85 |
+
def white(img: Image.Image) -> int:
|
| 86 |
+
return int((np.asarray(hn.spotlight(img, loc), np.int32).min(axis=-1) >= 228).sum())
|
| 87 |
+
# twice the side: twice the perimeter and twice the line width -> about four times the white pixels
|
| 88 |
+
assert 3.0 < white(_photo(1536, 1024)) / white(_photo(768, 512)) < 5.0
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_nothing_to_point_at_returns_the_photo_unchanged():
|
| 92 |
+
img = _photo()
|
| 93 |
+
assert np.array_equal(np.asarray(hn.spotlight(img, _blob_map(), blanket=True)), np.asarray(img))
|
| 94 |
+
assert np.array_equal(np.asarray(hn.spotlight(img, np.full((24, 24), 0.4, np.float32))), np.asarray(img))
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_jet_overlay_is_unchanged_by_the_new_style():
|
| 98 |
+
"""overlay() is the rollback path (AEYE_HEATMAP_STYLE=jet): its pixels must be what RC1 served."""
|
| 99 |
+
img, loc = _photo(), _blob_map()
|
| 100 |
+
rgb = np.asarray(img.convert("RGB"), np.float32) / 255.0
|
| 101 |
+
big = hn._big(hn._focus(loc), img.size)
|
| 102 |
+
alpha = 0.92 * np.clip((big - 0.30) / 0.70, 0.0, 1.0) ** 0.55
|
| 103 |
+
want = (np.clip(rgb * (1 - alpha[..., None]) + hn.jet(big) * alpha[..., None], 0, 1) * 255).astype(np.uint8)
|
| 104 |
+
assert np.array_equal(np.asarray(hn.overlay(img, loc, 1.0, blanket=False)), want)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# --------------------------------------------------------------------------- configuration
|
| 108 |
+
def test_style_variable_parsing():
|
| 109 |
+
import main_hybrid as mh
|
| 110 |
+
assert mh.parse_heatmap_style(None) == "spotlight"
|
| 111 |
+
assert mh.parse_heatmap_style("") == "spotlight"
|
| 112 |
+
assert mh.parse_heatmap_style(" JET ") == "jet"
|
| 113 |
+
assert mh.parse_heatmap_style("spotlight") == "spotlight"
|
| 114 |
+
assert mh.parse_heatmap_style("rainbow") == "spotlight" # logged, never a start-up failure
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_dockerfile_serves_spotlight():
|
| 118 |
+
with open(os.path.join(ROOT, "Dockerfile"), encoding="utf-8") as fh:
|
| 119 |
+
assert "AEYE_HEATMAP_STYLE=spotlight" in fh.read()
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_startup_banner_names_the_style():
|
| 123 |
+
import guards
|
| 124 |
+
import main_hybrid as mh
|
| 125 |
+
assert f"heatmap=dense/{mh.HEATMAP_STYLE}" in mh.startup_banner(guards.Settings(), 0.5, "v6", 9)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# --------------------------------------------------------------------------- the field reaches the client
|
| 129 |
+
def test_schema_declares_heatmap_style():
|
| 130 |
+
from schemas import AnalyzeResponse
|
| 131 |
+
base = {"score": 0.1, "verdict": "REAL", "heatmap_b64": "", "overlay_b64": "", "model_version": "x", "elapsed_ms": 1}
|
| 132 |
+
assert AnalyzeResponse(**base).heatmap_style is None
|
| 133 |
+
assert AnalyzeResponse(**base, heatmap_style="spotlight").heatmap_style == "spotlight"
|
| 134 |
+
with pytest.raises(Exception):
|
| 135 |
+
AnalyzeResponse(**base, heatmap_style="rainbow")
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@pytest.mark.parametrize("path", ["/api/analyze", "/predict"])
|
| 139 |
+
def test_heatmap_style_survives_the_api_response(path):
|
| 140 |
+
"""FastAPI drops every key the response model does not declare (HANDOFF trap 28d) - only an HTTP-level test
|
| 141 |
+
shows the field actually reaches the app."""
|
| 142 |
+
import httpx
|
| 143 |
+
import guards
|
| 144 |
+
import main_hybrid as mh
|
| 145 |
+
|
| 146 |
+
class Stub:
|
| 147 |
+
model_version = "stub"
|
| 148 |
+
|
| 149 |
+
def analyze(self, raw: bytes, filename: str | None = None) -> dict:
|
| 150 |
+
return {"score": 0.99, "verdict": "AI", "heatmap_b64": "", "overlay_b64": "", "model_version": "stub",
|
| 151 |
+
"elapsed_ms": 1, "threshold": 0.97, "source": "model", "basis": "model", "model_score": 0.99,
|
| 152 |
+
"verdict_band": "AI", "display_score": 99, "display_band": "ai", "heatmap_style": "spotlight"}
|
| 153 |
+
|
| 154 |
+
buf = io.BytesIO()
|
| 155 |
+
Image.new("RGB", (64, 64), (90, 120, 150)).save(buf, "PNG")
|
| 156 |
+
old = (guards.SETTINGS, mh._detector)
|
| 157 |
+
guards.configure(guards.Settings(anon_per_minute=1000, anon_per_day=100000, max_concurrency=1, max_queue=4,
|
| 158 |
+
queue_timeout_s=1.0, trusted_proxy_hops=1))
|
| 159 |
+
mh._detector = Stub()
|
| 160 |
+
|
| 161 |
+
async def go():
|
| 162 |
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=mh.app), base_url="http://test") as c:
|
| 163 |
+
return await c.post(path, files={"image": ("x.png", buf.getvalue(), "image/png")})
|
| 164 |
+
try:
|
| 165 |
+
body = asyncio.run(go()).json()
|
| 166 |
+
finally:
|
| 167 |
+
guards.configure(old[0])
|
| 168 |
+
mh._detector = old[1]
|
| 169 |
+
assert body["heatmap_style"] == "spotlight"
|
tests/test_lattice_service.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GPT round 3 lattice features (checkpoint config model.lattice_features) in the service. Run:
|
| 2 |
+
.venv\\Scripts\\python -m pytest 1_deployed_service/tests/test_lattice_service.py -q
|
| 3 |
+
|
| 4 |
+
Pinned here (CPU, a tiny random CLIP stands in for CLIP-L in both copies; the DINOv2 case skips without the pinned cache):
|
| 5 |
+
* lattice_features.py and crop_geometry.py are BYTE-identical to the training copies (4_model_training/src/models,
|
| 6 |
+
4_model_training/src/data) - the statistic the service computes is the one the model was trained on;
|
| 7 |
+
* a checkpoint without the key builds the historic v6 (no constructor key); with it the key reaches ZeroShotV6Detector;
|
| 8 |
+
an unknown statistic fails closed before CLIP is loaded;
|
| 9 |
+
* flags off: the service ZeroShotV6Detector is bit-identical to the pinned pre-change service module (slow: also with the
|
| 10 |
+
deployed RC1 checkpoint models/best69.pt, real CLIP-L + DINOv2-S);
|
| 11 |
+
* PARITY: the service model (built by _verdict_kwargs_from_checkpoint + _load_state_strict) equals the training model
|
| 12 |
+
with the same weights on 9 native crops with the geometry plane: logits / p_ai / artifact vector |diff| < 1e-5 on CPU,
|
| 13 |
+
and the lattice features themselves bit for bit;
|
| 14 |
+
* _native_crops attaches exactly eval_sealed's plane for a lattice verdict (the evaluator's grid, the decoded image's
|
| 15 |
+
frame, pad_to offsets for small images) and nothing for any other verdict.
|
| 16 |
+
The training-side comparisons skip in the service image (no 4_model_training).
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import copy
|
| 21 |
+
import importlib.util
|
| 22 |
+
import os
|
| 23 |
+
import subprocess
|
| 24 |
+
import sys
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
import pytest
|
| 29 |
+
import torch
|
| 30 |
+
from PIL import Image
|
| 31 |
+
|
| 32 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 33 |
+
REPO = os.path.dirname(ROOT)
|
| 34 |
+
sys.path.insert(0, ROOT)
|
| 35 |
+
GIT_REPO = os.environ.get("AEYE_REPO", REPO)
|
| 36 |
+
BASE_COMMIT = "0905ac8" # the tree before lattice_features (flags-off reference)
|
| 37 |
+
|
| 38 |
+
import crop_geometry as svc_cg # noqa: E402
|
| 39 |
+
import lattice_features as svc_lf # noqa: E402
|
| 40 |
+
import main_hybrid as mh # noqa: E402
|
| 41 |
+
import multiview # noqa: E402
|
| 42 |
+
import zero_shot_v4 as svc_v4 # noqa: E402
|
| 43 |
+
from zero_shot_v6 import ZeroShotV6Detector as ServiceV6 # noqa: E402
|
| 44 |
+
|
| 45 |
+
TOL = 1e-5
|
| 46 |
+
SPEC = {"stats": ["nyq_min_img", "d4_img"], "per_crop": True, "clip": 4.0, "sides": "positive"}
|
| 47 |
+
V6_MODEL = {
|
| 48 |
+
"type": "zero_shot_v6", "clip_backbone": "clip-vit-l-14", "clip_layers": [13], "anchor_layer": 13,
|
| 49 |
+
"clip_adapter": "effort", "adapter_rank": 4, "adapter_targets": ["q_proj", "k_proj", "v_proj", "out_proj"],
|
| 50 |
+
"semantic_dim": 32, "forensic_dim": 16, "frequency_dim": 24, "fft_bins": 8, "fft_nyquist": True, "num_classes": 2,
|
| 51 |
+
"dropout": 0.26, "source_grl_lambda": 0.0, "patch_backbone": "none", "patch_dim": 16, "n_crops_train": 4,
|
| 52 |
+
"n_crops_eval": 9, "crop_pool": "mean", "grad_checkpointing": True, "num_sources": 3,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _training():
|
| 57 |
+
training = os.path.join(REPO, "4_model_training")
|
| 58 |
+
if not os.path.isdir(training):
|
| 59 |
+
pytest.skip("4_model_training not present (service image): nothing to compare against")
|
| 60 |
+
if training not in sys.path:
|
| 61 |
+
sys.path.insert(0, training)
|
| 62 |
+
from src.models import factory
|
| 63 |
+
import src.models.zero_shot_v4 as trn_v4
|
| 64 |
+
return factory, trn_v4
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class _TinyCLIP:
|
| 68 |
+
base = None
|
| 69 |
+
|
| 70 |
+
@classmethod
|
| 71 |
+
def from_pretrained(cls, *_a, **_k):
|
| 72 |
+
if cls.base is None:
|
| 73 |
+
from transformers import CLIPVisionConfig, CLIPVisionModelWithProjection
|
| 74 |
+
|
| 75 |
+
with torch.random.fork_rng():
|
| 76 |
+
torch.manual_seed(1234)
|
| 77 |
+
cls.base = CLIPVisionModelWithProjection(CLIPVisionConfig(
|
| 78 |
+
hidden_size=64, intermediate_size=128, num_hidden_layers=13, num_attention_heads=4, image_size=224,
|
| 79 |
+
patch_size=32, projection_dim=32))
|
| 80 |
+
return copy.deepcopy(cls.base)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _renderer_image(w=1086, h=725, seed=0, amp=3.0):
|
| 84 |
+
"""Content + the owner-train lattice on the 16-multiple grid, slightly up-sampled to (w, h)."""
|
| 85 |
+
rng = np.random.default_rng(seed)
|
| 86 |
+
w0, h0 = 16 * (w // 16), 16 * (h // 16)
|
| 87 |
+
base = np.asarray(Image.fromarray(rng.integers(20, 236, (h0 // 8 + 1, w0 // 8 + 1, 3), dtype=np.uint8)).resize(
|
| 88 |
+
(w0, h0), Image.Resampling.BICUBIC)).astype(np.float64)
|
| 89 |
+
t = np.asarray(svc_lf.T_REF)
|
| 90 |
+
tile = np.tile(t, (h0 // 8 + 1, w0 // 8 + 1))[:h0, :w0] / np.abs(t).max()
|
| 91 |
+
img = Image.fromarray(np.clip(np.round(base + amp * tile[..., None]), 0, 255).astype(np.uint8))
|
| 92 |
+
return img.resize((w, h), Image.Resampling.BICUBIC)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _plane_crops(img: Image.Image, k: int = 9) -> torch.Tensor:
|
| 96 |
+
"""eval_sealed.RecordSet(crop_geometry=True)'s crops, rebuilt here from multiview.py (== the evaluator's grid)."""
|
| 97 |
+
import torchvision.transforms as T
|
| 98 |
+
|
| 99 |
+
tf = T.Compose([T.ToTensor(), T.Normalize(svc_v4.CLIP_MEAN, svc_v4.CLIP_STD)])
|
| 100 |
+
w0, h0 = img.size
|
| 101 |
+
p = multiview.pad_to(img, 224)
|
| 102 |
+
w, h = p.size
|
| 103 |
+
boxes = multiview.grid_boxes(w, h, 224, k)
|
| 104 |
+
pix = torch.stack([tf(p.crop((x, y, x + 224, y + 224))) for x, y in boxes])
|
| 105 |
+
ox, oy = max(0, 224 - w0) // 2, max(0, 224 - h0) // 2
|
| 106 |
+
return svc_cg.attach_geometry(pix, [svc_cg.geometry_row(x - ox, y - oy, w0, h0) for x, y in boxes])
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# --------------------------------------------------------------------------- copies and config
|
| 110 |
+
@pytest.mark.parametrize("name,training_rel", [("lattice_features.py", ("src", "models", "lattice_features.py")),
|
| 111 |
+
("crop_geometry.py", ("src", "data", "crop_geometry.py"))])
|
| 112 |
+
def test_service_copies_are_byte_identical_to_the_training_modules(name, training_rel):
|
| 113 |
+
trn = os.path.join(REPO, "4_model_training", *training_rel)
|
| 114 |
+
if not os.path.isfile(trn):
|
| 115 |
+
pytest.skip("4_model_training not present (service image)")
|
| 116 |
+
with open(os.path.join(ROOT, name), "rb") as a, open(trn, "rb") as b:
|
| 117 |
+
assert a.read().replace(b"\r\n", b"\n") == b.read().replace(b"\r\n", b"\n")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_checkpoints_without_lattice_features_are_untouched():
|
| 121 |
+
for mcfg in (dict(V6_MODEL), dict(V6_MODEL, lattice_features=None)):
|
| 122 |
+
arch, kwargs = mh._verdict_kwargs_from_checkpoint({"config": {"model": mcfg}})
|
| 123 |
+
assert arch == "v6" and kwargs.get("lattice_features") is None
|
| 124 |
+
arch, kwargs = mh._verdict_kwargs_from_checkpoint({"config": {"model": dict(V6_MODEL, lattice_features=SPEC)}})
|
| 125 |
+
assert arch == "v6" and kwargs["lattice_features"] == SPEC and "lattice_features" in mh._V6_KEYS
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def test_an_unknown_statistic_fails_closed_before_clip_loads(monkeypatch):
|
| 129 |
+
def boom(*_a, **_k): # CLIP must not even be built
|
| 130 |
+
raise AssertionError("CLIP was loaded before the lattice spec was validated")
|
| 131 |
+
|
| 132 |
+
monkeypatch.setattr(svc_v4.CLIPVisionModelWithProjection, "from_pretrained", boom)
|
| 133 |
+
for bad in ({"stats": ["nyq16"]}, {"stats": ["d4_img"], "per_crop": False}, {"statz": ["d4_img"]}):
|
| 134 |
+
_, kwargs = mh._verdict_kwargs_from_checkpoint({"config": {"model": dict(V6_MODEL, lattice_features=bad)}})
|
| 135 |
+
with pytest.raises(ValueError, match="lattice_features"):
|
| 136 |
+
ServiceV6(**kwargs)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# --------------------------------------------------------------------------- flags off = the pre-change service module
|
| 140 |
+
def test_flags_off_service_model_is_bit_identical_to_the_base(monkeypatch, tmp_path):
|
| 141 |
+
try:
|
| 142 |
+
out = subprocess.run(["git", "-C", GIT_REPO, "show", f"{BASE_COMMIT}:1_deployed_service/zero_shot_v6.py"],
|
| 143 |
+
capture_output=True, timeout=60)
|
| 144 |
+
except (OSError, subprocess.TimeoutExpired) as exc: # pragma: no cover
|
| 145 |
+
pytest.skip(f"git not available: {exc}")
|
| 146 |
+
if out.returncode != 0:
|
| 147 |
+
pytest.skip("pre-change service module not readable from git")
|
| 148 |
+
src = tmp_path / "_base_service_zero_shot_v6.py"
|
| 149 |
+
src.write_bytes(out.stdout)
|
| 150 |
+
spec = importlib.util.spec_from_file_location("_base_service_zero_shot_v6", src)
|
| 151 |
+
base = importlib.util.module_from_spec(spec)
|
| 152 |
+
spec.loader.exec_module(base)
|
| 153 |
+
monkeypatch.setattr(svc_v4, "CLIPVisionModelWithProjection", _TinyCLIP)
|
| 154 |
+
_, kwargs = mh._verdict_kwargs_from_checkpoint({"config": {"model": dict(V6_MODEL)}})
|
| 155 |
+
torch.manual_seed(7)
|
| 156 |
+
old = base.ZeroShotV6Detector(**kwargs).eval()
|
| 157 |
+
torch.manual_seed(7)
|
| 158 |
+
new = ServiceV6(**kwargs).eval()
|
| 159 |
+
so, sn = old.state_dict(), new.state_dict()
|
| 160 |
+
assert list(so) == list(sn) and all(torch.equal(so[k], sn[k]) for k in so)
|
| 161 |
+
g = torch.Generator().manual_seed(3)
|
| 162 |
+
x = (torch.rand(2, 3, 224, 224, generator=g) - 0.45) / 0.27
|
| 163 |
+
crops = (torch.rand(2, 9, 3, 224, 224, generator=g) - 0.45) / 0.27
|
| 164 |
+
with torch.no_grad():
|
| 165 |
+
a, b = old(x, crops), new(x, crops)
|
| 166 |
+
assert set(a) == set(b) and all(torch.equal(a[k], b[k]) for k in a)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
@pytest.mark.slow
|
| 170 |
+
def test_flags_off_real_rc1_checkpoint_is_bit_identical_to_the_base(tmp_path):
|
| 171 |
+
"""The deployed RC1 verdict (models/best69.pt, real CLIP-L + DINOv2-S) through the pre-change and the current service
|
| 172 |
+
ZeroShotV6Detector: every output tensor equal on a fixed input with 9 native crops."""
|
| 173 |
+
ckpt = os.path.join(ROOT, "models", "best69.pt")
|
| 174 |
+
if not os.path.isfile(ckpt):
|
| 175 |
+
pytest.skip("models/best69.pt not present")
|
| 176 |
+
try:
|
| 177 |
+
out = subprocess.run(["git", "-C", GIT_REPO, "show", f"{BASE_COMMIT}:1_deployed_service/zero_shot_v6.py"],
|
| 178 |
+
capture_output=True, timeout=60)
|
| 179 |
+
except (OSError, subprocess.TimeoutExpired) as exc: # pragma: no cover
|
| 180 |
+
pytest.skip(f"git not available: {exc}")
|
| 181 |
+
if out.returncode != 0:
|
| 182 |
+
pytest.skip("pre-change service module not readable from git")
|
| 183 |
+
src = tmp_path / "_base_service_zero_shot_v6_rc1.py"
|
| 184 |
+
src.write_bytes(out.stdout)
|
| 185 |
+
spec = importlib.util.spec_from_file_location("_base_service_zero_shot_v6_rc1", src)
|
| 186 |
+
base = importlib.util.module_from_spec(spec)
|
| 187 |
+
spec.loader.exec_module(base)
|
| 188 |
+
ck = torch.load(ckpt, map_location="cpu", weights_only=True)
|
| 189 |
+
state = ck["model_trainable_state_dict"]
|
| 190 |
+
arch, kwargs = mh._verdict_kwargs_from_checkpoint(ck)
|
| 191 |
+
assert arch == "v6" and kwargs.get("lattice_features") is None
|
| 192 |
+
g = torch.Generator().manual_seed(5)
|
| 193 |
+
x = (torch.rand(1, 3, 224, 224, generator=g) - 0.45) / 0.27
|
| 194 |
+
crops = (torch.rand(1, 9, 3, 224, 224, generator=g) - 0.45) / 0.27
|
| 195 |
+
outs = []
|
| 196 |
+
for cls in (base.ZeroShotV6Detector, ServiceV6):
|
| 197 |
+
m = cls(**kwargs)
|
| 198 |
+
mh._load_state_strict(m, state, "verdict(v6)")
|
| 199 |
+
m.eval()
|
| 200 |
+
with torch.no_grad():
|
| 201 |
+
outs.append({k: v.clone() for k, v in m(x, crops).items()})
|
| 202 |
+
del m
|
| 203 |
+
assert set(outs[0]) == set(outs[1]) and all(torch.equal(outs[0][k], outs[1][k]) for k in outs[0])
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# --------------------------------------------------------------------------- parity with the training model
|
| 207 |
+
def _parity(monkeypatch, mcfg, x, crops):
|
| 208 |
+
factory, trn_v4 = _training()
|
| 209 |
+
monkeypatch.setattr(trn_v4, "CLIPVisionModelWithProjection", _TinyCLIP)
|
| 210 |
+
monkeypatch.setattr(svc_v4, "CLIPVisionModelWithProjection", _TinyCLIP)
|
| 211 |
+
cfg = {"model": mcfg, "data": {"image_size": 224, "multiview": True, "decode_draft_size": 2048}}
|
| 212 |
+
torch.manual_seed(7)
|
| 213 |
+
trained = factory.build_model(cfg).eval()
|
| 214 |
+
with torch.no_grad(): # move the fresh heads away from their init
|
| 215 |
+
for n, p in trained.named_parameters():
|
| 216 |
+
if p.requires_grad and not n.startswith("clip."):
|
| 217 |
+
p.add_(0.02 * torch.randn_like(p))
|
| 218 |
+
ck = {"model_trainable_state_dict": trained.trainable_state_dict(), "config": cfg}
|
| 219 |
+
arch, kwargs = mh._verdict_kwargs_from_checkpoint(ck)
|
| 220 |
+
assert arch == "v6" and kwargs["lattice_features"] == mcfg["lattice_features"]
|
| 221 |
+
served = ServiceV6(**kwargs)
|
| 222 |
+
mh._load_state_strict(served, ck["model_trainable_state_dict"], "verdict(v6)")
|
| 223 |
+
served.eval()
|
| 224 |
+
with torch.no_grad():
|
| 225 |
+
a, b = trained(x, crops), served(x, crops)
|
| 226 |
+
pix, geom = svc_cg.split_geometry(crops)
|
| 227 |
+
fa = trained.lattice(trained._to_raw_rgb(pix.flatten(0, 1)), geom.flatten(0, 1))
|
| 228 |
+
fb = served.lattice(served._to_raw_rgb(pix.flatten(0, 1)), geom.flatten(0, 1))
|
| 229 |
+
assert torch.equal(fa, fb)
|
| 230 |
+
d = {k: float((a[k] - b[k]).abs().max()) for k in ("logits", "artifact_features", "features")}
|
| 231 |
+
d["p_ai"] = float((torch.softmax(a["logits"], 1)[:, 1] - torch.softmax(b["logits"], 1)[:, 1]).abs().max())
|
| 232 |
+
assert max(d.values()) < TOL, d
|
| 233 |
+
return trained, served, fa
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def test_service_equals_training_with_lattice_features(monkeypatch):
|
| 237 |
+
img = _renderer_image(seed=1)
|
| 238 |
+
crops = torch.stack([_plane_crops(img), _plane_crops(_renderer_image(700, 500, seed=2, amp=0.0))])
|
| 239 |
+
g = torch.Generator().manual_seed(11)
|
| 240 |
+
x = (torch.rand(2, 3, 224, 224, generator=g) - 0.45) / 0.27
|
| 241 |
+
_, served, feats = _parity(monkeypatch, dict(V6_MODEL, lattice_features=SPEC), x, crops)
|
| 242 |
+
f = feats.view(2, 9, 2)
|
| 243 |
+
assert float(f[0, :, 0].mean()) > 2.0 > float(f[1, :, 0].mean()) # lattice image vs the same content without it
|
| 244 |
+
with pytest.raises(ValueError, match="geometry plane"):
|
| 245 |
+
served(x, svc_cg.split_geometry(crops)[0]) # native crops without the plane
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def test_service_equals_training_with_lattice_features_and_dinov2(monkeypatch):
|
| 249 |
+
from transformers import AutoModel
|
| 250 |
+
|
| 251 |
+
from zero_shot_v6 import PATCH_BACKBONES
|
| 252 |
+
|
| 253 |
+
repo, rev = PATCH_BACKBONES["dinov2-small"]
|
| 254 |
+
try:
|
| 255 |
+
AutoModel.from_pretrained(repo, revision=rev)
|
| 256 |
+
except Exception as exc: # pragma: no cover - machine dependent
|
| 257 |
+
pytest.skip(f"pinned {repo} not available: {type(exc).__name__}")
|
| 258 |
+
crops = _plane_crops(_renderer_image(seed=3), k=9).unsqueeze(0)
|
| 259 |
+
g = torch.Generator().manual_seed(12)
|
| 260 |
+
x = (torch.rand(1, 3, 224, 224, generator=g) - 0.45) / 0.27
|
| 261 |
+
_parity(monkeypatch, dict(V6_MODEL, patch_backbone="dinov2-small", lattice_features=SPEC), x, crops)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
# --------------------------------------------------------------------------- the service's own crops
|
| 265 |
+
class _Stub:
|
| 266 |
+
def __init__(self, lattice: bool, k: int = 9):
|
| 267 |
+
self.verdict = type("V", (), {"lattice_spec": svc_lf.parse_lattice_features(SPEC) if lattice else None})()
|
| 268 |
+
self.verdict_crops = k
|
| 269 |
+
import torchvision.transforms as T
|
| 270 |
+
|
| 271 |
+
self.transform_crop = T.Compose([T.ToTensor(), T.Normalize(svc_v4.CLIP_MEAN, svc_v4.CLIP_STD)])
|
| 272 |
+
self.device = torch.device("cpu")
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
@pytest.mark.parametrize("size", [(1086, 725), (1024, 1024), (300, 200), (180, 500)])
|
| 276 |
+
def test_native_crops_attach_the_evaluators_plane_only_for_a_lattice_verdict(size):
|
| 277 |
+
img = _renderer_image(*size, seed=4)
|
| 278 |
+
plain = mh.HybridDetector._native_crops(_Stub(False), img)
|
| 279 |
+
with_plane = mh.HybridDetector._native_crops(_Stub(True), img)
|
| 280 |
+
assert plain.shape == (1, 9, 3, 224, 224) and with_plane.shape == (1, 9, 4, 224, 224)
|
| 281 |
+
pix, geom = svc_cg.split_geometry(with_plane)
|
| 282 |
+
assert torch.equal(pix, plain)
|
| 283 |
+
assert torch.equal(with_plane[0], _plane_crops(img))
|
| 284 |
+
assert (geom[..., 2:4] == torch.tensor(size, dtype=torch.float64)).all()
|
tests/test_patch_adapter_merge.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DINOv2 patch adapter (GPT round 2, R2C, 2026-09-24) in the service. Run:
|
| 2 |
+
.venv\\Scripts\\python -m pytest 1_deployed_service/tests/test_patch_adapter_merge.py -q
|
| 3 |
+
|
| 4 |
+
A verdict trained with an Effort adapter on its DINOv2 patch linears (training config model.patch_adapter) is served
|
| 5 |
+
MERGED: the export folds W_p + U_k diag(s_k) V_k into plain weights stored under the frozen backbone's own names, and the
|
| 6 |
+
service builds the ordinary v6 and loads them with its existing strict loader. These tests pin:
|
| 7 |
+
* checkpoints without a patch adapter are untouched (no new constructor key, no new tensors);
|
| 8 |
+
* an UNMERGED adapter checkpoint is refused before anything is built (the service has no adapter code path);
|
| 9 |
+
* a merged checkpoint must carry exactly the merged tensors its config announces (every targeted linear of every layer);
|
| 10 |
+
* PARITY: the merged service forward equals the adapted training forward, |diff| < 1e-5 on CPU - on the DINOv2 branch
|
| 11 |
+
with the real pinned dinov2-small, and on the whole v6 model (a tiny random CLIP stands in for CLIP-L so the test
|
| 12 |
+
needs no 1.7 GB backbone; DINOv2 is the real one).
|
| 13 |
+
The DINOv2 tests skip when the pinned dinov2-small is not in the local Hugging Face cache; the training-side tests skip in
|
| 14 |
+
the service image (no 4_model_training).
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import copy
|
| 19 |
+
import os
|
| 20 |
+
import sys
|
| 21 |
+
|
| 22 |
+
import pytest
|
| 23 |
+
import torch
|
| 24 |
+
import torch.nn as nn
|
| 25 |
+
|
| 26 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 27 |
+
REPO = os.path.dirname(ROOT)
|
| 28 |
+
sys.path.insert(0, ROOT)
|
| 29 |
+
|
| 30 |
+
import main_hybrid as mh # noqa: E402
|
| 31 |
+
import zero_shot_v4 as svc_v4 # noqa: E402
|
| 32 |
+
from zero_shot_v6 import PATCH_BACKBONES # noqa: E402
|
| 33 |
+
|
| 34 |
+
TARGETS = ["query", "key", "value", "dense"]
|
| 35 |
+
TOL = 1e-5
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _training_v6():
|
| 39 |
+
training = os.path.join(REPO, "4_model_training")
|
| 40 |
+
if not os.path.isdir(training):
|
| 41 |
+
pytest.skip("4_model_training not present (service image): nothing to compare against")
|
| 42 |
+
if training not in sys.path:
|
| 43 |
+
sys.path.insert(0, training)
|
| 44 |
+
from src.models import zero_shot_v6 as z6
|
| 45 |
+
return z6
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _service_dinov2():
|
| 49 |
+
"""The service's own call (zero_shot_v6: AutoModel.from_pretrained(repo, revision=pinned)), frozen, eval."""
|
| 50 |
+
from transformers import AutoModel
|
| 51 |
+
|
| 52 |
+
repo, rev = PATCH_BACKBONES["dinov2-small"]
|
| 53 |
+
try:
|
| 54 |
+
model = AutoModel.from_pretrained(repo, revision=rev)
|
| 55 |
+
except Exception as exc: # pragma: no cover - depends on the machine's cache / network
|
| 56 |
+
pytest.skip(f"pinned {repo}@{rev[:8]} not available: {type(exc).__name__}")
|
| 57 |
+
for p in model.parameters():
|
| 58 |
+
p.requires_grad = False
|
| 59 |
+
return model.eval()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _tokens(backbone: nn.Module, x: torch.Tensor) -> torch.Tensor:
|
| 63 |
+
"""CLS ⊕ mean patch token - exactly what ZeroShotV6Detector._patch_tokens hands to patch_proj."""
|
| 64 |
+
with torch.no_grad():
|
| 65 |
+
out = backbone(pixel_values=x).last_hidden_state
|
| 66 |
+
return torch.cat([out[:, 0], out[:, 1:].mean(dim=1)], dim=1).float()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _perturb(wrapped: dict, seed: int = 0) -> None:
|
| 70 |
+
"""Move the Effort factors well away from their SVD init (a 'trained' adapter)."""
|
| 71 |
+
g = torch.Generator().manual_seed(seed)
|
| 72 |
+
with torch.no_grad():
|
| 73 |
+
for m in wrapped.values():
|
| 74 |
+
m.U_k.add_(0.05 * torch.randn(m.U_k.shape, generator=g))
|
| 75 |
+
m.V_k.add_(0.05 * torch.randn(m.V_k.shape, generator=g))
|
| 76 |
+
m.s_k.mul_(1.0 + 4.0 * torch.rand(m.s_k.shape, generator=g)).add_(0.2)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _crops(n: int, seed: int = 1) -> torch.Tensor:
|
| 80 |
+
g = torch.Generator().manual_seed(seed)
|
| 81 |
+
x = torch.rand(n, 3, 224, 224, generator=g)
|
| 82 |
+
mean = torch.tensor((0.485, 0.456, 0.406)).view(1, 3, 1, 1)
|
| 83 |
+
std = torch.tensor((0.229, 0.224, 0.225)).view(1, 3, 1, 1)
|
| 84 |
+
return (x - mean) / std
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class _Holder(nn.Module):
|
| 88 |
+
"""Just enough v6 for _load_state_strict / _check_patch_adapter: a `patch` DINOv2."""
|
| 89 |
+
|
| 90 |
+
def __init__(self, patch: nn.Module) -> None:
|
| 91 |
+
super().__init__()
|
| 92 |
+
self.patch = patch
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _tiny_dinov2(layers: int = 2) -> nn.Module:
|
| 96 |
+
from transformers import Dinov2Config, Dinov2Model
|
| 97 |
+
|
| 98 |
+
cfg = Dinov2Config(hidden_size=32, num_hidden_layers=layers, num_attention_heads=2, intermediate_size=64, image_size=28,
|
| 99 |
+
patch_size=14)
|
| 100 |
+
return Dinov2Model(cfg).eval()
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
V6_MODEL = {
|
| 104 |
+
"type": "zero_shot_v6", "clip_backbone": "clip-vit-l-14", "clip_layers": [13], "anchor_layer": 13,
|
| 105 |
+
"clip_adapter": "effort", "adapter_rank": 4, "adapter_targets": ["q_proj", "k_proj", "v_proj", "out_proj"],
|
| 106 |
+
"semantic_dim": 512, "forensic_dim": 256, "frequency_dim": 192, "fft_bins": 48, "num_classes": 2,
|
| 107 |
+
"dropout": 0.26, "source_grl_lambda": 0.0, "patch_backbone": "dinov2-small",
|
| 108 |
+
"patch_dim": 256, "n_crops_train": 4, "n_crops_eval": 9, "crop_pool": "mean", "grad_checkpointing": True,
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# --------------------------------------------------------------------------- contract (no model download)
|
| 113 |
+
def test_service_target_table_is_the_training_table():
|
| 114 |
+
z6 = _training_v6()
|
| 115 |
+
assert mh._PATCH_ADAPTER_TARGETS == z6.PATCH_ADAPTER_TARGETS
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_checkpoints_without_a_patch_adapter_are_untouched():
|
| 119 |
+
for mcfg in (dict(V6_MODEL), dict(V6_MODEL, patch_adapter=None), dict(V6_MODEL, patch_adapter={"kind": "none"}),
|
| 120 |
+
dict(V6_MODEL, patch_adapter="none")):
|
| 121 |
+
assert mh._patch_adapter_spec(mcfg) is None
|
| 122 |
+
arch, kwargs = mh._verdict_kwargs_from_checkpoint({"config": {"model": mcfg}})
|
| 123 |
+
assert arch == "v6" and "patch_adapter" not in kwargs
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_an_unmerged_adapter_checkpoint_is_refused_before_anything_is_built():
|
| 127 |
+
mcfg = dict(V6_MODEL, patch_adapter={"kind": "effort", "rank": 4, "targets": TARGETS})
|
| 128 |
+
with pytest.raises(ValueError, match="UNMERGED"):
|
| 129 |
+
mh._verdict_kwargs_from_checkpoint({"config": {"model": mcfg}})
|
| 130 |
+
with pytest.raises(ValueError, match="UNMERGED"):
|
| 131 |
+
mh._verdict_kwargs_from_checkpoint({"config": {"model": dict(mcfg, patch_adapter=dict(mcfg["patch_adapter"], merged=False))}})
|
| 132 |
+
with pytest.raises(ValueError, match="targets"):
|
| 133 |
+
mh._verdict_kwargs_from_checkpoint({"config": {"model": dict(V6_MODEL, patch_adapter={
|
| 134 |
+
"kind": "effort", "rank": 4, "targets": ["qkv"], "merged": True})}})
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_a_merged_adapter_checkpoint_builds_the_plain_v6():
|
| 138 |
+
mcfg = dict(V6_MODEL, patch_adapter={"kind": "effort", "rank": 4, "targets": TARGETS, "merged": True})
|
| 139 |
+
arch, kwargs = mh._verdict_kwargs_from_checkpoint({"config": {"model": mcfg}})
|
| 140 |
+
assert arch == "v6"
|
| 141 |
+
assert "patch_adapter" not in kwargs # the service's ZeroShotV6Detector has no adapter code path
|
| 142 |
+
assert mh._patch_adapter_spec(mcfg)["targets"] == TARGETS
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_merged_tensors_must_be_exactly_the_ones_the_config_announces():
|
| 146 |
+
holder = _Holder(_tiny_dinov2(layers=2))
|
| 147 |
+
merged_cfg = {"patch_adapter": {"kind": "effort", "rank": 4, "targets": TARGETS, "merged": True}}
|
| 148 |
+
own = holder.state_dict()
|
| 149 |
+
want = [f"patch.encoder.layer.{i}.{mh._PATCH_ADAPTER_TARGETS[t]}.weight" for i in range(2) for t in TARGETS]
|
| 150 |
+
state = {k: torch.randn_like(own[k]) for k in want}
|
| 151 |
+
assert mh._load_state_strict(holder, state, "tiny") == 8
|
| 152 |
+
assert mh._check_patch_adapter(holder, merged_cfg, state, "tiny") == 8
|
| 153 |
+
for k in want: # the merged weights really are in the backbone now
|
| 154 |
+
assert torch.equal(holder.state_dict()[k], state[k])
|
| 155 |
+
|
| 156 |
+
with pytest.raises(RuntimeError, match="1 missing"):
|
| 157 |
+
mh._check_patch_adapter(holder, merged_cfg, {k: v for k, v in state.items() if k != want[3]}, "tiny")
|
| 158 |
+
extra = dict(state, **{"patch.encoder.layer.0.mlp.fc1.weight": own["patch.encoder.layer.0.mlp.fc1.weight"]})
|
| 159 |
+
with pytest.raises(RuntimeError, match="1 unexpected"):
|
| 160 |
+
mh._check_patch_adapter(holder, merged_cfg, extra, "tiny")
|
| 161 |
+
with pytest.raises(RuntimeError, match="no patch_adapter"):
|
| 162 |
+
mh._check_patch_adapter(holder, {}, state, "tiny") # merged weights whose config flag was lost
|
| 163 |
+
assert mh._check_patch_adapter(holder, {}, {"head.0.weight": torch.zeros(1)}, "tiny") == 0
|
| 164 |
+
with pytest.raises(RuntimeError, match="no DINOv2"):
|
| 165 |
+
mh._check_patch_adapter(nn.Linear(2, 2), merged_cfg, state, "tiny")
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# --------------------------------------------------------------------------- parity (real pinned dinov2-small)
|
| 169 |
+
def test_merged_dinov2_branch_equals_the_adapted_branch():
|
| 170 |
+
"""merge_patch_adapter_state (training package) -> the service's plain pinned DINOv2 through _load_state_strict:
|
| 171 |
+
the tokens patch_proj consumes must equal the adapted training forward to 1e-5 (CPU, fp32)."""
|
| 172 |
+
z6 = _training_v6()
|
| 173 |
+
spec = z6.parse_patch_adapter({"kind": "effort", "rank": 4, "targets": TARGETS})
|
| 174 |
+
adapted, _ = z6.load_patch_backbone("dinov2-small")
|
| 175 |
+
pretrained_tokens = None
|
| 176 |
+
x = _crops(3)
|
| 177 |
+
pretrained_tokens = _tokens(adapted, x) # before wrapping: the frozen historic branch
|
| 178 |
+
wrapped = z6.wrap_patch_backbone(adapted, spec)
|
| 179 |
+
assert len(wrapped) == 12 * 4
|
| 180 |
+
assert float((_tokens(adapted, x) - pretrained_tokens).abs().max()) < 1e-3 # SVD init = the pretrained layer
|
| 181 |
+
_perturb(wrapped)
|
| 182 |
+
adapted_tokens = _tokens(adapted, x)
|
| 183 |
+
assert float((adapted_tokens - pretrained_tokens).abs().max()) > 1e-2 # the test can see an adapter at all
|
| 184 |
+
|
| 185 |
+
state = {f"patch.{p}.{n}": getattr(m, n).detach().clone() for p, m in wrapped.items() for n in z6.PATCH_ADAPTER_FACTORS}
|
| 186 |
+
state["head.8.bias"] = torch.arange(2.0) # a non-patch tensor rides along untouched
|
| 187 |
+
mcfg = {"patch_backbone": "dinov2-small", "patch_adapter": {"kind": "effort", "rank": 4, "targets": TARGETS}}
|
| 188 |
+
merged, merged_cfg = z6.merge_patch_adapter_state(state, mcfg)
|
| 189 |
+
assert merged_cfg["patch_adapter"]["merged"] is True and "merged" not in mcfg["patch_adapter"]
|
| 190 |
+
assert torch.equal(merged["head.8.bias"], state["head.8.bias"])
|
| 191 |
+
patch_state = {k: v for k, v in merged.items() if k.startswith("patch.")}
|
| 192 |
+
assert len(patch_state) == 48 and all(k.endswith(".weight") for k in patch_state)
|
| 193 |
+
|
| 194 |
+
service = _Holder(_service_dinov2())
|
| 195 |
+
assert mh._load_state_strict(service, patch_state, "parity") == 48
|
| 196 |
+
assert mh._check_patch_adapter(service, merged_cfg, merged, "parity") == 48
|
| 197 |
+
diff = float((_tokens(service.patch, x) - adapted_tokens).abs().max())
|
| 198 |
+
assert diff < TOL, f"merged service tokens differ from the adapted training tokens by {diff:.3g}"
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# --------------------------------------------------------------------------- parity (whole v6, tiny CLIP)
|
| 202 |
+
class _TinyCLIP:
|
| 203 |
+
"""Stands in for CLIPVisionModelWithProjection.from_pretrained in BOTH copies: identical random weights every call."""
|
| 204 |
+
base = None
|
| 205 |
+
|
| 206 |
+
@classmethod
|
| 207 |
+
def from_pretrained(cls, *_a, **_k):
|
| 208 |
+
if cls.base is None:
|
| 209 |
+
from transformers import CLIPVisionConfig, CLIPVisionModelWithProjection
|
| 210 |
+
|
| 211 |
+
with torch.random.fork_rng():
|
| 212 |
+
torch.manual_seed(1234)
|
| 213 |
+
cls.base = CLIPVisionModelWithProjection(CLIPVisionConfig(
|
| 214 |
+
hidden_size=64, intermediate_size=128, num_hidden_layers=13, num_attention_heads=4, image_size=224,
|
| 215 |
+
patch_size=32, projection_dim=32))
|
| 216 |
+
return copy.deepcopy(cls.base)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def test_whole_v6_merged_service_equals_the_adapted_training_model(monkeypatch):
|
| 220 |
+
"""Train-side ZeroShotV6Detector with a 'trained' patch adapter -> tools/merge_patch_adapter.merge_checkpoint ->
|
| 221 |
+
the SERVICE's _verdict_kwargs_from_checkpoint + service ZeroShotV6Detector + _load_state_strict +
|
| 222 |
+
_check_patch_adapter: logits and p_ai must agree to 1e-5 with 9 native crops; the unmerged file is refused."""
|
| 223 |
+
z6 = _training_v6()
|
| 224 |
+
_service_dinov2() # skip early when the pinned backbone is not available
|
| 225 |
+
from src.models import factory
|
| 226 |
+
import src.models.zero_shot_v4 as trn_v4
|
| 227 |
+
|
| 228 |
+
monkeypatch.setattr(trn_v4, "CLIPVisionModelWithProjection", _TinyCLIP)
|
| 229 |
+
monkeypatch.setattr(svc_v4, "CLIPVisionModelWithProjection", _TinyCLIP)
|
| 230 |
+
mcfg = dict(V6_MODEL, semantic_dim=32, forensic_dim=16, frequency_dim=24, fft_bins=8, patch_dim=16, num_sources=3,
|
| 231 |
+
patch_adapter={"kind": "effort", "rank": 4, "targets": TARGETS})
|
| 232 |
+
cfg = {"model": mcfg, "data": {"image_size": 224, "multiview": True, "decode_draft_size": 2048}}
|
| 233 |
+
torch.manual_seed(7)
|
| 234 |
+
trained = factory.build_model(cfg).eval()
|
| 235 |
+
assert len(trained.patch_adapted) == 48
|
| 236 |
+
_perturb(trained.patch_adapted, seed=3)
|
| 237 |
+
ck = {"model_trainable_state_dict": trained.trainable_state_dict(), "config": cfg,
|
| 238 |
+
"metrics": {"calibrated_threshold": 0.5}, "epoch": 0, "source_to_id": {"a": 0, "b": 1, "c": 2}}
|
| 239 |
+
assert sum(1 for k in ck["model_trainable_state_dict"] if k.startswith("patch.")) == 48 * 3
|
| 240 |
+
|
| 241 |
+
with pytest.raises(ValueError, match="UNMERGED"):
|
| 242 |
+
mh._verdict_kwargs_from_checkpoint(ck) # the training file itself is never served
|
| 243 |
+
|
| 244 |
+
tools = os.path.join(REPO, "tools")
|
| 245 |
+
if tools not in sys.path:
|
| 246 |
+
sys.path.insert(0, tools)
|
| 247 |
+
import merge_patch_adapter as mpa
|
| 248 |
+
|
| 249 |
+
merged = mpa.merge_checkpoint(ck)
|
| 250 |
+
assert mpa.patch_adapter_state(merged) == "merged" and merged["patch_adapter_merge"]["merged_tensors"] == 48
|
| 251 |
+
arch, kwargs = mh._verdict_kwargs_from_checkpoint(merged)
|
| 252 |
+
assert arch == "v6" and kwargs["num_sources"] == 3 and "patch_adapter" not in kwargs
|
| 253 |
+
from zero_shot_v6 import ZeroShotV6Detector as ServiceV6
|
| 254 |
+
|
| 255 |
+
served = ServiceV6(**kwargs)
|
| 256 |
+
state = merged["model_trainable_state_dict"]
|
| 257 |
+
mh._load_state_strict(served, state, "verdict(v6)")
|
| 258 |
+
assert mh._check_patch_adapter(served, merged["config"]["model"], state, "verdict(v6)") == 48
|
| 259 |
+
served.eval()
|
| 260 |
+
|
| 261 |
+
g = torch.Generator().manual_seed(11)
|
| 262 |
+
x = (torch.rand(2, 3, 224, 224, generator=g) - 0.45) / 0.27
|
| 263 |
+
crops = (torch.rand(2, 9, 3, 224, 224, generator=g) - 0.45) / 0.27
|
| 264 |
+
with torch.no_grad():
|
| 265 |
+
a, b = trained(x, crops), served(x, crops)
|
| 266 |
+
d_logits = float((a["logits"] - b["logits"]).abs().max())
|
| 267 |
+
d_p = float((torch.softmax(a["logits"], 1)[:, 1] - torch.softmax(b["logits"], 1)[:, 1]).abs().max())
|
| 268 |
+
d_art = float((a["artifact_features"] - b["artifact_features"]).abs().max())
|
| 269 |
+
assert d_logits < TOL and d_p < TOL and d_art < TOL, (d_logits, d_p, d_art)
|
tests/test_renderer_trace.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""renderer_trace (GPT round 4 TRACE basis, _collab/prereg_gpt_round4.json 'trace'). Run:
|
| 2 |
+
.venv\\Scripts\\python -m pytest 1_deployed_service/tests/test_renderer_trace.py -q
|
| 3 |
+
|
| 4 |
+
Pinned here (CPU, numpy only):
|
| 5 |
+
* 1_deployed_service/renderer_trace.py is BYTE-identical to tools/renderer_trace.py (the statistic the service computes
|
| 6 |
+
is the one tools/trace_calib.py calibrated t* on); the service copy imports nothing from tools/ or 4_model_training;
|
| 7 |
+
* REPRODUCTION: both per-image statistics (mean over the 9 native crops of nyq_min_img and of d4_img) reproduce the
|
| 8 |
+
wf_r3 measurement (scratch wf_r3/measure supp.jsonl 'crop_nyqmin_img' / d4all.jsonl 'crop_d4_img') within |dz| < 1e-4
|
| 9 |
+
on 24 files x {pristine, jpeg85}: 8 owner ChatGPT images, 6 public gpt-image-1.5, 10 camera / web reals. The
|
| 10 |
+
reference values are copied below (the jsonl lives in a session scratch folder); a missing file skips its case;
|
| 11 |
+
* the gate (decoded long side <= 2048, else not computed and never fires), the v2 lower bound gate_min (1024: the gate
|
| 12 |
+
becomes [1024, 2048]; gate_min = 0 is v1 bit for bit), strict one-sided firing, the JPEG draft
|
| 13 |
+
decode, small-image padding, the constants (= lattice_features.PHI0 / T_REF) and the statistic fingerprint.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import hashlib
|
| 18 |
+
import io
|
| 19 |
+
import os
|
| 20 |
+
import sys
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
import pytest
|
| 24 |
+
from PIL import Image
|
| 25 |
+
|
| 26 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 27 |
+
REPO = os.path.dirname(ROOT)
|
| 28 |
+
sys.path.insert(0, ROOT)
|
| 29 |
+
|
| 30 |
+
import renderer_trace as RT # noqa: E402 (the service copy)
|
| 31 |
+
|
| 32 |
+
TOL = 1e-4
|
| 33 |
+
FINGERPRINT = "a0a1d8550df34d6a"
|
| 34 |
+
|
| 35 |
+
# (repo-relative path, wf_r3 group, variant, crop_nyqmin_img, crop_d4_img) - wf_r3 measure, 2026-09-25
|
| 36 |
+
FIXTURE = [
|
| 37 |
+
('data_v7/user_gpt25/a-eye_training/ai_everyday_022_traditional_market.png', 'owner_full_train', 'pristine', 10.694231784121602, 7.053609886764008),
|
| 38 |
+
('data_v7/user_gpt25/a-eye_training/ai_everyday_022_traditional_market.png', 'owner_full_train', 'jpeg85', 3.8448883508778184, 1.5490452713572158),
|
| 39 |
+
('data_v7/user_gpt25/a-eye_training/ai_office_017_reverse_wall.png', 'owner_full_heldout', 'pristine', 7.509497901867495, 11.99300998013274),
|
| 40 |
+
('data_v7/user_gpt25/a-eye_training/ai_office_017_reverse_wall.png', 'owner_full_heldout', 'jpeg85', 1.4971738582275438, 0.7040067768732041),
|
| 41 |
+
('data_v7/user_gpt25/new_full/new_full_004_snow_gas_station.png', 'owner_full_train', 'pristine', 9.915207132243928, 11.944014308888827),
|
| 42 |
+
('data_v7/user_gpt25/new_full/new_full_004_snow_gas_station.png', 'owner_full_train', 'jpeg85', 4.700899091654044, 0.5649400186262407),
|
| 43 |
+
('data_v7/user_gpt25/new_full/new_full_046_street_food_cleanup.png', 'owner_full_heldout', 'pristine', 11.741384243125863, 27.425297221152103),
|
| 44 |
+
('data_v7/user_gpt25/new_full/new_full_046_street_food_cleanup.png', 'owner_full_heldout', 'jpeg85', 2.7849015304305027, 3.1751882303490047),
|
| 45 |
+
('data_v7/user_gpt25/new_partial/new_partial_058_breakwater_seaweed.png', 'owner_edit_train', 'pristine', 12.908809115322608, 10.274090149233054),
|
| 46 |
+
('data_v7/user_gpt25/new_partial/new_partial_058_breakwater_seaweed.png', 'owner_edit_train', 'jpeg85', 6.48981954897316, 3.783058830310471),
|
| 47 |
+
('data_v7/user_gpt25/partial_a/partial_012_giraffes_flowers.png', 'owner_edit_heldout', 'pristine', 18.188971110754164, 5.5211916611012795),
|
| 48 |
+
('data_v7/user_gpt25/partial_a/partial_012_giraffes_flowers.png', 'owner_edit_heldout', 'jpeg85', 6.367683024856147, 1.3704302113977171),
|
| 49 |
+
('data_v7/user_gpt25/partial_a/partial_038_penguin_footprint.png', 'owner_edit_train', 'pristine', 7.673462680713445, 9.425334150837061),
|
| 50 |
+
('data_v7/user_gpt25/partial_a/partial_038_penguin_footprint.png', 'owner_edit_train', 'jpeg85', 1.5099918077834746, 0.6029541341489189),
|
| 51 |
+
('data_v7/user_gpt25/partial_a/partial_096_chapel_mat.png', 'owner_edit_heldout', 'pristine', 9.062102812393352, 5.121994810678637),
|
| 52 |
+
('data_v7/user_gpt25/partial_a/partial_096_chapel_mat.png', 'owner_edit_heldout', 'jpeg85', 3.120949925188513, 0.8041635773940743),
|
| 53 |
+
('data_v7/raw_gpt/corebench_gpt/corebench_gpt_gpt-image-15_R-RR-010_0_0a384d685b.png', 'pub_gptimage15_corebench', 'pristine', 17.167610965410557, 3.4572679573815597),
|
| 54 |
+
('data_v7/raw_gpt/corebench_gpt/corebench_gpt_gpt-image-15_R-RR-010_0_0a384d685b.png', 'pub_gptimage15_corebench', 'jpeg85', 14.126586320136362, 1.8836683582564007),
|
| 55 |
+
('data_v7/raw_gpt/corebench_gpt/corebench_gpt_gpt-image-15_R-RR-076_2_cf5971e759.png', 'pub_gptimage15_corebench', 'pristine', 17.77225490959163, 11.894073200270906),
|
| 56 |
+
('data_v7/raw_gpt/corebench_gpt/corebench_gpt_gpt-image-15_R-RR-076_2_cf5971e759.png', 'pub_gptimage15_corebench', 'jpeg85', 8.572520708585278, 0.987548239945752),
|
| 57 |
+
('data_v7/raw/qwen_image_bench/qwen_image_bench_000181_3f841da5_0cf131f70b.png', 'pub_gptimage15_qwenbench', 'pristine', 19.645701698236145, 2.6086218555412657),
|
| 58 |
+
('data_v7/raw/qwen_image_bench/qwen_image_bench_000181_3f841da5_0cf131f70b.png', 'pub_gptimage15_qwenbench', 'jpeg85', 17.33900084069554, 2.3035405333164176),
|
| 59 |
+
('data_v7/raw/qwen_image_bench/qwen_image_bench_000344_07c67df4_d6cf4ef42e.png', 'pub_gptimage15_qwenbench', 'pristine', 18.290488867974393, 7.715865686147459),
|
| 60 |
+
('data_v7/raw/qwen_image_bench/qwen_image_bench_000344_07c67df4_d6cf4ef42e.png', 'pub_gptimage15_qwenbench', 'jpeg85', 11.603799818013378, 4.570794330842024),
|
| 61 |
+
('data_v7/raw/aas_gpt_image_15/aas_gpt_image_15_s0_r8_image_original_e021d63fa1.png', 'pub_gptimage15_aas_original', 'pristine', 17.515796904076957, 21.406413514315226),
|
| 62 |
+
('data_v7/raw/aas_gpt_image_15/aas_gpt_image_15_s0_r8_image_original_e021d63fa1.png', 'pub_gptimage15_aas_original', 'jpeg85', 7.8591363403512435, 1.7628790897834679),
|
| 63 |
+
('data_v7/raw/aas_gpt_image_15/aas_gpt_image_15_s1_r96_image_original_4429b99683.png', 'pub_gptimage15_aas_original', 'pristine', 16.761184695245028, 18.30242810917687),
|
| 64 |
+
('data_v7/raw/aas_gpt_image_15/aas_gpt_image_15_s1_r96_image_original_4429b99683.png', 'pub_gptimage15_aas_original', 'jpeg85', 10.2207251650532, 4.355083679796606),
|
| 65 |
+
('data_v7/owner_phone/202410_a_camera/IMG_9603.jpg', 'owner_iphone_train', 'pristine', -0.8201789631900693, 0.42727664067819165),
|
| 66 |
+
('data_v7/owner_phone/202410_a_camera/IMG_9603.jpg', 'owner_iphone_train', 'jpeg85', -0.6152301935999862, 0.43513888854761046),
|
| 67 |
+
('data_v7/owner_phone/202412_a_camera/IMG_0311.jpg', 'owner_iphone_train', 'pristine', -0.7716893052470097, -1.4053186442213916),
|
| 68 |
+
('data_v7/owner_phone/202412_a_camera/IMG_0311.jpg', 'owner_iphone_train', 'jpeg85', -0.7351182241908449, -0.6816478831554456),
|
| 69 |
+
('data_v7/owner_phone/202602_a_camera/IMG_9166.JPG', 'owner_iphone_cal', 'pristine', -4.186727597582083, 0.17815959009504395),
|
| 70 |
+
('data_v7/owner_phone/202602_a_camera/IMG_9166.JPG', 'owner_iphone_cal', 'jpeg85', -0.4227467981441239, 0.4360545710864139),
|
| 71 |
+
('data_v7/owner_phone/202602_a_camera/IQXK0846.jpg', 'owner_iphone_cal', 'pristine', -0.4859276786759066, 0.32001647669682437),
|
| 72 |
+
('data_v7/owner_phone/202602_a_camera/IQXK0846.jpg', 'owner_iphone_cal', 'jpeg85', -0.9064865761990328, -0.0043320272202975),
|
| 73 |
+
('data_v7/raw/dresden_forchheim/dresden_forchheim_D01_img_orig_0058_0199b63992.jpg', 'real_dresden_forchheim', 'pristine', 7.187986564275387, -1.1119387119998743),
|
| 74 |
+
('data_v7/raw/dresden_forchheim/dresden_forchheim_D01_img_orig_0058_0199b63992.jpg', 'real_dresden_forchheim', 'jpeg85', 2.2629310284484245, -3.066560814365873),
|
| 75 |
+
('data_v7/raw/dresden_forchheim/dresden_forchheim_D27_img_orig_0047_a9f235dde9.jpg', 'real_dresden_forchheim', 'pristine', -0.26274310767954984, -0.243590503121684),
|
| 76 |
+
('data_v7/raw/dresden_forchheim/dresden_forchheim_D27_img_orig_0047_a9f235dde9.jpg', 'real_dresden_forchheim', 'jpeg85', -1.112180078403801, 0.4938441155635647),
|
| 77 |
+
('data_v7/raw/pexels_janpf/pexels_janpf_13639f277329ac6d8cd418d9c8c6d9b2_e79068cb6b.jpg', 'real_pexels_janpf', 'pristine', -0.9935981233036278, 0.4815018842525822),
|
| 78 |
+
('data_v7/raw/pexels_janpf/pexels_janpf_13639f277329ac6d8cd418d9c8c6d9b2_e79068cb6b.jpg', 'real_pexels_janpf', 'jpeg85', -0.9936821685532186, 0.4796875691748703),
|
| 79 |
+
('data_v7/raw/unsplash_lite/unsplash_lite_u5350_33ae12457c.jpg', 'real_unsplash_lite', 'pristine', -0.7474683860488689, 0.009228328801253892),
|
| 80 |
+
('data_v7/raw/unsplash_lite/unsplash_lite_u5350_33ae12457c.jpg', 'real_unsplash_lite', 'jpeg85', -0.46249269289745776, -0.14576180125523094),
|
| 81 |
+
('data_v7/raw/open_images_web/open_images_web_000000341_426b89cd58.jpg', 'real_open_images_web', 'pristine', -1.3376806754480057, -1.2142137789608374),
|
| 82 |
+
('data_v7/raw/open_images_web/open_images_web_000000341_426b89cd58.jpg', 'real_open_images_web', 'jpeg85', -1.143992187479694, -1.0283076112850447),
|
| 83 |
+
('data_v7/raw/open_images_web/open_images_web_000004944_f112f2bd22.jpg', 'real_open_images_web', 'pristine', -0.8030112395735514, 0.46365719571240027),
|
| 84 |
+
('data_v7/raw/open_images_web/open_images_web_000004944_f112f2bd22.jpg', 'real_open_images_web', 'jpeg85', -0.6547083754197037, 0.2684789778029803),
|
| 85 |
+
]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _variant(img: Image.Image, variant: str) -> Image.Image:
|
| 89 |
+
"""wf_r3 measure.make_variant for the two variants pinned here (jpeg85 = PIL JPEG q85, default 4:2:0)."""
|
| 90 |
+
if variant == "pristine":
|
| 91 |
+
return img
|
| 92 |
+
if variant == "jpeg85":
|
| 93 |
+
b = io.BytesIO()
|
| 94 |
+
img.save(b, "JPEG", quality=85)
|
| 95 |
+
b.seek(0)
|
| 96 |
+
return Image.open(b).convert("RGB")
|
| 97 |
+
raise ValueError(variant)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_copies_byte_identical():
|
| 101 |
+
tools_copy = os.path.join(REPO, "tools", "renderer_trace.py")
|
| 102 |
+
if not os.path.exists(tools_copy):
|
| 103 |
+
pytest.skip("tools/ not present (service image)")
|
| 104 |
+
h = lambda p: hashlib.sha256(open(p, "rb").read()).hexdigest() # noqa: E731
|
| 105 |
+
assert h(tools_copy) == h(os.path.join(ROOT, "renderer_trace.py"))
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_service_copy_is_standalone():
|
| 109 |
+
import ast
|
| 110 |
+
tree = ast.parse(open(os.path.join(ROOT, "renderer_trace.py"), encoding="utf-8").read())
|
| 111 |
+
mods = set()
|
| 112 |
+
for node in ast.walk(tree):
|
| 113 |
+
if isinstance(node, ast.Import):
|
| 114 |
+
mods.update(a.name.split(".")[0] for a in node.names)
|
| 115 |
+
elif isinstance(node, ast.ImportFrom):
|
| 116 |
+
mods.add((node.module or "").split(".")[0])
|
| 117 |
+
assert mods <= {"__future__", "argparse", "hashlib", "io", "json", "math", "sys", "numpy", "PIL", "scipy"}, mods
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@pytest.mark.parametrize("rel,group,variant,ref_nyq,ref_d4", FIXTURE, ids=[f"{os.path.basename(f[0])}-{f[2]}" for f in FIXTURE])
|
| 121 |
+
def test_reproduces_wf_r3(rel, group, variant, ref_nyq, ref_d4):
|
| 122 |
+
path = os.path.join(REPO, *rel.split("/"))
|
| 123 |
+
if not os.path.exists(path):
|
| 124 |
+
pytest.skip(f"{rel} not on disk")
|
| 125 |
+
st = RT.trace_stat(_variant(RT.decode(path), variant), force=True)
|
| 126 |
+
assert abs(st["nyq_min_img_mean"] - ref_nyq) < TOL
|
| 127 |
+
assert abs(st["d4_img_mean"] - ref_d4) < TOL
|
| 128 |
+
assert st["s"] == pytest.approx(min(ref_nyq / RT.SCALE_NYQ, ref_d4 / RT.SCALE_D4), abs=TOL)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_fixture_covers_the_registered_groups():
|
| 132 |
+
groups = {g for _, g, _, _, _ in FIXTURE}
|
| 133 |
+
files = {p for p, _, _, _, _ in FIXTURE}
|
| 134 |
+
assert len(files) >= 20
|
| 135 |
+
assert any(g.startswith("owner_") and "iphone" not in g for g in groups) # owner ChatGPT images
|
| 136 |
+
assert any(g.startswith("pub_gptimage15") for g in groups) # public gpt-image-1.5
|
| 137 |
+
assert any(g in ("owner_iphone_train", "owner_iphone_cal", "real_dresden_forchheim") for g in groups) # camera reals
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _noise(w: int, h: int, seed: int = 0) -> Image.Image:
|
| 141 |
+
return Image.fromarray(np.random.default_rng(seed).integers(0, 256, (h, w, 3), dtype=np.uint8))
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_gate():
|
| 145 |
+
inside = RT.trace_stat(_noise(2048, 300))
|
| 146 |
+
assert inside["gated"] is False and inside["s"] is not None and inside["long_side"] == 2048
|
| 147 |
+
above = RT.trace_stat(_noise(300, 2049))
|
| 148 |
+
assert above == {"s": None, "nyq_min_img_mean": None, "d4_img_mean": None, "long_side": 2049, "gated": True}
|
| 149 |
+
assert RT.fires(_noise(300, 2049), -1e9) is False
|
| 150 |
+
forced = RT.trace_stat(_noise(300, 2049), force=True)
|
| 151 |
+
assert forced["gated"] is True and forced["s"] is not None
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_gate_min_v2():
|
| 155 |
+
small, low, top = _noise(1023, 700, 7), _noise(700, 1024, 8), _noise(2048, 900, 9)
|
| 156 |
+
# gate_min = 0 is v1, bit for bit
|
| 157 |
+
for img in (small, low, top):
|
| 158 |
+
assert RT.trace_stat(img, gate_min=0) == RT.trace_stat(img)
|
| 159 |
+
assert RT.fires(img, -1e9, gate_min=0) == RT.fires(img, -1e9)
|
| 160 |
+
# v2 gate 1024 <= long side <= 2048
|
| 161 |
+
below = RT.trace_stat(small, gate_min=1024)
|
| 162 |
+
assert below == {"s": None, "nyq_min_img_mean": None, "d4_img_mean": None, "long_side": 1023, "gated": True}
|
| 163 |
+
assert RT.fires(small, -1e9, gate_min=1024) is False and RT.fires(small, -1e9) is True
|
| 164 |
+
assert RT.trace_stat(low, gate_min=1024) == RT.trace_stat(low) # 1024 is inside
|
| 165 |
+
assert RT.trace_stat(top, gate_min=1024) == RT.trace_stat(top) # 2048 is inside
|
| 166 |
+
assert RT.trace_stat(_noise(300, 2049), gate_min=1024)["gated"] is True
|
| 167 |
+
forced = RT.trace_stat(small, force=True, gate_min=1024)
|
| 168 |
+
assert forced["gated"] is True and forced["s"] == RT.trace_stat(small)["s"]
|
| 169 |
+
assert RT.outside_gate(1023, 1024) and not RT.outside_gate(1024, 1024) and not RT.outside_gate(2048, 1024)
|
| 170 |
+
assert RT.outside_gate(2049, 0) and not RT.outside_gate(10, 0)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_fires_is_strict_and_one_sided():
|
| 174 |
+
img = _noise(640, 480, 3)
|
| 175 |
+
s = RT.trace_stat(img)["s"]
|
| 176 |
+
assert RT.fires(img, s) is False
|
| 177 |
+
assert RT.fires(img, s - 1e-9) is True
|
| 178 |
+
assert RT.fires(img, s + 1.0) is False
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def test_small_image_is_padded_like_wf_r3():
|
| 182 |
+
img = _noise(200, 150, 5)
|
| 183 |
+
nyq, d4 = RT.crop_z(img)
|
| 184 |
+
assert len(nyq) == len(d4) == RT.N_CROPS
|
| 185 |
+
padded = RT.pad_to(np.asarray(img), RT.CROP)
|
| 186 |
+
assert padded.shape[:2] == (224, 224)
|
| 187 |
+
# the statistic of the small image is the statistic of its padded frame
|
| 188 |
+
assert RT.crop_z(Image.fromarray(padded)) == (nyq, d4)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def test_decode_uses_the_evaluator_draft():
|
| 192 |
+
def jpeg(w, h):
|
| 193 |
+
b = io.BytesIO()
|
| 194 |
+
Image.new("RGB", (w, h), (120, 130, 140)).save(b, "JPEG", quality=90)
|
| 195 |
+
return b.getvalue()
|
| 196 |
+
assert RT.decode(jpeg(4096, 4096)).size == (2048, 2048) # both sides >= 2 x 2048: DCT scale 1/2
|
| 197 |
+
assert RT.decode(jpeg(4000, 3000)).size == (4000, 3000) # otherwise full size (outside the gate)
|
| 198 |
+
assert RT.decode(jpeg(1600, 1200)).size == (1600, 1200)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def test_constants_match_lattice_features():
|
| 202 |
+
try:
|
| 203 |
+
import lattice_features as LF # service flat layout (needs torch)
|
| 204 |
+
except Exception as exc: # noqa: BLE001
|
| 205 |
+
pytest.skip(f"lattice_features not importable here: {exc}")
|
| 206 |
+
assert tuple(LF.PHI0) == tuple(RT.PHI0)
|
| 207 |
+
assert tuple(map(tuple, LF.T_REF)) == tuple(map(tuple, RT.T_REF))
|
| 208 |
+
assert tuple(LF.CTRL_EPS) == tuple(float(e) for e in RT.CTRL_EPS)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def test_fingerprint_pinned():
|
| 212 |
+
assert RT.statistic_fingerprint() == FINGERPRINT
|
| 213 |
+
assert (RT.SCALE_NYQ, RT.SCALE_D4, RT.GATE_LONG_SIDE, RT.CROP, RT.N_CROPS) == (1.08, 4.42, 2048, 224, 9)
|
webapp/_expo/static/js/web/{entry-c72d53cd4e22507b95184fe0159c61c3.js → entry-73d73bf1e3d88c923bfb3f7b7cae3866.js}
RENAMED
|
The diff for this file is too large to render.
See raw diff
|
|
|
webapp/assets/assets/logo.9add05631c93e895ff3b380bf07ae0b0.png
DELETED
Git LFS Details
|
webapp/assets/assets/onboarding/p2_ai.0b15766d558e195bc5587b85c0417bff.webp
ADDED
|
webapp/assets/assets/onboarding/p2_real.15019b6a906480c5cbafa43036b7531f.webp
ADDED
|
webapp/assets/assets/onboarding/p3_messenger.6fd165b9e4ba0ce3ef707e5f6722006a.jpg
ADDED
|
webapp/assets/assets/onboarding/p3_original.91ab13644283dc95349469ea82650133.webp
ADDED
|
webapp/assets/assets/onboarding/p3_rephoto.1e35d25745130d74193c6b89fe94e651.webp
ADDED
|
webapp/assets/assets/onboarding/p3_rephoto_bg.1f99f9dd1101bf90539c1fbdf765d72c.webp
ADDED
|
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/AntDesign.3f78af31cca60105799838a1a7a59fbd.ttf
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:08a9d5b513cf947e22ede81e9a843bd8d7763a63444cd21484bfde7662cc6543
|
| 3 |
-
size 130484
|
|
|
|
|
|
|
|
|
|
|
|
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Entypo.31b5ffea3daddc69dd01a1f3d6cf63c5.ttf
DELETED
|
Binary file (66.2 kB)
|
|
|
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/EvilIcons.140c53a7643ea949007aa9a282153849.ttf
DELETED
|
Binary file (13.5 kB)
|
|
|
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Feather.ca4b48e04dc1ce10bfbddb262c8b835f.ttf
DELETED
|
Binary file (55.6 kB)
|
|
|
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome.b06871f281fee6b241d60582ae9369b9.ttf
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:aa58f33f239a0fb02f5c7a6c45c043d7a9ac9a093335806694ecd6d4edc0d6a8
|
| 3 |
-
size 165548
|
|
|
|
|
|
|
|
|
|
|
|
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome5_Brands.3b89dd103490708d19a95adcae52210e.ttf
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:06f4d00923ea24697df5df0b92984175991d8bd25776a02d531bb401e393ec42
|
| 3 |
-
size 134040
|
|
|
|
|
|
|
|
|
|
|
|
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome5_Regular.1f77739ca9ff2188b539c36f30ffa2be.ttf
DELETED
|
Binary file (33.7 kB)
|
|
|
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome5_Solid.605ed7926cf39a2ad5ec2d1f9d391d3d.ttf
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:3d06af1f31cd83ace7a265a014b8fb5dee15770ecac8f7a55555190e627e03c2
|
| 3 |
-
size 202744
|
|
|
|
|
|
|
|
|
|
|
|
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome6_Brands.56c8d80832e37783f12c05db7c8849e2.ttf
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:bc844b5b02d3f3adabb514de4e0c0208252d3123413bd03f6287fa446acee859
|
| 3 |
-
size 209376
|
|
|
|
|
|
|
|
|
|
|
|
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome6_Regular.370dd5af19f8364907b6e2c41f45dbbf.ttf
DELETED
|
Binary file (68 kB)
|
|
|
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome6_Solid.adec7d6f310bc577f05e8fe06a5daccf.ttf
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:31f099c13f6e4ba05f1b471bf170cb5493249474222917372de3ca5cf29e6a1a
|
| 3 |
-
size 423676
|
|
|
|
|
|
|
|
|
|
|
|
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Fontisto.b49ae8ab2dbccb02c4d11caaacf09eab.ttf
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:94dab9f1d5b13ea0769d124a2da0d02433f3b99d6dea5a07c0446a77158100c0
|
| 3 |
-
size 313528
|
|
|
|
|
|
|
|
|
|
|
|
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Foundation.e20945d7c929279ef7a6f1db184a4470.ttf
DELETED
|
Binary file (57 kB)
|
|
|
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/MaterialCommunityIcons.6e435534bd35da5fef04168860a9b8fa.ttf
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:61e8aba5a4e981fe22cf7c8e8bcdbea00476e75c62c37f01bf7ee33361d68428
|
| 3 |
-
size 1307660
|
|
|
|
|
|
|
|
|
|
|
|
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/MaterialIcons.4e85bc9ebe07e0340c9c4fc2f6c38908.ttf
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:ef149f08bdd2ff09a4e2c8573476b7b0f3fbb15b623954ade59899e7175bedda
|
| 3 |
-
size 356840
|
|
|
|
|
|
|
|
|
|
|
|
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Octicons.871378c6eab492a3e689a9385dc45a12.ttf
DELETED
|
Binary file (69.4 kB)
|
|
|
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/SimpleLineIcons.d2285965fe34b05465047401b8595dd0.ttf
DELETED
|
Binary file (54.1 kB)
|
|
|
webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Zocial.1681f34aaca71b8dfb70756bca331eb2.ttf
DELETED
|
Binary file (25.8 kB)
|
|
|
webapp/index.html
CHANGED
|
@@ -34,6 +34,11 @@
|
|
| 34 |
height: 100%;
|
| 35 |
flex: 1;
|
| 36 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
/* Desktop: the app is a phone layout; centre it in a frame instead of stretching it across a monitor. */
|
| 39 |
@media (min-width: 768px) {
|
|
@@ -58,6 +63,6 @@
|
|
| 58 |
</noscript>
|
| 59 |
<!-- The root element for your Expo app. -->
|
| 60 |
<div id="root"></div>
|
| 61 |
-
<script src="/_expo/static/js/web/entry-
|
| 62 |
</body>
|
| 63 |
</html>
|
|
|
|
| 34 |
height: 100%;
|
| 35 |
flex: 1;
|
| 36 |
}
|
| 37 |
+
/* Hangul: break between words, not inside them (the native apps get this from the OS line breaker) */
|
| 38 |
+
#root {
|
| 39 |
+
word-break: keep-all;
|
| 40 |
+
overflow-wrap: anywhere;
|
| 41 |
+
}
|
| 42 |
|
| 43 |
/* Desktop: the app is a phone layout; centre it in a frame instead of stretching it across a monitor. */
|
| 44 |
@media (min-width: 768px) {
|
|
|
|
| 63 |
</noscript>
|
| 64 |
<!-- The root element for your Expo app. -->
|
| 65 |
<div id="root"></div>
|
| 66 |
+
<script src="/_expo/static/js/web/entry-73d73bf1e3d88c923bfb3f7b7cae3866.js" defer></script>
|
| 67 |
</body>
|
| 68 |
</html>
|
webapp/legal.css
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
Shared stylesheet of the public legal pages (privacy.html, privacy-en.html, support.html).
|
| 3 |
+
|
| 4 |
+
Audience: a store reviewer, or someone deciding whether to install - both want to confirm in half a minute what
|
| 5 |
+
happens to their photo. So the page opens with the path one photo takes (device -> server -> result -> gone) and
|
| 6 |
+
everything after it is a quiet, left-aligned reading column.
|
| 7 |
+
Colours are the app tokens from constants/colors.ts and nothing else. The verdict colours (real / uncertain / ai)
|
| 8 |
+
are deliberately absent: nothing on these pages is a verdict. "Kept" vs "not kept" is said in words and in the
|
| 9 |
+
line style of the path (solid = travels, dashed = discarded), not in colour.
|
| 10 |
+
No web fonts, no scripts, no third-party requests: a privacy page should not call anyone.
|
| 11 |
+
*/
|
| 12 |
+
:root {
|
| 13 |
+
--bg: #FAFAFC;
|
| 14 |
+
--surface2: #F1F2F7;
|
| 15 |
+
--fg: #0F172A;
|
| 16 |
+
--muted: #64748B;
|
| 17 |
+
--border: #E2E8F0;
|
| 18 |
+
--border-strong: #CBD5E1;
|
| 19 |
+
--primary: #5B6BFF;
|
| 20 |
+
--secondary: #06B6D4;
|
| 21 |
+
color-scheme: light dark;
|
| 22 |
+
}
|
| 23 |
+
@media (prefers-color-scheme: dark) {
|
| 24 |
+
:root {
|
| 25 |
+
--bg: #0A0E1A;
|
| 26 |
+
--surface2: #1F2937;
|
| 27 |
+
--fg: #F9FAFB;
|
| 28 |
+
--muted: #9CA3AF;
|
| 29 |
+
--border: #1E293B;
|
| 30 |
+
--border-strong: #334155;
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
* { box-sizing: border-box; }
|
| 35 |
+
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; scroll-padding-top: 24px; }
|
| 36 |
+
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
|
| 37 |
+
body {
|
| 38 |
+
margin: 0;
|
| 39 |
+
background: var(--bg);
|
| 40 |
+
color: var(--fg);
|
| 41 |
+
font-family: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo",
|
| 42 |
+
"Noto Sans KR", "Malgun Gothic", "Segoe UI", Roboto, sans-serif;
|
| 43 |
+
font-size: 17px;
|
| 44 |
+
line-height: 1.75;
|
| 45 |
+
word-break: keep-all; /* Korean breaks between words, not inside them */
|
| 46 |
+
overflow-wrap: anywhere;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.page { max-width: 1040px; margin: 0 auto; padding: 40px 24px 96px; }
|
| 50 |
+
|
| 51 |
+
/* ---- top bar */
|
| 52 |
+
.bar { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; }
|
| 53 |
+
.brand { font-weight: 800; letter-spacing: 0.08em; font-size: 0.95rem; text-decoration: none; color: var(--fg); }
|
| 54 |
+
.langs { display: flex; gap: 16px; font-size: 0.9rem; }
|
| 55 |
+
.langs [aria-current="page"] { color: var(--muted); text-decoration: none; }
|
| 56 |
+
|
| 57 |
+
/* links: text stays in the foreground colour (primary on the light background is 4:1, under AA), the brand
|
| 58 |
+
colour carries the underline */
|
| 59 |
+
a { color: var(--fg); text-decoration: underline; text-decoration-color: var(--primary);
|
| 60 |
+
text-decoration-thickness: 2px; text-underline-offset: 4px; }
|
| 61 |
+
a:hover { text-decoration-color: var(--secondary); }
|
| 62 |
+
:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; border-radius: 2px; }
|
| 63 |
+
|
| 64 |
+
/* ---- title block */
|
| 65 |
+
h1 { font-size: clamp(2rem, 5.2vw, 3.1rem); line-height: 1.18; letter-spacing: -0.03em; font-weight: 800;
|
| 66 |
+
margin: 56px 0 12px; max-width: 16em; }
|
| 67 |
+
.lede { font-size: 1.12rem; line-height: 1.7; max-width: 34em; margin: 0 0 8px; }
|
| 68 |
+
.meta { color: var(--muted); font-size: 0.92rem; margin: 0; }
|
| 69 |
+
|
| 70 |
+
/* ---- the path one photo takes */
|
| 71 |
+
.path { list-style: none; margin: 48px 0 64px; padding: 0; display: grid; grid-template-columns: repeat(4, 1fr);
|
| 72 |
+
gap: 0; counter-reset: step; }
|
| 73 |
+
.path li { position: relative; padding: 30px 24px 0 0; }
|
| 74 |
+
.path li::before { /* the line */
|
| 75 |
+
content: ""; position: absolute; top: 7px; left: 0; right: 0; height: 0;
|
| 76 |
+
border-top: 2px solid var(--secondary);
|
| 77 |
+
}
|
| 78 |
+
.path li:nth-child(2)::before { border-image: linear-gradient(90deg, var(--secondary), var(--primary)) 1; }
|
| 79 |
+
.path li:nth-child(3)::before { border-top-color: var(--primary); }
|
| 80 |
+
.path li:last-child::before { border-top: 2px dashed var(--border-strong); } /* discarded: nothing travels on */
|
| 81 |
+
.path li::after { /* the node */
|
| 82 |
+
content: ""; position: absolute; top: 0; left: 0; width: 16px; height: 16px; border-radius: 50%;
|
| 83 |
+
background: var(--bg); border: 2px solid var(--secondary);
|
| 84 |
+
}
|
| 85 |
+
.path li:nth-child(n+3)::after { border-color: var(--primary); }
|
| 86 |
+
.path li:last-child::after { border-color: var(--border-strong); border-style: dashed; }
|
| 87 |
+
.path h2 { font-size: 1.02rem; font-weight: 700; margin: 0 0 4px; letter-spacing: -0.01em; }
|
| 88 |
+
.path p { margin: 0; font-size: 0.92rem; line-height: 1.6; color: var(--muted); }
|
| 89 |
+
.path strong { color: var(--fg); font-weight: 600; }
|
| 90 |
+
|
| 91 |
+
/* ---- reading layout */
|
| 92 |
+
.layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 56px; align-items: start; }
|
| 93 |
+
.toc { position: sticky; top: 24px; font-size: 0.9rem; line-height: 1.5; }
|
| 94 |
+
.toc ol { list-style: none; margin: 0; padding: 0; border-left: 1px solid var(--border-strong); }
|
| 95 |
+
.toc li { margin: 0; }
|
| 96 |
+
.toc a { display: block; padding: 6px 0 6px 16px; text-decoration: none; color: var(--muted); margin-left: -1px;
|
| 97 |
+
border-left: 1px solid transparent; }
|
| 98 |
+
.toc a:hover { color: var(--fg); border-left-color: var(--primary); }
|
| 99 |
+
|
| 100 |
+
.doc { max-width: 38em; }
|
| 101 |
+
.doc section + section { margin-top: 56px; }
|
| 102 |
+
.doc h2 { font-size: 1.4rem; line-height: 1.35; letter-spacing: -0.02em; font-weight: 700; margin: 0 0 16px; }
|
| 103 |
+
.doc h3 { font-size: 1.02rem; font-weight: 700; margin: 28px 0 8px; }
|
| 104 |
+
.doc p { margin: 0 0 14px; }
|
| 105 |
+
.doc ul { margin: 0 0 14px; padding-left: 1.15em; }
|
| 106 |
+
.doc li { margin-bottom: 6px; }
|
| 107 |
+
.doc li::marker { color: var(--muted); }
|
| 108 |
+
.note { color: var(--muted); font-size: 0.93rem; }
|
| 109 |
+
|
| 110 |
+
/* ---- tables: rules, not boxes */
|
| 111 |
+
.table-wrap { margin: 20px 0 22px; }
|
| 112 |
+
table { width: 100%; border-collapse: collapse; font-size: 0.93rem; line-height: 1.6; }
|
| 113 |
+
th, td { text-align: left; vertical-align: top; padding: 12px 16px 12px 0; border-bottom: 1px solid var(--border); }
|
| 114 |
+
thead th { font-weight: 600; color: var(--muted); border-bottom: 1px solid var(--border-strong); font-size: 0.86rem; }
|
| 115 |
+
tbody th { font-weight: 600; width: 34%; }
|
| 116 |
+
td:last-child, th:last-child { padding-right: 0; }
|
| 117 |
+
|
| 118 |
+
.foot { margin-top: 80px; padding-top: 20px; border-top: 1px solid var(--border); color: var(--muted);
|
| 119 |
+
font-size: 0.88rem; display: flex; flex-wrap: wrap; gap: 8px 24px; }
|
| 120 |
+
|
| 121 |
+
/* ---- small screens: the path turns into a vertical line, the contents list goes inline, tables stack */
|
| 122 |
+
@media (max-width: 860px) {
|
| 123 |
+
body { font-size: 16px; }
|
| 124 |
+
.page { padding: 28px 20px 72px; }
|
| 125 |
+
h1 { margin-top: 40px; }
|
| 126 |
+
.layout { grid-template-columns: minmax(0, 1fr); gap: 32px; }
|
| 127 |
+
.toc { position: static; }
|
| 128 |
+
.path { grid-template-columns: 1fr; margin: 36px 0 48px; }
|
| 129 |
+
.path li { padding: 0 0 28px 34px; }
|
| 130 |
+
.path li:last-child { padding-bottom: 0; }
|
| 131 |
+
.path li::before { top: 8px; bottom: -8px; left: 7px; right: auto; height: auto; width: 0;
|
| 132 |
+
border-top: 0; border-left: 2px solid var(--secondary); border-image: none; }
|
| 133 |
+
.path li:nth-child(2)::before { border-image: linear-gradient(180deg, var(--secondary), var(--primary)) 1; }
|
| 134 |
+
.path li:nth-child(3)::before { border-left: 2px dashed var(--border-strong); }
|
| 135 |
+
.path li:last-child::before { display: none; }
|
| 136 |
+
.path li::after { top: 4px; }
|
| 137 |
+
|
| 138 |
+
table, thead, tbody, tr, th, td { display: block; }
|
| 139 |
+
thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
| 140 |
+
tr { padding: 14px 0; border-bottom: 1px solid var(--border); }
|
| 141 |
+
th, td { border: 0; padding: 0; }
|
| 142 |
+
tbody th { width: auto; margin-bottom: 6px; }
|
| 143 |
+
td { color: var(--fg); margin-top: 4px; }
|
| 144 |
+
td::before { content: attr(data-label); display: block; color: var(--muted); font-size: 0.82rem; margin-top: 8px; }
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
@media print {
|
| 148 |
+
.toc, .langs { display: none; }
|
| 149 |
+
.layout { grid-template-columns: 1fr; }
|
| 150 |
+
body { font-size: 11pt; }
|
| 151 |
+
}
|
webapp/privacy-en.html
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<!-- English version of privacy.html. Same content, same effective date - change both (and constants/legal.ts) together. -->
|
| 3 |
+
<html lang="en">
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="utf-8" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 7 |
+
<title>A-EYE Privacy Policy</title>
|
| 8 |
+
<meta name="description" content="A-EYE sends only the one photo you choose to its analysis server and does not keep it after the analysis. No account, no ad tracking." />
|
| 9 |
+
<meta name="theme-color" content="#FAFAFC" media="(prefers-color-scheme: light)" />
|
| 10 |
+
<meta name="theme-color" content="#0A0E1A" media="(prefers-color-scheme: dark)" />
|
| 11 |
+
<link rel="alternate" hreflang="ko" href="privacy.html" />
|
| 12 |
+
<link rel="stylesheet" href="legal.css" />
|
| 13 |
+
<style>body { word-break: normal; }</style>
|
| 14 |
+
</head>
|
| 15 |
+
<body>
|
| 16 |
+
<div class="page">
|
| 17 |
+
<header class="bar">
|
| 18 |
+
<a class="brand" href="/">A-EYE</a>
|
| 19 |
+
<nav class="langs" aria-label="Language">
|
| 20 |
+
<a href="privacy.html" lang="ko">한국어</a>
|
| 21 |
+
<a href="privacy-en.html" aria-current="page" lang="en">English</a>
|
| 22 |
+
</nav>
|
| 23 |
+
</header>
|
| 24 |
+
|
| 25 |
+
<h1>Your photo is on our server only while it is analysed</h1>
|
| 26 |
+
<p class="lede">This is the privacy policy of A-EYE, a mobile app and web service that estimates whether a photo was generated by AI. It works without an account.</p>
|
| 27 |
+
<p class="meta">Effective 24 September 2026</p>
|
| 28 |
+
|
| 29 |
+
<ol class="path" aria-label="The path one photo takes">
|
| 30 |
+
<li>
|
| 31 |
+
<h2>Your device</h2>
|
| 32 |
+
<p>You pick or take a photo. <strong>Your analysis history is stored on this device only.</strong></p>
|
| 33 |
+
</li>
|
| 34 |
+
<li>
|
| 35 |
+
<h2>Upload</h2>
|
| 36 |
+
<p>Only the photo you chose travels to the analysis server, over an encrypted connection (HTTPS).</p>
|
| 37 |
+
</li>
|
| 38 |
+
<li>
|
| 39 |
+
<h2>Analysis</h2>
|
| 40 |
+
<p>The server analyses it in memory and returns the result with a heatmap of suspicious areas.</p>
|
| 41 |
+
</li>
|
| 42 |
+
<li>
|
| 43 |
+
<h2>Discarded</h2>
|
| 44 |
+
<p>The photo is gone from the server once the result is returned. <strong>What remains is an operational record that cannot reconstruct the photo.</strong></p>
|
| 45 |
+
</li>
|
| 46 |
+
</ol>
|
| 47 |
+
|
| 48 |
+
<div class="layout">
|
| 49 |
+
<nav class="toc" aria-label="Contents">
|
| 50 |
+
<ol>
|
| 51 |
+
<li><a href="#items">1. What we process, and why</a></li>
|
| 52 |
+
<li><a href="#retention">2. Retention and deletion</a></li>
|
| 53 |
+
<li><a href="#training">3. Not used for model training</a></li>
|
| 54 |
+
<li><a href="#sharing">4. Sharing with third parties</a></li>
|
| 55 |
+
<li><a href="#transfer">5. Hosting and international transfer</a></li>
|
| 56 |
+
<li><a href="#rights">6. Your rights</a></li>
|
| 57 |
+
<li><a href="#permissions">7. App permissions</a></li>
|
| 58 |
+
<li><a href="#security">8. Security</a></li>
|
| 59 |
+
<li><a href="#cookies">9. Cookies and automatic collection</a></li>
|
| 60 |
+
<li><a href="#children">10. Children</a></li>
|
| 61 |
+
<li><a href="#contact">11. Contact</a></li>
|
| 62 |
+
<li><a href="#remedy">12. Complaints</a></li>
|
| 63 |
+
<li><a href="#changes">13. Changes to this policy</a></li>
|
| 64 |
+
</ol>
|
| 65 |
+
</nav>
|
| 66 |
+
|
| 67 |
+
<main class="doc">
|
| 68 |
+
<section id="items">
|
| 69 |
+
<h2>1. What we process, and why</h2>
|
| 70 |
+
<p>A-EYE processes only the following: what an analysis request needs, and the minimum record required to keep the service running.</p>
|
| 71 |
+
<div class="table-wrap">
|
| 72 |
+
<table>
|
| 73 |
+
<thead>
|
| 74 |
+
<tr><th scope="col">Data</th><th scope="col">Purpose</th><th scope="col">Retention</th></tr>
|
| 75 |
+
</thead>
|
| 76 |
+
<tbody>
|
| 77 |
+
<tr>
|
| 78 |
+
<th scope="row">The photo you submit, and its file name</th>
|
| 79 |
+
<td data-label="Purpose">Estimating whether it is AI-generated; reading provenance information (such as C2PA)</td>
|
| 80 |
+
<td data-label="Retention">Not retained. Discarded as soon as the result is returned.</td>
|
| 81 |
+
</tr>
|
| 82 |
+
<tr>
|
| 83 |
+
<th scope="row">Operational record — request number, the first 12 characters of the photo file’s hash, image format, dimensions and size, the verdict and scores, processing time</th>
|
| 84 |
+
<td data-label="Purpose">Troubleshooting and abuse prevention</td>
|
| 85 |
+
<td data-label="Retention">Exists only in the server’s run-time log and disappears when the server restarts. We do not copy it anywhere else.</td>
|
| 86 |
+
</tr>
|
| 87 |
+
<tr>
|
| 88 |
+
<th scope="row">IP address</th>
|
| 89 |
+
<td data-label="Purpose">Rate limiting (20 requests a minute, 300 a day)</td>
|
| 90 |
+
<td data-label="Retention">In server memory only, for at most 24 hours. Never written to disk; cleared when the server restarts.</td>
|
| 91 |
+
</tr>
|
| 92 |
+
<tr>
|
| 93 |
+
<th scope="row">Analysis history, settings, date you accepted the terms</th>
|
| 94 |
+
<td data-label="Purpose">Showing your history, keeping your settings</td>
|
| 95 |
+
<td data-label="Retention">Stored on your device only. Never sent to the server.</td>
|
| 96 |
+
</tr>
|
| 97 |
+
</tbody>
|
| 98 |
+
</table>
|
| 99 |
+
</div>
|
| 100 |
+
<p>A photo file can contain metadata (EXIF) such as the camera model, the capture time and sometimes the location, and it travels with the file. The server reads it only to check provenance and does not store it. If you do not want location included, use the option your operating system offers for removing location when you pick a photo, or use a photo without location data.</p>
|
| 101 |
+
<p>The hash in the operational record is a short fingerprint computed from the photo; it cannot be used to reconstruct the photo or learn what it shows. The record contains no name, account, IP address or file name.</p>
|
| 102 |
+
<p>A photo may show other people. A-EYE does not identify people in photos or extract facial features; it only computes signals of whether the image was generated.</p>
|
| 103 |
+
<h3>What we do not collect</h3>
|
| 104 |
+
<p>Name, email address, phone number, account, advertising identifier, contacts, location permission, microphone. The app contains no advertising or behavioural-analytics SDK.</p>
|
| 105 |
+
</section>
|
| 106 |
+
|
| 107 |
+
<section id="retention">
|
| 108 |
+
<h2>2. Retention and deletion</h2>
|
| 109 |
+
<ul>
|
| 110 |
+
<li>A submitted photo is processed in the analysis server’s memory and discarded as soon as the result is returned. Temporary files that may be created while the request is handled are deleted when the request ends.</li>
|
| 111 |
+
<li>The operational record and IP addresses are kept as described in the table above.</li>
|
| 112 |
+
<li>The history on your device stays until you delete it or uninstall the app. Only the latest 100 entries are kept; older ones are removed automatically.</li>
|
| 113 |
+
</ul>
|
| 114 |
+
</section>
|
| 115 |
+
|
| 116 |
+
<section id="training">
|
| 117 |
+
<h2>3. Not used for model training</h2>
|
| 118 |
+
<p>We do not use submitted photos to train or improve AI models. If a future feature (such as reporting a wrong verdict) asks you to share a photo, we will ask for separate consent and revise this policy first.</p>
|
| 119 |
+
</section>
|
| 120 |
+
|
| 121 |
+
<section id="sharing">
|
| 122 |
+
<h2>4. Sharing with third parties</h2>
|
| 123 |
+
<p>We do not sell or share your information with third parties. The exception is a lawful request from an authority; even then, all we hold is the operational record described in section 1.</p>
|
| 124 |
+
</section>
|
| 125 |
+
|
| 126 |
+
<section id="transfer">
|
| 127 |
+
<h2>5. Hosting and international transfer</h2>
|
| 128 |
+
<p>The analysis server runs on Hugging Face’s cloud service, so a photo you submit is transferred to a server outside Korea.</p>
|
| 129 |
+
<div class="table-wrap">
|
| 130 |
+
<table>
|
| 131 |
+
<tbody>
|
| 132 |
+
<tr><th scope="row">Recipient</th><td>Hugging Face, SAS (9 rue des Colonnes, 75002 Paris, France) · privacy@huggingface.co</td></tr>
|
| 133 |
+
<tr><th scope="row">Country</th><td>United States (location of Hugging Face’s servers)</td></tr>
|
| 134 |
+
<tr><th scope="row">When and how</th><td>When you request an analysis, over an encrypted connection (HTTPS). When you tap the camera or gallery button to start one, and while you wait for the result, the app checks whether the server is up; while the server is still getting ready this repeats every few seconds for up to about four minutes. These checks send no photo, only the connection itself (your IP address).</td></tr>
|
| 135 |
+
<tr><th scope="row">Data transferred</th><td>The photo file and file name from section 1, and your IP address</td></tr>
|
| 136 |
+
<tr><th scope="row">Purpose</th><td>Hosting the analysis server (processing on our behalf)</td></tr>
|
| 137 |
+
<tr><th scope="row">Retention</th><td>As in section 2: the photo is discarded right after the analysis. Access records that Hugging Face keeps in its own infrastructure follow the <a href="https://huggingface.co/privacy">Hugging Face privacy policy</a>.</td></tr>
|
| 138 |
+
<tr><th scope="row">How to refuse</th><td>Nothing is transferred unless you use the analysis feature (the camera or gallery button). Without it, photos cannot be analysed.</td></tr>
|
| 139 |
+
</tbody>
|
| 140 |
+
</table>
|
| 141 |
+
</div>
|
| 142 |
+
<p>In the Android app only: when the phone’s DNS (the lookup of internet addresses) cannot find the analysis server’s address or has not answered within 2.5 seconds, the app looks up that one name (wonjun12-aeye-backend.hf.space) with two public DNS services, Cloudflare (1.1.1.1) and Google (8.8.8.8), over an encrypted connection. No photo or history is sent; the two services handle the name looked up and your IP address under their own privacy policies (<a href="https://www.cloudflare.com/privacypolicy/">Cloudflare</a>, <a href="https://developers.google.com/speed/public-dns/privacy">Google</a>).</p>
|
| 143 |
+
</section>
|
| 144 |
+
|
| 145 |
+
<section id="rights">
|
| 146 |
+
<h2>6. Your rights</h2>
|
| 147 |
+
<ul>
|
| 148 |
+
<li>You can delete the history stored on your device at any time: Settings > Delete all history, or uninstall the app (on the web, clear the site data in your browser).</li>
|
| 149 |
+
<li>The server keeps nothing that identifies you, so in principle there is no personal data to access, correct, delete or restrict. If you still want confirmation, write to the contact in section 11; we answer within 10 days.</li>
|
| 150 |
+
<li>You may exercise these rights through a legal representative or an authorised agent.</li>
|
| 151 |
+
</ul>
|
| 152 |
+
</section>
|
| 153 |
+
|
| 154 |
+
<section id="permissions">
|
| 155 |
+
<h2>7. App permissions</h2>
|
| 156 |
+
<ul>
|
| 157 |
+
<li>Camera (optional): used only when you take a photo to analyse. No video or sound is recorded. Without it you can still analyse photos you pick.</li>
|
| 158 |
+
<li>Picking a photo: the app uses the operating system’s photo picker, which needs no permission. The app never gets access to your whole library, only to the one photo you choose.</li>
|
| 159 |
+
<li>Saving a result card (iPhone, optional): only when you save the card to Photos with Save Image in the share sheet, iOS asks for permission to add photos. It is an add-only permission; the app never reads your photo library.</li>
|
| 160 |
+
</ul>
|
| 161 |
+
<p>A permission is requested only when you use the feature, and you can withdraw it at any time in your device settings.</p>
|
| 162 |
+
</section>
|
| 163 |
+
|
| 164 |
+
<section id="security">
|
| 165 |
+
<h2>8. Security</h2>
|
| 166 |
+
<ul>
|
| 167 |
+
<li>All traffic is encrypted in transit (HTTPS).</li>
|
| 168 |
+
<li>Uploaded files are validated for type and size, and requests are rate-limited.</li>
|
| 169 |
+
<li>File names and IP addresses are kept out of the operational record.</li>
|
| 170 |
+
<li>We ask for as little as possible in the first place: there are no accounts and there is no database.</li>
|
| 171 |
+
</ul>
|
| 172 |
+
</section>
|
| 173 |
+
|
| 174 |
+
<section id="cookies">
|
| 175 |
+
<h2>9. Cookies and automatic collection</h2>
|
| 176 |
+
<p>We use no cookies and no advertising identifiers. The web version keeps your analysis history in browser storage (IndexedDB); it is never sent to the server.</p>
|
| 177 |
+
</section>
|
| 178 |
+
|
| 179 |
+
<section id="children">
|
| 180 |
+
<h2>10. Children</h2>
|
| 181 |
+
<p>A-EYE does not knowingly collect personal information from children under 14. Because it takes no account or identifying information, it cannot know a user’s age.</p>
|
| 182 |
+
</section>
|
| 183 |
+
|
| 184 |
+
<section id="contact">
|
| 185 |
+
<h2>11. Contact</h2>
|
| 186 |
+
<p>Privacy officer: Wonjun Choi (developer and operator of A-EYE)<br />
|
| 187 |
+
Email: <a href="mailto:oscar43@naver.com">oscar43@naver.com</a></p>
|
| 188 |
+
<p class="note">For questions about using the app, see the <a href="support.html">support page</a>.</p>
|
| 189 |
+
</section>
|
| 190 |
+
|
| 191 |
+
<section id="remedy">
|
| 192 |
+
<h2>12. Complaints</h2>
|
| 193 |
+
<p>In Korea you can contact the following bodies about a privacy infringement.</p>
|
| 194 |
+
<ul>
|
| 195 |
+
<li>KISA Privacy Infringement Report Center — privacy.kisa.or.kr, dial 118</li>
|
| 196 |
+
<li>Personal Information Dispute Mediation Committee — www.kopico.go.kr, 1833-6972</li>
|
| 197 |
+
<li>Supreme Prosecutors’ Office, Cyber Investigation — www.spo.go.kr, dial 1301</li>
|
| 198 |
+
<li>Korean National Police Agency, Cyber Bureau — ecrm.police.go.kr, dial 182</li>
|
| 199 |
+
</ul>
|
| 200 |
+
</section>
|
| 201 |
+
|
| 202 |
+
<section id="changes">
|
| 203 |
+
<h2>13. Changes to this policy</h2>
|
| 204 |
+
<p>This policy applies from 24 September 2026. We announce changes in the app and on this page at least 7 days before they take effect (30 days for changes that materially affect your rights).</p>
|
| 205 |
+
</section>
|
| 206 |
+
</main>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
<footer class="foot">
|
| 210 |
+
<span>A-EYE</span>
|
| 211 |
+
<a href="support.html">Support</a>
|
| 212 |
+
<a href="privacy.html" lang="ko">개인정보처리방침 (한국어)</a>
|
| 213 |
+
</footer>
|
| 214 |
+
</div>
|
| 215 |
+
</body>
|
| 216 |
+
</html>
|
webapp/privacy.html
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<!--
|
| 3 |
+
Public privacy policy (Korean). This is the URL registered in App Store Connect and Google Play.
|
| 4 |
+
Same content and date as constants/legal.ts (the in-app version) and privacy-en.html - change all three together.
|
| 5 |
+
Every statement about the server was checked against 1_deployed_service (main_hybrid._analyze_upload, guards.py):
|
| 6 |
+
the upload is processed in memory, the log line carries rid / sha256[:12] / format / size / verdict / latency and
|
| 7 |
+
no file name or IP, and the rate limiter keeps IPs in memory for at most 24 h.
|
| 8 |
+
-->
|
| 9 |
+
<html lang="ko">
|
| 10 |
+
<head>
|
| 11 |
+
<meta charset="utf-8" />
|
| 12 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 13 |
+
<title>A-EYE 개인정보처리방침</title>
|
| 14 |
+
<meta name="description" content="A-EYE는 분석할 사진 한 장만 서버로 보내고, 분석이 끝나면 서버에 남기지 않습니다. 회원가입과 광고 추적이 없습니다." />
|
| 15 |
+
<meta name="theme-color" content="#FAFAFC" media="(prefers-color-scheme: light)" />
|
| 16 |
+
<meta name="theme-color" content="#0A0E1A" media="(prefers-color-scheme: dark)" />
|
| 17 |
+
<link rel="alternate" hreflang="en" href="privacy-en.html" />
|
| 18 |
+
<link rel="stylesheet" href="legal.css" />
|
| 19 |
+
</head>
|
| 20 |
+
<body>
|
| 21 |
+
<div class="page">
|
| 22 |
+
<header class="bar">
|
| 23 |
+
<a class="brand" href="/">A-EYE</a>
|
| 24 |
+
<nav class="langs" aria-label="언어">
|
| 25 |
+
<a href="privacy.html" aria-current="page" lang="ko">한국어</a>
|
| 26 |
+
<a href="privacy-en.html" lang="en">English</a>
|
| 27 |
+
</nav>
|
| 28 |
+
</header>
|
| 29 |
+
|
| 30 |
+
<h1>사진은 분석하는 동안만 서버에 있습니다</h1>
|
| 31 |
+
<p class="lede">A-EYE 개인정보처리방침입니다. A-EYE는 사진이 AI로 만들어졌을 가능성을 분석하는 모바일 앱과 웹 서비스이며, 회원가입 없이 사용합니다.</p>
|
| 32 |
+
<p class="meta">시행일 2026년 9월 24일</p>
|
| 33 |
+
|
| 34 |
+
<ol class="path" aria-label="사진 한 장이 거치는 길">
|
| 35 |
+
<li>
|
| 36 |
+
<h2>내 기기</h2>
|
| 37 |
+
<p>사진을 고르거나 촬영합니다. <strong>분석 기록은 이 기기에만 저장됩니다.</strong></p>
|
| 38 |
+
</li>
|
| 39 |
+
<li>
|
| 40 |
+
<h2>전송</h2>
|
| 41 |
+
<p>고른 사진 한 장만 암호화된 통신(HTTPS)으로 분석 서버에 보냅니다.</p>
|
| 42 |
+
</li>
|
| 43 |
+
<li>
|
| 44 |
+
<h2>분석</h2>
|
| 45 |
+
<p>서버 메모리에서 분석하고 결과와 의심 영역 이미지를 돌려줍니다.</p>
|
| 46 |
+
</li>
|
| 47 |
+
<li>
|
| 48 |
+
<h2>폐기</h2>
|
| 49 |
+
<p>결과를 돌려준 직후 사진은 서버에서 사라집니다. <strong>남는 것은 사진을 복원할 수 없는 운영 기록뿐입니다.</strong></p>
|
| 50 |
+
</li>
|
| 51 |
+
</ol>
|
| 52 |
+
|
| 53 |
+
<div class="layout">
|
| 54 |
+
<nav class="toc" aria-label="목차">
|
| 55 |
+
<ol>
|
| 56 |
+
<li><a href="#items">1. 처리하는 정보와 목적</a></li>
|
| 57 |
+
<li><a href="#retention">2. 보관 기간과 파기</a></li>
|
| 58 |
+
<li><a href="#training">3. 모델 학습에 쓰지 않습니다</a></li>
|
| 59 |
+
<li><a href="#sharing">4. 제3자 제공</a></li>
|
| 60 |
+
<li><a href="#transfer">5. 처리 위탁과 국외 이전</a></li>
|
| 61 |
+
<li><a href="#rights">6. 이용자의 권리</a></li>
|
| 62 |
+
<li><a href="#permissions">7. 앱 접근 권한</a></li>
|
| 63 |
+
<li><a href="#security">8. 안전성 확보 조치</a></li>
|
| 64 |
+
<li><a href="#cookies">9. 쿠키와 자동 수집</a></li>
|
| 65 |
+
<li><a href="#children">10. 만 14세 미만 아동</a></li>
|
| 66 |
+
<li><a href="#contact">11. 보호책임자와 문의</a></li>
|
| 67 |
+
<li><a href="#remedy">12. 권익 침해 구제</a></li>
|
| 68 |
+
<li><a href="#changes">13. 방침의 변경</a></li>
|
| 69 |
+
</ol>
|
| 70 |
+
</nav>
|
| 71 |
+
|
| 72 |
+
<main class="doc">
|
| 73 |
+
<section id="items">
|
| 74 |
+
<h2>1. 처리하는 정보와 목적</h2>
|
| 75 |
+
<p>A-EYE는 아래 정보만 처리합니다. 분석 요청에 꼭 필요한 것과, 서비스를 안정적으로 운영하기 위한 최소한의 기록입니다.</p>
|
| 76 |
+
<div class="table-wrap">
|
| 77 |
+
<table>
|
| 78 |
+
<thead>
|
| 79 |
+
<tr><th scope="col">항목</th><th scope="col">목적</th><th scope="col">보관</th></tr>
|
| 80 |
+
</thead>
|
| 81 |
+
<tbody>
|
| 82 |
+
<tr>
|
| 83 |
+
<th scope="row">분석할 사진 파일과 파일명</th>
|
| 84 |
+
<td data-label="목적">AI 생성 여부 분석, 출처 정보(C2PA 등) 확인</td>
|
| 85 |
+
<td data-label="보관">보관하지 않습니다. 결과를 돌려준 직후 폐기합니다.</td>
|
| 86 |
+
</tr>
|
| 87 |
+
<tr>
|
| 88 |
+
<th scope="row">운영 기록 — 요청 번호, 사진 파일 해시값의 앞 12자리, 이미지 형식·크기·용량, 판정 결과와 점수, 처리 시간</th>
|
| 89 |
+
<td data-label="목적">장애 대응, 남용 방지</td>
|
| 90 |
+
<td data-label="보관">분석 서버의 실행 로그에만 남고 서버가 재시작되면 함께 사라집니다. 다른 곳에 복사해 보관하지 않습니다.</td>
|
| 91 |
+
</tr>
|
| 92 |
+
<tr>
|
| 93 |
+
<th scope="row">접속 IP 주소</th>
|
| 94 |
+
<td data-label="목적">과도한 요청 차단(분당 20회, 하루 300회)</td>
|
| 95 |
+
<td data-label="보관">서버 메모리에서만 최대 24시간. 디스크에 기록하지 않으며 서버가 재시작되면 즉시 사라집니다.</td>
|
| 96 |
+
</tr>
|
| 97 |
+
<tr>
|
| 98 |
+
<th scope="row">분석 기록, 설정, 약관 동의 일자</th>
|
| 99 |
+
<td data-label="목적">기록 보기, 설정 유지</td>
|
| 100 |
+
<td data-label="보관">이용자의 기기에만 저장합니다. 서버로 전송하지 않습니다.</td>
|
| 101 |
+
</tr>
|
| 102 |
+
</tbody>
|
| 103 |
+
</table>
|
| 104 |
+
</div>
|
| 105 |
+
<p>사진 파일에는 촬영 기기와 일시, 경우에 따라 위치 같은 메타데이터(EXIF)가 들어 있을 수 있고, 파일과 함께 전송됩니다. 서버는 이 정보를 출처 확인에만 읽고 저장하지 않습니다. 위치가 담기는 것을 원하지 않으면 사진을 고를 때 운영체제가 제공하는 위치 정보 제외 옵션을 쓰거나, 위치 정보가 없는 사진을 사용해 주세요.</p>
|
| 106 |
+
<p>운영 기록의 해시값은 사진에서 계산한 짧은 지문으로, 이것으로 사진을 복원하거나 내용을 알아낼 수 없습니다. 운영 기록에는 이름·계정·IP 주소·파일명이 들어가지 않습니다.</p>
|
| 107 |
+
<p>사진에 다른 사람의 얼굴 등이 담겨 있을 수 있습니다. A-EYE는 사진 속 인물을 식별하거나 얼굴 특징을 추출하지 않습니다. 이미지가 생성된 것인지에 대한 신호만 계산합니다.</p>
|
| 108 |
+
<h3>수집하지 않는 것</h3>
|
| 109 |
+
<p>이름, 이메일, 전화번호, 계정, 광고 식별자, 연락처, 위치 권한, 마이크. 광고나 행동 분석을 위한 외부 SDK를 사용하지 않습니다.</p>
|
| 110 |
+
</section>
|
| 111 |
+
|
| 112 |
+
<section id="retention">
|
| 113 |
+
<h2>2. 보관 기간과 파기</h2>
|
| 114 |
+
<ul>
|
| 115 |
+
<li>전송된 사진은 분석 서버의 메모리에서 처리되고, 결과를 돌려준 직후 폐기됩니다. 요청을 처리하는 동안 만들어질 수 있는 임시 파일도 요청이 끝나면 삭제됩니다.</li>
|
| 116 |
+
<li>운영 기록과 IP 주소의 보관은 1항의 표와 같습니다.</li>
|
| 117 |
+
<li>기기에 저장된 분석 기록은 이용자가 지우거나 앱을 삭제할 때까지 남습니다. 최근 100건까지만 보관하고 그보다 오래된 기록은 자동으로 지워집니다.</li>
|
| 118 |
+
</ul>
|
| 119 |
+
</section>
|
| 120 |
+
|
| 121 |
+
<section id="training">
|
| 122 |
+
<h2>3. 모델 학습에 쓰지 않습니다</h2>
|
| 123 |
+
<p>전송된 사진을 AI 모델의 학습이나 개선에 사용하지 않습니다. 앞으로 오판 신고 같은 기능으로 사진을 제공받게 된다면, 그 전에 별도의 동의를 받고 이 방침을 먼저 개정합니다.</p>
|
| 124 |
+
</section>
|
| 125 |
+
|
| 126 |
+
<section id="sharing">
|
| 127 |
+
<h2>4. 제3자 제공</h2>
|
| 128 |
+
<p>이용자의 정보를 제3자에게 제공하거나 판매하지 않습니다. 법령에 따라 수사기관 등이 적법한 절차로 요구하는 경우는 예외이며, 그때에도 A-EYE가 가진 것은 1항의 운영 기록뿐입니다.</p>
|
| 129 |
+
</section>
|
| 130 |
+
|
| 131 |
+
<section id="transfer">
|
| 132 |
+
<h2>5. 처리 위탁과 국외 이전</h2>
|
| 133 |
+
<p>분석 서버는 Hugging Face의 클라우드 서비스에서 운영됩니다. 그래서 분석을 요청하면 사진이 국외에 있는 서버로 전송됩니다.</p>
|
| 134 |
+
<div class="table-wrap">
|
| 135 |
+
<table>
|
| 136 |
+
<tbody>
|
| 137 |
+
<tr><th scope="row">이전받는 자</th><td>Hugging Face, SAS (9 rue des Colonnes, 75002 Paris, France) · privacy@huggingface.co</td></tr>
|
| 138 |
+
<tr><th scope="row">이전되는 국가</th><td>미국 (Hugging Face의 서버 소재지)</td></tr>
|
| 139 |
+
<tr><th scope="row">일시와 방법</th><td>이용자가 분석을 요청하는 시점에, 암호화된 통신(HTTPS)으로 전송. 분석을 시작하려고 촬영이나 갤러리를 누를 때와 결과를 기다리는 동안에는 서버가 켜져 있는지 확인하기 위해 사진 없이 서버 상태를 조회합니다. 서버가 아직 준비되지 않았으면 준비될 때까지(최대 약 4분) 몇 초 간격으로 반복하며, 이때는 접속 정보만 전달됩니다.</td></tr>
|
| 140 |
+
<tr><th scope="row">이전 항목</th><td>1항의 사진 파일과 파일명, 접속 IP 주소</td></tr>
|
| 141 |
+
<tr><th scope="row">목적</th><td>분석 서버 호스팅(서버 운영 위탁)</td></tr>
|
| 142 |
+
<tr><th scope="row">보유 기간</th><td>2항과 같습니다. 사진은 분석 직후 폐기됩니다. Hugging Face가 자사 인프라에서 남기는 접속 기록은 <a href="https://huggingface.co/privacy">Hugging Face 개인정보처리방침</a>을 따릅니다.</td></tr>
|
| 143 |
+
<tr><th scope="row">거부 방법</th><td>분석 기능(촬영·갤러리 버튼)을 사용하지 않으면 이전되지 않습니다. 이 경우 사진 분석을 이용할 수 없습니다.</td></tr>
|
| 144 |
+
</tbody>
|
| 145 |
+
</table>
|
| 146 |
+
</div>
|
| 147 |
+
<p>Android 앱에��는 휴대폰의 DNS(인터넷 주소 조회)가 분석 서버의 주소를 찾지 못하거나 2.5초 안에 답하지 않을 때에만, 분석 서버의 이름(wonjun12-aeye-backend.hf.space) 하나를 공개 DNS 서비스인 Cloudflare(1.1.1.1)와 Google(8.8.8.8)에 암호화된 통신으로 조회합니다. 사진이나 기록은 보내지 않으며, 두 서비스는 조회된 이름과 접속 IP 주소를 각자의 개인정보처리방침(<a href="https://www.cloudflare.com/privacypolicy/">Cloudflare</a>, <a href="https://developers.google.com/speed/public-dns/privacy">Google</a>)에 따라 처리합니다.</p>
|
| 148 |
+
</section>
|
| 149 |
+
|
| 150 |
+
<section id="rights">
|
| 151 |
+
<h2>6. 이용자의 권리</h2>
|
| 152 |
+
<ul>
|
| 153 |
+
<li>기기에 저장된 분석 기록은 설정 > 기록 전체 삭제, 또는 앱 삭제(웹은 브라우저의 사이트 데이터 삭제)로 언제든 지울 수 있습니다.</li>
|
| 154 |
+
<li>서버에는 이용자를 식별할 수 있는 정보를 보관하지 않으므로 열람·정정·삭제·처리정지를 요청할 개인정보가 원칙적으로 없습니다. 그래도 확인이 필요하면 11항의 문의처로 요청해 주세요. 10일 안에 답변합니다.</li>
|
| 155 |
+
<li>권리 행사는 법정대리인이나 위임받은 대리인을 통해서도 할 수 있습니다.</li>
|
| 156 |
+
</ul>
|
| 157 |
+
</section>
|
| 158 |
+
|
| 159 |
+
<section id="permissions">
|
| 160 |
+
<h2>7. 앱 접근 권한</h2>
|
| 161 |
+
<ul>
|
| 162 |
+
<li>카메라(선택): 분석할 사진을 직접 촬영할 때만 사용합니다. 동영상과 소리는 기록하지 않습니다. 허용하지 않아도 사진 선택으로 분석할 수 있습니다.</li>
|
| 163 |
+
<li>사진 선택: 운영체제의 사진 선택 화면을 사용하므로 별도 권한이 없습니다. 앱은 사진 보관함 전체에 접근하지 않고 이용자가 고른 사진 한 장만 전달받습니다.</li>
|
| 164 |
+
<li>사진 저장(iPhone, 선택): 결과 카드를 공유 화면의 ‘이미지 저장’으로 사진 앱에 저장할 때만 운영체제가 사진 추가 권한을 묻습니다. 사진을 추가하는 권한이며, 앱은 사진 보관함의 사진을 읽지 않습니다.</li>
|
| 165 |
+
</ul>
|
| 166 |
+
<p>권한은 해당 기능을 쓸 때만 요청되며 기기 설정에서 언제든 철회할 수 있습니다.</p>
|
| 167 |
+
</section>
|
| 168 |
+
|
| 169 |
+
<section id="security">
|
| 170 |
+
<h2>8. 안전성 확보 조치</h2>
|
| 171 |
+
<ul>
|
| 172 |
+
<li>모든 전송 구간을 암호화합니다(HTTPS).</li>
|
| 173 |
+
<li>업로드 파일의 형식과 크기를 검증하고, 요청량을 제한합니다.</li>
|
| 174 |
+
<li>운영 기록에 파일명과 IP 주소를 남기지 않습니다.</li>
|
| 175 |
+
<li>처음부터 필요한 최소한의 정보만 받습니다. 계정과 데이터베이스가 없습니다.</li>
|
| 176 |
+
</ul>
|
| 177 |
+
</section>
|
| 178 |
+
|
| 179 |
+
<section id="cookies">
|
| 180 |
+
<h2>9. 쿠키와 자동 수집</h2>
|
| 181 |
+
<p>쿠키와 광고 식별자를 사용하지 않습니다. 웹 버전은 분석 기록을 브라우저 저장소(IndexedDB)에 보관하며, 이 데이터는 서버로 전송되지 않습니다.</p>
|
| 182 |
+
</section>
|
| 183 |
+
|
| 184 |
+
<section id="children">
|
| 185 |
+
<h2>10. 만 14세 미만 아동</h2>
|
| 186 |
+
<p>A-EYE는 만 14세 미만 아동의 개인정보를 알면서 수집하지 않습니다. 계정이나 식별 정보를 받지 않으므로 이용자의 나이를 알 수 없습니다.</p>
|
| 187 |
+
</section>
|
| 188 |
+
|
| 189 |
+
<section id="contact">
|
| 190 |
+
<h2>11. 보호책임자와 문의</h2>
|
| 191 |
+
<p>개인정보 보호책임자: 최원준 (A-EYE 개발·운영)<br />
|
| 192 |
+
이메일: <a href="mailto:oscar43@naver.com">oscar43@naver.com</a></p>
|
| 193 |
+
<p class="note">앱 사용에 관한 문의는 <a href="support.html">지원 페이지</a>를 확인해 주세요.</p>
|
| 194 |
+
</section>
|
| 195 |
+
|
| 196 |
+
<section id="remedy">
|
| 197 |
+
<h2>12. 권익 침해 구제</h2>
|
| 198 |
+
<p>개인정보 침해에 대한 상담이나 신고가 필요하면 아래 기관에 문의할 수 있습니다.</p>
|
| 199 |
+
<ul>
|
| 200 |
+
<li>개인정보침해신고센터 — privacy.kisa.or.kr, 국번 없이 118</li>
|
| 201 |
+
<li>개인정보분쟁조정위원회 — www.kopico.go.kr, 1833-6972</li>
|
| 202 |
+
<li>대검찰청 사이버수사과 — www.spo.go.kr, 국번 없이 1301</li>
|
| 203 |
+
<li>경찰청 사이버수사국 — ecrm.police.go.kr, 국번 없이 182</li>
|
| 204 |
+
</ul>
|
| 205 |
+
</section>
|
| 206 |
+
|
| 207 |
+
<section id="changes">
|
| 208 |
+
<h2>13. 방침의 변경</h2>
|
| 209 |
+
<p>이 방침은 2026년 9월 24일부터 적용됩니다. 내용이 바뀌면 시행 7일 전부터(이용자 권리에 중요한 변경은 30일 전부터) 앱과 이 페이지에서 알립니다.</p>
|
| 210 |
+
</section>
|
| 211 |
+
</main>
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
<footer class="foot">
|
| 215 |
+
<span>A-EYE</span>
|
| 216 |
+
<a href="support.html">지원</a>
|
| 217 |
+
<a href="privacy-en.html" lang="en">Privacy Policy (English)</a>
|
| 218 |
+
</footer>
|
| 219 |
+
</div>
|
| 220 |
+
</body>
|
| 221 |
+
</html>
|
webapp/support.html
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<!-- Support page: the "Support URL" of App Store Connect and the website field of Google Play.
|
| 3 |
+
Numbers come from 1_deployed_service/guards.py (28 MiB body, JPEG 64 MP / PNG-WebP 32 MP, 20/min 300/day). -->
|
| 4 |
+
<html lang="ko">
|
| 5 |
+
<head>
|
| 6 |
+
<meta charset="utf-8" />
|
| 7 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 8 |
+
<title>A-EYE 지원</title>
|
| 9 |
+
<meta name="description" content="A-EYE 사용 중 자주 묻는 질문과 문의 방법." />
|
| 10 |
+
<meta name="theme-color" content="#FAFAFC" media="(prefers-color-scheme: light)" />
|
| 11 |
+
<meta name="theme-color" content="#0A0E1A" media="(prefers-color-scheme: dark)" />
|
| 12 |
+
<link rel="stylesheet" href="legal.css" />
|
| 13 |
+
</head>
|
| 14 |
+
<body>
|
| 15 |
+
<div class="page">
|
| 16 |
+
<header class="bar">
|
| 17 |
+
<a class="brand" href="/">A-EYE</a>
|
| 18 |
+
<nav class="langs" aria-label="바로가기">
|
| 19 |
+
<a href="#english" lang="en">English</a>
|
| 20 |
+
<a href="privacy.html">개인정보처리방침</a>
|
| 21 |
+
</nav>
|
| 22 |
+
</header>
|
| 23 |
+
|
| 24 |
+
<h1>막히는 곳이 있으면 여기서 확인하세요</h1>
|
| 25 |
+
<p class="lede">A-EYE는 사진 한 장이 AI로 만들어졌을 가능성을 추정하고, 의심되는 영역을 보여 주는 앱입니다. 아래에서 답을 찾지 못하면 메일을 보내 주세요.</p>
|
| 26 |
+
<p class="meta">문의 <a href="mailto:oscar43@naver.com">oscar43@naver.com</a> · 보통 3일 안에 답변합니다</p>
|
| 27 |
+
|
| 28 |
+
<div class="layout" style="margin-top:56px">
|
| 29 |
+
<nav class="toc" aria-label="목차">
|
| 30 |
+
<ol>
|
| 31 |
+
<li><a href="#slow">분석이 오래 걸려요</a></li>
|
| 32 |
+
<li><a href="#files">어떤 사진을 올릴 수 있나요</a></li>
|
| 33 |
+
<li><a href="#wrong">결과가 틀린 것 같아요</a></li>
|
| 34 |
+
<li><a href="#limit">요청이 거절돼요</a></li>
|
| 35 |
+
<li><a href="#photo">내 사진은 어떻게 되나요</a></li>
|
| 36 |
+
<li><a href="#history">기록을 지우고 싶어요</a></li>
|
| 37 |
+
<li><a href="#english">English</a></li>
|
| 38 |
+
</ol>
|
| 39 |
+
</nav>
|
| 40 |
+
|
| 41 |
+
<main class="doc">
|
| 42 |
+
<section id="slow">
|
| 43 |
+
<h2>분석이 오래 걸려요</h2>
|
| 44 |
+
<p>분석은 보통 10초 안팎에 끝납니다. 오랜만에 분석할 때는 분석 서버를 준비하는 데 1~3분쯤 걸릴 수 있습니다. 기다리는 동안 앱이 ‘분석 서버 준비 중’과 기다린 시간을 보여 주고, 언제든 취소할 수 있습니다. 4분이 지나도 준비되지 않으면 앱이 알려 드리니, 잠시 뒤 다시 시도해 주세요.</p>
|
| 45 |
+
</section>
|
| 46 |
+
|
| 47 |
+
<section id="files">
|
| 48 |
+
<h2>어떤 사진을 올릴 수 있나요</h2>
|
| 49 |
+
<ul>
|
| 50 |
+
<li>JPEG, PNG, WebP를 분석합니다. 아이폰의 HEIC 사진은 앱이 자동으로 JPEG로 바꿔 보냅니다(웹 버전은 JPEG·PNG·WebP만).</li>
|
| 51 |
+
<li>파일은 약 25MB까지, 해상도는 JPEG 6,400만 화소·PNG/WebP 3,200만 화소까지입니다. 움직이는 이미지는 분석하지 않습니다.</li>
|
| 52 |
+
<li>가능하면 원본 파일을 그대로 올려 주세요. 메신저로 여러 번 전달됐거나 화면을 캡처한 이미지는 압축과 크기 변경으로 단서가 줄어 결과가 불확실해질 수 있습니다.</li>
|
| 53 |
+
</ul>
|
| 54 |
+
</section>
|
| 55 |
+
|
| 56 |
+
<section id="wrong">
|
| 57 |
+
<h2>결과가 틀린 것 같아요</h2>
|
| 58 |
+
<p>A-EYE의 결과는 통계적 추정입니다. 실제 사진을 AI로, AI 이미지를 실제로 판단할 수 있고, 새로 나온 생성 도구의 이미지일수록 놓치기 쉽습니다. ‘판단 유보’는 어느 쪽이라고 말하기 어렵다는 뜻이며 오류가 아닙니다.</p>
|
| 59 |
+
<p>법적 증거, 보도, 인사 결정처럼 중요한 판단의 유일한 근거로 쓰지 말고 출처, 촬영 맥락, 메타데이터를 함께 확인해 주세요. 잘못된 결과를 알려 주시면 개선에 참고하겠습니다. 이때 사진을 메일로 보내실지는 직접 결정하실 수 있습니다.</p>
|
| 60 |
+
</section>
|
| 61 |
+
|
| 62 |
+
<section id="limit">
|
| 63 |
+
<h2>요청이 거절돼요</h2>
|
| 64 |
+
<p>서비스를 누구나 쓸 수 있게 하려고 요청 수를 제한합니다(분당 20회, 하루 300회). ‘잠시 후 다시 시도’ 안내가 나오면 1분 뒤에 다시 해 보세요. 동시에 요청이 몰리면 대기 후 ‘서버가 바쁩니다’가 나올 수 있습니다.</p>
|
| 65 |
+
</section>
|
| 66 |
+
|
| 67 |
+
<section id="photo">
|
| 68 |
+
<h2>내 사진은 어떻게 되나요</h2>
|
| 69 |
+
<p>고른 사진 한 장만 암호화된 통신으로 분석 서버에 전송되고, 결과를 돌려준 직후 서버에서 폐기됩니다. 모델 학습에 쓰지 않습니다. 자세한 내용은 <a href="privacy.html">개인정보처리방침</a>에 있습니다.</p>
|
| 70 |
+
</section>
|
| 71 |
+
|
| 72 |
+
<section id="history">
|
| 73 |
+
<h2>기록을 지우고 싶어요</h2>
|
| 74 |
+
<p>설정 > 기록 > 기록 전체 삭제를 누르면 기기에 저장된 분석 기록이 모두 지워집니다. 기록 탭에서 한 건을 길게 누르면 그 기록만 지울 수도 있습니다. 앱을 삭제해도 함께 지워집니다. 기록은 서��에 저장되지 않으므로 따로 삭제를 요청할 필요가 없습니다.</p>
|
| 75 |
+
</section>
|
| 76 |
+
|
| 77 |
+
<section id="english" lang="en" style="word-break:normal">
|
| 78 |
+
<h2>English</h2>
|
| 79 |
+
<p>A-EYE estimates whether a photo was generated by AI and highlights suspicious areas. Results are statistical estimates, not proof: do not use them as the only basis for an important decision.</p>
|
| 80 |
+
<ul>
|
| 81 |
+
<li>A result usually takes about 10 seconds. The first analysis after a while can take 1–3 minutes while the analysis server gets ready; the app shows how long you have waited and you can cancel at any time.</li>
|
| 82 |
+
<li>Supported files: JPEG, PNG, WebP (HEIC is converted automatically in the app), up to about 25 MB.</li>
|
| 83 |
+
<li>Only the photo you choose is uploaded, over HTTPS, and it is discarded as soon as the result is returned. See the <a href="privacy-en.html">privacy policy</a>.</li>
|
| 84 |
+
<li>Delete your history in 설정 (Settings) > 기록 (History) > 기록 전체 삭제 (Delete all), or one record by long-pressing it in the history tab. It is stored on your device only.</li>
|
| 85 |
+
</ul>
|
| 86 |
+
<p>Contact: <a href="mailto:oscar43@naver.com">oscar43@naver.com</a></p>
|
| 87 |
+
</section>
|
| 88 |
+
</main>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<footer class="foot">
|
| 92 |
+
<span>A-EYE</span>
|
| 93 |
+
<a href="privacy.html">개인정보처리방침</a>
|
| 94 |
+
<a href="privacy-en.html" lang="en">Privacy Policy</a>
|
| 95 |
+
</footer>
|
| 96 |
+
</div>
|
| 97 |
+
</body>
|
| 98 |
+
</html>
|
zero_shot_v4.py
CHANGED
|
@@ -3,6 +3,8 @@
|
|
| 3 |
Ported verbatim from the model repo's ``src/models/zero_shot_v4.py``. The only
|
| 4 |
change is that ``CLIP_MEAN`` / ``CLIP_STD`` are inlined here instead of importing
|
| 5 |
``src.data.transforms`` (which is not part of the web backend).
|
|
|
|
|
|
|
| 6 |
|
| 7 |
- frozen CLIP ViT-L/14 intermediate patch tokens (semantic/texture cues)
|
| 8 |
- trainable forensic residual CNN (sensor/compression/noise evidence)
|
|
@@ -159,17 +161,32 @@ class ForensicResidualBranch(nn.Module):
|
|
| 159 |
|
| 160 |
|
| 161 |
class RadialFFTBranch(nn.Module):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
def __init__(
|
| 163 |
self,
|
| 164 |
image_size: int = 224,
|
| 165 |
bins: int = 48,
|
| 166 |
out_dim: int = 192,
|
| 167 |
dropout: float = 0.12,
|
|
|
|
| 168 |
):
|
| 169 |
super().__init__()
|
| 170 |
self.image_size = int(image_size)
|
| 171 |
self.bins = int(bins)
|
| 172 |
-
|
|
|
|
|
|
|
| 173 |
self.register_buffer("masks", masks)
|
| 174 |
in_dim = 3 * self.bins + 3
|
| 175 |
self.mlp = nn.Sequential(
|
|
@@ -186,18 +203,47 @@ class RadialFFTBranch(nn.Module):
|
|
| 186 |
nn.init.zeros_(module.bias)
|
| 187 |
|
| 188 |
@staticmethod
|
| 189 |
-
def _make_radial_masks(size: int, bins: int) -> torch.Tensor:
|
| 190 |
axis = torch.linspace(-1.0, 1.0, size)
|
| 191 |
yy, xx = torch.meshgrid(axis, axis, indexing="ij")
|
| 192 |
-
rr = torch.sqrt(xx.square() + yy.square())
|
|
|
|
|
|
|
| 193 |
edges = torch.linspace(0.0, 1.0, bins + 1)
|
| 194 |
masks = []
|
| 195 |
for idx in range(bins):
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
| 197 |
denom = mask.sum().clamp_min(1.0)
|
| 198 |
masks.append(mask / denom)
|
| 199 |
return torch.stack(masks, dim=0)
|
| 200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
def forward(self, raw: torch.Tensor) -> torch.Tensor:
|
| 202 |
freq = torch.fft.fftshift(torch.fft.fft2(raw, norm="ortho"), dim=(-2, -1))
|
| 203 |
mag = torch.log1p(torch.abs(freq))
|
|
@@ -239,9 +285,11 @@ class ZeroShotV4Detector(nn.Module):
|
|
| 239 |
dropout: float = 0.25,
|
| 240 |
source_grl_lambda: float = 1.0,
|
| 241 |
freeze_clip: bool = True,
|
|
|
|
| 242 |
):
|
| 243 |
super().__init__()
|
| 244 |
self.clip_backbone = clip_backbone
|
|
|
|
| 245 |
self.is_siglip = clip_backbone in SIGLIP_BACKBONES
|
| 246 |
if self.is_siglip:
|
| 247 |
if SiglipVisionModel is None:
|
|
@@ -285,6 +333,7 @@ class ZeroShotV4Detector(nn.Module):
|
|
| 285 |
bins=fft_bins,
|
| 286 |
out_dim=frequency_dim,
|
| 287 |
dropout=dropout * 0.5,
|
|
|
|
| 288 |
)
|
| 289 |
|
| 290 |
self.register_buffer("clip_mean", torch.tensor(CLIP_MEAN).view(1, 3, 1, 1))
|
|
|
|
| 3 |
Ported verbatim from the model repo's ``src/models/zero_shot_v4.py``. The only
|
| 4 |
change is that ``CLIP_MEAN`` / ``CLIP_STD`` are inlined here instead of importing
|
| 5 |
``src.data.transforms`` (which is not part of the web backend).
|
| 6 |
+
C3 (2026-09-24): ``RadialFFTBranch`` (incl. ``fft_nyquist``) is kept textually identical to the training copy -
|
| 7 |
+
tests/test_fft_nyquist_parity.py checks it.
|
| 8 |
|
| 9 |
- frozen CLIP ViT-L/14 intermediate patch tokens (semantic/texture cues)
|
| 10 |
- trainable forensic residual CNN (sensor/compression/noise evidence)
|
|
|
|
| 161 |
|
| 162 |
|
| 163 |
class RadialFFTBranch(nn.Module):
|
| 164 |
+
"""Radial log-magnitude spectrum (bins rings) + a high/low ratio -> MLP.
|
| 165 |
+
|
| 166 |
+
fft_nyquist (C3, 2026-09-24; default False = the historic masks, bit for bit): the historic masks clamp the radius
|
| 167 |
+
to 1.0 and the last ring needs r < 1, so everything at r >= 1 is dropped. The radius grid is linspace(-1, 1, 224),
|
| 168 |
+
which is not centred on DC (index 112 sits at +0.0045), so r >= 1 is rows/columns 0 AND 223 (index 0 = the
|
| 169 |
+
Nyquist frequency after fftshift, index 223 = +111/224 cycles, which maps to exactly 1.0) plus the four corners
|
| 170 |
+
(r up to sqrt 2): 22.2% of the 224x224 spectrum, the highest frequencies, where upsampler / decoder lattices live.
|
| 171 |
+
With nyquist=True the last ring spans [edges[-2], sqrt 2]
|
| 172 |
+
(no clamp); rings 0..bins-2 are unchanged, so a warm start from a clamped checkpoint only sees its last input move.
|
| 173 |
+
The masks are a persistent buffer (checkpoints carry them): a nyquist branch keeps its own masks when a checkpoint
|
| 174 |
+
made without the flag is loaded into it (warm start from RC1 / best49) - see _load_from_state_dict."""
|
| 175 |
+
|
| 176 |
def __init__(
|
| 177 |
self,
|
| 178 |
image_size: int = 224,
|
| 179 |
bins: int = 48,
|
| 180 |
out_dim: int = 192,
|
| 181 |
dropout: float = 0.12,
|
| 182 |
+
nyquist: bool = False,
|
| 183 |
):
|
| 184 |
super().__init__()
|
| 185 |
self.image_size = int(image_size)
|
| 186 |
self.bins = int(bins)
|
| 187 |
+
self.nyquist = bool(nyquist)
|
| 188 |
+
self.ignored_checkpoint_masks = False
|
| 189 |
+
masks = self._make_radial_masks(self.image_size, self.bins, nyquist=self.nyquist)
|
| 190 |
self.register_buffer("masks", masks)
|
| 191 |
in_dim = 3 * self.bins + 3
|
| 192 |
self.mlp = nn.Sequential(
|
|
|
|
| 203 |
nn.init.zeros_(module.bias)
|
| 204 |
|
| 205 |
@staticmethod
|
| 206 |
+
def _make_radial_masks(size: int, bins: int, nyquist: bool = False) -> torch.Tensor:
|
| 207 |
axis = torch.linspace(-1.0, 1.0, size)
|
| 208 |
yy, xx = torch.meshgrid(axis, axis, indexing="ij")
|
| 209 |
+
rr = torch.sqrt(xx.square() + yy.square())
|
| 210 |
+
if not nyquist:
|
| 211 |
+
rr = rr.clamp(max=1.0) # historic: r >= 1 (rows/cols 0 and 223 + corners) falls in no ring
|
| 212 |
edges = torch.linspace(0.0, 1.0, bins + 1)
|
| 213 |
masks = []
|
| 214 |
for idx in range(bins):
|
| 215 |
+
if nyquist and idx == bins - 1:
|
| 216 |
+
mask = (rr >= edges[idx]).float() # last ring = [edges[-2], sqrt(2)]
|
| 217 |
+
else:
|
| 218 |
+
mask = ((rr >= edges[idx]) & (rr < edges[idx + 1])).float()
|
| 219 |
denom = mask.sum().clamp_min(1.0)
|
| 220 |
masks.append(mask / denom)
|
| 221 |
return torch.stack(masks, dim=0)
|
| 222 |
|
| 223 |
+
def _load_from_state_dict(self, state_dict, prefix, local_metadata, strict, missing_keys, unexpected_keys, error_msgs):
|
| 224 |
+
# The ring geometry is a function of the config, not a learned value. A nyquist branch must not be switched back
|
| 225 |
+
# to the clamped rings by a checkpoint made without the flag (train_v5 warm-starts every run from a source
|
| 226 |
+
# checkpoint, and load_trainable_state_dict copies every non-CLIP tensor, buffers included). Without the flag
|
| 227 |
+
# the historic behaviour (masks come from the checkpoint) is untouched - it only SAYS so when the checkpoint's
|
| 228 |
+
# rings are not the historic ones (a fft_nyquist checkpoint fine-tuned or evaluated without the flag: the run
|
| 229 |
+
# still computes the nyquist rings, but its saved config would no longer describe them).
|
| 230 |
+
key = prefix + "masks"
|
| 231 |
+
if key in state_dict:
|
| 232 |
+
incoming = state_dict[key]
|
| 233 |
+
differs = tuple(incoming.shape) == tuple(self.masks.shape) and not torch.equal(
|
| 234 |
+
incoming.detach().to(device=self.masks.device, dtype=self.masks.dtype), self.masks)
|
| 235 |
+
if differs and self.nyquist:
|
| 236 |
+
state_dict[key] = self.masks.detach().clone()
|
| 237 |
+
if not self.ignored_checkpoint_masks:
|
| 238 |
+
print(f"[fft] {key}: checkpoint masks differ from the fft_nyquist rings -> kept the nyquist masks "
|
| 239 |
+
f"(warm start from a clamped-ring checkpoint)", flush=True)
|
| 240 |
+
self.ignored_checkpoint_masks = True
|
| 241 |
+
elif differs:
|
| 242 |
+
print(f"[fft] WARNING {key}: the checkpoint carries non-historic rings (a fft_nyquist checkpoint?) and this "
|
| 243 |
+
f"model was built without fft_nyquist - they are loaded as they are; set model.fft_nyquist: true",
|
| 244 |
+
flush=True)
|
| 245 |
+
super()._load_from_state_dict(state_dict, prefix, local_metadata, strict, missing_keys, unexpected_keys, error_msgs)
|
| 246 |
+
|
| 247 |
def forward(self, raw: torch.Tensor) -> torch.Tensor:
|
| 248 |
freq = torch.fft.fftshift(torch.fft.fft2(raw, norm="ortho"), dim=(-2, -1))
|
| 249 |
mag = torch.log1p(torch.abs(freq))
|
|
|
|
| 285 |
dropout: float = 0.25,
|
| 286 |
source_grl_lambda: float = 1.0,
|
| 287 |
freeze_clip: bool = True,
|
| 288 |
+
fft_nyquist: bool = False,
|
| 289 |
):
|
| 290 |
super().__init__()
|
| 291 |
self.clip_backbone = clip_backbone
|
| 292 |
+
self.fft_nyquist = bool(fft_nyquist)
|
| 293 |
self.is_siglip = clip_backbone in SIGLIP_BACKBONES
|
| 294 |
if self.is_siglip:
|
| 295 |
if SiglipVisionModel is None:
|
|
|
|
| 333 |
bins=fft_bins,
|
| 334 |
out_dim=frequency_dim,
|
| 335 |
dropout=dropout * 0.5,
|
| 336 |
+
nyquist=self.fft_nyquist,
|
| 337 |
)
|
| 338 |
|
| 339 |
self.register_buffer("clip_mean", torch.tensor(CLIP_MEAN).view(1, 3, 1, 1))
|
zero_shot_v6.py
CHANGED
|
@@ -23,6 +23,11 @@ Design:
|
|
| 23 |
(`crop_logits` [B,K]) trained with mask-derived crop labels (multiview_dataset crop_labels), and `mil_logit` [B] =
|
| 24 |
log-mean-exp over the crops (a smooth max: one tampered crop is enough). `crop_pool="mil"` pools the crops with the
|
| 25 |
softmax of those logits, so the fused vector follows the most suspicious crop instead of the average.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
"""
|
| 27 |
from __future__ import annotations
|
| 28 |
|
|
@@ -33,6 +38,7 @@ import torch
|
|
| 33 |
import torch.nn as nn
|
| 34 |
import torch.nn.functional as F
|
| 35 |
|
|
|
|
| 36 |
from zero_shot_v4 import AttentionPool, _deep_head, gradient_reverse # service-local copy (no src package)
|
| 37 |
from zero_shot_v5 import ZeroShotV5Detector
|
| 38 |
|
|
@@ -58,6 +64,7 @@ class ZeroShotV6Detector(ZeroShotV5Detector):
|
|
| 58 |
semantic_dropout_p: float = 0.0,
|
| 59 |
artifact_dropout_p: float = 0.0,
|
| 60 |
attribution_classes: int | None = None,
|
|
|
|
| 61 |
**v5_kwargs,
|
| 62 |
) -> None:
|
| 63 |
# v4 does not keep these as attributes; remember them for the new heads (defaults = v4 defaults)
|
|
@@ -65,6 +72,7 @@ class ZeroShotV6Detector(ZeroShotV5Detector):
|
|
| 65 |
self.forensic_dim = int(v5_kwargs.get("forensic_dim", 256))
|
| 66 |
self.frequency_dim = int(v5_kwargs.get("frequency_dim", 192))
|
| 67 |
self.dropout_p = float(v5_kwargs.get("dropout", 0.25))
|
|
|
|
| 68 |
super().__init__(**v5_kwargs)
|
| 69 |
self.patch_backbone_name = str(patch_backbone).lower()
|
| 70 |
self.n_crops_train = int(n_crops_train)
|
|
@@ -91,7 +99,11 @@ class ZeroShotV6Detector(ZeroShotV5Detector):
|
|
| 91 |
self.patch_dim = int(patch_dim)
|
| 92 |
self.patch_proj = nn.Sequential(nn.LayerNorm(hidden * 2), nn.Linear(hidden * 2, self.patch_dim), nn.GELU(),
|
| 93 |
nn.Dropout(self.dropout_p * 0.5))
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
self.artifact_norm = nn.LayerNorm(self.artifact_dim)
|
| 96 |
self.crop_pool = AttentionPool(self.artifact_dim) if self.crop_pool_mode == "attention" else None
|
| 97 |
self.crop_logit = None
|
|
@@ -124,18 +136,25 @@ class ZeroShotV6Detector(ZeroShotV5Detector):
|
|
| 124 |
return torch.cat([out[:, 0], out[:, 1:].mean(dim=1)], dim=1).float()
|
| 125 |
|
| 126 |
def artifact_features(self, crops: torch.Tensor) -> torch.Tensor:
|
| 127 |
-
"""crops [B,K,3,H,W] (CLIP-normalised like x
|
|
|
|
| 128 |
return self._artifact(crops)[0]
|
| 129 |
|
| 130 |
-
def _artifact(self, crops: torch.Tensor
|
| 131 |
-
|
|
|
|
|
|
|
| 132 |
b, k = crops.shape[:2]
|
|
|
|
|
|
|
| 133 |
flat = crops.flatten(0, 1)
|
| 134 |
raw = self._to_raw_rgb(flat)
|
| 135 |
parts = []
|
| 136 |
if self.patch is not None:
|
| 137 |
parts.append(self.patch_proj(self._patch_tokens(raw)))
|
| 138 |
parts += [self.forensic_branch(raw), self.frequency_branch(raw)]
|
|
|
|
|
|
|
| 139 |
per_crop = self.artifact_norm(torch.cat(parts, dim=1)).view(b, k, -1)
|
| 140 |
crop_logits = self.crop_logit(per_crop).squeeze(-1) if self.crop_logit is not None else None
|
| 141 |
if self.crop_pool_mode == "mil":
|
|
@@ -149,9 +168,12 @@ class ZeroShotV6Detector(ZeroShotV5Detector):
|
|
| 149 |
|
| 150 |
def forward(self, x: torch.Tensor, crops: torch.Tensor | None = None) -> dict[str, torch.Tensor]:
|
| 151 |
semantic_raw = self.semantic_features(x)
|
|
|
|
| 152 |
if crops is None:
|
| 153 |
crops = x.unsqueeze(1) # the global view as a single crop (aux batches / callers without crops)
|
| 154 |
-
|
|
|
|
|
|
|
| 155 |
semantic, artifact = semantic_raw, artifact_raw
|
| 156 |
if self.training and (self.semantic_dropout_p > 0 or self.artifact_dropout_p > 0):
|
| 157 |
u = torch.rand(x.shape[0], device=x.device)
|
|
@@ -183,6 +205,9 @@ class ZeroShotV6Detector(ZeroShotV5Detector):
|
|
| 183 |
def param_summary(self) -> str:
|
| 184 |
total = sum(p.numel() for p in self.parameters())
|
| 185 |
trainable = sum(p.numel() for p in self.parameters() if p.requires_grad)
|
|
|
|
|
|
|
|
|
|
| 186 |
return (f"[ZeroShotV6: {self.clip_backbone} layers={self.clip_layers} + patches={self.patch_backbone_name} "
|
| 187 |
-
f"pool={self.crop_pool_mode} crops train/eval={self.n_crops_train}/{self.n_crops_eval} adapter={self.clip_adapter}] "
|
| 188 |
f"total={total:,} trainable={trainable:,} ({100 * trainable / max(1, total):.2f}%) fused_dim={self.fused_dim}")
|
|
|
|
| 23 |
(`crop_logits` [B,K]) trained with mask-derived crop labels (multiview_dataset crop_labels), and `mil_logit` [B] =
|
| 24 |
log-mean-exp over the crops (a smooth max: one tampered crop is enough). `crop_pool="mil"` pools the crops with the
|
| 25 |
softmax of those logits, so the fused vector follows the most suspicious crop instead of the average.
|
| 26 |
+
+ lattice features (2026-09-26, GPT round 3; checkpoint config model.lattice_features): per crop, the one-sided ChatGPT-
|
| 27 |
+
renderer lattice statistics of lattice_features.py (a byte-identical copy of the training module) are appended to the
|
| 28 |
+
per-crop artifact vector before artifact_norm. They are computed in IMAGE coordinates, which travel with the crops as a
|
| 29 |
+
fourth "geometry plane" channel (crop_geometry.py; main_hybrid._native_crops attaches it for such a checkpoint). Absent =
|
| 30 |
+
the historic model, bit for bit.
|
| 31 |
"""
|
| 32 |
from __future__ import annotations
|
| 33 |
|
|
|
|
| 38 |
import torch.nn as nn
|
| 39 |
import torch.nn.functional as F
|
| 40 |
|
| 41 |
+
from lattice_features import LatticeFeatures, own_frame_geometry, parse_lattice_features, split_geometry # service copy
|
| 42 |
from zero_shot_v4 import AttentionPool, _deep_head, gradient_reverse # service-local copy (no src package)
|
| 43 |
from zero_shot_v5 import ZeroShotV5Detector
|
| 44 |
|
|
|
|
| 64 |
semantic_dropout_p: float = 0.0,
|
| 65 |
artifact_dropout_p: float = 0.0,
|
| 66 |
attribution_classes: int | None = None,
|
| 67 |
+
lattice_features: dict | None = None, # GPT round 3: per-crop renderer-lattice statistics (None = historic)
|
| 68 |
**v5_kwargs,
|
| 69 |
) -> None:
|
| 70 |
# v4 does not keep these as attributes; remember them for the new heads (defaults = v4 defaults)
|
|
|
|
| 72 |
self.forensic_dim = int(v5_kwargs.get("forensic_dim", 256))
|
| 73 |
self.frequency_dim = int(v5_kwargs.get("frequency_dim", 192))
|
| 74 |
self.dropout_p = float(v5_kwargs.get("dropout", 0.25))
|
| 75 |
+
lattice_spec = parse_lattice_features(lattice_features) # fail closed on an unknown stat before CLIP loads
|
| 76 |
super().__init__(**v5_kwargs)
|
| 77 |
self.patch_backbone_name = str(patch_backbone).lower()
|
| 78 |
self.n_crops_train = int(n_crops_train)
|
|
|
|
| 99 |
self.patch_dim = int(patch_dim)
|
| 100 |
self.patch_proj = nn.Sequential(nn.LayerNorm(hidden * 2), nn.Linear(hidden * 2, self.patch_dim), nn.GELU(),
|
| 101 |
nn.Dropout(self.dropout_p * 0.5))
|
| 102 |
+
# lattice features: no parameters (nothing in a state dict); only the widths below change when they are on
|
| 103 |
+
self.lattice_spec = lattice_spec
|
| 104 |
+
self.lattice = LatticeFeatures(lattice_spec) if lattice_spec is not None else None
|
| 105 |
+
self.lattice_dim = self.lattice.out_dim if self.lattice is not None else 0
|
| 106 |
+
self.artifact_dim = self.patch_dim + self.forensic_dim + self.frequency_dim + self.lattice_dim
|
| 107 |
self.artifact_norm = nn.LayerNorm(self.artifact_dim)
|
| 108 |
self.crop_pool = AttentionPool(self.artifact_dim) if self.crop_pool_mode == "attention" else None
|
| 109 |
self.crop_logit = None
|
|
|
|
| 136 |
return torch.cat([out[:, 0], out[:, 1:].mean(dim=1)], dim=1).float()
|
| 137 |
|
| 138 |
def artifact_features(self, crops: torch.Tensor) -> torch.Tensor:
|
| 139 |
+
"""crops [B,K,3,H,W] (CLIP-normalised like x; [B,K,4,H,W] with the geometry plane when lattice_features is on)
|
| 140 |
+
-> pooled artifact vector [B, artifact_dim]."""
|
| 141 |
return self._artifact(crops)[0]
|
| 142 |
|
| 143 |
+
def _artifact(self, crops: torch.Tensor, crop_geom: torch.Tensor | None = None
|
| 144 |
+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | None]:
|
| 145 |
+
"""-> (pooled [B,D], per-crop [B,K,D], per-crop tamper logits [B,K] or None). `crop_geom` [B,K,6] is only used
|
| 146 |
+
with lattice_features (None = read the geometry plane of `crops`)."""
|
| 147 |
b, k = crops.shape[:2]
|
| 148 |
+
if self.lattice is not None and crop_geom is None:
|
| 149 |
+
crops, crop_geom = split_geometry(crops) # raises when the plane is missing: never a crop-coordinate statistic
|
| 150 |
flat = crops.flatten(0, 1)
|
| 151 |
raw = self._to_raw_rgb(flat)
|
| 152 |
parts = []
|
| 153 |
if self.patch is not None:
|
| 154 |
parts.append(self.patch_proj(self._patch_tokens(raw)))
|
| 155 |
parts += [self.forensic_branch(raw), self.frequency_branch(raw)]
|
| 156 |
+
if self.lattice is not None:
|
| 157 |
+
parts.append(self.lattice(raw, crop_geom.reshape(b * k, -1)))
|
| 158 |
per_crop = self.artifact_norm(torch.cat(parts, dim=1)).view(b, k, -1)
|
| 159 |
crop_logits = self.crop_logit(per_crop).squeeze(-1) if self.crop_logit is not None else None
|
| 160 |
if self.crop_pool_mode == "mil":
|
|
|
|
| 168 |
|
| 169 |
def forward(self, x: torch.Tensor, crops: torch.Tensor | None = None) -> dict[str, torch.Tensor]:
|
| 170 |
semantic_raw = self.semantic_features(x)
|
| 171 |
+
crop_geom = None
|
| 172 |
if crops is None:
|
| 173 |
crops = x.unsqueeze(1) # the global view as a single crop (aux batches / callers without crops)
|
| 174 |
+
if self.lattice is not None:
|
| 175 |
+
crop_geom = own_frame_geometry(crops) # a resized view: its own image (no renderer lattice survives it)
|
| 176 |
+
artifact_raw, _per_crop, crop_logits = self._artifact(crops, crop_geom)
|
| 177 |
semantic, artifact = semantic_raw, artifact_raw
|
| 178 |
if self.training and (self.semantic_dropout_p > 0 or self.artifact_dropout_p > 0):
|
| 179 |
u = torch.rand(x.shape[0], device=x.device)
|
|
|
|
| 205 |
def param_summary(self) -> str:
|
| 206 |
total = sum(p.numel() for p in self.parameters())
|
| 207 |
trainable = sum(p.numel() for p in self.parameters() if p.requires_grad)
|
| 208 |
+
la = "" # without lattice features the summary line is the historic one
|
| 209 |
+
if self.lattice is not None:
|
| 210 |
+
la = f" lattice={'+'.join(self.lattice.stats)}(clip={self.lattice.clip:g}, {self.lattice.sides}, per crop, image coords)"
|
| 211 |
return (f"[ZeroShotV6: {self.clip_backbone} layers={self.clip_layers} + patches={self.patch_backbone_name} "
|
| 212 |
+
f"pool={self.crop_pool_mode} crops train/eval={self.n_crops_train}/{self.n_crops_eval} adapter={self.clip_adapter}{la}] "
|
| 213 |
f"total={total:,} trainable={trainable:,} ({100 * trainable / max(1, total):.2f}%) fused_dim={self.fused_dim}")
|