File size: 7,571 Bytes
68e2686
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
#!/usr/bin/env python3
"""teaser · Boss 换装用的视觉打分工具(img env:$M/envs/img/bin/python,GPU)。被 bossswap_f0.py / bossswap_metrics.py import。

* BossSeg:SAM2.1-hiera-large(transformers Sam2Model,本地 HF_HOME snapshot),提示 = 条件 Boss 掩码(膨胀 10 px)的外接框 +
  身体内核(非武器部件,腐蚀 3 px)最远点采样 4 个正点 + 武器中线上离身体最远的一点;玩家身体内核 2 个负点。
  输出限制在 Boss 掩码膨胀 40 px 内(防止整块地面被分进去)。iou(seg, cond) = 生成画面里 Boss 轮廓与条件掩码的重合度。
  注意:胶囊掩码只是近似轮廓(f0_realchar 实测真实角色 vs 胶囊 IoU 0.39–0.75),所以要和原版 Boss 同一指标比相对值。
* BossClip:CLIP ViT-L/14,Boss 裁图对 16 种外观 + 原版描述做零样本分类,p(目标) 当「认不认得出是这个 Boss」。
"""
from __future__ import annotations

import glob
import os

import numpy as np

os.environ.setdefault("HF_HOME", "/opt/dlami/nvme/zhiyangdeng/.cache/huggingface")
HUB = f"{os.environ['HF_HOME']}/hub"
PLAYER, BOSS = (10, 12, 13, 14, 15, 16), (11, 17, 18, 19, 20, 21)
WEAPONS = (16, 21)


def snap(repo: str) -> str:
    p = sorted(glob.glob(f"{HUB}/models--{repo.replace('/', '--')}/snapshots/*"))
    if not p:
        raise FileNotFoundError(repo)
    return p[-1]


def grow(mask: np.ndarray, r: int) -> np.ndarray:
    import cv2
    if r == 0:
        return mask.copy()
    k = np.ones((2 * abs(r) + 1, 2 * abs(r) + 1), np.uint8)
    f = cv2.dilate if r > 0 else cv2.erode
    return f(mask.astype(np.uint8), k) > 0


def fps_points(core: np.ndarray, k: int = 4) -> list[list[float]]:
    ys, xs = np.nonzero(core)
    P = np.stack([xs, ys], 1).astype(np.float32)
    sel = [P[np.argmin(((P - P.mean(0)) ** 2).sum(1))]]
    for _ in range(k - 1):
        sel.append(P[np.argmax(np.min([((P - q) ** 2).sum(1) for q in sel], 0))])
    return [[float(x), float(y)] for x, y in sel]


def bbox(mask: np.ndarray, pad: float = 0.0, min_side: int = 1, W: int = 832, H: int = 480):
    ys, xs = np.nonzero(mask)
    x0, x1, y0, y1 = xs.min(), xs.max() + 1, ys.min(), ys.max() + 1
    w, h = x1 - x0, y1 - y0
    cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
    w, h = max(min_side, w * (1 + pad)), max(min_side, h * (1 + pad))
    return (int(max(0, cx - w / 2)), int(max(0, cy - h / 2)), int(min(W, cx + w / 2)), int(min(H, cy + h / 2)))


