#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
validated_impl.py — 八轮实测中被验证正确的实现，直接复用，别重新发明。
每个函数上方注明它解决了什么问题、实测收益、以及踩过的坑。
"""
import numpy as np, cv2, torch

# ============================================================
# 1) CPU→GPU 上传：13.79ms → 0.90ms（快 15.3 倍）
#    坑：旧写法在 CPU 侧 .float() 把 6MB uint8 膨胀成 24MB float32 再走 PCIe
# ============================================================
def to_gpu(bgr_u8):
    """BGR uint8 HWC → RGB float NCHW，类型转换与通道翻转全在 GPU 完成"""
    t = torch.from_numpy(bgr_u8).cuda(non_blocking=True)      # 只传 uint8
    return t[:, :, [2, 1, 0]].permute(2, 0, 1)[None].float().div_(255.)


def from_gpu(t):
    """RGB float NCHW → BGR uint8 HWC"""
    a = (t[0].permute(1, 2, 0).clamp(0, 1) * 255).to(torch.uint8).cpu().numpy()
    return cv2.cvtColor(a, cv2.COLOR_RGB2BGR)


# ============================================================
# 2) 预羽化遮罩：启动时算一次，干掉每帧的大核 erode + GaussianBlur
#    实测换脸单项 24.89ms → 18.13ms
#    坑：原版每帧用 核=ROI边长/10 的 erode（400px ROI 就是 40×40）= 上亿次运算
#    附带好处：在对齐空间羽化后再缩放，羽化量随人脸大小自然缩放，比原写法更正确
# ============================================================
def build_feather_mask(size=128, erode_k=13, blur_k=13, shape="ellipse"):
    """shape="ellipse"（默认）：遮罩做成贴合人脸的椭圆。

    🔴 为什么不能用方形（2026-08-11 实测）：方形遮罩在对齐空间是全幅的，映射回原图
       后边界会**横切脖子**——成品上下巴下方永远有一条清晰的水平接缝，无论怎么羽化、
       怎么做颜色匹配都消不掉，因为那条线两侧本来就是两个人的皮肤。
       椭圆让边界落在脸颊/下颌轮廓附近，接缝藏进人脸自身的明暗过渡里。
    传 shape="rect" 可退回原来的方形行为。
    """
    m = np.zeros((size, size), np.float32)
    if shape == "ellipse":
        cv2.ellipse(m, (size // 2, int(size * 0.53)),
                    (int(size * 0.36), int(size * 0.42)), 0, 0, 360, 255, -1)
        return cv2.GaussianBlur(m, (blur_k * 2 + 1, blur_k * 2 + 1), 0)
    m[:] = 255
    m = cv2.erode(m, np.ones((erode_k, erode_k), np.uint8), 1)
    return cv2.GaussianBlur(m, (blur_k, blur_k), 0)


# ============================================================
# 3) ROI 回贴换脸：65.18ms → 17.59ms（3.71 倍）
#    坑：inswapper 原版 paste_back=True 在 1920×1080 全帧做 3×warpAffine
#        + erode + GaussianBlur + 全帧 blend，占总耗时 89.3%（神经网络只占 7ms）
# ============================================================
class RoiSwapper:
    """color_match=True：回贴前把换出来的脸的颜色统计拉到**目标原脸**上。

    🔴 为什么需要（2026-08-11 实测）：不开时下巴下方会出现一条清晰的水平接缝、
       发际线有色调断层——因为换脸源与目标的肤色统计差得远（实测 persona 偏黄
       vs 目标偏红），羽化过渡再宽也盖不住色差，只能把色差本身消掉。
       身份靠五官结构承载，肤色本来就该跟随目标场景的光照，所以这样做不损身份。
    """

    def __init__(self, swapper, src_face, feather=None, pad=12, color_match=False,
                 occluder=None):
        from insightface.utils import face_align
        self.fa = face_align
        self.sw = swapper
        self.S = swapper.input_size[0]
        self.pad = pad
        self.mask = feather if feather is not None else build_feather_mask(self.S)
        lat = np.dot(src_face.normed_embedding.reshape((1, -1)), swapper.emap)
        self.lat = lat / np.linalg.norm(lat)
        self.in0 = swapper.session.get_inputs()[0].name
        self.in1 = swapper.session.get_inputs()[1].name
        self.color_match = color_match
        # 边界亮环收敛强度（0=关，1=全量）。见 __call__ 里的实现注释。
        self.edge_converge = 1.0
        # 局部光照迁移（colorfit，§11.30）：把目标脸的明暗分布搬到换出来的脸上。照片路线默认关（pipeline 每帧同步开关）。
        self.local_color = False
        self.local_color_strength = 0.65
        self.cm_every = 4          # 颜色系数隔几帧重算一次（人在动，但肤色统计变化很慢）
        self.cm_ema = 0.75         # 🔴 系数时域平滑：每次重算的 gain/bias 与历史 EMA，避免脸忽明忽暗
        self._cm = None
        self._cm_n = 0
        # 遮挡感知（§11.2）：占位符，实际由 pipeline 每帧按 use_occluder 开关赋值。
        # None = 关闭，行为与八轮验证版完全一致；非 None 时把「露出的脸区」并进融合遮罩，
        # 手/眼镜/麦克风等挡在脸前的东西保留原像素，不被换脸糊掉。
        self.occluder = occluder
        # occ_every：遮挡器隔几帧推理一次，中间复用对齐空间缓存遮罩。
        # xseg 已图手术改造为全 GPU（见 occluder._patch_to_gpu），单次仅 ~3ms，默认 1=每帧
        # 跑、零滞后。仅当图手术失败退回 CPU-回落模型时才需调大（那时单次 ~26ms 会掉帧）。
        self.occ_every = 1
        self._occ_cache = None
        self._occ_n = 0

    def __call__(self, img, tgt_face):
        sw, S = self.sw, self.S
        aimg, M = self.fa.norm_crop2(img, tgt_face.kps, S)
        blob = cv2.dnn.blobFromImage(aimg, 1.0 / sw.input_std, sw.input_size,
                                     (sw.input_mean,) * 3, swapRB=True)
        pr = sw.session.run(sw.output_names, {self.in0: blob, self.in1: self.lat})[0]
        fake = np.clip(255 * pr.transpose((0, 2, 3, 1))[0], 0, 255).astype(np.uint8)[:, :, ::-1]

        h, w = img.shape[:2]
        IM = cv2.invertAffineTransform(M)
        corners = np.array([[[0, 0], [S, 0], [S, S], [0, S]]], np.float32)
        d = cv2.transform(corners, IM).reshape(-1, 2)
        x0 = max(int(d[:, 0].min()) - self.pad, 0); x1 = min(int(d[:, 0].max()) + self.pad, w)
        y0 = max(int(d[:, 1].min()) - self.pad, 0); y1 = min(int(d[:, 1].max()) + self.pad, h)
        rw, rh = x1 - x0, y1 - y0
        if rw <= 2 or rh <= 2:
            return img
        IMr = IM.copy(); IMr[0, 2] -= x0; IMr[1, 2] -= y0
        fr = cv2.warpAffine(fake, IMr, (rw, rh), borderValue=0.0)
        # 融合遮罩：椭圆羽化 × 遮挡感知（都在 S×S 对齐空间做，再一起 warp 回 ROI）。
        # 🔴 遮挡遮罩必须在对齐空间乘进去、和羽化遮罩一起 warp——不能各自 warp 再乘，
        #    那样两次插值边界对不齐、会在手的轮廓上留下毛边。
        align_mask = self.mask
        if self.occluder is not None:
            # 隔 occ_every 帧跑一次 xseg，其余帧复用对齐空间缓存（见 __init__ 的性能说明）
            if self._occ_cache is None or self._occ_n % self.occ_every == 0:
                self._occ_cache = self.occluder.face_mask(aimg, out_size=(S, S))
            self._occ_n += 1
            align_mask = self.mask * self._occ_cache               # 遮挡处 →0，保留原像素
        mk = (cv2.warpAffine(align_mask, IMr, (rw, rh), borderValue=0.0) / 255.)[..., None]
        roi = img[y0:y1, x0:x1]
        if self.color_match:
            # 🔴 统计口径 = 合成口径：直接用羽化权重 mk（连续 0~1），不再是 mk>0.5 的硬核心。
            #    硬核心会把羽化过渡带排除在拉齐目标外 → 那一圈亮度对不上 = user 看到的
            #    「人脸边缘白色」（实测边界带 +1.9 vs 脸内 −3.0）。详见 _lab_gain_bias 注释。
            wm = mk[..., 0]
            if float(wm.sum()) > 500:
                if self._cm is None or self._cm_n % self.cm_every == 0:
                    new_cm = _lab_gain_bias(fr, roi, wm)
                    if self._cm is None:
                        self._cm = new_cm
                    else:                            # 与历史系数 EMA，稳住脸的亮度/肤色不跳
                        a = self.cm_ema
                        self._cm = (a * self._cm[0] + (1 - a) * new_cm[0],
                                    a * self._cm[1] + (1 - a) * new_cm[1])
                self._cm_n += 1
                fr = _apply_lab(fr, self._cm)
                # 🔴 边界亮环根治（2026-08-17，user「人脸和头发边缘有白色」）：
                #    color_match 是**全局**线性变换，修不了「中心偏暗而羽化带偏亮」这种
                #    空间不均匀偏差（实测按半径分环：脸内 −3.0 而边界带 +1.9，落差 ~5 L，
                #    在平滑皮肤上被马赫带效应放大成一圈可见亮边；改统计口径只收敛了 10%）。
                #    根因是 inswapper 在椭圆边界外围（脸颊/发际线交界）生成质量差且偏亮。
                #    修法＝**只在羽化过渡带**把 fr 的亮度往 roi 收敛，权重 (1−mk)：
                #    核心区 mk=1 → 不动（保住 color_match 的结果与身份）；
                #    边缘 mk→0 → 全量收敛（fr 贴近原图，接缝无处可辨）。
                #    只动 L 通道；ab 归 color_match 管。钳 ±18 防个别帧极端值翻车。
                if self.edge_converge > 0:
                    bnd = (wm > 0.05) & (wm < 0.95)
                    if int(bnd.sum()) > 200:
                        fl = cv2.cvtColor(fr, cv2.COLOR_BGR2LAB).astype(np.float32)
                        rl = cv2.cvtColor(roi, cv2.COLOR_BGR2LAB).astype(np.float32)
                        dL = float(rl[..., 0][bnd].mean() - fl[..., 0][bnd].mean())
                        dL = max(-18.0, min(18.0, dL)) * float(self.edge_converge)
                        fl[..., 0] = np.clip(fl[..., 0] + dL * (1.0 - wm), 0, 255)
                        fr = cv2.cvtColor(fl.astype(np.uint8), cv2.COLOR_LAB2BGR)
        if self.local_color and float(mk.sum()) > 500:
            from colorfit import local_color_transfer               # 与 DFM 路线同一份实现（webrtc_node/colorfit.py）
            fr = local_color_transfer(roi, fr, mk[..., 0], float(self.local_color_strength))
        roi = roi.astype(np.float32)
        img[y0:y1, x0:x1] = (mk * fr + (1 - mk) * roi).astype(np.uint8)
        return img


def _lab_gain_bias(src, ref, mask, std_clamp=(0.8, 1.25)):
    """算出 LAB 三通道的 (gain, bias)。src 与 ref 的统计口径必须相同（都只用 mask 内像素）——
    口径不一致会算出完全错误的系数，实测表现为回贴区出现矩形色块。

    mask 支持两种形态：
      · bool 掩膜  = 旧口径（硬核心区）
      · 0~1 float = **羽化权重**，与合成时用的 mk 完全一致 ← 现在走这条

    🔴 2026-08-17 改用羽化权重的原因（user 反馈「人脸边缘有白色」）：合成是
       `mk*fr + (1-mk)*roi` 的**连续**混合，而统计原先只取 `mk>0.5` 的硬核心，
       等于把羽化过渡带排除在"要拉齐"的目标之外 → 那一圈的色差没人管。
       实测（换脸前后 ΔL 按到脸中心的归一化半径分环）：脸内部 r<0.7 是 **−3.0（偏暗）**，
       而羽化带 r0.7~0.8 是 **+1.9（偏亮）**，梯度差约 5 个 L —— 平滑皮肤上正好被
       马赫带效应放大成一圈可见亮边。把过渡带按权重纳入统计后，系数会同时照顾到它。
    """
    s = cv2.cvtColor(src, cv2.COLOR_BGR2LAB).astype(np.float32)
    r = cv2.cvtColor(ref, cv2.COLOR_BGR2LAB).astype(np.float32)
    g, b = [], []
    if mask.dtype == np.bool_:
        su, ru = s[mask], r[mask]
        for c in range(3):
            ss = max(float(su[:, c].std()), 1e-6)
            k = float(np.clip(float(ru[:, c].std()) / ss, *std_clamp))
            g.append(k)
            b.append(float(ru[:, c].mean()) - k * float(su[:, c].mean()))
        return np.array(g, np.float32), np.array(b, np.float32)
    w = mask.astype(np.float32)
    tot = max(float(w.sum()), 1e-6)
    for c in range(3):
        sc, rc = s[..., c], r[..., c]
        sm = float((sc * w).sum() / tot)
        rm = float((rc * w).sum() / tot)
        ssd = max(float(np.sqrt(((sc - sm) ** 2 * w).sum() / tot)), 1e-6)
        rsd = float(np.sqrt(((rc - rm) ** 2 * w).sum() / tot))
        k = float(np.clip(rsd / ssd, *std_clamp))
        g.append(k)
        b.append(rm - k * sm)
    return np.array(g, np.float32), np.array(b, np.float32)


def _apply_lab(img, gb):
    g, b = gb
    x = cv2.cvtColor(img, cv2.COLOR_BGR2LAB).astype(np.float32) * g + b
    return cv2.cvtColor(np.clip(x, 0, 255).astype(np.uint8), cv2.COLOR_LAB2BGR)


# ============================================================
# 4) 光照方向估计：准确识别强方向光，用于「按方向选/生成背景」+ 生成结果闭环校验
#    阈值 0.35 由实测间隙标定（front 0.22 / top 0.73 / 左右 1.3）
#    坑：阈值取 0.06 会把正面平光误判成 top
# ============================================================
def estimate_light_dir(img, face_app, thresh=0.35):
    from insightface.utils import face_align
    fs = face_app.get(img)
    if not fs:
        return None
    f = max(fs, key=lambda x: (x.bbox[2] - x.bbox[0]) * (x.bbox[3] - x.bbox[1]))
    a = face_align.norm_crop(img, f.kps, 256)
    g = cv2.cvtColor(a, cv2.COLOR_BGR2GRAY).astype(np.float32)
    m = np.zeros_like(g)
    cv2.ellipse(m, (128, 138), (78, 96), 0, 0, 360, 1, -1)   # 只取脸中心，排除背景与头发
    g = g * m
    L = g[:, :128].sum() / max(m[:, :128].sum(), 1)
    R = g[:, 128:].sum() / max(m[:, 128:].sum(), 1)
    T = g[:128].sum() / max(m[:128].sum(), 1)
    B = g[128:].sum() / max(m[128:].sum(), 1)
    mean = g.sum() / max(m.sum(), 1)
    dx = (L - R) / max(mean, 1e-6)      # >0 左亮 → 光从左
    dy = (T - B) / max(mean, 1e-6)      # >0 上亮 → 光从上
    mag = float(np.hypot(dx, dy))
    if mag < thresh:
        cls = "front"
    elif abs(dx) >= abs(dy):
        cls = "left" if dx > 0 else "right"
    else:
        cls = "top" if dy > 0 else "bottom"
    return {"dx": float(dx), "dy": float(dy), "mag": mag, "cls": cls}


# ============================================================
# 5) 画质匹配：把 AI 背景「降级」到与摄像头一致（实测锐度误差 1.4%）
#    🔴 顺序铁律：色彩迁移必须在解模糊之前——它改对比度→改 Laplacian 方差→
#       会把刚解好的模糊系数冲掉（搞反过一次，误差 67.2%）
#    实测：AI 背景比真实摄像头素材锐 16.6 倍
# ============================================================
def laplacian_sharpness(img, mask=None):
    """mask=True 的像素才计入（用于只统计摄像头画面里的背景区域，排除人物）"""
    l = cv2.Laplacian(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY).astype(np.float32), cv2.CV_32F)
    return float(l[mask].var() if mask is not None and mask.any() else l.var())


def color_transfer(src, ref, ref_mask=None, std_clamp=(0.85, 1.2), l_mean_w=0.25,
                   src_mask=None):
    """Reinhard LAB 色彩迁移，两处按实测结果收紧（2026-08-11）。

    🔴 ref_mask：只用参考图的这部分像素算统计量。摄像头帧里人脸/人身占大面积，
       不加 mask 会把肤色与主体的明暗统计强加到背景上——实测表现为背景被染色
       （AI 人像参考 → 蓝紫/橙偏）。v7 没检出这条，因为当时只量了锐度误差 1.4%，没看颜色。

    🔴 std_clamp：限制标准差缩放倍率。本场景的"参考"是摄像头看到的真实背景，
       常常是一面平墙（实测素材就是浅灰影棚布，std 极小）。原版强制 std 相等，
       会把 AI 背景的对比度整个压平成一堵灰墙。这里要匹配的是**相机的色彩响应**
       （白平衡/曝光/色调压缩），不是那面墙本身的颜色，所以倍率必须有界。
    🔴 l_mean_w：亮度均值只跟 1/4。背景本来就可以比摄像头看到的那面墙更亮或更暗，
       强行对齐亮度均值等于强迫两者同曝光。a/b（色度）均值仍然全跟——那才是白平衡，
       也是这一步真正必须做的事。
    参数由实测四组对照定标（真实影棚素材 + AI 房间背景，results/color_fix/ct_params.jpg）：
       原图                b均值 142.6（明显暖偏）  L标准差 62.0
       (0.65,1.5) L跟0.5   b→131.3 ✔ 但 L标准差压到 40.3，画面发灰发白
       (0.85,1.2) L跟0.25  b→131.5 ✔ L标准差 52.7  ← 采用：去掉暖偏又保住对比度
       只做白平衡 k=1      b→132.0 ✔ L标准差 61.8（最保守，完全不跟曝光/对比度）
       摄像头背景区真值     b 131.9              L标准差 40.2（一面平墙，不代表相机对比度）
    🔴 src_mask：**src 的统计口径必须和 ref 的一致**。换脸回贴时 src 是整个 ROI 矩形、
       mask 外全是黑边，若 src 统计算全矩形而 ref 只算脸部，两边口径不一致，
       算出的变换系数完全错——实测表现为回贴区出现一个矩形色块边界（比不做还糟）。
    传 std_clamp=None + l_mean_w=1.0 可退回原始标准 Reinhard 行为。
    """
    s = cv2.cvtColor(src, cv2.COLOR_BGR2LAB).astype(np.float32)
    r = cv2.cvtColor(ref, cv2.COLOR_BGR2LAB).astype(np.float32)
    use = r[ref_mask] if ref_mask is not None and ref_mask.any() else r.reshape(-1, 3)
    su = s[src_mask] if src_mask is not None and src_mask.any() else s.reshape(-1, 3)
    for c in range(3):
        sm, ss = su[:, c].mean(), max(su[:, c].std(), 1e-6)
        k = use[:, c].std() / ss
        if std_clamp:
            k = float(np.clip(k, std_clamp[0], std_clamp[1]))
        tgt = use[:, c].mean()
        if c == 0:                                   # L：均值只跟 l_mean_w 那么多
            tgt = sm + (tgt - sm) * l_mean_w
        s[:, :, c] = (s[:, :, c] - sm) * k + tgt
    return cv2.cvtColor(np.clip(s, 0, 255).astype(np.uint8), cv2.COLOR_LAB2BGR)


def estimate_noise_sigma(frames, sat_guard=12, stride=2):
    """时域差分 + 稳健尺度估计**单帧单通道**噪声 σ（BGR 逐通道，与加噪端同域）。

    🔴 2026-08-11 修正。原实现记为"σ≥3 时估计值恒为真值的 0.699 倍"，并计划
       "多点标定后固化一个校正系数"——那个计划会得出错误结论。实测拆出三件事：
       ① **域不一致（真根因）**：原实现先 BGR2GRAY 再估，量到的是灰度噪声；
          而三通道独立噪声经 0.114/0.587/0.299 加权平均后 std 只剩
          √(0.114²+0.587²+0.299²) = 0.669 倍 —— 这就是那个"0.699"。
          它不是估计器有偏，是「灰度域测量 vs 逐通道注入」对不上。
          产线里后果是实打实的：按灰度 σ 逐通道加噪会少加 1/0.669 ≈ 1.49 倍。
          改法：不转灰度，直接在 BGR 三通道上估，输出可直接喂给逐通道加噪。
       ② **量化台阶**：uint8 作差是整数，MAD 只能取整数 → 估计值被钉死在
          1.4826/√2 = 1.048 的整数倍（实测输出恒为 0/1.048/2.097/3.145…）。
          改法：MAD 只用来定截断半径，最终尺度取中心区间的标准差（连续量）。
       ③ **饱和截断**：暗部/高光像素噪声被 clip，差分接近 0 把 MAD 拉低，σ 越大拉得越狠。
          改法：排除任一帧上接近 0/255 的像素。
    标定见 calib_noise.py（真值 σ=0.5→8，静态基底 + 每帧独立噪声）。
    """
    a = [f[::stride, ::stride].astype(np.float32) for f in frames]   # 抽样只为省内存，不影响统计
    vs = []
    for i in range(len(a) - 1):
        d = a[i + 1] - a[i]
        m = ((a[i] > sat_guard) & (a[i] < 255 - sat_guard) &
             (a[i + 1] > sat_guard) & (a[i + 1] < 255 - sat_guard))
        vs.append(d[m] if m.sum() > 1000 else d.ravel())
    v = np.concatenate(vs) if vs else np.zeros(1, np.float32)
    med = float(np.median(v))
    mad = float(np.median(np.abs(v - med)))
    s0 = max(1.4826 * mad, 1.0)            # 只作截断半径用，量化台阶不影响最终值
    core = v[np.abs(v - med) < 3 * s0]
    if core.size < 1000:
        core = v
    # 1.0294 = ±3σ 截断正态的标准差修正（截断后 std 约为真值的 0.9714 倍）
    return float(core.std() * 1.0294 / np.sqrt(2))


def textured_sharpness(img, mask=None, block=64, pct=80.0):
    """按块算 Laplacian 方差、取高分位 =「有纹理处的锐度」。

    正源平移自 offline_bg/quality.py（第九轮验证，2026-08-15 迁入唯一真源供
    webrtc_node 复用；quality.py 留有同实现，后续统一指到这里）。
    🔴 为什么不用全图 Laplacian 方差：它和**内容量**强耦合——平坦白墙全图值极低，
       反映的是"没东西可拍"不是"镜头软"，拿它当目标会把有内容的背景糊成一团雾
       （§9.3 第3条实测：整帧 10.4 / 仅背景区 4.3 / 纹理块口径才可比）。
    """
    g = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY).astype(np.float32)
    l = cv2.Laplacian(g, cv2.CV_32F)
    h, w = l.shape
    vals = []
    for y in range(0, h - block + 1, block):
        for x in range(0, w - block + 1, block):
            if mask is not None and mask[y:y + block, x:x + block].mean() < 0.9:
                continue                       # 块里混进人物就不要，避免污染基准
            vals.append(l[y:y + block, x:x + block].var())
    if not vals:
        return float(l[mask].var() if mask is not None and mask.any() else l.var())
    return float(np.percentile(vals, pct))


def match_background_to_camera(bg, cam_frame, cam_frames=None, seed=0, cam_mask=None,
                               target_sharp=None):
    """把 AI 背景降级到与摄像头画质一致。返回 (处理后背景, 诊断信息)

    cam_mask：摄像头帧里「背景区域」的布尔掩膜（True=背景）。传了它，色彩统计与
    锐度目标都只从背景区域取——人物区域的肤色和对焦锐度不该被当成背景基准。
    target_sharp：解模糊的目标锐度覆盖值。调用方若打算在之后再加噪，必须把噪声
    贡献先扣掉后传进来（见 quality.match_quality），否则加完噪声必然超标。
    """
    if target_sharp is None:
        target_sharp = laplacian_sharpness(cam_frame, cam_mask)
    bg_c = color_transfer(bg, cam_frame, cam_mask)        # 🔴 必须先做
    lo, hi = 0.0, 8.0                                     # 再二分解模糊
    for _ in range(20):
        mid = (lo + hi) / 2
        s = laplacian_sharpness(cv2.GaussianBlur(bg_c, (0, 0), mid) if mid > 0.05 else bg_c)
        if s > target_sharp: lo = mid
        else: hi = mid
    bs = (lo + hi) / 2
    out = cv2.GaussianBlur(bg_c, (0, 0), bs) if bs > 0.05 else bg_c.copy()
    if cam_frames and len(cam_frames) > 1:
        sig = estimate_noise_sigma(cam_frames)
        if sig > 0.2:
            rng = np.random.default_rng(seed)
            out = np.clip(out.astype(np.float32) + rng.normal(0, sig, out.shape), 0, 255).astype(np.uint8)
    return out, {"target_sharp": target_sharp, "blur_sigma": bs,
                 "final_sharp": laplacian_sharpness(out)}


# ============================================================
# 6) IC-Light 背景重打光 —— 🔴 连错两轮才对，三个要点缺一不可
#    ① 权重是 OFFSET，必须加到原 UNet 上（直接替换 → 输出纯噪声）
#    ② 方向靠「线性渐变图作 img2img 起点」注入，**完全不靠 prompt**
#       实测：只写prompt 左/右亮度差 +20.90 vs +20.51（无效）
#             用渐变图  左/右亮度差 +126.07 vs −120.99（符号翻转，生效）
#    ③ conv_in 先 4→8 扩展；lat=randn*init_noise_sigma；每步 scale_model_input
#    速度：1.0–1.7s/张 @768×448 RTX5090
# ============================================================
def patch_unet_for_iclight(unet, offset_path, device="cuda", dtype=torch.float16):
    from safetensors.torch import load_file
    with torch.no_grad():
        o = unet.conv_in
        n = torch.nn.Conv2d(8, o.out_channels, o.kernel_size, o.stride, o.padding)
        n.weight.zero_(); n.weight[:, :4].copy_(o.weight.float()); n.bias.copy_(o.bias.float())
        unet.conv_in = n.to(device, dtype)
        unet.config.in_channels = 8
    sd_o, sd_f = unet.state_dict(), load_file(offset_path)
    merged = {k: (sd_o[k].float() + sd_f[k].float().to(device)).to(dtype) if k in sd_f else sd_o[k]
              for k in sd_o}                                  # 🔴 相加，不是替换
    unet.load_state_dict(merged, strict=True)
    return sum(1 for k in sd_o if k in sd_f)


def patch_unet_for_iclight_fbc(unet, offset_path, device="cuda", dtype=torch.float16):
    """IC-Light **FBC**（前景+背景双条件）变体，conv_in 4→**12** 通道
    （4 噪声 + 4 前景条件 + 4 背景条件）——不是 patch_unet_for_iclight 的 8 通道 FC 变体。

    2026-08-15 首次引入，源码级核对自官方仓库 lllyasviel/IC-Light 的
    gradio_demo_bg.py（FBC 官方 demo 脚本），不是套用 FC 的假设：
      · conv_in 新增通道零初始化、前 4 通道复制原权重（与 FC 同一手法）
      · 权重同样是 **offset 相加**（demo 源码 `sd_merged = {k: sd_origin[k]+sd_offset[k]...}`，
        直接从 FBC demo 脚本本体摘录，不是从 FC 沿用的假设——两个模型是不同 checkpoint，
        offset 约定必须逐一核实，§4 的教训是"连错两轮才对"，这次先查官方源码再写代码）
    """
    from safetensors.torch import load_file
    with torch.no_grad():
        o = unet.conv_in
        n = torch.nn.Conv2d(12, o.out_channels, o.kernel_size, o.stride, o.padding)
        n.weight.zero_(); n.weight[:, :4].copy_(o.weight.float()); n.bias.copy_(o.bias.float())
        unet.conv_in = n.to(device, dtype)
        unet.config.in_channels = 12
    sd_o, sd_f = unet.state_dict(), load_file(offset_path)
    merged = {k: (sd_o[k].float() + sd_f[k].float().to(device)).to(dtype) if k in sd_f else sd_o[k]
              for k in sd_o}
    unet.load_state_dict(merged, strict=True)
    return sum(1 for k in sd_o if k in sd_f)


def rmbg_cutout(img_bgr, alpha):
    """人像抠图前景，按 IC-Light FBC 官方 run_rmbg() 的确切填充公式（非猜测，源码摘录）：
    背景区填充**灰 127**，`result = 127 + (img-127)*alpha`。alpha=0-1 float，(H,W)。
    🔴 这个填充值不能随便选：FBC 训练数据的"背景已移除"区域就是这个分布，
    填别的值（纯黑/纯白/透明）会让模型看到训练时没见过的输入。"""
    a = alpha[:, :, None].astype(np.float32)
    out = 127.0 + (img_bgr.astype(np.float32) - 127.0) * a
    return np.clip(out, 0, 255).astype(np.uint8)


def resize_and_center_crop(img_bgr, tw, th):
    """官方 FBC demo 的预处理手法：等比缩放到覆盖目标框，再居中裁切（不拉伸变形）。"""
    h, w = img_bgr.shape[:2]
    k = max(tw / w, th / h)
    rw, rh = int(round(w * k)), int(round(h * k))
    r = cv2.resize(img_bgr, (rw, rh), interpolation=cv2.INTER_LANCZOS4)
    x0, y0 = (rw - tw) // 2, (rh - th) // 2
    return r[y0:y0 + th, x0:x0 + tw]


def gradient_light_map(w, h, side):
    """方向注入的核心：线性渐变图，作为 img2img 的初始 latent"""
    if side in ("left", "right"):
        g = np.linspace(255, 0, w) if side == "left" else np.linspace(0, 255, w)
        im = np.tile(g, (h, 1))
    else:
        g = np.linspace(255, 0, h) if side == "top" else np.linspace(0, 255, h)
        im = np.tile(g[:, None], (1, w))
    return np.stack([im] * 3, -1).astype(np.uint8)


def lr_brightness_diff(img):
    """客观判据：左半-右半平均亮度差，用于确认光照方向真的变了"""
    g = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY).astype(np.float32)
    return float(g[:, :g.shape[1] // 2].mean() - g[:, g.shape[1] // 2:].mean())
