Files
hello_js_reverse_skill/tcaptcha/siamese.py
T
2026-09-11 22:56:03 +08:00

229 lines
7.7 KiB
Python

"""
图标孪生神经网络 (Icon Siamese Network)
用于计算提示图图标与背景候选图标之间的形状相似度(抗背景噪点、抗旋转)
"""
import json
import logging
import random
from pathlib import Path
from typing import List, Tuple
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import torchvision.transforms as transforms
from PIL import Image
from torch.utils.data import DataLoader, Dataset
import sys
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from tcaptcha.slicer import slice_prompt_image
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("siamese")
MODEL_PATH = PROJECT_ROOT / "tcaptcha/models/siamese_matcher.pt"
LABEL_FILE = PROJECT_ROOT / "tcaptcha/data/labels.jsonl"
RAW_DIR = PROJECT_ROOT / "tcaptcha/data/raw"
CROPS_DIR = PROJECT_ROOT / "tcaptcha/data/crops"
class IconSiameseNet(nn.Module):
"""轻量级 CNN 孪生特征提取网络"""
def __init__(self, emb_dim: int = 128):
super().__init__()
self.encoder = nn.Sequential(
nn.Conv2d(3, 32, kernel_size=3, padding=1),
nn.BatchNorm2d(32),
nn.LeakyReLU(0.1, inplace=True),
nn.MaxPool2d(2, 2), # 32x32
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
nn.LeakyReLU(0.1, inplace=True),
nn.MaxPool2d(2, 2), # 16x16
nn.Conv2d(64, 128, kernel_size=3, padding=1),
nn.BatchNorm2d(128),
nn.LeakyReLU(0.1, inplace=True),
nn.MaxPool2d(2, 2), # 8x8
nn.Conv2d(128, 256, kernel_size=3, padding=1),
nn.BatchNorm2d(256),
nn.LeakyReLU(0.1, inplace=True),
nn.AdaptiveAvgPool2d((1, 1)), # 256x1x1
)
self.head = nn.Sequential(
nn.Flatten(),
nn.Linear(256, emb_dim),
)
def forward_once(self, x: torch.Tensor) -> torch.Tensor:
feat = self.encoder(x)
emb = self.head(feat)
return F.normalize(emb, p=2, dim=1)
def forward(self, x1: torch.Tensor, x2: torch.Tensor) -> torch.Tensor:
e1 = self.forward_once(x1)
e2 = self.forward_once(x2)
# 余弦相似度 [-1, 1] 映射到 [0, 1]
cosine = (e1 * e2).sum(dim=1)
return (cosine + 1.0) / 2.0
class IconPairDataset(Dataset):
"""构建 (Prompt图标, Target图块) 正负样本对"""
def __init__(self, pairs: List[Tuple[Image.Image, Image.Image, int]], transform=None):
self.pairs = pairs
self.transform = transform
def __len__(self):
return len(self.pairs)
def __getitem__(self, idx):
img1, img2, label = self.pairs[idx]
if self.transform:
img1 = self.transform(img1)
img2 = self.transform(img2)
return img1, img2, torch.tensor(label, dtype=torch.float32)
def load_pairs_from_labels():
if not LABEL_FILE.exists():
return [], []
with open(LABEL_FILE, "r", encoding="utf-8") as f:
records = [json.loads(line) for line in f if line.strip()]
all_prompts = []
all_targets = []
for r in records:
sid = r["sample_id"]
prompt_path = RAW_DIR / f"{sid}_prompt.png"
if not prompt_path.exists():
continue
try:
p_crops = slice_prompt_image(prompt_path)
for i in range(3):
t_crop_path = CROPS_DIR / f"{sid}_target_{i+1}.png"
if t_crop_path.exists() and i < len(p_crops):
p_img = p_crops[i][0].convert("RGB")
t_img = Image.open(t_crop_path).convert("RGB")
all_prompts.append(p_img)
all_targets.append(t_img)
except Exception as e:
logger.warning("解析样本 %s 异常: %s", sid, e)
return all_prompts, all_targets
def build_augmented_pairs(prompts, targets, num_neg_per_pos: int = 3):
pairs = []
n = len(prompts)
for i in range(n):
# 正样本对 (label = 1)
pairs.append((prompts[i], targets[i], 1))
# 负样本对 (label = 0)
for _ in range(num_neg_per_pos):
neg_idx = random.choice([j for j in range(n) if j != i])
pairs.append((prompts[i], targets[neg_idx], 0))
random.shuffle(pairs)
return pairs
def train_siamese(epochs: int = 25, batch_size: int = 32, lr: float = 0.001):
MODEL_PATH.parent.mkdir(parents=True, exist_ok=True)
device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
logger.info("采用训练加速设备: %s", device)
prompts, targets = load_pairs_from_labels()
logger.info("读取到 %d 组高质量正样本对应关系", len(prompts))
if len(prompts) < 10:
logger.error("标注样本不足,无法训练孪生网络")
return
# 数据集划分 (80% 训练, 20% 验证)
indices = list(range(len(prompts)))
random.shuffle(indices)
split = int(len(prompts) * 0.8)
train_p = [prompts[i] for i in indices[:split]]
train_t = [targets[i] for i in indices[:split]]
val_p = [prompts[i] for i in indices[split:]]
val_t = [targets[i] for i in indices[split:]]
train_pairs = build_augmented_pairs(train_p, train_t, num_neg_per_pos=3)
val_pairs = build_augmented_pairs(val_p, val_t, num_neg_per_pos=3)
train_transform = transforms.Compose([
transforms.Resize((64, 64)),
transforms.RandomRotation(degrees=45), # 强化抗旋转
transforms.ColorJitter(brightness=0.2, contrast=0.2),
transforms.ToTensor(),
transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]),
])
val_transform = transforms.Compose([
transforms.Resize((64, 64)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]),
])
train_loader = DataLoader(IconPairDataset(train_pairs, train_transform), batch_size=batch_size, shuffle=True)
val_loader = DataLoader(IconPairDataset(val_pairs, val_transform), batch_size=batch_size, shuffle=False)
model = IconSiameseNet(emb_dim=128).to(device)
criterion = nn.BCELoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4)
logger.info("开始训练孪生图标比对网络 (Train: %d 对, Val: %d 对)...", len(train_pairs), len(val_pairs))
best_val_acc = 0.0
for epoch in range(1, epochs + 1):
model.train()
train_loss = 0.0
for x1, x2, y in train_loader:
x1, x2, y = x1.to(device), x2.to(device), y.to(device)
optimizer.zero_grad()
pred = model(x1, x2)
loss = criterion(pred, y)
loss.backward()
optimizer.step()
train_loss += loss.item()
# 验证
model.eval()
correct = 0
total = 0
with torch.no_grad():
for x1, x2, y in val_loader:
x1, x2, y = x1.to(device), x2.to(device), y.to(device)
pred = model(x1, x2)
predicted_labels = (pred > 0.5).float()
correct += (predicted_labels == y).sum().item()
total += y.size(0)
val_acc = correct / total if total > 0 else 0
if val_acc > best_val_acc:
best_val_acc = val_acc
torch.save(model.state_dict(), MODEL_PATH)
if epoch % 5 == 0 or epoch == epochs:
logger.info("Epoch [%d/%d] Loss: %.4f | Val Pair Acc: %.2f%% (Best: %.2f%%)", epoch, epochs, train_loss / len(train_loader), val_acc * 100, best_val_acc * 100)
logger.info("✓ 孪生网络训练完成,最佳权重已保存至: %s", MODEL_PATH)
if __name__ == "__main__":
train_siamese(epochs=30)