#!/usr/bin/env python """Easy4K SFDP Siamese NET AI pipeline (compact).""" import argparse, csv, hashlib, http.cookiejar, json, math, os, random, re, subprocess, sys, time import urllib.error, urllib.parse, urllib.request from functools import lru_cache import cv2, numpy as np, torch, torch.nn as nn from torch.utils.data import DataLoader, Dataset, Sampler class _Tee: """把 stdout/stderr 同时写到一份可由外部读取的实时日志。""" def __init__(self, *streams): self.streams = streams def write(self, s): for st in self.streams: try: st.write(s) except Exception: pass return len(s) def flush(self): for st in self.streams: try: st.flush() except Exception: pass _live = os.environ.get("SFDP_LIVE_LOG") if _live: try: _lf = open(_live, "a", encoding="utf-8", buffering=1) sys.stdout = _Tee(sys.stdout, _lf) sys.stderr = _Tee(sys.stderr, _lf) except Exception: pass DATA = r"D:\sfdp-ai\siamese\data" TILES = os.environ.get("SFDP_TILES") or os.path.join(DATA, "tiles") LABELS = os.path.join(DATA, "labels") TEACHER = r"D:\sfdp-ai\bin\SfdpTeacher.exe" RAW = os.path.join(DATA, "raw") BILI_RAW = os.path.join(DATA, "raw_bili") TEST_TILES = os.path.join(DATA, "tiles_test") TEST_LABELS = os.path.join(DATA, "labels_test") class DSConv(nn.Module): def __init__(self, cin, cout, stride=1): super().__init__() self.dw = nn.Conv2d(cin, cin, 3, stride, 1, groups=cin, bias=False) self.bn1 = nn.BatchNorm2d(cin) self.pw = nn.Conv2d(cin, cout, 1, bias=False) self.bn2 = nn.BatchNorm2d(cout) self.act = nn.ReLU6(inplace=True) def forward(self, x): x = self.act(self.bn1(self.dw(x))) return self.act(self.bn2(self.pw(x))) class Encoder(nn.Module): def __init__(self, emb=512): super().__init__() self.stem = nn.Sequential(nn.Conv2d(1, 32, 3, 2, 1, bias=False), nn.BatchNorm2d(32), nn.ReLU6(inplace=True)) self.blocks = nn.Sequential( DSConv(32, 48, 2), DSConv(48, 48), DSConv(48, 96, 2), DSConv(96, 96), DSConv(96, 160, 2), DSConv(160, 160), DSConv(160, 224, 2), DSConv(224, 224), DSConv(224, 320, 2), DSConv(320, 320)) self.proj = nn.Conv2d(320, emb, 1, bias=False) self.bn = nn.BatchNorm2d(emb) self.act = nn.ReLU6(inplace=True) def forward(self, x): x = self.act(self.bn(self.proj(self.blocks(self.stem(x))))) return x.mean(dim=(2, 3)) class SiameseSFDP(nn.Module): def __init__(self, emb=512, hidden=1024): super().__init__() self.encoder = Encoder(emb) self.head = nn.Sequential(nn.Linear(emb * 4, hidden), nn.ReLU(inplace=True), nn.Dropout(0.1), nn.Linear(hidden, 1), nn.Sigmoid()) def forward(self, a, b): ea = self.encoder(a); eb = self.encoder(b) return self.head(torch.cat([ea, eb, (ea - eb).abs(), ea * eb], dim=1)) def count_params(m): return sum(p.numel() for p in m.parameters()) @lru_cache(maxsize=4096) def load_gray(p): img = cv2.imread(p, cv2.IMREAD_GRAYSCALE) if img is None: raise FileNotFoundError(p) return img _GRAY_CACHE = {} def load_gray_cached(path): """同一切片会被同片段的多个样本反复读取:按 worker 进程做一个小 LRU, 省掉重复 PNG 解码(320x320 约 100KB,512 条 ≈ 50MB/worker)。""" a = _GRAY_CACHE.get(path) if a is not None: return a try: a = load_gray(path) except FileNotFoundError: # 切片可能正被搬运/重写:短暂重试,实在没有就返回黑图,绝不让整轮训练崩掉 a = None for _ in range(3): time.sleep(0.2) try: a = load_gray(path) break except FileNotFoundError: continue if a is None: a = np.zeros((320, 320), np.uint8) if len(_GRAY_CACHE) >= 512: _GRAY_CACHE.clear() _GRAY_CACHE[path] = a return a FALLBACK_TILES = os.path.join(DATA, "tiles") def _resolve_tile(root, rel): """Hot tiles (SSD) may still be copying: fall back to the original tiles dir.""" p = os.path.join(root, rel) if os.path.exists(p): return p fb = os.path.join(FALLBACK_TILES, rel) return fb if os.path.exists(fb) else p class PairDataset(Dataset): def __init__(self, manifest, tiles_root, crop=128, augment=False): self.items = [json.loads(l) for l in open(manifest, "r", encoding="utf-8") if l.strip()] self.tiles_root = tiles_root; self.crop = crop; self.augment = augment def __len__(self): return len(self.items) def __getitem__(self, i): it = self.items[i] a = load_gray_cached(_resolve_tile(self.tiles_root, it["a"])) b = load_gray_cached(_resolve_tile(self.tiles_root, it["b"])) c = self.crop; h, w = a.shape x = min(max(0, it["x"]), w - c); y = min(max(0, it["y"]), h - c) pa, pb = a[y:y + c, x:x + c], b[y:y + c, x:x + c] if self.augment: if np.random.rand() < 0.5: pa, pb = pa[:, ::-1], pb[:, ::-1] if np.random.rand() < 0.3: g = 0.8 + np.random.rand() * 0.45 pa = np.clip((pa / 255.0) ** g * 255.0, 0, 255).astype(np.uint8) pb = np.clip((pb / 255.0) ** g * 255.0, 0, 255).astype(np.uint8) ta = torch.from_numpy(np.ascontiguousarray(pa)).float().div_(255.0).unsqueeze(0) tb = torch.from_numpy(np.ascontiguousarray(pb)).float().div_(255.0).unsqueeze(0) return (ta, tb, torch.tensor([it["soft"]], dtype=torch.float32), torch.tensor([it["hard"]], dtype=torch.float32)) # ---------------- bilibili ---------------- BUA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0 Safari/537.36") BILI_META = os.path.join(BILI_RAW, "sources_bili.json") _jar = http.cookiejar.CookieJar() _op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(_jar)) _op.addheaders = [("User-Agent", BUA), ("Referer", "https://www.bilibili.com/")] def bjson(url, params=None): full = url + ("?" + urllib.parse.urlencode(params) if params else "") last = None for i in range(3): try: with _op.open(full, timeout=25) as r: return json.loads(r.read().decode("utf-8")) except urllib.error.HTTPError as e: if e.code == 412: raise last = e; time.sleep(2 + i * 2) except Exception as e: last = e; time.sleep(2 + i * 2) raise last def bview(bvid): d = bjson("https://api.bilibili.com/x/web-interface/view", {"bvid": bvid}) return (d.get("data") or None) if d.get("code") == 0 else None def bplay(bvid, cid, qn): d = bjson("https://api.bilibili.com/x/player/playurl", {"bvid": bvid, "cid": cid, "qn": qn, "fnval": 0, "fourk": 0}) if d.get("code") != 0: return None dd = d.get("data") or {}; durl = dd.get("durl") or [] return {"quality": dd.get("quality"), "url": durl[0].get("url")} if durl else None BILI_MAX_BPS = 6_000_000 # 下载限速 ~48 Mbps(千兆宽带留给家里其他人) def bdownload(url, dst): tmp = dst + ".part" req = urllib.request.Request(url, headers={"User-Agent": BUA, "Referer": "https://www.bilibili.com/"}) for _ in range(3): try: with urllib.request.urlopen(req, timeout=30) as r, open(tmp, "wb") as f: t0 = time.time() got = 0 while True: ch = r.read(262144) if not ch: break f.write(ch) got += len(ch) # 令牌桶式限速:按已下载字节应花的时间补 sleep expected = got / BILI_MAX_BPS elapsed = time.time() - t0 if expected > elapsed: time.sleep(expected - elapsed) os.replace(tmp, dst); return True except Exception: if os.path.exists(tmp): os.remove(tmp) time.sleep(3) return False def cmd_rank(args): root = getattr(args, "outdir", None) or BILI_RAW meta_path = os.path.join(root, "sources_bili.json") os.makedirs(root, exist_ok=True) try: _op.open("https://www.bilibili.com/", timeout=20).read(2048) except Exception: pass meta = json.load(open(meta_path, "r", encoding="utf-8")) if os.path.exists(meta_path) else [] done = {m["bvid"] for m in meta} seen, cands = set(), [] def add(items): for it in items: b = it.get("bvid"); dur = int(it.get("duration") or 0); title = it.get("title", "") if not b or b in seen or b in done or not (args.min_sec <= dur <= args.max_sec): continue if any(x in title for x in ("直播", "回放", "合集", "教程", "reaction", "解说")): continue seen.add(b) cands.append({"bvid": b, "title": title, "author": (it.get("owner") or {}).get("name", ""), "duration": dur}) for rid in (3, 129, 1, 181, 71, 160, 4): try: d = bjson("https://api.bilibili.com/x/web-interface/ranking/v2", {"rid": rid}) add((d.get("data") or {}).get("list") or []) except Exception as e: print("RANK_FAIL", rid, e, flush=True) time.sleep(1.2) for pn in range(1, args.pages + 1): try: d = bjson("https://api.bilibili.com/x/web-interface/popular", {"ps": 20, "pn": pn}) add((d.get("data") or {}).get("list") or []) except Exception as e: print("POPULAR_FAIL", pn, e, flush=True) time.sleep(1.2) for rid in (3, 129, 1, 181): for pn in range(1, 6): try: d = bjson("https://api.bilibili.com/x/web-interface/dynamic/region", {"rid": rid, "ps": 20, "pn": pn}) add((d.get("data") or {}).get("archives") or []) except Exception as e: print("REGION_FAIL", rid, pn, e, flush=True); break time.sleep(1.0) existing = list(done); random.shuffle(existing) seeds = ([c["bvid"] for c in cands] + existing[:args.seed_existing])[:args.seeds + args.seed_existing] for b in seeds: try: d = bjson("https://api.bilibili.com/x/web-interface/archive/related", {"bvid": b}) add(d.get("data") or []) except Exception as e: print("RELATED_FAIL", b, e, flush=True) time.sleep(0.5) print("CANDIDATES", len(cands), flush=True) saved = 0 for it in cands: if saved >= args.target: break v = bview(it["bvid"]) if not v: continue pu = None for qn in (80, 64, 32): pu = bplay(it["bvid"], v["cid"], qn) if pu: break if not pu or not pu.get("url"): continue name = re.sub(r"[^0-9A-Za-z._-]+", "_", it["title"])[:60] or it["bvid"] ext = ".flv" if ".flv" in pu["url"] else ".mp4" fname = f"{it['bvid']}_{name}{ext}" dst = os.path.join(root, fname) if os.path.exists(dst): continue if not bdownload(pu["url"], dst): print("DL_FAIL", it["bvid"], flush=True); continue meta.append({**it, "file": fname, "cid": v["cid"], "qn": pu["quality"], "license": "research use only, not redistributed"}) saved += 1 print("OK", fname, flush=True) json.dump(meta, open(meta_path, "w", encoding="utf-8"), ensure_ascii=False, indent=2) print("TOTAL_FILES", len(meta), flush=True) # ---------------- tiles ---------------- PATTERNS = {"1-1": [1], "2-2": [2], "3-3": [3], "mix": [2, 3, 2, 1, 3, 2]} VEXT = (".webm", ".ogg", ".ogv", ".mp4", ".mov", ".mkv", ".flv") def clip_id(stem, seg, pat): s = re.sub(r"[^0-9A-Za-z._-]+", "_", stem).strip("_")[:40] if any(ord(c) > 127 for c in stem) or len(s) < 4: s = (s + "_" if s else "mv_") + hashlib.sha1(stem.encode("utf-8")).hexdigest()[:8] return f"{s}_s{seg:02d}_{pat}" def pulldown(frames, rounds, n): out, i = [], 0 while len(out) < n: out.extend([frames[i % len(frames)]] * rounds[i % len(rounds)]); i += 1 return out[:n] def cmd_tiles(args): rng = random.Random(args.seed); os.makedirs(args.out, exist_ok=True) idx_path = os.path.join(args.out, "index.jsonl") done = set() if os.path.exists(idx_path): for line in open(idx_path, "r", encoding="utf-8"): try: done.add(json.loads(line)["clip"]) except Exception: pass videos = [n for n in sorted(os.listdir(args.raw)) if os.path.splitext(n)[1].lower() in VEXT] print("VIDEOS", len(videos), "ALREADY", len(done), flush=True) total = 0 with open(idx_path, "a", encoding="utf-8") as idx: for vi, name in enumerate(videos): cap = cv2.VideoCapture(os.path.join(args.raw, name)) if not cap.isOpened(): print("OPEN_FAIL", name, flush=True); continue nf = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0) fps = float(cap.get(cv2.CAP_PROP_FPS) or 0) w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0) h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0) if nf < args.frames * 3 or w < max(args.tile, args.min_width) or h < args.tile: print("SKIP_DIM", name, w, h, nf, flush=True); cap.release(); continue max_start = max(0, nf - args.frames * 4) for si in range(args.segments): start = rng.randint(0, max_start) x = rng.randint(0, w - args.tile); y = rng.randint(0, h - args.tile) cap.set(cv2.CAP_PROP_POS_FRAMES, start) frames = [] for _ in range(max(4, args.frames // 2)): ok, fr = cap.read() if not ok: break frames.append(cv2.cvtColor(fr[y:y + args.tile, x:x + args.tile], cv2.COLOR_BGR2GRAY)) if len(frames) < 4: continue for pat, rounds in PATTERNS.items(): clip = clip_id(os.path.splitext(name)[0], si, pat) if clip in done: continue cdir = os.path.join(args.out, clip); os.makedirs(cdir, exist_ok=True) for k, img in enumerate(pulldown(frames, rounds, args.frames)): cv2.imwrite(os.path.join(cdir, f"f{k:04d}.png"), img) idx.write(json.dumps({"clip": clip, "src": name, "pattern": pat, "start_frame": start, "x": x, "y": y, "tile": args.tile, "frames": args.frames, "fps": fps}, ensure_ascii=False) + "\n") idx.flush(); total += 1 cap.release() if (vi + 1) % 10 == 0: print("PROGRESS", vi + 1, "/", len(videos), "clips", total, flush=True) print("TOTAL_CLIPS", total, flush=True) def cmd_teacher(args): os.makedirs(args.labels, exist_ok=True) print("SCAN_START", args.tiles, flush=True) dirs = [d for d in sorted(os.listdir(args.tiles)) if os.path.isdir(os.path.join(args.tiles, d))] print("SCAN_DIRS", len(dirs), flush=True) todo = [] for i, d in enumerate(dirs): if not os.path.exists(os.path.join(args.labels, d + ".csv")): todo.append(d) if (i + 1) % 2000 == 0: print("SCAN", i + 1, "/", len(dirs), flush=True) print("DIRS", len(dirs), "TODO", len(todo), flush=True) for i, d in enumerate(todo): subprocess.run([TEACHER, os.path.join(args.tiles, d), os.path.join(args.labels, d + ".csv"), "--mode", "uhd", "--crop", "128", "--per-pair", "4", "--limit", "0"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if (i + 1) % 10 == 0: print("PROGRESS", i + 1, "/", len(todo), flush=True) print("DONE", flush=True) # ---------------- label / train / eval / pack ---------------- def soft_from_flow(flow): return max(0.0, min(1.0, 1.0 - flow / 2.0)) def cmd_label(args): rng = random.Random(args.seed) clip_src = {} p = os.path.join(TILES, "index.jsonl") if os.path.exists(p): for line in open(p, "r", encoding="utf-8"): try: r = json.loads(line); clip_src[r["clip"]] = r["src"] except Exception: pass rows = [] for name in sorted(os.listdir(LABELS)): if not name.endswith(".csv"): continue clip = name[:-4]; src = clip_src.get(clip, "unknown") with open(os.path.join(LABELS, name), "r", encoding="utf-8", newline="") as f: for r in csv.DictReader(f): try: mad, flow, var = float(r["mad"]), float(r["flow"]), float(r["var"]) stage = r.get("stage", "") or "" dup = int(float(r.get("dup", 0) or 0)) x, y = int(float(r["x"])), int(float(r["y"])) except (KeyError, ValueError, TypeError): continue # 被中断写坏的 CSV 行(缺列/None)直接跳过 if dup == 1: soft = 1.0 if (flow == 0.0 and var == 0.0) else soft_from_flow(flow) elif stage == "Flow": if 0.1 <= flow <= 0.4: continue soft = soft_from_flow(flow) else: soft = 0.0 rows.append({"a": os.path.join(clip, r["frame_a"]), "b": os.path.join(clip, r["frame_b"]), "x": x, "y": y, "crop": 128, "soft": round(soft, 4), "hard": 1 if (stage == "Flow" and mad < 1.0) else 0, "stage": stage, "src": src, "clip": clip}) pos = [r for r in rows if r["soft"] >= 0.9] neg = [r for r in rows if r["soft"] <= 0.1] mid = [r for r in rows if 0.1 < r["soft"] < 0.9] rng.shuffle(pos); rng.shuffle(neg); rng.shuffle(mid) neg.sort(key=lambda r: -r["hard"]) m = int(len(pos) * args.max_neg_ratio) neg_keep = neg[:m] if len(neg) > m else neg if neg_keep and len(pos) > len(neg_keep): pos = pos[:len(neg_keep)] mid_keep = mid[:int((len(pos) + len(neg_keep)) * args.mid_ratio)] data = pos + neg_keep + mid_keep; rng.shuffle(data) val_f = lambda r: sum(ord(c) for c in r["src"]) % 7 == 0 train = [r for r in data if not val_f(r)]; val = [r for r in data if val_f(r)] for path, items in ((os.path.join(DATA, "train.jsonl"), train), (os.path.join(DATA, "val.jsonl"), val)): with open(path, "w", encoding="utf-8") as f: for it in items: f.write(json.dumps(it, ensure_ascii=False) + "\n") st = {"raw": len(rows), "kept_pos": len(pos), "kept_neg": len(neg_keep), "kept_mid": len(mid_keep), "hard_mined": sum(1 for r in data if r["hard"]), "train": len(train), "val": len(val)} json.dump(st, open(os.path.join(DATA, "label_stats.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=2) print(json.dumps(st, ensure_ascii=False), flush=True) def eval_model(model, loader, device): model.eval(); se = n = correct = pe = pn = ne = nn_ = 0 with torch.no_grad(): for a, b, y, _ in loader: a, b, y = a.to(device), b.to(device), y.to(device) p = model(a, b) se += torch.abs(p - y).sum().item(); n += y.numel() correct += ((p >= 0.5) == (y >= 0.5)).sum().item() mm = y >= 0.9 if mm.any(): pe += torch.abs(p[mm] - y[mm]).sum().item(); pn += int(mm.sum()) mm = y <= 0.1 if mm.any(): ne += torch.abs(p[mm] - y[mm]).sum().item(); nn_ += int(mm.sum()) return {"mae": se / max(1, n), "acc": correct / max(1, n), "pos_mae": pe / max(1, pn), "neg_mae": ne / max(1, nn_), "n": n} class ClipGroupSampler(Sampler): """按片段分组取样:同一 clip 的切片连续读,机械盘上把随机读变成顺序读。 片段之间每个 epoch 重新打乱、片段内部也打乱,样本分布不变。""" def __init__(self, dataset, shuffle=True, seed=1234): self.groups = {} for i, it in enumerate(dataset.items): self.groups.setdefault(it["clip"], []).append(i) self.clips = sorted(self.groups.keys()) self.shuffle = shuffle self.seed = seed self.epoch = 0 self.n = len(dataset) def set_epoch(self, e): self.epoch = e def __len__(self): return self.n def __iter__(self): rng = random.Random(self.seed + self.epoch) clips = self.clips[:] if self.shuffle: rng.shuffle(clips) for c in clips: idx = self.groups[c] if self.shuffle: idx = idx[:] rng.shuffle(idx) for i in idx: yield i def _worker_init(_): # 每个 DataLoader worker 只用 1 个 torch 线程:默认多线程在 8 个 worker 里会互相抢核, # 实测反而拖慢取数(GPU 在等 batch)。解码/切片本身是单线程活。 try: torch.set_num_threads(1) except Exception: pass def cmd_train(args): random.seed(args.seed); np.random.seed(args.seed) torch.manual_seed(args.seed); torch.cuda.manual_seed_all(args.seed) torch.backends.cudnn.benchmark = True os.makedirs(args.out, exist_ok=True) dev = "cuda" if torch.cuda.is_available() else "cpu" print("DEVICE", dev, torch.cuda.get_device_name(0) if dev == "cuda" else "", flush=True) tr = PairDataset(os.path.join(DATA, "train.jsonl"), TILES, augment=bool(args.augment)) va = PairDataset(os.path.join(DATA, "val.jsonl"), TILES) print("SAMPLES", len(tr), len(va), flush=True) tl = DataLoader(tr, batch_size=args.batch, sampler=ClipGroupSampler(tr, shuffle=True), num_workers=args.workers, worker_init_fn=_worker_init, prefetch_factor=2, pin_memory=(dev == "cuda"), drop_last=True, persistent_workers=args.workers > 0) vl = DataLoader(va, batch_size=args.batch, sampler=ClipGroupSampler(va, shuffle=False), num_workers=max(2, args.workers // 2), worker_init_fn=_worker_init, pin_memory=(dev == "cuda")) model = SiameseSFDP().to(dev); print("PARAMS", count_params(model), flush=True) opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=1e-4) steps = max(1, len(tl)); total = args.epochs * steps def lr_at(step): warm = args.warmup * steps if step < warm: return (step + 1) / max(1, warm) return 0.5 * (1 + math.cos(math.pi * (step - warm) / max(1, total - warm))) sched = torch.optim.lr_scheduler.LambdaLR(opt, lr_at) best = 1e9 if args.resume and os.path.exists(args.resume): ck = torch.load(args.resume, map_location=dev, weights_only=True) model.load_state_dict(ck["model"]); opt.load_state_dict(ck["opt"]) best = ck.get("best", best) print("RESUME", args.resume, "prev", ck.get("epoch"), flush=True) log = os.path.join(args.out, "train_log.csv") if not os.path.exists(log): with open(log, "w", newline="", encoding="utf-8") as f: csv.writer(f).writerow(["epoch", "lr", "loss", "val_mae", "val_acc", "pos_mae", "neg_mae", "sec"]) for ep in range(args.epochs): if hasattr(tl.sampler, "set_epoch"): tl.sampler.set_epoch(ep) model.train(); t0 = time.time(); run = cnt = 0 se = 0 step_i = 0 for a, b, y, hard in tl: a, b, y, hard = a.to(dev), b.to(dev), y.to(dev), hard.to(dev) p = model(a, b); w = 1.0 + (args.hard_weight - 1.0) * hard loss = (w * (p - y) ** 2).mean() opt.zero_grad(set_to_none=True); loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 5.0) opt.step(); sched.step() run += loss.item() * a.size(0); cnt += a.size(0) se += torch.abs(p - y).sum().item() step_i += 1 if step_i % 50 == 0: print(json.dumps({"step": step_i, "steps": len(tl), "ep": ep, "loss": round(run / max(1, cnt), 5), "pmae": round(se / max(1, cnt), 5), "elapsed": round(time.time() - t0, 1)}), flush=True) m = eval_model(model, vl, dev); sec = time.time() - t0; lr = opt.param_groups[0]["lr"] print(json.dumps({"epoch": ep, "loss": round(run / max(1, cnt), 5), "lr": round(lr, 6), **{k: round(v, 5) for k, v in m.items()}, "sec": round(sec, 1)}), flush=True) with open(log, "a", newline="", encoding="utf-8") as f: csv.writer(f).writerow([ep, round(lr, 6), round(run / max(1, cnt), 5), round(m["mae"], 5), round(m["acc"], 5), round(m["pos_mae"], 5), round(m["neg_mae"], 5), round(sec, 1)]) ck = {"model": model.state_dict(), "opt": opt.state_dict(), "epoch": ep, "best": best} torch.save(ck, os.path.join(args.out, "last.pt")) if m["mae"] < best: best = m["mae"]; ck["best"] = best torch.save(ck, os.path.join(args.out, "best.pt")) print("BEST", round(best, 5), "epoch", ep, flush=True) print("TRAIN_DONE best_mae", round(best, 5), flush=True) ck_best = os.path.join(args.out, "best.pt") if not os.path.exists(ck_best): ck_best = os.path.join(args.out, "last.pt") # 本轮没刷新最优时用 last.pt 导出,避免导出报 FileNotFoundError print("EXPORT_SRC", ck_best, flush=True) export_onnx(ck_best, os.path.join(args.out, "easy4k_sfdp_siamese_fp32.onnx")) def export_onnx(ckpt, out, opset=17): import onnx ck = torch.load(ckpt, map_location="cpu", weights_only=True) m = SiameseSFDP(); m.load_state_dict(ck["model"]); m.eval() a = torch.rand(2, 1, 128, 128); b = torch.rand(2, 1, 128, 128) torch.onnx.export(m, (a, b), out, input_names=["frame_a", "frame_b"], output_names=["score"], dynamic_axes={"frame_a": {0: "batch"}, "frame_b": {0: "batch"}, "score": {0: "batch"}}, opset_version=opset, do_constant_folding=True) o = onnx.load(out); onnx.checker.check_model(o) meta = {"model_name": "Easy4K SFDP - Siamese NET AI", "model_version": "0.6.0", "task": "frame-duplicate similarity regression", "input": "two grayscale 128x128 patches, float32, [0,1]", "output": "score in [0,1]; 1 = duplicate/same picture", "training": "knowledge distillation from Easy4K SFDP four-stage judge", "runtime": "onnxruntime DirectML (NVIDIA / AMD / Intel) or CPU", "license": "cc-by-nc-4.0", "params": str(count_params(m))} have = {p.key for p in o.metadata_props} for k, v in meta.items(): if k not in have: p = o.metadata_props.add(); p.key, p.value = k, v onnx.save(o, out) print("EXPORTED", out, "size_mb", round(os.path.getsize(out) / 1048576, 2), flush=True) # FP16 版:内部权重/算子转半精度,输入输出仍保持 float32,调用方不用改代码 try: from onnxconverter_common import float16 as _f16 o16 = _f16.convert_float_to_float16(onnx.load(out), keep_io_types=True) p16 = out.replace("_fp32.onnx", "_fp16.onnx") if out.endswith("_fp32.onnx") else (out[:-5] + "_fp16.onnx") onnx.save(o16, p16) print("EXPORTED_FP16", p16, "size_mb", round(os.path.getsize(p16) / 1048576, 2), flush=True) except Exception as e: print("EXPORTED_FP16_SKIP", repr(e), flush=True) def cmd_eval(args): ds = PairDataset(os.path.join(DATA, "val.jsonl"), TILES) dl = DataLoader(ds, batch_size=512, shuffle=False, num_workers=6) ck = torch.load(args.ckpt, map_location="cpu", weights_only=True) net = SiameseSFDP(); net.load_state_dict(ck["model"]); net.eval() S, Y, ST = [], [], [] with torch.no_grad(): off = 0 for a, b, y, _ in dl: p = net(a, b).squeeze(1).numpy(); S.append(p); Y.append(y.squeeze(1).numpy()) ST.extend([ds.items[off + i]["stage"] for i in range(len(p))]); off += len(p) s = np.concatenate(S); y = np.concatenate(Y); t = (y >= 0.5).astype(int); p = (s >= 0.5).astype(int) tp = int(((p == 1) & (t == 1)).sum()); fp = int(((p == 1) & (t == 0)).sum()) fn = int(((p == 0) & (t == 1)).sum()); tn = int(((p == 0) & (t == 0)).sum()) print(json.dumps({"n": len(s), "acc": round((tp + tn) / len(s), 5), "precision": round(tp / max(1, tp + fp), 5), "recall": round(tp / max(1, tp + fn), 5)}), flush=True) by = {} for st in sorted(set(ST)): mm = np.array([x == st for x in ST]) by[st] = {"n": int(mm.sum()), "mean_score": round(float(s[mm].mean()), 4), "mean_target": round(float(y[mm].mean()), 4)} print("BY_STAGE", json.dumps(by, ensure_ascii=False), flush=True) for th in (0.4, 0.5, 0.6, 0.7, 0.8): pp = (s >= th).astype(int) tp_ = int(((pp == 1) & (t == 1)).sum()); fp_ = int(((pp == 1) & (t == 0)).sum()) fn_ = int(((pp == 0) & (t == 1)).sum()) pr = tp_ / max(1, tp_ + fp_); rc = tp_ / max(1, tp_ + fn_) print(json.dumps({"t": th, "acc": round(float((pp == t).mean()), 5), "precision": round(pr, 5), "recall": round(rc, 5), "f1": round(2 * pr * rc / max(1e-9, pr + rc), 5)}), flush=True) def cmd_compare(args): items = [] for name in sorted(os.listdir(TEST_LABELS)): if not name.endswith(".csv"): continue clip = name[:-4] for r in csv.DictReader(open(os.path.join(TEST_LABELS, name), "r", encoding="utf-8", newline="")): flow, var = float(r["flow"]), float(r["var"]); dup = int(float(r.get("dup", 0) or 0)) stage = r.get("stage", "") soft = (1.0 if (flow == 0.0 and var == 0.0) else soft_from_flow(flow)) if dup == 1 else \ (soft_from_flow(flow) if stage == "Flow" else 0.0) items.append((os.path.join(clip, r["frame_a"]), os.path.join(clip, r["frame_b"]), int(float(r["x"])), int(float(r["y"])), soft)) print("TEST_ITEMS", len(items), flush=True) A, B = [], [] for pa, pb, x, y, _ in items: a = load_gray(os.path.join(TEST_TILES, pa)); b = load_gray(os.path.join(TEST_TILES, pb)) h, w = a.shape; x = min(max(0, x), w - 128); y = min(max(0, y), h - 128) A.append(torch.from_numpy(np.ascontiguousarray(a[y:y + 128, x:x + 128])).float().div_(255.0).unsqueeze(0)) B.append(torch.from_numpy(np.ascontiguousarray(b[y:y + 128, x:x + 128])).float().div_(255.0).unsqueeze(0)) A = torch.stack(A); B = torch.stack(B) y = torch.tensor([it[4] for it in items], dtype=torch.float32).unsqueeze(1) t = (y >= 0.5).int().numpy() for path in args.ckpts.split(","): path = path.strip() if not os.path.exists(path): print("MISSING", path, flush=True); continue ck = torch.load(path, map_location="cpu", weights_only=True) net = SiameseSFDP(); net.load_state_dict(ck["model"]); net.eval() outs = [] with torch.no_grad(): for i in range(0, len(A), 512): outs.append(net(A[i:i + 512], B[i:i + 512]).numpy()) s = np.concatenate(outs) rec = {"ckpt": os.path.basename(os.path.dirname(path)), "epoch": ck.get("epoch"), "mae": round(float(np.abs(s - y.numpy()).mean()), 5)} for th in (0.5, 0.6, 0.7): pp = (s >= th).astype(int) tp = int(((pp == 1) & (t == 1)).sum()); fp = int(((pp == 1) & (t == 0)).sum()) fn = int(((pp == 0) & (t == 1)).sum()); tn = int(((pp == 0) & (t == 0)).sum()) rec[f"acc@{th}"] = round((tp + tn) / len(s), 4) print(rec, flush=True) def cmd_pack(args): import shutil card = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "card.md"), "r", encoding="utf-8").read() os.makedirs(args.out, exist_ok=True) shutil.copy2(args.onnx, os.path.join(args.out, "model.onnx")) _p16 = args.onnx.replace("_fp32.onnx", "_fp16.onnx") if os.path.exists(_p16): shutil.copy2(_p16, os.path.join(args.out, "model_fp16.onnx")) size = round(os.path.getsize(args.onnx) / 1048576, 2) best = {} if os.path.exists(args.log): for row in csv.DictReader(open(args.log, "r", encoding="utf-8")): if not best or float(row["val_mae"]) < float(best["val_mae"]): best = row ev = {} if os.path.exists(args.eval): for enc in ("utf-8-sig", "utf-16", "utf-16-le"): try: for line in open(args.eval, "r", encoding=enc, errors="ignore"): line = line.strip() if line.startswith("{") and '"precision"' in line: ev = json.loads(line); break except Exception: pass if ev: break st = json.load(open(os.path.join(DATA, "label_stats.json"), "r", encoding="utf-8")) clips = len([d for d in os.listdir(TILES) if os.path.isdir(os.path.join(TILES, d))]) pct = lambda x: ("%.2f%%" % (float(x) * 100)) if str(x) not in ("", "n/a") else "n/a" vals = {"params": args.params, "opset": 17, "size_mb": size, "epochs": args.epochs, "clips": clips, "samples": st.get("train", 0) + st.get("val", 0), "train": st.get("train"), "val": st.get("val"), "ratio": "1 : %.2f" % (st.get("kept_neg", 0) / max(1, st.get("kept_pos", 1))), "hard": st.get("hard_mined"), "mae": best.get("val_mae", "n/a"), "acc": pct(best.get("val_acc", "n/a")), "precision": pct(ev.get("precision", "n/a")), "recall": pct(ev.get("recall", "n/a")), "pos_mae": best.get("pos_mae", "n/a"), "neg_mae": best.get("neg_mae", "n/a"), "amd_ms": args.amd_ms, "cpu_ms": args.cpu_ms} for k, v in vals.items(): card = card.replace("{" + k + "}", str(v)) cfg = {"model_name": "Easy4K SFDP - Siamese NET AI", "model_version": "0.6.0", "architecture": "siamese depthwise-separable CNN + difference/product fusion head", "input": {"names": ["frame_a", "frame_b"], "shape": ["batch", 1, 128, 128], "dtype": "float32", "range": [0, 1], "color": "grayscale"}, "output": {"name": "score", "shape": ["batch", 1], "meaning": "1 = duplicate"}, "opset": 17, "providers": ["DmlExecutionProvider", "CPUExecutionProvider"], "recommended_threshold": 0.5, "metrics": {"val_mae": float(best.get("val_mae", 0)), "val_acc": float(best.get("val_acc", 0)), "precision": float(ev.get("precision", 0)), "recall": float(ev.get("recall", 0))}, "license": "cc-by-nc-4.0"} json.dump(cfg, open(os.path.join(args.out, "config.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=2) open(os.path.join(args.out, "README.md"), "w", encoding="utf-8").write(card) open(os.path.join(args.out, "LICENSE"), "w", encoding="utf-8").write( "Easy4K SFDP - Siamese NET AI\nCopyright (c) 2026 Easy4K\nCC BY-NC 4.0\n" "https://creativecommons.org/licenses/by-nc/4.0/legalcode\n") open(os.path.join(args.out, "requirements.txt"), "w", encoding="utf-8").write( "onnxruntime-directml>=1.18\nnumpy>=1.26\n") src = os.path.join(RAW, "sources.json") if os.path.exists(src): shutil.copy2(src, os.path.join(args.out, "sources.json")) print("PACKED", args.out, flush=True) def main(): ap = argparse.ArgumentParser(); sub = ap.add_subparsers(dest="cmd", required=True) p = sub.add_parser("rank") p.add_argument("--target", type=int, default=40); p.add_argument("--pages", type=int, default=6) p.add_argument("--min-sec", type=int, default=60); p.add_argument("--max-sec", type=int, default=420) p.add_argument("--seeds", type=int, default=60); p.add_argument("--seed-existing", type=int, default=300) p.add_argument("--outdir", default=BILI_RAW); p.set_defaults(func=cmd_rank) p = sub.add_parser("tiles") p.add_argument("--raw", required=True); p.add_argument("--out", default=TILES) p.add_argument("--tile", type=int, default=320); p.add_argument("--frames", type=int, default=32) p.add_argument("--segments", type=int, default=4); p.add_argument("--min-width", type=int, default=640) p.add_argument("--seed", type=int, default=1234); p.set_defaults(func=cmd_tiles) p = sub.add_parser("teacher") p.add_argument("--tiles", default=TILES); p.add_argument("--labels", default=LABELS) p.set_defaults(func=cmd_teacher) p = sub.add_parser("label") p.add_argument("--max-neg-ratio", type=float, default=2.0); p.add_argument("--mid-ratio", type=float, default=0.25) p.add_argument("--seed", type=int, default=2026); p.set_defaults(func=cmd_label) p = sub.add_parser("train") p.add_argument("--out", required=True); p.add_argument("--epochs", type=int, default=8) p.add_argument("--batch", type=int, default=256); p.add_argument("--lr", type=float, default=2e-3) p.add_argument("--workers", type=int, default=8); p.add_argument("--hard-weight", type=float, default=2.0) p.add_argument("--warmup", type=int, default=2); p.add_argument("--augment", type=int, default=0) p.add_argument("--seed", type=int, default=2026); p.add_argument("--resume", default="") p.set_defaults(func=cmd_train) p = sub.add_parser("eval"); p.add_argument("--ckpt", required=True); p.set_defaults(func=cmd_eval) p = sub.add_parser("compare"); p.add_argument("--ckpts", required=True); p.set_defaults(func=cmd_compare) p = sub.add_parser("pack") p.add_argument("--onnx", required=True); p.add_argument("--out", required=True) p.add_argument("--log", required=True); p.add_argument("--eval", required=True) p.add_argument("--params", default="2602017"); p.add_argument("--epochs", default="8") p.add_argument("--amd-ms", default="0.56"); p.add_argument("--cpu-ms", default="0.317") p.set_defaults(func=cmd_pack) args = ap.parse_args(); args.func(args) if __name__ == "__main__": main()