Release original LTF v3.3 epoch 20 inference model
Browse files- demo/README.md +27 -0
- demo/app.py +96 -5
- demo/attention.py +226 -0
- demo/training_reference.json +21 -0
demo/README.md
CHANGED
|
@@ -59,3 +59,30 @@ LTF_MODEL_DIR=../model_repo python app.py
|
|
| 59 |
```
|
| 60 |
|
| 61 |
The web UI and its `predict` API both call the same original-file inference path.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
```
|
| 60 |
|
| 61 |
The web UI and its `predict` API both call the same original-file inference path.
|
| 62 |
+
|
| 63 |
+
## Attention and tile inspection
|
| 64 |
+
|
| 65 |
+
The Detect button additionally requests the model's existing `return_viz`
|
| 66 |
+
outputs during the same forward pass. Temporary observation hooks collect the
|
| 67 |
+
eight L1 across-tile aggregation heads. No model parameters are changed.
|
| 68 |
+
|
| 69 |
+
- Exact, numbered local crop boxes, including edge-aligned overlap and small-image padding.
|
| 70 |
+
- L1 tile-attention overlay: mean or any of the eight aggregation heads.
|
| 71 |
+
- L2 local-view overlay with the global-view attention weight listed separately.
|
| 72 |
+
- Top four local tiles, original crops paired with their 16×16 MIL patch attention.
|
| 73 |
+
- A tile selector for inspecting any local tile without rerunning inference.
|
| 74 |
+
- Exact view-weight table, embedding fusion gate and read-only model/inference parameters.
|
| 75 |
+
- Historical training settings from the original saved configuration, explicitly labeled Training only.
|
| 76 |
+
|
| 77 |
+
Colors use a separate min-max scale for each map (blue→yellow is low→high).
|
| 78 |
+
Overlapping image pixels use the mean of covering tile weights. A patch map is
|
| 79 |
+
normalized within its own tile. Raw normalized view weights are available in
|
| 80 |
+
the table and prediction JSON; colors cannot be compared across images or tiles.
|
| 81 |
+
These are model attention values, not pixel-level forgery probabilities or
|
| 82 |
+
causal explanations. The eight selectable heads belong to the L1 across-tile
|
| 83 |
+
aggregator; L1 patch pooling and L2 view pooling use gated MIL attention.
|
| 84 |
+
|
| 85 |
+
The original five-output `predict` API is preserved. The new `explain` API
|
| 86 |
+
returns prediction results plus visualization images, tables and attention metadata.
|
| 87 |
+
Changing a head or inspected tile uses per-session cached data, retained for up
|
| 88 |
+
to an hour. The unchanged checkpoint is used for both endpoints.
|
demo/app.py
CHANGED
|
@@ -22,6 +22,10 @@ from huggingface_hub import snapshot_download
|
|
| 22 |
from PIL import Image, UnidentifiedImageError
|
| 23 |
import torch
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
LOG = logging.getLogger(__name__)
|
| 26 |
MAX_PIXELS = int(os.environ.get('LTF_MAX_PIXELS', '16000000'))
|
| 27 |
MAX_VIEWS = int(os.environ.get('LTF_MAX_VIEWS', '400'))
|
|
@@ -108,7 +112,7 @@ def get_detector():
|
|
| 108 |
return _detector
|
| 109 |
|
| 110 |
|
| 111 |
-
def
|
| 112 |
source, width, height, views = validate_upload(image_path)
|
| 113 |
if branch not in ('fusion', 'level1', 'level2'):
|
| 114 |
raise gr.Error('Unknown model branch.')
|
|
@@ -118,7 +122,14 @@ def predict(image_path, branch='fusion', threshold=0.5):
|
|
| 118 |
try:
|
| 119 |
with MODEL_LOCK:
|
| 120 |
detector = get_detector()
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
except torch.cuda.OutOfMemoryError:
|
| 123 |
torch.cuda.empty_cache()
|
| 124 |
raise gr.Error('GPU memory is insufficient. Please try a smaller image.') from None
|
|
@@ -144,7 +155,42 @@ def predict(image_path, branch='fusion', threshold=0.5):
|
|
| 144 |
rows = [[name, result['probabilities'][name]['real'],
|
| 145 |
result['probabilities'][name]['fake']]
|
| 146 |
for name in ('fusion', 'level1', 'level2')]
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
|
| 150 |
def build_demo():
|
|
@@ -170,8 +216,53 @@ def build_demo():
|
|
| 170 |
table = gr.Dataframe(headers=['Branch', 'Real score', 'AI-generated score'],
|
| 171 |
datatype=['str', 'number', 'number'], interactive=False)
|
| 172 |
details = gr.JSON(label='Prediction')
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
gr.Markdown('分数是模型输出,不能单独作为图片真伪的证明。'
|
| 176 |
' / Model scores are not calibrated probabilities or proof of authenticity.')
|
| 177 |
return demo.queue(max_size=16, default_concurrency_limit=1)
|
|
|
|
| 22 |
from PIL import Image, UnidentifiedImageError
|
| 23 |
import torch
|
| 24 |
|
| 25 |
+
sys.path.insert(0, str(Path(__file__).absolute().parent))
|
| 26 |
+
from attention import (capture_prediction, draw_grid, head_view, parameter_rows,
|
| 27 |
+
patch_view, public_attention, tile_boxes, tile_overlay, top_tiles)
|
| 28 |
+
|
| 29 |
LOG = logging.getLogger(__name__)
|
| 30 |
MAX_PIXELS = int(os.environ.get('LTF_MAX_PIXELS', '16000000'))
|
| 31 |
MAX_VIEWS = int(os.environ.get('LTF_MAX_VIEWS', '400'))
|
|
|
|
| 112 |
return _detector
|
| 113 |
|
| 114 |
|
| 115 |
+
def _predict_request(image_path, branch='fusion', threshold=0.5, with_attention=False):
|
| 116 |
source, width, height, views = validate_upload(image_path)
|
| 117 |
if branch not in ('fusion', 'level1', 'level2'):
|
| 118 |
raise gr.Error('Unknown model branch.')
|
|
|
|
| 122 |
try:
|
| 123 |
with MODEL_LOCK:
|
| 124 |
detector = get_detector()
|
| 125 |
+
if with_attention:
|
| 126 |
+
result, attention = capture_prediction(detector, source, branch, float(threshold))
|
| 127 |
+
state = dict(path=str(source), attention=attention,
|
| 128 |
+
boxes=tile_boxes(width, height, int(detector.model.input_resolution)),
|
| 129 |
+
parameters=parameter_rows(detector, branch, float(threshold), views))
|
| 130 |
+
else:
|
| 131 |
+
result = detector.predict(source, branch, float(threshold))
|
| 132 |
+
state = None
|
| 133 |
except torch.cuda.OutOfMemoryError:
|
| 134 |
torch.cuda.empty_cache()
|
| 135 |
raise gr.Error('GPU memory is insufficient. Please try a smaller image.') from None
|
|
|
|
| 155 |
rows = [[name, result['probabilities'][name]['real'],
|
| 156 |
result['probabilities'][name]['fake']]
|
| 157 |
for name in ('fusion', 'level1', 'level2')]
|
| 158 |
+
if state:
|
| 159 |
+
result['attention'] = public_attention(state)
|
| 160 |
+
return (preview, summary, {'Real': scores['real'], 'AI-generated': scores['fake']}, rows, result), state
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def predict(image_path, branch='fusion', threshold=0.5):
|
| 164 |
+
"""Keep the original five-output public prediction API compatible."""
|
| 165 |
+
return _predict_request(image_path, branch, threshold)[0]
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def explain(image_path, branch='fusion', threshold=0.5):
|
| 169 |
+
base, state = _predict_request(image_path, branch, threshold, with_attention=True)
|
| 170 |
+
attention = state['attention']
|
| 171 |
+
count = len(state['boxes'])
|
| 172 |
+
if len(attention['l1']) != count or len(attention['l2']) != count + 1:
|
| 173 |
+
raise gr.Error('Attention and original image tile counts disagree.')
|
| 174 |
+
grid = draw_grid(state['path'], state['boxes'])
|
| 175 |
+
l1_image, l1_description = head_view(state, 'mean')
|
| 176 |
+
l2_image = tile_overlay(state['path'], state['boxes'], attention['l2'][1:])
|
| 177 |
+
description = (f'**L2 global-view weight: {attention["l2"][0]:.6f}** · '
|
| 178 |
+
f'local-view weights sum: {float(attention["l2"][1:].sum()):.6f}.\n\n'
|
| 179 |
+
'L2 图仅着色局部分块;全局权重单列,不铺满整张图。'
|
| 180 |
+
' / The L2 overlay shows local views only; the global weight is listed separately.\n\n'
|
| 181 |
+
f'Fusion embedding gate: L1 × **{attention["gate"]:.4f}**, '
|
| 182 |
+
f'L2 × **{1 - attention["gate"]:.4f}**. '
|
| 183 |
+
'这是特征缩放系数,不是分支概率 / These scale embeddings, not branch probabilities.')
|
| 184 |
+
weight_rows = [['Global', 'whole image', None, float(attention['l2'][0])]]
|
| 185 |
+
weight_rows.extend([f'T{i + 1}', str(list(box)), float(attention['l1'][i]), float(attention['l2'][i + 1])]
|
| 186 |
+
for i, box in enumerate(state['boxes']))
|
| 187 |
+
selected = int(attention['l1'].argmax()) + 1
|
| 188 |
+
tile, patch, patch_description = patch_view(state, str(selected))
|
| 189 |
+
return (*base, grid, l1_image, l1_description, l2_image, description, top_tiles(state),
|
| 190 |
+
state['parameters'], weight_rows, state,
|
| 191 |
+
gr.Dropdown(choices=[str(i + 1) for i in range(count)], value=str(selected)),
|
| 192 |
+
tile, patch, patch_description,
|
| 193 |
+
gr.Dropdown(choices=['mean'] + [f'head_{i + 1}' for i in range(attention['l1_heads'].shape[0])], value='mean'))
|
| 194 |
|
| 195 |
|
| 196 |
def build_demo():
|
|
|
|
| 216 |
table = gr.Dataframe(headers=['Branch', 'Real score', 'AI-generated score'],
|
| 217 |
datatype=['str', 'number', 'number'], interactive=False)
|
| 218 |
details = gr.JSON(label='Prediction')
|
| 219 |
+
state = gr.State(value=None, time_to_live=3600)
|
| 220 |
+
with gr.Accordion('分块与 MIL Attention / Tiles and model attention', open=True):
|
| 221 |
+
gr.Markdown('以下权重直接来自本次模型前向计算。Attention 是相对关注程度,'
|
| 222 |
+
'不能直接作为伪造区域或因果解释。'
|
| 223 |
+
' / These are actual forward-pass weights, not forgery probabilities or causal explanations.')
|
| 224 |
+
with gr.Tabs():
|
| 225 |
+
with gr.Tab('原图分块 / Tile grid'):
|
| 226 |
+
grid = gr.Image(label='224×224 local tiles · T1…Tn · edge-aligned', interactive=False)
|
| 227 |
+
gr.Markdown('分块编号按行排列,边缘对齐时允许重叠。全局视图独立于局部分块。'
|
| 228 |
+
' / Row-major IDs; edge-aligned tiles may overlap. The global view is separate.')
|
| 229 |
+
with gr.Tab('L1 分块注意力'):
|
| 230 |
+
head = gr.Dropdown(['mean'], value='mean', label='Across-tile aggregation head')
|
| 231 |
+
l1_image = gr.Image(label='L1 tile attention', interactive=False)
|
| 232 |
+
l1_description = gr.Markdown()
|
| 233 |
+
with gr.Tab('L2 视图注意力'):
|
| 234 |
+
l2_image = gr.Image(label='L2 local-view attention', interactive=False)
|
| 235 |
+
attention_description = gr.Markdown()
|
| 236 |
+
weight_table = gr.Dataframe(headers=['View', 'Original crop box', 'L1 weight', 'L2 weight'],
|
| 237 |
+
datatype=['str', 'str', 'number', 'number'], interactive=False)
|
| 238 |
+
with gr.Tab('Top-K 与块内 Patch Attention'):
|
| 239 |
+
gallery = gr.Gallery(label='Top 4 L1 tiles: original + within-tile patch attention', columns=4,
|
| 240 |
+
object_fit='contain', height='auto')
|
| 241 |
+
tile_number = gr.Dropdown(choices=[], label='Inspect local tile / 分块编号', allow_custom_value=False)
|
| 242 |
+
with gr.Row():
|
| 243 |
+
selected_tile = gr.Image(label='Original 224×224 crop', interactive=False)
|
| 244 |
+
selected_patch = gr.Image(label='MIL patch attention (16×16)', interactive=False)
|
| 245 |
+
patch_description = gr.Markdown()
|
| 246 |
+
gr.Markdown('热图分别归一化:蓝色低、黄色高,不能跨图片按颜色比较。'
|
| 247 |
+
'重叠像素显示覆盖分块的平均权重,精确权重见表格。'
|
| 248 |
+
' / Heatmaps use separate min-max scales; overlap uses mean weights.')
|
| 249 |
+
with gr.Accordion('模型与超参数 / Model and hyperparameters (read only)', open=False):
|
| 250 |
+
parameters = gr.Dataframe(headers=['Scope', 'Parameter', 'Value'],
|
| 251 |
+
datatype=['str', 'str', 'str'], interactive=False)
|
| 252 |
+
gr.Markdown('Training only 是历史训练配置;Cross-shuffle、随机增强和 dropout '
|
| 253 |
+
'在本次推理中不启用。Head 数量与网络结构随权重固定。')
|
| 254 |
+
button.click(explain, [upload, branch, threshold],
|
| 255 |
+
[preview, summary, scores, table, details, grid, l1_image, l1_description,
|
| 256 |
+
l2_image, attention_description, gallery, parameters, weight_table, state,
|
| 257 |
+
tile_number, selected_tile, selected_patch, patch_description, head],
|
| 258 |
+
api_name='explain', concurrency_limit=1, concurrency_id='ltf-inference')
|
| 259 |
+
head.change(head_view, [state, head], [l1_image, l1_description], api_name=False)
|
| 260 |
+
tile_number.change(patch_view, [state, tile_number],
|
| 261 |
+
[selected_tile, selected_patch, patch_description], api_name=False)
|
| 262 |
+
# Preserve the original prediction endpoint for existing API clients.
|
| 263 |
+
api_button = gr.Button(visible=False)
|
| 264 |
+
api_button.click(predict, [upload, branch, threshold], [preview, summary, scores, table, details],
|
| 265 |
+
api_name='predict', concurrency_limit=1, concurrency_id='ltf-inference')
|
| 266 |
gr.Markdown('分数是模型输出,不能单独作为图片真伪的证明。'
|
| 267 |
' / Model scores are not calibrated probabilities or proof of authenticity.')
|
| 268 |
return demo.queue(max_size=16, default_concurrency_limit=1)
|
demo/attention.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Read actual LTF MIL attention and render its original-image coordinates."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import math
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def capture_prediction(detector, path, branch, threshold):
|
| 13 |
+
"""Observe one ordinary prediction; temporary hooks are always removed.
|
| 14 |
+
|
| 15 |
+
Call under the app's model lock. return_viz only appends existing model
|
| 16 |
+
tensors to the output; it does not change logits or rerun the backbone.
|
| 17 |
+
"""
|
| 18 |
+
captured = {}
|
| 19 |
+
|
| 20 |
+
def enable_viz(module, args, kwargs):
|
| 21 |
+
return args, dict(kwargs, return_viz=True)
|
| 22 |
+
|
| 23 |
+
def collect(module, args, output):
|
| 24 |
+
viz = output['viz']
|
| 25 |
+
for key in ('tile_attn', 'level1_patch_attn', 'level2_attn', 'gate'):
|
| 26 |
+
captured[key] = viz[key].detach().float().cpu().numpy()
|
| 27 |
+
|
| 28 |
+
def collect_heads(module, args, output):
|
| 29 |
+
captured['head_attn'] = output[1][0, :, 0, :].detach().float().cpu().numpy()
|
| 30 |
+
|
| 31 |
+
handles = []
|
| 32 |
+
try:
|
| 33 |
+
handles.append(detector.model.register_forward_pre_hook(enable_viz, with_kwargs=True))
|
| 34 |
+
handles.append(detector.model.register_forward_hook(collect))
|
| 35 |
+
handles.append(detector.model.core.level1_aggregate_attn.register_forward_hook(collect_heads))
|
| 36 |
+
result = detector.predict(path, branch, threshold)
|
| 37 |
+
finally:
|
| 38 |
+
for handle in handles:
|
| 39 |
+
handle.remove()
|
| 40 |
+
nt = result['views'] - 1
|
| 41 |
+
patches = captured['level1_patch_attn'].reshape(nt, -1)
|
| 42 |
+
side = math.isqrt(patches.shape[1])
|
| 43 |
+
if side * side != patches.shape[1]:
|
| 44 |
+
raise ValueError('Patch attention does not form a square grid.')
|
| 45 |
+
attention = dict(
|
| 46 |
+
l1=captured['tile_attn'].reshape(nt),
|
| 47 |
+
l1_heads=captured['head_attn'],
|
| 48 |
+
l2=captured['level2_attn'].reshape(nt + 1),
|
| 49 |
+
patch=patches.reshape(nt, side, side),
|
| 50 |
+
gate=float(captured['gate'].reshape(-1)[0]),
|
| 51 |
+
)
|
| 52 |
+
for key in ('l1', 'l2', 'patch', 'l1_heads'):
|
| 53 |
+
weights = attention[key]
|
| 54 |
+
if not np.isfinite(weights).all() or (weights < 0).any():
|
| 55 |
+
raise ValueError('Attention contains invalid values.')
|
| 56 |
+
return result, attention
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def tile_boxes(width, height, tile_size=224):
|
| 60 |
+
# Use the exact same edge-aligned coordinates and row-major order as inference.
|
| 61 |
+
from utils.tile_dataset_srm import get_start_positions
|
| 62 |
+
return [(x, y, x + tile_size, y + tile_size)
|
| 63 |
+
for y in get_start_positions(height, tile_size)
|
| 64 |
+
for x in get_start_positions(width, tile_size)]
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def relative_scale(values):
|
| 68 |
+
values = np.asarray(values, dtype=np.float32)
|
| 69 |
+
low, high = float(values.min()), float(values.max())
|
| 70 |
+
if high - low < 1e-12:
|
| 71 |
+
return np.full_like(values, 0.5)
|
| 72 |
+
return (values - low) / (high - low)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def colors(values):
|
| 76 |
+
"""Continuous dark-blue → cyan → yellow scale; no probability semantics."""
|
| 77 |
+
x = np.clip(np.asarray(values), 0, 1)
|
| 78 |
+
stops = np.array([[35, 47, 110], [31, 164, 172], [253, 225, 77]], dtype=np.float32)
|
| 79 |
+
first = np.minimum((x * 2).astype(int), 1)
|
| 80 |
+
fraction = (x * 2 - first)[..., None]
|
| 81 |
+
return (stops[first] * (1 - fraction) + stops[first + 1] * fraction).astype(np.uint8)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def preview_image(path):
|
| 85 |
+
with Image.open(path) as source:
|
| 86 |
+
image = source.convert('RGB')
|
| 87 |
+
image.thumbnail((1200, 1200))
|
| 88 |
+
return image
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def draw_grid(path, boxes):
|
| 92 |
+
image = preview_image(path)
|
| 93 |
+
with Image.open(path) as original:
|
| 94 |
+
sx, sy = image.width / original.width, image.height / original.height
|
| 95 |
+
draw = ImageDraw.Draw(image)
|
| 96 |
+
font = ImageFont.load_default(size=14)
|
| 97 |
+
labels = []
|
| 98 |
+
for index, (x0, y0, x1, y1) in enumerate(boxes, 1):
|
| 99 |
+
rect = (int(x0 * sx), int(y0 * sy), min(image.width - 1, int(x1 * sx) - 1),
|
| 100 |
+
min(image.height - 1, int(y1 * sy) - 1))
|
| 101 |
+
draw.rectangle(rect, outline=(255, 212, 74), width=2)
|
| 102 |
+
label = f'T{index}'
|
| 103 |
+
left, top = rect[0] + 3, rect[1] + 3
|
| 104 |
+
# Edge alignment can place two rows just one pixel apart. Keep their
|
| 105 |
+
# exact crop borders while moving text labels to avoid hiding earlier IDs.
|
| 106 |
+
for candidate_top in range(top, max(top + 1, rect[3] - 15), 18):
|
| 107 |
+
extent = draw.textbbox((left, candidate_top), label, font=font)
|
| 108 |
+
if not any(left < old[2] + 3 and extent[2] + 3 > old[0]
|
| 109 |
+
and candidate_top < old[3] + 3 and extent[3] + 3 > old[1]
|
| 110 |
+
for old in labels):
|
| 111 |
+
top = candidate_top
|
| 112 |
+
break
|
| 113 |
+
extent = draw.textbbox((left, top), label, font=font)
|
| 114 |
+
labels.append((left, top, extent[2], extent[3]))
|
| 115 |
+
draw.rectangle((left - 1, top - 1, extent[2] + 2, extent[3] + 2), fill=(18, 28, 45))
|
| 116 |
+
draw.text((left, top), label, fill='white', font=font)
|
| 117 |
+
return image
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def tile_overlay(path, boxes, weights):
|
| 121 |
+
image = preview_image(path)
|
| 122 |
+
with Image.open(path) as original:
|
| 123 |
+
sx, sy = image.width / original.width, image.height / original.height
|
| 124 |
+
summed = np.zeros((image.height, image.width), dtype=np.float32)
|
| 125 |
+
coverage = np.zeros_like(summed)
|
| 126 |
+
for box, weight in zip(boxes, weights, strict=True):
|
| 127 |
+
x0, y0, x1, y1 = box
|
| 128 |
+
x0, y0 = int(x0 * sx), int(y0 * sy)
|
| 129 |
+
x1, y1 = min(image.width, math.ceil(x1 * sx)), min(image.height, math.ceil(y1 * sy))
|
| 130 |
+
summed[y0:y1, x0:x1] += float(weight)
|
| 131 |
+
coverage[y0:y1, x0:x1] += 1
|
| 132 |
+
averaged = summed / np.maximum(coverage, 1)
|
| 133 |
+
heat = Image.fromarray(colors(relative_scale(averaged)))
|
| 134 |
+
return Image.blend(image, heat, 0.48)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def patch_view(state, tile_number):
|
| 138 |
+
if not state:
|
| 139 |
+
return None, None, '请先检测图片 / Run detection first.'
|
| 140 |
+
index = int(tile_number) - 1
|
| 141 |
+
if index < 0 or index >= len(state['boxes']):
|
| 142 |
+
raise ValueError('Tile number is out of range.')
|
| 143 |
+
box = state['boxes'][index]
|
| 144 |
+
with Image.open(state['path']) as source:
|
| 145 |
+
tile = source.convert('RGB').crop(box)
|
| 146 |
+
weights = state['attention']['patch'][index]
|
| 147 |
+
heat = Image.fromarray(colors(relative_scale(weights))).resize(tile.size, Image.Resampling.NEAREST)
|
| 148 |
+
overlay = Image.blend(tile, heat, 0.48)
|
| 149 |
+
description = (f'T{index + 1} · original box {list(box)} · patch grid '
|
| 150 |
+
f'{weights.shape[0]} × {weights.shape[1]} · '
|
| 151 |
+
f'patch weights sum = {float(weights.sum()):.6f}. '
|
| 152 |
+
'颜色在当前块内归一化;不能按颜色跨块比较。'
|
| 153 |
+
' / Colors are scaled within this tile only. '
|
| 154 |
+
'Small images retain the model’s original black padding.')
|
| 155 |
+
return tile, overlay, description
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def top_tiles(state, k=4):
|
| 159 |
+
order = np.argsort(-state['attention']['l1'], kind='stable')[:k]
|
| 160 |
+
gallery = []
|
| 161 |
+
for index in order:
|
| 162 |
+
tile, overlay, _ = patch_view(state, int(index) + 1)
|
| 163 |
+
weight = float(state['attention']['l1'][index])
|
| 164 |
+
gallery.extend([(tile, f'T{index + 1} · L1 weight {weight:.6f}'),
|
| 165 |
+
(overlay, f'T{index + 1} · within-tile patch attention')])
|
| 166 |
+
return gallery
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def head_view(state, selection):
|
| 170 |
+
if not state:
|
| 171 |
+
return None, '请先检测图片 / Run detection first.'
|
| 172 |
+
if selection == 'mean':
|
| 173 |
+
weights = state['attention']['l1']
|
| 174 |
+
label = 'Mean across L1 aggregation heads'
|
| 175 |
+
else:
|
| 176 |
+
index = int(selection.split('_')[-1]) - 1
|
| 177 |
+
weights = state['attention']['l1_heads'][index]
|
| 178 |
+
label = f'L1 aggregation head {index + 1}'
|
| 179 |
+
return tile_overlay(state['path'], state['boxes'], weights), (
|
| 180 |
+
f'{label} · sum = {float(weights.sum()):.6f}. '
|
| 181 |
+
'蓝→黄表示本图内相对权重低→高;重叠区域取覆盖分块权重的平均值。'
|
| 182 |
+
' / Blue→yellow: lower→higher within this image; overlap uses mean weight. '
|
| 183 |
+
'精确权重见表格 / Exact weights are in the table.')
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def parameter_rows(detector, branch, threshold, views):
|
| 187 |
+
core, visual = detector.model.core, detector.model.visual
|
| 188 |
+
mil1, mil2 = core.mil_level1.head.head, core.mil_level2.head.head
|
| 189 |
+
rows = [
|
| 190 |
+
['Inference', 'Branch / threshold', f'{branch} / {threshold:.2f}'],
|
| 191 |
+
['Inference', 'Precision / device', f'{visual.conv1.weight.dtype} / {detector.device}'],
|
| 192 |
+
['Inference', 'CLIP chunk size', str(detector.model.eval_chunk_size)],
|
| 193 |
+
['Inference', 'Views: global + local', f'1 + {views - 1} = {views}'],
|
| 194 |
+
['Architecture', 'Backbone', 'Frozen CLIP ViT-L/14'],
|
| 195 |
+
['Architecture', 'Local tile size', f'{int(detector.model.input_resolution)} × {int(detector.model.input_resolution)}'],
|
| 196 |
+
['Architecture', 'CLIP patch size', str(visual.conv1.kernel_size[0])],
|
| 197 |
+
['Architecture', 'Feature dimension', str(detector.model.embed_dim)],
|
| 198 |
+
['Architecture', 'L1 across-tile aggregation heads', str(core.level1_aggregate_attn.num_heads)],
|
| 199 |
+
['Architecture', 'L1 MIL pooling / hidden', f'{mil1.pooling} / {mil1.attn_V.out_features} ({mil1.attn_bag_mode})'],
|
| 200 |
+
['Architecture', 'L2 MIL pooling / hidden', f'{mil2.pooling} / {mil2.attn_V.out_features} ({mil2.attn_bag_mode})'],
|
| 201 |
+
['Architecture', 'Fusion / gate hidden', f'pool-logit gated / {core.pool_gate[0].out_features}'],
|
| 202 |
+
['Architecture', 'Fusion MLP hidden', str(core.fusion_head[0].out_features)],
|
| 203 |
+
['Inference', 'Cross-shuffle / dropout', 'Disabled in evaluation mode'],
|
| 204 |
+
]
|
| 205 |
+
reference = Path(__file__).with_name('training_reference.json')
|
| 206 |
+
if reference.is_file():
|
| 207 |
+
training = json.loads(reference.read_text())
|
| 208 |
+
rows.extend(['Training only', key, json.dumps(value, ensure_ascii=False)]
|
| 209 |
+
for key, value in training.items())
|
| 210 |
+
return rows
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def public_attention(state):
|
| 214 |
+
weights = state['attention']
|
| 215 |
+
return dict(
|
| 216 |
+
meaning='Relative model attention, not a forgery probability or causal explanation.',
|
| 217 |
+
display='Each heatmap is min-max scaled independently; overlap uses mean tile weight.',
|
| 218 |
+
local_tile_boxes=[list(box) for box in state['boxes']],
|
| 219 |
+
l1_tile_weights=weights['l1'].tolist(),
|
| 220 |
+
l1_aggregation_head_weights=weights['l1_heads'].tolist(),
|
| 221 |
+
l2_view_weights=weights['l2'].tolist(),
|
| 222 |
+
l2_view_order=['global'] + [f'T{i + 1}' for i in range(len(state['boxes']))],
|
| 223 |
+
patches_per_tile=list(weights['patch'].shape[1:]),
|
| 224 |
+
fusion_embedding_gate_l1=weights['gate'],
|
| 225 |
+
fusion_embedding_gate_l2=1 - weights['gate'],
|
| 226 |
+
)
|
demo/training_reference.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"seed": 16,
|
| 3 |
+
"epochs": 20,
|
| 4 |
+
"batch_size": 64,
|
| 5 |
+
"learning_rate": 0.0004,
|
| 6 |
+
"weight_decay": 0.05,
|
| 7 |
+
"dropout": 0.1,
|
| 8 |
+
"default_train_tiles": 16,
|
| 9 |
+
"train_set_size": 50000,
|
| 10 |
+
"v2_l1_cross_shuffle_p": 0.9,
|
| 11 |
+
"v2_l1_replace_ratio_range": [
|
| 12 |
+
0.5,
|
| 13 |
+
0.9
|
| 14 |
+
],
|
| 15 |
+
"v2_l2_cross_shuffle_p": 0.9,
|
| 16 |
+
"v2_l2_replace_ratio_range": [
|
| 17 |
+
0.1,
|
| 18 |
+
0.8
|
| 19 |
+
],
|
| 20 |
+
"v2_bag_token_shuffle_p": 0.5
|
| 21 |
+
}
|