# 三关色相 × 五组微光互辨抽测——S1 取铺底值(机检 D)+ S2 出并列样图(机检 A/B/C)。 # 执行依据:reports/提案-三关色相互辨抽测方案-2026-10-29.md(抽测任务=待办清单 # 「三关色相 × 五组微光互辨抽测」条)。产物:并列样图(artwork/,不入 git)+逐对读数表(reports/)。 # 口径出处(冲突时以 owner 为准): # 微光色值与呈现参数 = game/presentation/views/ui_kit.gd TG_GLOW_*/TYPE_GLOW_*(v2 冻结); # 合成公式 = game/presentation/views/cell_type_glow.gdshader(blend_mix 距离场柔光); # 带域/格心取样 = game/probe/type_glow_shot_probe.gd(de<格边×0.125 / 内缩 20%); # 准入门值(亮度地板/饱和上限/禁入色相带/近色容差)单点在本件 FLOOR_L·CEIL_SAT· # BANNED_HUE·NEAR_EPS;art-bible §4.1 只述「未开格底与微光存在相对亮度关系」并指路本件。 # 既有色板比对池 = game/tests/golden/palette_baseline/ 两张条带并集(palette_check.py 同源)。 # 方案 §2.3 机检 C 口径订正:模型 0.49/0.51 是反解近似(ui_kit.gd 头注「模型域与实测的差 # = shader 距离场权重非扁平混合」),比较对象改为 shader 精确合成模型(逐像素复刻),判据线 # ±2/255 沿用在役值。 # 方案 §7 表二行数笔误订正:表二实为 90 行(3 关 × 3 底型 × 10 组对),非 30 行。 # # 用法: # python tools/glow_discrimination_sheet.py --art [--no-sheet] # 输出: # /glow-discrimination-sheet-2026-11-05.png(样图:正图段+灰度副本段+图例) # reports/读数-三关色相互辨抽测-2026-11-05.md(逐对表 45 行+组间表 90 行+机检汇总) import argparse import math import os import sys from PIL import Image REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # ── ui_kit.gd 冻结常量(逐字转写,禁改值)── GLOWS = [ ("BUFF", (247, 223, 67)), # 通用增益·芥末黄 ("HEAL", (104, 208, 66)), # 治疗·黄绿 ("PIERCE", (48, 166, 166)), # 穿刺·青 ("MAGIC", (123, 91, 214)), # 魔法·紫罗兰 ("AOE", (166, 29, 55)), # 群伤·绯红 ] GLOW_ALPHA = 170.0 / 255.0 BAND_FRAC = 0.125 SOFT = 0.55 CORNER = 2.0 CELL = 45 # D 区实比格边 BAND = CELL * BAND_FRAC # 5.625 CORE_PAD = int(CELL * 0.20) # 格心内缩(探针 CORE_PAD_FRAC=0.20) # ── 铺底准入门值单点(文档侧只述「未开格底与微光存在相对亮度关系」,数值归本件)── FLOOR_L = 0.30 # 未开格底相对亮度地板(线性域) CEIL_SAT = 0.55 # 饱和度上限 BANNED_HUE = (300.0, 345.0) # 禁入色相带(Boss 紫赤独占) NEAR_EPS = 8 # 近色容差(三通道差全 <此值 即近色) # 世界底层两子带门值(art-bible §2 只述「底层分两子带、间隔不小于门值」,数值归本件)── BAND_LOW_TOP = 0.06 # 底层下沿带上沿(背景/舞台底件不得越界侵入上沿带) BAND_HIGH = (0.08, 0.14) # 底层上沿带(已开格底——渲染面由 tile_palette 归一化目标 0.10 落带承载) BAND_GAP = 0.02 # 两子带最小间隔 B_MIN_CH_DIFF = 8 # 机检 B|格底-微光可辨底线(三通道至少一通道 ≥此值) C_MODEL_TOL = 2 # 机检 C|样图与合成模型容差(±此值 /255) BOARD_TYPES = ["dungeon", "underdark", "deep_underdark"] BASE_VARIANTS = [ # (底型名,瓦片件名后缀)——素砖 + 两种装饰变体(高墙瓦片词表在册件) ("素砖", "terrain_wall_center"), ("裂纹", "terrain_wall_center_crack"), ("碎石斑", "terrain_wall_center_speckle"), ] TILE_DIR = os.path.join(REPO, "game", "assets", "art", "board") BASELINE_DIR = os.path.join(REPO, "game", "tests", "golden", "palette_baseline") CHECKS = [] def check(cid, name, ok, detail=""): CHECKS.append((cid, name, bool(ok), detail)) print("[%s] %s %s — %s%s" % ("PASS" if ok else "FAIL", cid, name, "✓" if ok else "✗", (";" + detail) if detail else "")) # ── 色彩数学 ── def to_linear(v): c = v / 255.0 return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 def rel_lum(rgb): return 0.2126 * to_linear(rgb[0]) + 0.7152 * to_linear(rgb[1]) + 0.0722 * to_linear(rgb[2]) def gray709(rgb): return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] def hsv(rgb): r, g, b = [v / 255.0 for v in rgb] mx, mn = max(r, g, b), min(r, g, b) d = mx - mn if d == 0: h = 0.0 elif mx == r: h = 60 * ((g - b) / d % 6) elif mx == g: h = 60 * ((b - r) / d + 2) else: h = 60 * ((r - g) / d + 4) s = 0.0 if mx == 0 else d / mx return h % 360, s def wcag(c1, c2): l1, l2 = rel_lum(c1), rel_lum(c2) return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05) def smoothstep(e0, e1, x): t = max(0.0, min(1.0, (x - e0) / (e1 - e0))) return t * t * (3.0 - 2.0 * t) def glow_weight(px, py): """shader 复刻:像素中心 (px,py) 的微光权重 = glow_alpha × a(d) × mask。""" h = CELL / 2.0 d = min(px, py, CELL - px, CELL - py) r = min(CORNER, CELL * 0.25) qx = abs(px - h) - (h - r) qy = abs(py - h) - (h - r) outside = math.hypot(max(qx, 0.0), max(qy, 0.0)) + min(max(qx, qy), 0.0) - r mask = 1.0 - smoothstep(-1.0, 0.5, outside) a = 0.0 if d < BAND: a = 1.0 - smoothstep(SOFT, 1.0, d / BAND) return GLOW_ALPHA * a * mask # ── 素材装载 ── def load_tiles(): tiles = {} for bt in BOARD_TYPES: for vn, stem in BASE_VARIANTS: path = os.path.join(TILE_DIR, "%s_%s.png" % (stem, bt)) if not os.path.exists(path): print("[阻塞] 素材缺位:%s" % path, file=sys.stderr) sys.exit(2) img = Image.open(path).convert("RGB") if img.size != (CELL, CELL): img = img.resize((CELL, CELL), Image.NEAREST) tiles[(bt, vn)] = img.load() return tiles def lit_slots(tiles): """铺底代表值 = 该关三块底型瓦片像素里线性亮度最大的实测色(顶面受光档)。 零表值:不引用任何色表常量,素材换色后读数自动跟随。""" slots = {} for bt in BOARD_TYPES: best = None for vn, _ in BASE_VARIANTS: px = tiles[(bt, vn)] for y in range(CELL): for x in range(CELL): c = px[x, y] if best is None or rel_lum(c) > rel_lum(best): best = c slots[bt] = best return slots # ── 带域/格心取样(探针口径)── def band_pixels(): """带域像素集(整数格坐标 de < 格边×0.125,type_glow_shot_probe 口径)。""" return [(x, y) for x in range(CELL) for y in range(CELL) if min(x, y, CELL - 1 - x, CELL - 1 - y) < BAND] def core_pixels(): """格心像素集(内缩 20%)。""" return [(x, y) for x in range(CELL) for y in range(CELL) if min(x, y, CELL - 1 - x, CELL - 1 - y) >= CELL * 0.20] BAND_PX = band_pixels() CORE_PX = core_pixels() def mean_rgb(px, pts): n = len(pts) return tuple(round(sum(px[x, y][i] for x, y in pts) / n) for i in range(3)) def model_band_mean(base_px, glow_rgb): """shader 精确合成模型:逐像素 blend_mix 理论带内均值(独立实现路径)。""" n = len(BAND_PX) acc = [0.0, 0.0, 0.0] for x, y in BAND_PX: w = glow_weight(x + 0.5, y + 0.5) b = base_px[x, y] for i in range(3): acc[i] += w * glow_rgb[i] + (1.0 - w) * b[i] return tuple(round(v / n) for v in acc) # ── 机检 D:铺底代表值五门 ── def load_baseline_palette(): cols = set() for fn in ["ui_tokens.png", "art_snapshot.png"]: p = os.path.join(BASELINE_DIR, fn) img = Image.open(p).convert("RGB") for x in range(img.width): for y in range(img.height): cols.add(img.getpixel((x, y))) return sorted(cols) def gate_d(tiles): baseline = load_baseline_palette() lit = lit_slots(tiles) rows = [] all_pass = True for bt in BOARD_TYPES: rgb = lit[bt] h, s = hsv(rgb) lin = rel_lum(rgb) l_ok = lin >= FLOOR_L s_ok = s <= CEIL_SAT hue_ok = not (BANNED_HUE[0] <= h <= BANNED_HUE[1]) # 近色门:三值=在役素材色(已在基线池内),palette_check 双门②只判新增色——报读数不判红 near = [b for b in baseline if all(abs(rgb[i] - b[i]) < NEAR_EPS for i in range(3))] ok = l_ok and s_ok and hue_ok all_pass = all_pass and ok rows.append((bt, rgb, lin, s, h, len(near), ok)) check("D-" + bt, "铺底代表值门(底型瓦片实测顶面受光档;亮度/饱和/禁入带,近色只报读数)", ok, "RGB=%s L=%.3f 饱和=%.3f 色相=%.1f° 近色对=%d(在役素材色,非新增)%s%s%s" % (rgb, lin, s, h, len(near), "" if l_ok else " [L红]", "" if s_ok else " [饱和红]", "" if hue_ok else " [禁入带红]")) # 语义检查=设计面人工核对(palette_check 同口径),打印读数不判红 print(" [语义] %s=%s 环境域(不占红/金/蓝/紫赤/绿语义段)——人工核对" % (bt, rgb)) # 渲染面带域均值 = 读数不判红(方案机检 D 只判槽值;本读数供观感定性参考) for bt in BOARD_TYPES: for vn, _ in BASE_VARIANTS: m = mean_rgb(tiles[(bt, vn)], BAND_PX) lin = rel_lum(m) print(" [读数] 渲染面带域均值 %s/%s RGB=%s L=%.3f(不判红)" % (bt, vn, m, lin)) return all_pass, rows # ── 机检 E:世界底层两子带(资产侧门;下沿带成员=三关背景件,上沿带成员=已开格底, # 其渲染面由 tile_palette.gd 归一化目标常量 0.10 承载、对比判据由 ui_contrast_probe 兜—— # 原图瓦片亮度≠渲染面〔装载归一化路径〕,不作本门判据)── def gate_e(): rows = [] all_pass = True # 结构自检:两子带不重叠且间隔达门值(常量自身恒真——防改常量时破结构) check("E-struct", "下沿带上沿+间隔 ≤ 上沿带下沿(两子带不重叠)", BAND_LOW_TOP + BAND_GAP <= BAND_HIGH[0], "下沿带上沿 %.2f 间隔 %.2f 上沿带 %s" % (BAND_LOW_TOP, BAND_GAP, BAND_HIGH)) # 三关背景件 → 下沿带(只判上沿不越界;逐件读数落 stdout 供观感对照) scene_dir = os.path.join(REPO, "game", "assets", "art", "scene") for bt in BOARD_TYPES: fn = "scene_stage_bg_%s.png" % bt path = os.path.join(scene_dir, fn) if not os.path.exists(path): print("[阻塞] 素材缺位:%s" % path, file=sys.stderr) sys.exit(2) img = Image.open(path).convert("RGB") pts = [(x, y) for x in range(img.width) for y in range(img.height)] rgb = mean_rgb(img.load(), pts) lin = rel_lum(rgb) ok = lin <= BAND_LOW_TOP all_pass = all_pass and ok rows.append(("bg", bt, lin, ok)) check("E-bg-%s" % bt, "背景件 L ≤ 下沿带上沿 %.2f(不侵入上沿带)" % BAND_LOW_TOP, ok, "RGB=%s L=%.4f%s" % (rgb, lin, "" if ok else " [越界红]")) return all_pass, rows # ── 样图绘制 ── PAGE_W, PAGE_H = 640, 1120 MARGIN, HEAD, BLOCK_TITLE, GAP, LEGEND = 10, 60, 20, 30, 80 def draw_sheet(out_path): import argparse # noqa: F401 from PIL import ImageDraw, ImageFont img = Image.new("RGB", (PAGE_W, PAGE_H), (255, 255, 255)) d = ImageDraw.Draw(img) try: f_zh = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 13) f_zh_s = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 11) except OSError: f_zh = ImageFont.load_default() f_zh_s = f_zh blocks = [] y = MARGIN + HEAD for bt in BOARD_TYPES: d.text((MARGIN + 4, y + 3), "%s —— 列0=基准格,列1–5=五组微光(阶梯序)" % bt, fill=(0, 0, 0), font=f_zh) y += BLOCK_TITLE blocks.append((bt, y)) y += 3 * CELL tiles = load_tiles() # 画正图段 54 格 cell_pngs = {} for bt, y0 in blocks: for ri, (vn, _) in enumerate(BASE_VARIANTS): for ci, gname in enumerate(["BASE"] + [g[0] for g in GLOWS]): base = tiles[(bt, vn)] cell = Image.new("RGB", (CELL, CELL)) cp = cell.load() if gname == "BASE": for x in range(CELL): for yy in range(CELL): cp[x, yy] = base[x, yy] else: glow = dict(GLOWS)[gname] for x in range(CELL): for yy in range(CELL): w = glow_weight(x + 0.5, yy + 0.5) b = base[x, yy] cp[x, yy] = tuple(round(w * glow[i] + (1 - w) * b[i]) for i in range(3)) gx = MARGIN + 170 + ci * CELL gy = y0 + ri * CELL img.paste(cell, (gx, gy)) cell_pngs[(bt, vn, gname, "main")] = (gx, gy) # 行标注 for ri, (vn, _) in enumerate(BASE_VARIANTS): d.text((MARGIN, y0 + ri * CELL + 16), vn, fill=(0, 0, 0), font=f_zh_s) # 灰度副本段(等幅面,正图段整段 Rec.709 去色) main_top = MARGIN + HEAD main_bot = y gray_top = main_bot + GAP for xx in range(MARGIN, PAGE_W - MARGIN): for yy in range(main_top, main_bot): r, g, b = img.getpixel((xx, yy)) v = int(round(gray709((r, g, b)))) img.putpixel((xx, gray_top + (yy - main_top)), (v, v, v)) d.text((MARGIN + 4, gray_top - 2), "灰度副本段(Rec.709 去色——色弱通道目检面)", fill=(80, 80, 80), font=f_zh_s) # 图例段 leg_y = gray_top + (main_bot - main_top) + 10 d.text((MARGIN + 4, leg_y), "图例(五组冻结色值 ui_kit.gd TG_GLOW_*;阶梯序=合成空间亮度递减)", fill=(0, 0, 0), font=f_zh_s) for i, (gn, rgb) in enumerate(GLOWS): x0 = MARGIN + 170 + i * (CELL + 4) d.rectangle([x0, leg_y + 18, x0 + CELL, leg_y + 18 + 28], fill=rgb, outline=(0, 0, 0)) d.text((x0, leg_y + 48), gn, fill=(0, 0, 0), font=f_zh_s) img.save(out_path) return img, cell_pngs, tiles, blocks, gray_top, main_top, main_bot # ── 机检 A/B/C + 灰度/净空机检 ── def run_checks(img, cell_pngs, tiles, blocks, gray_top, main_top, main_bot): sheet_px = img.load() table1 = [] # 45 行逐对表 table2 = [] # 90 行组间表 band_means = {} fail_b, fail_c = [], [] lit = lit_slots(tiles) for bt, y0 in blocks: for vn, _ in BASE_VARIANTS: # 列 0 基准格带域均值 = 该底型的模型底值 gx0, gy0 = cell_pngs[(bt, vn, "BASE", "main")] band_base = mean_rgb(tiles[(bt, vn)], BAND_PX) for gi, (gname, glow) in enumerate(GLOWS): gx, gy = cell_pngs[(bt, vn, gname, "main")] px = tiles[(bt, vn)] # 样图带内均值(样图渲染面读数) n = len(BAND_PX) acc = [0.0, 0.0, 0.0] for x, yy in BAND_PX: p = sheet_px[gx + x, gy + yy] for i in range(3): acc[i] += p[i] band_mean = tuple(round(v / n) for v in acc) core_mean = mean_rgb(px, CORE_PX) model = model_band_mean(px, glow) diff_model = max(abs(band_mean[i] - model[i]) for i in range(3)) ch_diff = [abs(band_mean[i] - core_mean[i]) for i in range(3)] b_ok = max(ch_diff) >= B_MIN_CH_DIFF c_ok = diff_model <= C_MODEL_TOL if not b_ok: fail_b.append("%s/%s/%s" % (bt, vn, gname)) if not c_ok: fail_c.append("%s/%s/%s" % (bt, vn, gname)) hue_gap = abs(hsv(lit[bt])[0] - hsv(glow)[0]) hue_gap = min(hue_gap, 360 - hue_gap) table1.append((bt, vn, gname, band_mean, core_mean, model, diff_model, ch_diff, hue_gap, b_ok and c_ok)) band_means[(bt, vn, gname)] = band_mean # 机检 A:组间表 90 行 for bt, y0 in blocks: for vn, _ in BASE_VARIANTS: for i in range(len(GLOWS)): for j in range(i + 1, len(GLOWS)): a, b = band_means[(bt, vn, GLOWS[i][0])], band_means[(bt, vn, GLOWS[j][0])] cr = wcag(a, b) ok = cr >= 1.20 table2.append((bt, vn, "%s/%s" % (GLOWS[i][0], GLOWS[j][0]), cr, gray709(a), gray709(b), ok)) stair_ok = True for bt, y0 in blocks: for vn, _ in BASE_VARIANTS: seq = [gray709(band_means[(bt, vn, GLOWS[k][0])]) for k in range(len(GLOWS))] mono = all(seq[k] > seq[k + 1] for k in range(len(seq) - 1)) stair_ok = stair_ok and mono check("A-stair-%s-%s" % (bt, vn[:4]), "明度阶梯单调递减", mono, "→".join("%.3f" % v for v in seq)) min_cr = min(t[3] for t in table2) worst = min(table2, key=lambda t: t[3]) check("A-CR", "组间合成对比最弱对 ≥1.20(90 对)", min_cr >= 1.20, "最弱 %s/%s CR=%.3f" % (worst[0], worst[2], min_cr)) check("B", "格底-微光可辨底线(45 行三通道差不全<8)", len(fail_b) == 0, "违例 %d 行:%s" % (len(fail_b), ", ".join(fail_b[:6]) or "无")) check("C", "绘制参数校准(45 行模型差≤±2/255)", len(fail_c) == 0, "违例 %d 行:%s" % (len(fail_c), ", ".join(fail_c[:6]) or "无")) # 灰度段零彩色像素 color_hit = 0 for xx in range(MARGIN, PAGE_W - MARGIN): for yy in range(gray_top + BLOCK_TITLE, gray_top + (main_bot - main_top)): r, g, b = sheet_px[xx, yy] if not (r == g == b): color_hit += 1 check("GRAY", "灰度副本段零彩色像素", color_hit == 0, "命中 %d" % color_hit) # 四缘净空(A5 同款) edge_hit = 0 for xx in range(PAGE_W): for yy in range(PAGE_H): if xx < MARGIN or xx >= PAGE_W - MARGIN or yy < MARGIN or yy >= PAGE_H - MARGIN: if sheet_px[xx, yy] != (255, 255, 255): edge_hit += 1 check("EDGE", "四缘 10px 带内零内容像素", edge_hit == 0, "命中 %d" % edge_hit) return table1, table2 def write_report(table1, table2, d_rows): rep = os.path.join(REPO, "reports", "读数-三关色相互辨抽测-2026-11-05.md") lines = [ "# 读数:三关色相 × 五组微光互辨抽测(2026-11-05)", "", "> 接收方:待办清单「三关色相 × 五组微光互辨抽测——三关图类型色板终值冻结的前置抽测」条。", "> 执行依据:reports/提案-三关色相互辨抽测方案-2026-10-29.md(S1/S2 步)。", "> 样图:artwork/mockups/glow-discrimination-sheet-2026-11-05.png(不入 git)。", "", "## 机检汇总", "", "| 项 | 判定 | 说明 |", "|---|---|---|", ] for cid, name, ok, detail in CHECKS: lines.append("| %s | %s | %s:%s |" % (cid, "PASS" if ok else "FAIL", name, detail)) lines += ["", "## S1 机检 D:铺底代表值(底型瓦片实测顶面受光档)五门", "", "| 关 | RGB | L(线性域) | 饱和 | 色相 | 近色对 | 判定 |", "|---|---|---|---|---|---|---|"] for bt, rgb, lin, s, h, near, ok in d_rows: lines.append("| %s | %s | %.3f | %.3f | %.1f° | %d | %s |" % (bt, rgb, lin, s, h, near, "过" if ok else "红")) lines += ["", "## 表一 逐对表(45 行)", "", "| 关 | 底型 | 微光组 | 带内合成均值 | 格心素底均值 | 模型带内均值 | 模型差 | 三通道差 | 色相差° | 判定 |", "|---|---|---|---|---|---|---|---|---|---|"] for bt, vn, gn, bm, cm, md, dm, cd, hg, ok in table1: lines.append("| %s | %s | %s | %s | %s | %s | %d | %s | %.0f | %s |" % (bt, vn, gn, bm, cm, md, dm, cd, hg, "过" if ok else "红")) lines += ["", "## 表二 组间表(90 行)", "", "| 关 | 底型 | 组对 | 合成对比 | 灰度亮度a | 灰度亮度b | 判定 |", "|---|---|---|---|---|---|---|"] for bt, vn, pair, cr, ga, gb, ok in table2: lines.append("| %s | %s | %s | %.3f | %.3f | %.3f | %s |" % (bt, vn, pair, cr, ga, gb, "过" if ok else "红")) n_fail = sum(1 for c in CHECKS if not c[2]) lines += ["", "---", "", "机检 %d 项,FAIL %d 项。" % (len(CHECKS), n_fail), "", "目检(S3)归用户:四条判据见方案 §2.2(逐对指认 45/45/灰度副本 45/45/相邻并列 36 对可辨/装饰不抢读)。", ""] with open(rep, "w", encoding="utf-8") as f: f.write("\n".join(lines)) print("[报告] %s" % rep) def main(): ap = argparse.ArgumentParser() ap.add_argument("--art", default=os.path.join(REPO, "artwork", "mockups")) ap.add_argument("--no-sheet", action="store_true") args = ap.parse_args() tiles = load_tiles() print("== S1 机检 D:三关铺底代表值 ==") d_ok, d_rows = gate_d(tiles) print("== S1b 机检 E:世界底层两子带 ==") e_ok, _e_rows = gate_e() if args.no_sheet: return 0 if (d_ok and e_ok) else 1 os.makedirs(args.art, exist_ok=True) out = os.path.join(args.art, "glow-discrimination-sheet-2026-11-05.png") print("== S2 出并列样图 ==") img, cell_pngs, tiles2, blocks, gray_top, main_top, main_bot = draw_sheet(out) print("[样图] %s" % out) t1, t2 = run_checks(img, cell_pngs, tiles2, blocks, gray_top, main_top, main_bot) write_report(t1, t2, d_rows) n_fail = sum(1 for c in CHECKS if not c[2]) print("== 抽测机检完成:%d 项,FAIL %d ==" % (len(CHECKS), n_fail)) return 0 if n_fail == 0 else 1 if __name__ == "__main__": sys.exit(main())