wonjun12 commited on
Commit
dfd692a
·
verified ·
1 Parent(s): 8a2120c

Release: display score + provenance fix + heatmap spotlight + web bundle with policy pages (a-eye f1f73c2edcd4)

Browse files
Files changed (50) hide show
  1. .gitattributes +0 -10
  2. Dockerfile +2 -0
  3. crop_geometry.py +121 -0
  4. heatmap_nextgen.py +55 -2
  5. lattice_features.py +237 -0
  6. main_hybrid.py +129 -10
  7. provenance_gate.py +9 -2
  8. renderer_trace.py +269 -0
  9. schemas.py +8 -1
  10. score_display.py +100 -0
  11. tests/fixtures/display_regression_golden.json +505 -0
  12. tests/test_display_score.py +364 -0
  13. tests/test_fft_nyquist_parity.py +147 -0
  14. tests/test_heatmap_spotlight.py +169 -0
  15. tests/test_lattice_service.py +284 -0
  16. tests/test_patch_adapter_merge.py +269 -0
  17. tests/test_renderer_trace.py +213 -0
  18. webapp/_expo/static/js/web/{entry-c72d53cd4e22507b95184fe0159c61c3.js → entry-73d73bf1e3d88c923bfb3f7b7cae3866.js} +0 -0
  19. webapp/assets/assets/logo.9add05631c93e895ff3b380bf07ae0b0.png +0 -3
  20. webapp/assets/assets/onboarding/p2_ai.0b15766d558e195bc5587b85c0417bff.webp +0 -0
  21. webapp/assets/assets/onboarding/p2_real.15019b6a906480c5cbafa43036b7531f.webp +0 -0
  22. webapp/assets/assets/onboarding/p3_messenger.6fd165b9e4ba0ce3ef707e5f6722006a.jpg +0 -0
  23. webapp/assets/assets/onboarding/p3_original.91ab13644283dc95349469ea82650133.webp +0 -0
  24. webapp/assets/assets/onboarding/p3_rephoto.1e35d25745130d74193c6b89fe94e651.webp +0 -0
  25. webapp/assets/assets/onboarding/p3_rephoto_bg.1f99f9dd1101bf90539c1fbdf765d72c.webp +0 -0
  26. webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/AntDesign.3f78af31cca60105799838a1a7a59fbd.ttf +0 -3
  27. webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Entypo.31b5ffea3daddc69dd01a1f3d6cf63c5.ttf +0 -0
  28. webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/EvilIcons.140c53a7643ea949007aa9a282153849.ttf +0 -0
  29. webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Feather.ca4b48e04dc1ce10bfbddb262c8b835f.ttf +0 -0
  30. webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome.b06871f281fee6b241d60582ae9369b9.ttf +0 -3
  31. webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome5_Brands.3b89dd103490708d19a95adcae52210e.ttf +0 -3
  32. webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome5_Regular.1f77739ca9ff2188b539c36f30ffa2be.ttf +0 -0
  33. webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome5_Solid.605ed7926cf39a2ad5ec2d1f9d391d3d.ttf +0 -3
  34. webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome6_Brands.56c8d80832e37783f12c05db7c8849e2.ttf +0 -3
  35. webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome6_Regular.370dd5af19f8364907b6e2c41f45dbbf.ttf +0 -0
  36. webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/FontAwesome6_Solid.adec7d6f310bc577f05e8fe06a5daccf.ttf +0 -3
  37. webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Fontisto.b49ae8ab2dbccb02c4d11caaacf09eab.ttf +0 -3
  38. webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Foundation.e20945d7c929279ef7a6f1db184a4470.ttf +0 -0
  39. webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/MaterialCommunityIcons.6e435534bd35da5fef04168860a9b8fa.ttf +0 -3
  40. webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/MaterialIcons.4e85bc9ebe07e0340c9c4fc2f6c38908.ttf +0 -3
  41. webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Octicons.871378c6eab492a3e689a9385dc45a12.ttf +0 -0
  42. webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/SimpleLineIcons.d2285965fe34b05465047401b8595dd0.ttf +0 -0
  43. webapp/assets/node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Zocial.1681f34aaca71b8dfb70756bca331eb2.ttf +0 -0
  44. webapp/index.html +6 -1
  45. webapp/legal.css +151 -0
  46. webapp/privacy-en.html +216 -0
  47. webapp/privacy.html +221 -0
  48. webapp/support.html +98 -0
  49. zero_shot_v4.py +53 -4
  50. 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
- Pure numpy / PIL / scipy. New file; nothing existing is modified.
 
 
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
- _V4_KEYS = ("clip_backbone", "clip_layer", "semantic_dim", "forensic_dim", "frequency_dim", "fft_bins", "image_size", "num_classes")
 
 
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"crops={self.verdict_crops} decode<={self.verdict_decode_box or 'guard default'}")
 
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
- crops = [self.transform_crop(padded.crop((cx, cy, cx + INPUT_SIZE, cy + INPUT_SIZE)))
651
- for cx, cy in multiview.grid_boxes(w, h, INPUT_SIZE, self.verdict_crops)]
652
- return torch.stack(crops).unsqueeze(0).to(self.device)
 
 
 
 
 
 
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(render_overlay(display, loc, conf, blanket=False))
 
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
- "Adobe/Firefly": (b"adobe firefly", b"firefly image", b"firefly", b"xmlns:cai=", b"cai:"),
 
 
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

  • SHA256: 287d21fd40f36d92e80af9741f551183b7535089dedcf26c9a951f4808f9823d
  • Pointer size: 131 Bytes
  • Size of remote file: 685 kB
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-c72d53cd4e22507b95184fe0159c61c3.js" defer></script>
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 &gt; 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>기기에 저장된 분석 기록은 설정 &gt; 기록 전체 삭제, 또는 앱 삭제(웹은 브라우저의 사이트 데이터 삭제)로 언제든 지울 수 있습니다.</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>설정 &gt; 기록 &gt; 기록 전체 삭제를 누르면 기기에 저장된 분석 기록이 모두 지워집니다. 기록 탭에서 한 건을 길게 누르면 그 기록만 지울 수도 있습니다. 앱을 삭제해도 함께 지워집니다. 기록은 서��에 저장되지 않으므로 따로 삭제를 요청할 필요가 없습니다.</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) &gt; 기록 (History) &gt; 기록 전체 삭제 (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
- masks = self._make_radial_masks(self.image_size, self.bins)
 
 
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()).clamp(max=1.0)
 
 
193
  edges = torch.linspace(0.0, 1.0, bins + 1)
194
  masks = []
195
  for idx in range(bins):
196
- mask = ((rr >= edges[idx]) & (rr < edges[idx + 1])).float()
 
 
 
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
- self.artifact_dim = self.patch_dim + self.forensic_dim + self.frequency_dim
 
 
 
 
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) -> pooled artifact vector [B, artifact_dim]."""
 
128
  return self._artifact(crops)[0]
129
 
130
- def _artifact(self, crops: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | None]:
131
- """-> (pooled [B,D], per-crop [B,K,D], per-crop tamper logits [B,K] or None)."""
 
 
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
- artifact_raw, _per_crop, crop_logits = self._artifact(crops)
 
 
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}")