dsh-continuity 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
@@ -0,0 +1,156 @@
1
+ # ==========================================
2
+ # 配置 —— 全部来自环境变量, 默认值面向"本机单卡, 引擎跑在 docker 里"。
3
+ #
4
+ # 目录有两套路径, 分清楚很重要:
5
+ # 本进程看到的 {STATE_DIR}/actors/王五.wav
6
+ # 音频引擎看到的 /actors/王五.wav (compose 把同一个目录 bind 进容器)
7
+ # 克隆用的 voice_ref 是交给引擎去打开的, 所以那条路径必须写引擎的视角。
8
+ # 早期版本两边混用, 表现是"铸声成功、说台词报文件不存在"。
9
+ # ==========================================
10
+ import os
11
+ import re
12
+ from pathlib import Path
13
+
14
+
15
+ def _env(name, default=None):
16
+ """把空字符串当成"没设"。
17
+
18
+ dsh 的 cordis patch 里 env 是 z.dict(String), 值不能是 undefined —— 所以那边写的是
19
+ `process.env.X ?? ''`, 没设的变量会以空串传进来。os.getenv(name, default) 只在
20
+ "键不存在"时才给默认值, 空串会原样返回, 于是 SD_SERVER 会变成 '' 而不是本机地址。
21
+ 默认值只留一份, 在这里。
22
+ """
23
+ return os.getenv(name) or default
24
+
25
+
26
+ def _dir(env, default):
27
+ p = Path(_env(env, default)).expanduser()
28
+ p.mkdir(parents=True, exist_ok=True)
29
+ return p
30
+
31
+
32
+ STATE_DIR = Path(_env("CONTINUITY_STATE_DIR", "~/.continuity")).expanduser()
33
+ # 参考音和定妆图是不可复现的长期资产 —— 重铸出来是另一个人。它们和有保留期清理的
34
+ # generated/ 分开放, 不是为了整洁, 是为了不让清理逻辑哪天顺手删掉整部戏的角色。
35
+ ACTORS_DIR = _dir("CONTINUITY_ACTORS_DIR", STATE_DIR / "actors")
36
+ SUBJECTS_DIR = _dir("CONTINUITY_SUBJECTS_DIR", STATE_DIR / "subjects")
37
+ GENERATED_DIR = _dir("CONTINUITY_GENERATED_DIR", STATE_DIR / "generated")
38
+
39
+ # 引擎容器里的挂载点。远程引擎 / 自定义挂载时改这两个。
40
+ ENGINE_ACTORS_DIR = _env("CONTINUITY_ENGINE_ACTORS_DIR", "/actors")
41
+
42
+ SD_SERVER = _env("SD_SERVER", "http://127.0.0.1:9020")
43
+ AUDIO_SERVER = _env("AUDIO_SERVER", "http://127.0.0.1:9021")
44
+
45
+ MUSIC_MODEL_ID = _env("MUSIC_MODEL_ID", "stable-audio")
46
+ DESIGN_MODEL_ID = _env("DESIGN_MODEL_ID", "qwen3-tts") # VoiceDesign: 描述 -> 声音
47
+ CLONE_MODEL_ID = _env("CLONE_MODEL_ID", "qwen3-tts-base") # Base: 参考音 -> 声音
48
+ AUDIO_MODELS = {MUSIC_MODEL_ID, DESIGN_MODEL_ID, CLONE_MODEL_ID}
49
+
50
+ MAX_IMAGE_SIZE = int(_env("MAX_IMAGE_SIZE", "1024"))
51
+ # 引擎自己在 120s 硬截断: 请求 180/240/300/480 都返回 120001 ms 且不报错。
52
+ # 保留这个上限不是防炸 (显存/耗时都与时长无关), 而是把引擎的"静默截断"变成响应里
53
+ # 显式的 clamped 字段, 让调用方知道自己被截了。
54
+ MAX_AUDIO_SECONDS = float(_env("MAX_AUDIO_SECONDS", "120"))
55
+ JOB_TIMEOUT_S = float(_env("JOB_TIMEOUT_S", "900"))
56
+ # 引擎冷启动的等待上限 (宿主机重启后要从磁盘重读十几 GB 权重)
57
+ ENGINE_WAIT_S = float(_env("ENGINE_WAIT_S", "180"))
58
+
59
+ # 文本长度上限 —— 这是护栏, 不是礼貌性的限制。实测:
60
+ # 201 字 -> 45.7 s 音频, 生成 16 s OK
61
+ # 402 字 -> 90.3 s 音频, 生成 42 s OK
62
+ # 600 字 -> ~135 s 音频, 生成 ~63 s GPU 挂死 (amdgpu GPU reset(6), 连带另一张卡上的
63
+ # 进程 SIGSEGV) —— 单次 Vulkan 提交扛不住这么长的持续计算。
64
+ # 200 字取在已知安全值 (402) 的一半, 而且 200 字已经是 45 秒旁白 —— 游戏里一句 NPC
65
+ # 台词通常 10~40 字, 这个上限不会碰到。更长的文本请分多次调用。
66
+ MAX_SPEECH_CHARS = int(_env("MAX_SPEECH_CHARS", "200"))
67
+ # 字数上限只挡住"输入长", 挡不住"输出跑飞": 引擎默认 max_tokens=2048 (~170 s 音频),
68
+ # 一句短台词一旦退化成循环, 照样能生成几分钟并拖挂 GPU。按字数推 token 预算,
69
+ # 让跑飞的请求早早自己停下。实测 ~2.7 token/字, 取 4.0 留余量。
70
+ SPEECH_TOKENS_PER_CHAR = float(_env("SPEECH_TOKENS_PER_CHAR", "4.0"))
71
+ SPEECH_MAX_TOKENS = int(_env("SPEECH_MAX_TOKENS", "900"))
72
+
73
+ # 参考音时长上限 —— 实测出来的, 不是拍的。克隆时参考音是要进模型的, 显存代价随它
74
+ # 线性涨 (基线 ~3 GiB + 约 0.19 GiB/秒):
75
+ # 5s 3.92 8s 4.58 12s 5.50 15s 6.59 18s 7.31 20s 7.58 30s 9.04
76
+ # 取 15s 是因为它是最后一个还压在生图峰值 (6.80 GiB) 之下的档 —— 再长, 配音就取代
77
+ # 生图成了整套东西的显存天花板, 8 GiB 的卡就装不下了。
78
+ # 大卡不单独放宽: 30s 的参考音对音色没有额外收益 (3~10 秒就足够定住), 为此多一个
79
+ # 按显存分档的配置项, 换来的是"同一段录音在你机器上能导入、在别人机器上不能"。
80
+ # 想要就自己设 CONTINUITY_REF_MAX_S。
81
+ REF_MIN_S = float(_env("CONTINUITY_REF_MIN_S", "2"))
82
+ REF_MAX_S = float(_env("CONTINUITY_REF_MAX_S", "15"))
83
+ # 铸声台词的字数上限, 单独于 MAX_SPEECH_CHARS —— 后者管的是"说一句台词"(200 字/45 秒),
84
+ # 而铸声产出的是参考音, 它会被之后每一句台词反复吃进显存。200 字铸出来是 45 秒的
85
+ # 参考音, 那之后每句话都要付 11 GiB 以上。60 字约 13 秒, 落在上面那张表的安全档里。
86
+ MAX_SAMPLE_CHARS = int(_env("CONTINUITY_MAX_SAMPLE_CHARS", "45"))
87
+
88
+ DEFAULT_VOICE = _env("DEFAULT_VOICE", "A neutral adult narrator, clear and natural")
89
+ # 铸声用的台词: 覆盖面尽量广, 时长 ~7 s (克隆参考音的常用区间)
90
+ DEFAULT_SAMPLE_TEXT = _env(
91
+ "DEFAULT_SAMPLE_TEXT",
92
+ "江湖路远,人心难测。今日一别,山高水长,来日方长,后会有期。")
93
+
94
+ # 空闲这么久之后把音频模型全卸掉, 让整张卡回到零常驻。
95
+ # 不"用完立刻卸"是因为重载要 4.3 s —— 连着配十句台词的人不该每句都付这个钱;
96
+ # 也不能不卸, 否则这张卡在用户不用我们的时候仍被占着 2 GB, 打不了游戏。
97
+ # 0 = 关闭空闲卸载 (始终常驻)。
98
+ AUDIO_IDLE_UNLOAD_S = float(_env("AUDIO_IDLE_UNLOAD_S", "120"))
99
+
100
+ # 生成产物保留天数; 0 表示不清理。actors/ 和 subjects/ 永不清理。
101
+ RETENTION_DAYS = float(_env("RETENTION_DAYS", "30"))
102
+ CLEANUP_INTERVAL_S = float(_env("CLEANUP_INTERVAL_S", "21600")) # 6 小时
103
+
104
+ # ---- 输出校验 ----
105
+ # 最贵的一课: 后端算错时会写出一个全 0 的 WAV 并返回 200。任何"看起来成功但内容
106
+ # 退化"的输出都必须让调用显式失败。
107
+ MIN_IMAGE_STD = float(_env("MIN_IMAGE_STD", "3.0")) # 纯灰图实测 std=0.5
108
+ MIN_AUDIO_RMS_DBFS = float(_env("MIN_AUDIO_RMS_DBFS", "-60"))
109
+
110
+ NAME_RE = re.compile(r"^[\w一-鿿-]{1,40}$")
111
+
112
+ # 三类要盯的东西不一样, 所以取景也不一样 —— 定妆图上没留下的信息, 出场景图时
113
+ # 模型只能自己编, 而它每次编得都不一样。
114
+ SUBJECT_FRAMING = {
115
+ # 人: 脸和衣着是识别点, 要正面全身看全
116
+ "character": "full body character reference, neutral standing pose, facing viewer, "
117
+ "plain flat background, clean game art",
118
+ # 动物: 体型比例和花纹分布是识别点, 四分之三站姿同时给出侧面轮廓和正面头部
119
+ "animal": "full body animal reference, standing in three-quarter view, head visible, "
120
+ "plain flat background, no scenery, clean game art",
121
+ # 物件: 几何是识别点, 且最容易漂 —— 正投影看不出体积, 换个角度就没有可对齐的信息
122
+ "object": "single game asset reference, three-quarter view, centered, isolated, "
123
+ "plain flat background, no scenery, clean game art",
124
+ }
125
+ DEFAULT_SUBJECT_KIND = "character"
126
+
127
+ # rembg 模型。同机实测 (512x512, 热推理):
128
+ # u2netp 0.16s 软边最多
129
+ # isnet-general-use 1.38s
130
+ # birefnet-general-lite 5.89s <- 默认
131
+ # bria-rmbg 10.85s 同样干净, 翻倍耗时只换来边缘一点点提升
132
+ # best 的代价不在时间而在内存: 峰值 ~6.8 GB 常驻内存 (与输入尺寸无关)。
133
+ # 16 GB 以下的机器由 continuity-setup 把默认值改成 fast。
134
+ REMBG_MODELS = {"best": "birefnet-general-lite", "fast": "u2netp"}
135
+ DEFAULT_CUTOUT_QUALITY = _env("CONTINUITY_CUTOUT_QUALITY", "best")
136
+
137
+ # 棋盘格判定阈值 (见 cutout.looks_like_checkerboard)
138
+ CHECKER_MIN_CAND = float(_env("CHECKER_MIN_CAND", "0.05"))
139
+ CHECKER_MIN_GAP = float(_env("CHECKER_MIN_GAP", "15"))
140
+ CHECKER_MAX_VALLEY = float(_env("CHECKER_MAX_VALLEY", "0.30"))
141
+ CHECKER_MIN_RUNS = float(_env("CHECKER_MIN_RUNS", "0.60"))
142
+
143
+ # alpha 退化判定 (见 cutout.alpha_report)
144
+ ALPHA_MAX_TRANSPARENT = float(_env("ALPHA_MAX_TRANSPARENT", "0.95"))
145
+ ALPHA_MIN_TRANSPARENT = float(_env("ALPHA_MIN_TRANSPARENT", "0.02"))
146
+ ALPHA_MIN_BLOB = float(_env("ALPHA_MIN_BLOB", "0.02"))
147
+ ALPHA_MAX_HOLES = float(_env("ALPHA_MAX_HOLES", "0.05"))
148
+ ALPHA_MAX_BG_DETAIL = float(_env("ALPHA_MAX_BG_DETAIL", "0.5"))
149
+
150
+ SFX_RATE = 44100
151
+ MAX_SFX_SECONDS = float(_env("MAX_SFX_SECONDS", "5"))
152
+
153
+ # 能力开关。显存不够时 continuity-setup 会关掉生图那半而保留音频那半 ——
154
+ # 见 preflight.py: 换模型省不下显存 (Q4 与 Q8 实测同为 6.6 GB), 能省的只有"不装"。
155
+ ENABLE_IMAGE = _env("CONTINUITY_ENABLE_IMAGE", "1") not in ("0", "false", "False")
156
+ ENABLE_AUDIO = _env("CONTINUITY_ENABLE_AUDIO", "1") not in ("0", "false", "False")
@@ -0,0 +1,315 @@
1
+ # ==========================================
2
+ # 抠图 / 切图 —— 纯 CPU, 不碰 GPU, 所以不走 jobs.py 那把串行锁。
3
+ #
4
+ # FLUX 生成不出 alpha: 你让它画"透明背景", 它把 PS 那种灰白棋盘格当成不透明像素画
5
+ # 出来。做精灵图必须转成真 RGBA。两条路径:
6
+ # checker 抠掉假透明棋盘格 (按结构证据判定, 不按"抠掉了多少")
7
+ # rembg 通用显著物体抠图 (onnxruntime, CPU)
8
+ # 外加一份质量报告: 抠成一堆碎片 / 主体被啃出洞 / 压根没有前景主体, 都会附警告。
9
+ # ==========================================
10
+ import logging
11
+
12
+ import numpy as np
13
+ from PIL import Image, ImageDraw
14
+
15
+ from .config import (REMBG_MODELS, CHECKER_MIN_CAND, CHECKER_MIN_GAP, CHECKER_MAX_VALLEY,
16
+ CHECKER_MIN_RUNS, ALPHA_MAX_TRANSPARENT, ALPHA_MIN_TRANSPARENT,
17
+ ALPHA_MIN_BLOB, ALPHA_MAX_HOLES, ALPHA_MAX_BG_DETAIL)
18
+
19
+ log = logging.getLogger("continuity")
20
+
21
+ _rembg_sessions = {} # 每个模型一个常驻 session (onnxruntime 初始化很贵)
22
+
23
+
24
+ def checker_candidates(a, bright=195, neutral=20):
25
+ """又亮又接近中性灰的像素 —— 棋盘格的必要条件, 但远不是充分条件。"""
26
+ return (a.min(2) >= bright) & ((a.max(2) - a.min(2)) <= neutral)
27
+
28
+
29
+ def key_checkerboard(img, bright=195, neutral=20):
30
+ """把 FLUX 画出来的"透明棋盘格"抠掉, 返回 alpha 数组。
31
+
32
+ FLUX.2 Klein 不会输出 alpha 通道: 你要"透明背景", 它就把 PS 那种灰白格子当成
33
+ 不透明像素画出来。这些格子的特征是又亮又接近中性灰 (实测 255 与 220 两种方块)。
34
+ 只按颜色判定会连鸟肚子上的白色一起抠掉, 所以再加一条: 必须与画面边缘连通。
35
+ """
36
+ a = np.asarray(img.convert("RGB")).astype(np.int16)
37
+ cand = checker_candidates(a, bright, neutral)
38
+ # Image.fromarray 返回的是只读 buffer, floodfill 的写入会被静默丢弃, 必须 copy()
39
+ m = Image.fromarray(np.where(cand, 255, 0).astype(np.uint8)).copy()
40
+ w, h = img.size
41
+ for xy in ((0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)):
42
+ if m.getpixel(xy) == 255:
43
+ ImageDraw.floodfill(m, xy, 128, thresh=0)
44
+ return np.where(np.array(m) == 128, 0, 255).astype(np.uint8)
45
+
46
+
47
+ def two_tone(grey, cand):
48
+ """候选区的灰度是不是"两级"。返回 (峰1, 峰2, 谷/峰)。
49
+
50
+ 真棋盘格实测并不是干净的两个值 (FLUX 画出来带噪): 255/254 一簇, 219~224 一簇。
51
+ 所以用平滑直方图找两个峰, 再看两峰之间的谷有多深 —— 连续渐变的摄影背景填满谷,
52
+ 两级方格则谷接近 0。
53
+ """
54
+ hist = np.bincount(grey[cand], minlength=256).astype(np.float64)
55
+ sm = np.convolve(hist, np.ones(5) / 5.0, mode="same")
56
+ total = sm.sum()
57
+ if total <= 0:
58
+ return 0, 0, 1.0
59
+ sm /= total
60
+ p1 = int(np.argmax(sm))
61
+ far = np.ones(256, dtype=bool)
62
+ far[max(0, p1 - 10):p1 + 11] = False # 第二个峰必须离第一个足够远
63
+ p2 = int(np.argmax(np.where(far, sm, -1.0)))
64
+ lo, hi = sorted((p1, p2))
65
+ peak = float(min(sm[p1], sm[p2]))
66
+ valley = float(sm[lo + 1:hi].min()) if hi - lo > 1 else peak
67
+ return p1, p2, (valley / peak if peak > 0 else 1.0)
68
+
69
+
70
+ def tone_runs(tone, cand):
71
+ """沿行统计交替方块的游程长度。
72
+
73
+ 只看候选像素占多数的行, 并丢掉每段两端不完整的游程 —— 半个方块会污染中位数。
74
+ """
75
+ out = []
76
+ for r in range(tone.shape[0]):
77
+ row = cand[r]
78
+ if row.mean() < 0.5:
79
+ continue
80
+ idx = np.flatnonzero(row)
81
+ if idx.size < 32:
82
+ continue
83
+ for span in np.split(idx, np.flatnonzero(np.diff(idx) > 1) + 1):
84
+ if span.size < 32:
85
+ continue
86
+ t = tone[r, span]
87
+ bounds = np.concatenate(([0], np.flatnonzero(np.diff(t)) + 1, [t.size]))
88
+ rl = np.diff(bounds)
89
+ if rl.size > 2:
90
+ out.extend(rl[1:-1].tolist())
91
+ return np.asarray(out, dtype=np.float64)
92
+
93
+
94
+ def looks_like_checkerboard(img, bright=195, neutral=20):
95
+ """这张图的亮中性区域到底是不是 FLUX 的"假透明"棋盘格。返回 (bool, 证据)。
96
+
97
+ 旧实现按"抠掉了多少"来判 (yield): 先抠一遍, 抠掉 >5% 就认为是棋盘格。那是错的 ——
98
+ 任何明亮中性的摄影背景都满足候选条件。实测白猫 + 浅灰影棚背景被判成 checker (0.662),
99
+ floodfill 从边缘连通进猫身体, 把猫身上和头上啃出大洞。
100
+
101
+ 改判结构证据: 棋盘格是恰好两级灰度 (实测 253 与 221) 铺成的固定边长方块。
102
+ 两个条件都成立才算; 否则一律走 rembg。
103
+ """
104
+ a = np.asarray(img.convert("RGB")).astype(np.int16)
105
+ cand = checker_candidates(a, bright, neutral)
106
+ ev = {"cand_ratio": round(float(cand.mean()), 4)}
107
+ if cand.mean() < CHECKER_MIN_CAND:
108
+ return False, dict(ev, reason="亮中性区域太小, 没有假透明背景")
109
+ grey = a.mean(2).astype(np.uint8)
110
+ p1, p2, valley = two_tone(grey, cand)
111
+ ev.update(tones=[p1, p2], tone_gap=abs(p1 - p2), valley_ratio=round(valley, 3))
112
+ if abs(p1 - p2) < CHECKER_MIN_GAP or valley > CHECKER_MAX_VALLEY:
113
+ return False, dict(ev, reason="灰度是连续渐变而非两级, 像摄影背景")
114
+ tone = grey >= (p1 + p2) / 2.0
115
+ rows, cols = tone_runs(tone, cand), tone_runs(tone.T, cand.T)
116
+ if rows.size < 16 or cols.size < 16:
117
+ return False, dict(ev, reason="没有成片的候选区可判周期")
118
+ cell_r, cell_c = float(np.median(rows)), float(np.median(cols))
119
+ cons_r = float((np.abs(rows - cell_r) <= max(1.0, 0.25 * cell_r)).mean())
120
+ cons_c = float((np.abs(cols - cell_c) <= max(1.0, 0.25 * cell_c)).mean())
121
+ ev.update(cell=[round(cell_r, 1), round(cell_c, 1)],
122
+ run_consistency=[round(cons_r, 3), round(cons_c, 3)])
123
+ if cons_r < CHECKER_MIN_RUNS or cons_c < CHECKER_MIN_RUNS:
124
+ return False, dict(ev, reason="方块边长不规则, 不是周期网格")
125
+ if not (3 <= cell_r <= 128 and 3 <= cell_c <= 128):
126
+ return False, dict(ev, reason="方块尺寸不合理")
127
+ if abs(cell_r - cell_c) > 0.25 * max(cell_r, cell_c):
128
+ return False, dict(ev, reason="方块不是正方形")
129
+ return True, dict(ev, reason="两级灰度 + 周期方格 = FLUX 假透明")
130
+
131
+
132
+ def rembg_alpha(img, quality="best"):
133
+ """通用显著物体抠图。放 CPU (onnxruntime) —— GPU 留给两个 ggml 引擎。"""
134
+ import onnxruntime as ort
135
+ from rembg import new_session, remove
136
+ model = REMBG_MODELS.get(quality, REMBG_MODELS["best"])
137
+ sess = _rembg_sessions.get(model)
138
+ if sess is None:
139
+ log.info("rembg: 首次加载 %s", model)
140
+ # onnxruntime 的 CPU memory arena 把推理峰值变成常驻内存, 且永不归还:
141
+ # 实测 1024x1024 两次调用后 RSS 0.06 -> 7.5 -> 12.1 GB 封顶不动
142
+ # (30 GB 的机器凭空少掉 40% 内存, available 只剩 2 GB)。
143
+ # 关掉 arena 后常驻 0.69 GB, alpha 输出逐位相同 —— 纯粹是分配器行为。
144
+ so = ort.SessionOptions()
145
+ so.enable_cpu_mem_arena = False
146
+ sess = _rembg_sessions[model] = new_session(model, sess_opts=so)
147
+ return model, np.array(remove(img.convert("RGB"), session=sess))[:, :, 3]
148
+
149
+ def largest_blob_ratio(mask, max_side=192):
150
+ """最大不透明连通块占整图的比例。
151
+
152
+ 缩到 <=192px 再做标签传播 (取 4 邻域最大值直到不动): 这个数只用来判"抠出来的东西
153
+ 碎成了渣", 不需要像素级精度, 而全分辨率的纯 python 连通域太慢。
154
+ """
155
+ h, w = mask.shape
156
+ s = max_side / max(h, w)
157
+ if s < 1.0:
158
+ m = np.asarray(Image.fromarray(mask.astype(np.uint8) * 255).resize(
159
+ (max(1, int(w * s)), max(1, int(h * s))), Image.NEAREST)) > 127
160
+ else:
161
+ m = mask
162
+ if not m.any():
163
+ return 0.0
164
+ lab = np.where(m, np.arange(1, m.size + 1).reshape(m.shape), 0)
165
+ for _ in range(4 * max(m.shape)):
166
+ nb = lab.copy()
167
+ nb[1:] = np.maximum(nb[1:], lab[:-1])
168
+ nb[:-1] = np.maximum(nb[:-1], lab[1:])
169
+ nb[:, 1:] = np.maximum(nb[:, 1:], lab[:, :-1])
170
+ nb[:, :-1] = np.maximum(nb[:, :-1], lab[:, 1:])
171
+ nb = np.where(m, nb, 0)
172
+ if np.array_equal(nb, lab):
173
+ break
174
+ lab = nb
175
+ counts = np.bincount(lab.ravel())
176
+ counts[0] = 0
177
+ return float(counts.max() / m.size)
178
+
179
+
180
+ def hole_ratio(alpha):
181
+ """主体轮廓内部被抠出来的洞, 占不透明面积的比例。
182
+
183
+ 白猫那次翻车不是比例算错 —— 0.662 对那张图完全是个合理的数字; 真正错的是
184
+ 猫身上和头上被啃出了洞。洞是"透明、但从画面边缘走不到"的像素, 用 key_checkerboard
185
+ 已经在用的 floodfill 就能数出来 (外面先套一圈透明, 让所有贴边的透明区连成一片,
186
+ 一次 floodfill 就够), 不必为此引入 scipy。
187
+ """
188
+ opaque = alpha > 0
189
+ n = int(opaque.sum())
190
+ if n == 0:
191
+ return 0.0
192
+ h, w = opaque.shape
193
+ pad = np.zeros((h + 2, w + 2), dtype=np.uint8)
194
+ pad[1:-1, 1:-1] = np.where(opaque, 255, 0)
195
+ m = Image.fromarray(pad).copy() # fromarray 的 buffer 只读, 必须 copy
196
+ ImageDraw.floodfill(m, (0, 0), 128, thresh=0)
197
+ return float((np.asarray(m)[1:-1, 1:-1] == 0).sum() / n)
198
+
199
+
200
+ def bg_detail_ratio(img, alpha):
201
+ """被抠掉的那片区域, 细节密度相对于留下来的主体有多高。
202
+
203
+ 合格的抠图, 去掉的是背景: 影棚灰、白桌面、绿幕、棋盘格, 都很平。当"背景"和主体
204
+ 一样满是边缘时 (集市那张: 0.90), 说明模型只是从一整幅场景里随手挑了几个人留下,
205
+ 这种结果多半不是调用方想要的。返回 (相对比值, 去掉区域的平均梯度)。
206
+ """
207
+ g = np.asarray(img.convert("L"), dtype=np.float32)
208
+ gx, gy = np.abs(np.diff(g, axis=1)), np.abs(np.diff(g, axis=0))
209
+ def mean_in(mask):
210
+ mx, my = mask[:, :-1] & mask[:, 1:], mask[:-1] & mask[1:]
211
+ v = np.concatenate([gx[mx], gy[my]])
212
+ return float(v.mean()) if v.size else 0.0
213
+ removed, kept = mean_in(alpha == 0), mean_in(alpha > 127)
214
+ return (removed / kept if kept > 1.0 else 0.0), removed
215
+
216
+
217
+ def alpha_report(img, alpha, extra=()):
218
+ """算出抠图质量指标, 并在明显不对时给一句话警告 (不失败, 只是别再默默报成功)。"""
219
+ tr = float((alpha == 0).mean())
220
+ solid = float((alpha > 127).mean())
221
+ holes = hole_ratio(alpha)
222
+ blob = largest_blob_ratio(alpha > 127)
223
+ detail, removed_grad = bg_detail_ratio(img, alpha)
224
+ m = {"transparent_ratio": round(tr, 4), "solid_ratio": round(solid, 4),
225
+ "hole_ratio": round(holes, 4), "largest_blob_ratio": round(blob, 4),
226
+ "bg_detail_ratio": round(detail, 3)}
227
+ w = list(extra)
228
+ if tr > ALPHA_MAX_TRANSPARENT:
229
+ w.append(f"{tr:.1%} 的像素被抠成了透明, 主体几乎整个没了")
230
+ if tr < ALPHA_MIN_TRANSPARENT:
231
+ w.append(f"只抠掉了 {tr:.1%}, 基本什么都没去掉")
232
+ if blob < ALPHA_MIN_BLOB:
233
+ w.append(f"最大的不透明连通块只占整图 {blob:.1%}, 抠出来的是碎片不是主体")
234
+ if holes > ALPHA_MAX_HOLES:
235
+ w.append(f"主体轮廓内部有 {holes:.1%} 的面积被挖成了洞 (相对不透明面积), "
236
+ f"多半是背景色和主体撞色被啃穿了")
237
+ if detail > ALPHA_MAX_BG_DETAIL and removed_grad > 5.0:
238
+ w.append(f"被去掉的区域细节密度是主体的 {detail:.0%}, 那不是背景而是画面的一部分, "
239
+ f"这张图没有明确的前景主体")
240
+ return m, ("抠图结果很可能不对: " + "; ".join(w) if w else None)
241
+
242
+
243
+ def trim_frame(frame):
244
+ """裁到非透明/非棋盘格的外接框。"""
245
+ if frame.mode == "RGBA":
246
+ box = frame.getchannel("A").getbbox()
247
+ else:
248
+ box = Image.fromarray(key_checkerboard(frame)).getbbox()
249
+ return frame.crop(box) if box else frame
250
+
251
+
252
+
253
+ def remove_bg(img, mode="auto", quality="best", new_name=None, out_dir=None):
254
+ """抠掉背景, 写出真正带 alpha 的 RGBA PNG。返回 (路径, 结果字典)。"""
255
+ if mode not in ("auto", "checker", "rembg"):
256
+ raise ValueError("mode 必须是 auto/checker/rembg")
257
+ if quality not in REMBG_MODELS:
258
+ raise ValueError(f"quality 必须是 {'/'.join(REMBG_MODELS)}")
259
+ evidence, is_checker, extra = None, None, []
260
+ if mode == "auto":
261
+ # 按结构证据路由, 不按"抠掉了多少" —— 后者会把浅色影棚背景误判成棋盘格,
262
+ # floodfill 从边缘连通进主体, 在猫身上和头上啃出洞。
263
+ is_checker, evidence = looks_like_checkerboard(img)
264
+ used = "checker" if is_checker else "rembg"
265
+ else:
266
+ used = mode
267
+ if used == "checker":
268
+ # 手动指定也照样取证: 强行对一张照片走 checker 正是上面那条翻车路径,
269
+ # 抠出来的洞常常与背景连通 (不是闭合的洞), hole_ratio 抓不到,
270
+ # 只有"这压根不是棋盘格"这个证据抓得到。
271
+ is_checker, evidence = looks_like_checkerboard(img)
272
+ if used == "checker":
273
+ alpha, model = key_checkerboard(img), None
274
+ if is_checker is False:
275
+ extra.append(f"你指定了 mode=checker, 但这张图不是 FLUX 的假透明棋盘格 "
276
+ f"({evidence.get('reason')}) —— 亮色背景会顺着边缘连通吃进主体, "
277
+ f"改用 mode=auto 或 mode=rembg")
278
+ else:
279
+ model, alpha = rembg_alpha(img, quality)
280
+ out = img.convert("RGBA")
281
+ out.putalpha(Image.fromarray(alpha))
282
+ path = out_dir / new_name
283
+ out.save(path, format="PNG")
284
+ metrics, warning = alpha_report(img, alpha, extra)
285
+ res = {"transparent_ratio": metrics["transparent_ratio"], "mode_used": used,
286
+ "model": model, "metrics": metrics}
287
+ if evidence:
288
+ res["checker_evidence"] = evidence
289
+ if warning:
290
+ log.warning("remove_bg %s: %s", new_name, warning)
291
+ res["warning"] = warning
292
+ return path, res
293
+
294
+
295
+ def slice_sheet(img, rows=None, cols=None, frame_width=None, frame_height=None,
296
+ trim=True, name_fn=None, out_dir=None):
297
+ """把排成网格的 sprite sheet 切成单帧 PNG。返回路径列表。"""
298
+ W, H = img.size
299
+ if rows and cols:
300
+ fw, fh = W // cols, H // rows
301
+ elif frame_width and frame_height:
302
+ fw, fh = frame_width, frame_height
303
+ rows, cols = H // fh, W // fw
304
+ else:
305
+ raise ValueError("必须提供 rows+cols 或 frame_width+frame_height")
306
+ paths = []
307
+ for r in range(rows):
308
+ for c in range(cols):
309
+ f = img.crop((c * fw, r * fh, (c + 1) * fw, (r + 1) * fh))
310
+ if trim:
311
+ f = trim_frame(f)
312
+ p = out_dir / name_fn()
313
+ f.save(p, format="PNG")
314
+ paths.append(p)
315
+ return paths
@@ -0,0 +1,58 @@
1
+ # ==========================================
2
+ # 两个 ggml/Vulkan 引擎的构建 + 运行镜像。
3
+ #
4
+ # 从源码编译而不是拉预编译二进制: Vulkan 后端的 shader 是运行时编译的 SPIR-V,
5
+ # 但 host 端还是要针对本机的编译器/库编。这也让镜像与厂商无关 —— 里面没有一行
6
+ # CUDA 或 ROCm。换卡不用换镜像, 但接入方式分两种:
7
+ # A 卡 / Intel 宿主机递 /dev/dri, ICD 用镜像里的 mesa (A 卡实测过)
8
+ # N 卡 nvidia-container-toolkit 注入宿主机驱动 (未实测, 见 nvidia 覆盖文件)
9
+ #
10
+ # 三个依赖不加就会构建失败, 每个都花过一次:
11
+ # mesa-vulkan-drivers Vulkan 的 ICD。A 卡 (RADV) 和 Intel (ANV) 的驱动都在 mesa 里,
12
+ # 缺了容器里 vulkaninfo 报 "Found no drivers"。
13
+ # N 卡不走这条: 它的 ICD 在宿主机驱动里, 由 nvidia-container-toolkit
14
+ # 注入, 见 docker-compose.nvidia.yml。
15
+ # spirv-headers ggml-vulkan.cpp 条件 include <spirv/unified1/spirv.hpp>,
16
+ # 缺了报 "'spv' has not been declared"
17
+ # glslc/glslang-tools 编译 shader
18
+ # libglslang-dev 在 Ubuntu 24.04 不存在, 别加。
19
+ # ==========================================
20
+ FROM ubuntu:24.04 AS build
21
+ ENV DEBIAN_FRONTEND=noninteractive
22
+ RUN apt-get update && apt-get install -y --no-install-recommends \
23
+ build-essential cmake git ca-certificates pkg-config ninja-build \
24
+ libvulkan-dev vulkan-tools mesa-vulkan-drivers \
25
+ glslc glslang-tools spirv-tools spirv-headers \
26
+ python3 curl \
27
+ && rm -rf /var/lib/apt/lists/*
28
+
29
+ # 固定到实测跑通的提交。浮动到 master 意味着某天的上游改动会静默改变本机行为,
30
+ # 而这套东西的全部价值就是"跨次调用不变"。
31
+ ARG SDCPP_REF=97d2990
32
+ ARG AUDIOCPP_REF=aec444c
33
+
34
+ WORKDIR /src
35
+ RUN git clone --recursive https://github.com/leejet/stable-diffusion.cpp.git \
36
+ && git -C stable-diffusion.cpp checkout ${SDCPP_REF} \
37
+ && git -C stable-diffusion.cpp submodule update --init --recursive \
38
+ && cmake -S stable-diffusion.cpp -B stable-diffusion.cpp/build \
39
+ -DCMAKE_BUILD_TYPE=Release -DSD_VULKAN=ON -G Ninja \
40
+ && cmake --build stable-diffusion.cpp/build -j"$(nproc)"
41
+
42
+ RUN git clone https://github.com/0xShug0/audio.cpp.git \
43
+ && git -C audio.cpp checkout ${AUDIOCPP_REF} \
44
+ && git -C audio.cpp submodule update --init --recursive \
45
+ && cd audio.cpp \
46
+ && bash scripts/build_linux.sh --backend vulkan \
47
+ --target audiocpp_cli --target audiocpp_server
48
+
49
+ # ---- 运行镜像: 只要 Vulkan 运行时, 不要编译器 ----
50
+ FROM ubuntu:24.04
51
+ ENV DEBIAN_FRONTEND=noninteractive
52
+ RUN apt-get update && apt-get install -y --no-install-recommends \
53
+ libvulkan1 mesa-vulkan-drivers vulkan-tools libgomp1 ca-certificates \
54
+ && rm -rf /var/lib/apt/lists/*
55
+ COPY --from=build /src/stable-diffusion.cpp/build/bin/ /opt/continuity/bin/
56
+ COPY --from=build /src/audio.cpp/build/linux-vulkan-release/bin/ /opt/continuity/bin/
57
+ ENV PATH="/opt/continuity/bin:${PATH}"
58
+ WORKDIR /opt/continuity
@@ -0,0 +1,37 @@
1
+ {
2
+ "host": "0.0.0.0",
3
+ "port": 9021,
4
+ "backend": "vulkan",
5
+ "device": __DEVICE__,
6
+ "threads": 4,
7
+ "lazy_load": false,
8
+ "models": [
9
+ {
10
+ "id": "stable-audio",
11
+ "family": "stable_audio",
12
+ "path": "/models/audio/stable-audio-3-small-music-f16.gguf",
13
+ "task": "gen",
14
+ "mode": "offline",
15
+ "session_options": { "stable_audio.mem_saver": true },
16
+ "lazy": true
17
+ },
18
+ {
19
+ "id": "qwen3-tts",
20
+ "family": "qwen3_tts",
21
+ "path": "/models/audio/Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF",
22
+ "task": "vdes",
23
+ "mode": "offline",
24
+ "session_options": { "qwen3_tts.mem_saver": true },
25
+ "lazy": true
26
+ },
27
+ {
28
+ "id": "qwen3-tts-base",
29
+ "family": "qwen3_tts",
30
+ "path": "/models/audio/Qwen3-TTS-12Hz-1.7B-Base-GGUF",
31
+ "task": "tts",
32
+ "mode": "offline",
33
+ "session_options": { "qwen3_tts.mem_saver": true },
34
+ "lazy": true
35
+ }
36
+ ]
37
+ }
@@ -0,0 +1,46 @@
1
+ # ==========================================
2
+ # N 卡的设备接入方式和 A 卡 / Intel 不一样, 所以单独一个覆盖文件。
3
+ #
4
+ # A 卡和 Intel 的 Vulkan 驱动 (RADV / ANV) 都在 mesa 里, 镜像里装了 mesa-vulkan-drivers
5
+ # 就有 ICD, 宿主机只要把 /dev/dri 递进来即可。
6
+ # N 卡不是: 它的 ICD (nvidia_icd.json) 和驱动库都在宿主机驱动里, 必须由
7
+ # nvidia-container-toolkit 在启动时注入 —— 只递 /dev/dri 的话容器里一块卡也看不见。
8
+ # 注入还必须带 graphics 能力: 默认的 NVIDIA_DRIVER_CAPABILITIES 只有 compute+utility,
9
+ # 那样 CUDA 能用而 Vulkan 仍然看不到设备。
10
+ #
11
+ # 前置: 宿主机装好 NVIDIA 驱动 + nvidia-container-toolkit。
12
+ # 用法 (continuity-setup 探到 N 卡时会自动这么做):
13
+ # docker compose -f docker-compose.yml -f docker-compose.nvidia.yml ...
14
+ #
15
+ # ⚠️ 这条路径没有在 N 卡上实测过 —— 手头只有 A 卡。它是按 NVIDIA 官方文档写的,
16
+ # ggml 的 Vulkan 后端本身在 N 卡上是被广泛使用的, 但"广泛使用"不等于"我验过"。
17
+ # 跑通或跑不通都欢迎开 issue 告诉我。
18
+ # ==========================================
19
+ services:
20
+ sd-server:
21
+ devices: !reset []
22
+ group_add: !reset []
23
+ environment:
24
+ NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES:-all}
25
+ NVIDIA_DRIVER_CAPABILITIES: graphics,compute,utility
26
+ deploy:
27
+ resources:
28
+ reservations:
29
+ devices:
30
+ - driver: nvidia
31
+ count: all
32
+ capabilities: [gpu]
33
+
34
+ audiocpp-server:
35
+ devices: !reset []
36
+ group_add: !reset []
37
+ environment:
38
+ NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES:-all}
39
+ NVIDIA_DRIVER_CAPABILITIES: graphics,compute,utility
40
+ deploy:
41
+ resources:
42
+ reservations:
43
+ devices:
44
+ - driver: nvidia
45
+ count: all
46
+ capabilities: [gpu]