class BossSeg:
    def __init__(self, device="cuda"):
        import torch
        from transformers import Sam2Model, Sam2Processor
        p = snap("facebook/sam2.1-hiera-large")
        self.torch, self.dev = torch, device
        self.proc = Sam2Processor.from_pretrained(p)
        self.model = Sam2Model.from_pretrained(p, dtype=torch.float32).to(device).eval()

    def prompts(self, ids: np.ndarray):
        mk = np.isin(ids, BOSS)
        if mk.sum() < 300:
            return None
        body = mk & ~np.isin(ids, WEAPONS)
        core = grow(body, -3)
        core = core if core.sum() >= 10 else (body if body.sum() >= 10 else mk)
        box = list(bbox(grow(mk, 10)))
        pts = fps_points(core)
        lab = [1] * len(pts)
        wp = grow(mk & np.isin(ids, WEAPONS), -2)
        if wp.sum() >= 60:
            ys, xs = np.nonzero(wp)
            c = np.array(pts[0])
            k = int(np.argmax((xs - c[0]) ** 2 + (ys - c[1]) ** 2))
            pts.append([float(xs[k]), float(ys[k])])
            lab.append(1)
        pc = grow(np.isin(ids, PLAYER) & ~np.isin(ids, WEAPONS), -3) & ~grow(mk, 4)
        if pc.sum() >= 30:
            for q in fps_points(pc, 2):
                pts.append(q)
                lab.append(0)
        return mk, box, pts, lab

    def __call__(self, rgbs: list[np.ndarray], idss: list[np.ndarray]) -> list[np.ndarray | None]:
        """每帧一个 Boss 掩码(看不到 Boss 的帧 = None)。批处理。"""
        outs: list = [None] * len(rgbs)
        todo = []
        for i, (rgb, ids) in enumerate(zip(rgbs, idss)):
            pr = self.prompts(ids)
            if pr is not None:
                todo.append((i, rgb, pr))
        for s in range(0, len(todo), 8):
            ch = todo[s:s + 8]
            inp = self.proc(images=[c[1] for c in ch], input_boxes=[[c[2][1]] for c in ch],
                            input_points=[[c[2][2]] for c in ch], input_labels=[[c[2][3]] for c in ch],
                            return_tensors="pt").to(self.dev)
            with self.torch.no_grad():
                res = self.model(**inp, multimask_output=False)
            ms = self.proc.post_process_masks(res.pred_masks.cpu(), inp["original_sizes"])
            for (i, _, pr), m in zip(ch, ms):
                outs[i] = (m[0, 0].numpy() > 0) & grow(pr[0], 40)
        return outs


def iou(a: np.ndarray, b: np.ndarray) -> float:
    u = (a | b).sum()
    return float((a & b).sum() / u) if u else 0.0


class BossClip:
    def __init__(self, labels: dict[str, str], device="cuda"):
        """labels:键 → 文本描述(含原版 Boss)。"""
        import torch
        from transformers import CLIPModel, CLIPProcessor
        p = snap("openai/clip-vit-large-patch14")
        self.torch, self.dev = torch, device
        self.model = CLIPModel.from_pretrained(p, dtype=torch.float16).to(device).eval()
        self.proc = CLIPProcessor.from_pretrained(p)
        self.keys = list(labels)
        txt = [f"a top-down video game screenshot of {labels[k]}" for k in self.keys]
        with torch.no_grad():
            t = self.proc(text=txt, return_tensors="pt", padding=True, truncation=True).to(device)
            e = self.model.get_text_features(**t)
            e = e if isinstance(e, torch.Tensor) else e.pooler_output
            self.temb = torch.nn.functional.normalize(e.float(), dim=-1)

    def probs(self, crops: list[np.ndarray]) -> np.ndarray:
        """crops:RGB uint8 → [N, K] softmax 概率(CLIP logit scale 100)。"""
        from PIL import Image
        torch = self.torch
        out = []
        for s in range(0, len(crops), 32):
            im = [Image.fromarray(c) for c in crops[s:s + 32]]
            with torch.no_grad():
                x = self.proc(images=im, return_tensors="pt").to(self.dev)
                e = self.model.get_image_features(pixel_values=x["pixel_values"].half())
                e = e if isinstance(e, torch.Tensor) else e.pooler_output
                e = torch.nn.functional.normalize(e.float(), dim=-1)
                out.append(torch.softmax(100 * e @ self.temb.T, -1).cpu().numpy())
        return np.concatenate(out, 0)


def boss_crop(rgb: np.ndarray, mask: np.ndarray, pad: float = 0.25, min_side: int = 96) -> np.ndarray:
    x0, y0, x1, y1 = bbox(mask, pad=pad, min_side=min_side)
    return np.ascontiguousarray(rgb[y0:y1, x0:x1])


def lab(img: np.ndarray) -> np.ndarray:
    import cv2
    return cv2.cvtColor(img, cv2.COLOR_RGB2LAB).astype(np.float32) * np.array([100 / 255, 1, 1], np.float32)


FFMPEG = f"/opt/dlami/nvme/zhiyangdeng/maliang/.venv/lib/python3.12/site-packages/imageio_ffmpeg/binaries/ffmpeg-linux-x86_64-v7.0.2"


def read_video(path: str, w: int = 832, h: int = 480) -> np.ndarray:
    """mp4 → RGB uint8 [F,h,w,3](ffmpeg 解码;尺寸须已知,不依赖 PyAV)。"""
    import subprocess
    p = subprocess.run([FFMPEG, "-loglevel", "error", "-i", path, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
                       capture_output=True, check=True)
    return np.frombuffer(p.stdout, np.uint8).reshape(-1, h, w, 3)