-
Notifications
You must be signed in to change notification settings - Fork 4
feat(framework): Provider 抽象 + 主体抠图 + 视频下载重试(#152 的前置 · Refs #171) #179
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
xiaocheny214
merged 12 commits into
1024XEngineer:main
from
johnnyzhang-eng:feat/provider-interfaces-and-matte
Aug 11, 2026
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
de57b4c
feat(framework): 补 provider 抽象接口与抠图/视频实现
johnnyzhang-eng bedeb4d
fix(providers): 首帧字段按模型选,并拦截被静默忽略的参考图
johnnyzhang-eng d2659e3
feat(providers): 按现行 FAL 队列接口重写 i2v,并回退按旧接口形状打的两处补丁
johnnyzhang-eng 5db2135
fix(providers): 抠图补底色清理,去掉猜背景色的静默兜底
johnnyzhang-eng 9b10b55
fix(framework): 依赖声明取主线版本,修 rebase 时 lock 与 pyproject 不一致
johnnyzhang-eng ae3657d
fix(providers): 视频下载不再把 API key 带给成品域名
johnnyzhang-eng 2a58964
feat(providers): 实现文生图 provider,端点不再必然失败
johnnyzhang-eng 1edbeb0
fix(providers): 文生图走配置里的路径,并把"网关没这个模型"翻译成能照着修的错误
johnnyzhang-eng 1667219
fix(providers): 抠图补封闭空洞,但腿间空隙必须按颜色豁免
johnnyzhang-eng 7245818
refactor(providers): 模型型号进配置,请求形状留在代码里
johnnyzhang-eng 9d9f943
refactor(providers): 移除从未真实调用过的 FAL 队列面
johnnyzhang-eng bb4a5aa
test(providers): 补 i2v 主流程与 cutout 装配的覆盖,并修一个除零
johnnyzhang-eng File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
21 changes: 20 additions & 1 deletion
21
backend/packages/framework/src/windup_framework/providers/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,32 @@ | ||
| """按模型能力划分的 AI Provider 接口。""" | ||
| """按模型能力划分的 AI Provider:官方客户端工厂 + 能力接口 + SUFY 实现。""" | ||
|
|
||
| from windup_framework.config.provider import AIProviderSettings | ||
| from windup_framework.providers.chat import create_chat_model | ||
| from windup_framework.providers.image import create_image_client | ||
| from windup_framework.providers.interfaces import ( | ||
| ImageProvider, | ||
| MatteProvider, | ||
| VideoProvider, | ||
| ) | ||
| from windup_framework.providers.matte import OnnxU2NetMatteProvider | ||
| from windup_framework.providers.sufy import ( | ||
| SufyImageProvider, | ||
| SufyVideoProvider, | ||
| ) | ||
| from windup_framework.providers.video import create_video_client | ||
|
|
||
| __all__ = [ | ||
| "AIProviderSettings", | ||
| "create_chat_model", | ||
| "create_image_client", | ||
| "create_video_client", | ||
| # 能力接口(ai_engine 依赖这些稳定契约) | ||
| "ImageProvider", | ||
| "VideoProvider", | ||
| "MatteProvider", | ||
| # 实现 | ||
| "SufyVideoProvider", | ||
| # FAL 队列面的 i2v(现役接口形态);首帧要公网 URL,故与 uploader 成对出现 | ||
| "SufyImageProvider", | ||
| "OnnxU2NetMatteProvider", | ||
| ] |
43 changes: 43 additions & 0 deletions
43
backend/packages/framework/src/windup_framework/providers/interfaces.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| """AI 模型底层适配器接口(framework)—— behind interface,key 由 config 注入。 | ||
|
|
||
| ai_engine 经这些接口调模型,不直接读 env、不锁死具体供应商 / 模型名(可 A/B 换)。 | ||
| 实测在用:图像 = gemini-flash-image;视频 = kling-v2-5-turbo(2026-07-27 端到端实测 | ||
| 到 completed;#53 早期"仅 o1 可用、v2-5-turbo 下架"的结论已被该实测推翻);抠图 = rembg。 | ||
|
|
||
| 本文件是接口契约(真);具体 HTTP 实现见 :mod:`.sufy`。 | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| from typing import Protocol, runtime_checkable | ||
|
|
||
|
|
||
| @runtime_checkable | ||
| class ImageProvider(Protocol): | ||
| """文 + 参考图 → 图(视角规整 / 定妆 / 逐帧生成)。""" | ||
|
|
||
| def gen_image(self, prompt: str, refs: list[bytes]) -> bytes: ... | ||
|
|
||
|
|
||
| @runtime_checkable | ||
| class VideoProvider(Protocol): | ||
| """首帧图 + 动作 prompt → 视频(i2v,步态位移动作用)。 | ||
|
|
||
| **入参恒为 bytes,不是 URL。** 上游(ai_engine.strategy)手里只有母版 bytes,而且它 | ||
| 必须有 bytes —— ``master_check`` 预检、``master_prep`` 预处理、像素化锁色板全都读 | ||
| 母版**像素**。让调用点改传 URL 的话,ai_engine 还得自己下载回 bytes 才能干活。 | ||
|
|
||
| 某些供应商的接口只吃公网 URL。那属于**该 provider 自己的适配问题**:在 provider | ||
| 内部完成 bytes → URL 的转换(需要一个上传能力时由组装层注入),而不是把这个差异 | ||
| 漏给上层。这样"用哪个厂商"不会改变 ai_engine 的一行代码。 | ||
| """ | ||
|
|
||
| def i2v( | ||
| self, first_frame: bytes, prompt: str, seconds: int = 5, size: str = "1280x720" | ||
| ) -> bytes: ... | ||
|
|
||
|
|
||
| @runtime_checkable | ||
| class MatteProvider(Protocol): | ||
| """主体抠图(rembg / u2net)—— 按主体抠,不抠颜色(浅色角色撞背景会抠穿)。""" | ||
|
|
||
| def cutout(self, frame: bytes) -> bytes: ... |
205 changes: 205 additions & 0 deletions
205
backend/packages/framework/src/windup_framework/providers/matte.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,205 @@ | ||
| """主体抠图 MatteProvider —— onnxruntime 直跑 u2netp,不依赖 rembg。 | ||
|
|
||
| 为什么不用 rembg:rembg → pymatting → numba 0.53 / llvmlite 0.36 这条老链在 Python | ||
| 3.12 无轮子(实测装不上)。而 rembg 内核就是"u2netp.onnx 过一遍 onnxruntime";默认 | ||
| ``alpha_matting=False`` 时根本不碰 pymatting。故直调 onnxruntime,甩掉整条死重依赖, | ||
| 3.12 干净可装、可进 lock。同模型(u2netp),同质量。 | ||
|
|
||
| 模型解析顺序:显式 ``model_path`` → 缓存目录已存在 → 从 ``model_url`` 惰性下载。 | ||
| onnxruntime 惰性导入(启动慢、按需加载),会话按需构建一次。 | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import io | ||
| import urllib.request | ||
| from pathlib import Path | ||
|
|
||
| import numpy as np | ||
| from PIL import Image | ||
|
|
||
| from .interfaces import MatteProvider | ||
|
|
||
| # u2netp:轻量版(~4.7MB)。rembg 官方 release 托管;国内不可达时可预置 model_path。 | ||
| _U2NETP_URL = "https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2netp.onnx" | ||
| _DEFAULT_CACHE = Path.home() / ".cache" / "windup" / "u2netp.onnx" | ||
|
|
||
| # u2net 预处理常量(与 rembg 一致)。 | ||
| _MEAN = (0.485, 0.456, 0.406) | ||
| _STD = (0.229, 0.224, 0.225) | ||
| _SIZE = (320, 320) | ||
|
|
||
|
|
||
| # 只清理"几乎精确等于底色"的像素。阈值必须窄:2026-08-07 实测,一个铁锈橙毛 | ||
| # (222,130,70)的角色配玫红底(222,41,124),两者红通道完全相同、欧氏距离仅 104 —— | ||
| # 宽阈值会把毛判成半透明并去"反解",越解越坏(先成橄榄绿再成亮绿)。橙毛 d≈117, | ||
| # 阈值 38 完全碰不到它;而闭合空隙里的背景 d≈0,能干净移除。 | ||
| _KEY_KILL = 38.0 # d < 此值 → 判为纯背景 | ||
| _KEY_SOFT = 14.0 # 到 _KEY_KILL + _KEY_SOFT 之间线性过渡,避免硬边锯齿 | ||
| _BG_FLAT_STD = 8.0 # 四角色标准差上限;超过说明底不是纯色,不做任何清理 | ||
|
|
||
| # 空洞填充用。_HOLE_ALPHA:低于此 alpha 才算"透明",参与空洞判定。 | ||
| # _HOLE_BG_TOL:到底色的距离低于此值 → 判为"确实是底色"。取值依据(2026-08-11 实测, | ||
| # 1280×720 真实视频帧):纯背景区域的色距 p99.9≈6.5、最大 11.1(视频压缩噪点); | ||
| # 而被误杀的浅肤色像素连通域中位色距 ≥17.1。14 落在这条 1.5 倍间隙里。 | ||
| _HOLE_ALPHA = 0.03 | ||
| _HOLE_BG_TOL = 14.0 | ||
|
|
||
|
|
||
| def _bg_key(rgb: np.ndarray) -> np.ndarray | None: | ||
| """四角取样估底色 key;底不够均匀(std 超阈值)时返回 None = 不做任何基于底色的判断。 | ||
|
|
||
| 抽成独立函数是为了让"底色是什么"只有一个真相源 —— 键控清理(``_flat_bg_penalty``) | ||
| 和空洞填充(``_fill_enclosed_holes``)必须按同一个 key 判断,否则一个把某块当背景 | ||
| 清掉、另一个又把它当主体填回来,互相打架。 | ||
| """ | ||
| corners = np.concatenate([ | ||
| rgb[:12, :12].reshape(-1, 3), rgb[:12, -12:].reshape(-1, 3), | ||
| rgb[-12:, :12].reshape(-1, 3), rgb[-12:, -12:].reshape(-1, 3), | ||
| ]) | ||
| if float(corners.std(axis=0).max()) > _BG_FLAT_STD: | ||
| return None | ||
| return np.median(corners, axis=0).astype(np.float32) | ||
|
|
||
|
|
||
| def _spread(seed: np.ndarray, region: np.ndarray) -> np.ndarray: | ||
| """在 ``region`` 内从 ``seed`` 出发做 4-邻接连通扩散,返回可达集合。 | ||
|
|
||
| 为什么不写逐像素 BFS:交付前的帧是 1280×720(约 92 万像素),纯 Python BFS 要几十秒, | ||
| 抠图是逐帧调用的,扛不住。这里按**行/列游程**传播 —— 一个 pass 就能把可达性推过 | ||
| 整条连续游程(距离不限),而不是每 pass 只推进一个像素,真实角色轮廓几个 pass 收敛。 | ||
|
|
||
| 同一行里被非 region 像素隔断的两段游程,``cumsum(~region)`` 必然取到不同的 id, | ||
| 因此可以用 ``bincount`` 一次算出"每条游程里有没有种子"。 | ||
| """ | ||
| reach = seed & region | ||
| while True: | ||
| before = int(reach.sum()) | ||
| for transposed in (False, True): | ||
| reg = region.T if transposed else region | ||
| rch = reach.T if transposed else reach | ||
| rows, cols = reg.shape | ||
| run = np.cumsum(~reg, axis=1) | ||
| keys = run + np.arange(rows)[:, None] * (cols + 1) | ||
| hit = np.bincount(keys[rch], minlength=rows * (cols + 1)) > 0 | ||
| new = reg & hit[keys] | ||
| reach = new.T if transposed else new | ||
| if int(reach.sum()) == before: | ||
| return reach | ||
|
|
||
|
|
||
| def _fill_enclosed_holes(alpha: np.ndarray, rgb: np.ndarray) -> np.ndarray: | ||
| """把"被主体围住、且整块都不是底色"的透明连通域填回主体(alpha=1)。 | ||
|
|
||
| 要解决的问题:u2netp 判错或键控误杀会在主体内部留下透明洞,放大看是背景直接透出来。 | ||
|
|
||
| **为什么只判"不与边界连通"不够 —— 会把两腿之间填实。** 直觉上腿间空隙从下方通到 | ||
| 画面底边,所以"从边界出发的连通域"就能保护它。2026-08-11 在真实走路帧上实测: | ||
| **不成立**。迈步相里两只靴子在下方交叠,把腿间空隙彻底封死 —— 它就是一块不与边界 | ||
| 连通的背景域(实测 src_017 有 530 像素、归档 frame_03 有 129 像素),只按连通性判, | ||
| 这一整块会被填成主体,两条腿直接焊在一起。 | ||
|
|
||
| 所以判据是**连通性 + 颜色**两条一起:一个透明连通域只要"碰到画面边界"或者"里面 | ||
| 存在任何一个确实是底色的像素",就不是洞。腿间空隙整块就是底色(实测中位色距 6.2, | ||
| 远低于 _HOLE_BG_TOL),必然被这条否决;而被误杀的主体像素(实测中位色距 ≥17.1) | ||
| 不含底色像素,才会被填。两条否决合成一次扩散:种子 = 边界上的透明像素 ∪ 底色像素。 | ||
|
|
||
| 与 ``_flat_bg_penalty`` 的分工:那个函数按颜色**做减法**(把闭合空隙里的底色清掉), | ||
| 这个函数按颜色**决定不加回来** —— 同一个 key、同一个方向,不会互相拆台。 | ||
| """ | ||
| key = _bg_key(rgb) | ||
| if key is None: | ||
| return alpha # 底不是纯色 → 无从判断哪块是真空隙,一律不填 | ||
| transparent = alpha < _HOLE_ALPHA | ||
| if not transparent.any(): | ||
| return alpha | ||
| border = np.zeros_like(transparent) | ||
| border[0, :] = border[-1, :] = True | ||
| border[:, 0] = border[:, -1] = True | ||
| is_bg_color = np.linalg.norm(rgb - key, axis=2) < _HOLE_BG_TOL | ||
| seed = transparent & (border | is_bg_color) | ||
| holes = transparent & ~_spread(seed, transparent) | ||
| if not holes.any(): | ||
| return alpha | ||
| out = alpha.copy() | ||
| out[holes] = 1.0 | ||
| return out | ||
|
|
||
|
|
||
| def _flat_bg_penalty(rgb: np.ndarray) -> np.ndarray: | ||
| """底色清理系数(0=纯背景,1=主体),形状与图同宽高。 | ||
|
|
||
| 为什么需要它:u2netp 是显著性模型,对**闭合区域**天然失灵 —— 四足角色腿间的 | ||
| 背景是一块被主体围住的空隙,显著性把它当成主体内部,整块底色留在产物里 | ||
| (2026-08-07 实测)。而母版底色是刻意生成的纯色,均匀度极高(实测四角标准差 1.0~1.2), | ||
| 用它做一次窄阈值清理就能补上这个洞。 | ||
|
|
||
| 与"按颜色抠是死路"那条规则的边界:那条说的是**拿颜色当主体判据**(白底浅色角色 | ||
| 会被抠穿)。这里主体判据仍然是 u2netp,颜色只用来**做减法** —— 绝不新增主体像素, | ||
| 最坏情况是少清理一点,不会抠穿角色。底色不够均匀时(std 超阈值)直接返回全 1, | ||
| 等于不清理。 | ||
| """ | ||
| key = _bg_key(rgb) | ||
| if key is None: | ||
| return np.ones(rgb.shape[:2], dtype=np.float32) # 底不是纯色 → 不动 | ||
| d = np.linalg.norm(rgb - key, axis=2) | ||
| return np.clip((d - _KEY_KILL) / _KEY_SOFT, 0.0, 1.0).astype(np.float32) | ||
|
|
||
|
|
||
| class OnnxU2NetMatteProvider(MatteProvider): | ||
| """u2netp.onnx via onnxruntime。frame bytes → 抠好的 PNG(RGBA) bytes。""" | ||
|
|
||
| def __init__(self, model_path: str | Path | None = None, model_url: str = _U2NETP_URL) -> None: | ||
| self._model_path = Path(model_path) if model_path else _DEFAULT_CACHE | ||
| self._model_url = model_url | ||
| self._session = None # 惰性 | ||
|
|
||
| def _ensure_model(self) -> Path: | ||
| if not self._model_path.exists(): | ||
| self._model_path.parent.mkdir(parents=True, exist_ok=True) | ||
| urllib.request.urlretrieve(self._model_url, self._model_path) | ||
| return self._model_path | ||
|
|
||
| def _get_session(self): | ||
| if self._session is None: | ||
| try: | ||
| import onnxruntime as ort # 惰性:导入慢 | ||
| except ImportError as e: # pragma: no cover - 取决于安装环境 | ||
| # **不静默降级。** 这里曾在 ImportError 时回落到"取四角主色做 chroma-key", | ||
| # 有两个问题:①猜背景色 —— 白底母版四角就是白色,浅色角色(骨白/银甲)与背景 | ||
| # 撞色会被抠穿;②静默 —— 开发机上看着能跑、输出其实是坏的,要到产物验收才发现。 | ||
| raise RuntimeError( | ||
| "onnxruntime 不可用,无法做主体抠图。请安装 onnxruntime" | ||
| "(注意 <1.24 才有 macOS Intel 轮子)。" | ||
| ) from e | ||
| self._session = ort.InferenceSession( | ||
| str(self._ensure_model()), providers=["CPUExecutionProvider"] | ||
| ) | ||
| return self._session | ||
|
|
||
| def _predict_mask(self, img: Image.Image) -> Image.Image: | ||
| """u2netp 前向 → 单通道显著性 mask(L,原图尺寸)。""" | ||
| im = img.convert("RGB").resize(_SIZE, Image.LANCZOS) | ||
| ary = np.array(im).astype(np.float32) | ||
| ary = ary / max(float(ary.max()), 1e-6) | ||
| tmp = np.zeros((_SIZE[1], _SIZE[0], 3), dtype=np.float32) | ||
| for c in range(3): | ||
| tmp[:, :, c] = (ary[:, :, c] - _MEAN[c]) / _STD[c] | ||
| tensor = np.expand_dims(tmp.transpose(2, 0, 1), 0).astype(np.float32) | ||
|
|
||
| session = self._get_session() | ||
| pred = session.run(None, {session.get_inputs()[0].name: tensor})[0][:, 0, :, :] | ||
| mi, ma = float(pred.min()), float(pred.max()) | ||
| pred = (pred - mi) / max(ma - mi, 1e-6) | ||
| mask = (pred.squeeze() * 255).astype(np.uint8) | ||
| return Image.fromarray(mask, "L").resize(img.size, Image.LANCZOS) | ||
|
|
||
| def cutout(self, frame: bytes) -> bytes: | ||
| img = Image.open(io.BytesIO(frame)).convert("RGBA") | ||
| rgb = np.asarray(img.convert("RGB"), dtype=np.float32) | ||
| alpha = np.asarray(self._predict_mask(img), dtype=np.float32) / 255.0 | ||
| alpha = alpha * _flat_bg_penalty(rgb) | ||
| alpha = _fill_enclosed_holes(alpha, rgb) | ||
| out = np.dstack([np.asarray(img.convert("RGB")), alpha * 255.0]).astype(np.uint8) | ||
| buf = io.BytesIO() | ||
| Image.fromarray(out, "RGBA").save(buf, "PNG") | ||
| return buf.getvalue() |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.