From 4dc0fe023bb8e78edbfb794cb3900e4d259a1c0b Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Thu, 27 Aug 2026 14:57:05 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(matte):=20=E6=8A=A0=E5=9B=BE=20provide?= =?UTF-8?q?r=20=E5=8A=A0=E9=80=89=E6=8B=A9=E5=BC=80=E5=85=B3,=E8=AE=A9?= =?UTF-8?q?=E5=B7=B2=E5=90=88=E5=85=A5=E7=9A=84=20BiRefNet=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=98=AF=E6=AD=BB=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BiRefNetMatteProvider 在 #686 已合入,但四个调用点全部硬编码 OnnxU2NetMatteProvider(), 也没有开关 —— 代码在仓里,跑的仍是 u2net。 不能直接切:BiRefNet 单帧峰值实测 6.85GB,而生产 worker 上限 5GiB、宿主 7.7GB, 换过去必 OOM,表现是 worker 无声重启、任务卡在 RUNNING。它的输入写死 1024(喂 512 会 InvalidArgument),降输入省内存不成立;u2net 同样写死 320,提分辨率也不成立。 但组员本机 16GB 跑得起来,而组内要产出高质量素材。故同一份代码两种装配,不为它开分支: 分支一定会漂,而漂出来的"更好的管线"产出的素材,产品复现不出来。 默认是 u2net:忘配等于用得起的那个,而不是忘配就把生产打 OOM。取值拼错回落 u2net 并留 WARNING —— 一个拼错的环境变量不该让整个 worker 起不来,两个方向的代价不对称。 既有的两条抠图装配用例改的是打桩点(生产已走工厂),断言的意图未变。 Closes #822 --- .../app/src/windup_app/bootstrap/worker.py | 4 +- .../server/orchestrator/executor.py | 10 ++-- .../orchestrator/view_sheet_executor.py | 4 +- .../windup_framework/providers/__init__.py | 2 + .../providers/matte_factory.py | 42 +++++++++++++++ backend/tests/test_custom_action.py | 6 +-- backend/tests/test_matte_provider_choice.py | 51 +++++++++++++++++++ backend/tests/test_mq_worker.py | 2 +- 8 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 backend/packages/framework/src/windup_framework/providers/matte_factory.py create mode 100644 backend/tests/test_matte_provider_choice.py diff --git a/backend/packages/app/src/windup_app/bootstrap/worker.py b/backend/packages/app/src/windup_app/bootstrap/worker.py index 578050b1..42a518e0 100644 --- a/backend/packages/app/src/windup_app/bootstrap/worker.py +++ b/backend/packages/app/src/windup_app/bootstrap/worker.py @@ -143,9 +143,9 @@ def _warmup_local_inference() -> None: except Exception: logger.warning("抽帧后端预热失败", exc_info=True) try: - from windup_framework.providers import OnnxU2NetMatteProvider + from windup_framework.providers import make_matte_provider - matte = OnnxU2NetMatteProvider() + matte = make_matte_provider() matte.warmup() bind_matte(matte) logger.info("ONNX 抠图会话已预热") diff --git a/backend/packages/app/src/windup_app/server/orchestrator/executor.py b/backend/packages/app/src/windup_app/server/orchestrator/executor.py index ea865784..59974841 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -848,12 +848,12 @@ def _assemble(self, directions: int) -> CharacterGeneratorPort: from windup_common.models import GenRoute from windup_framework.gateway import build_image_gateway, build_video_gateway from windup_framework.gateway.image import _CIRCUIT - from windup_framework.providers import OnnxU2NetMatteProvider + from windup_framework.providers import make_matte_provider from windup_app.server.media.first_frame import MediaFirstFrameUploader if self._matte is None: - self._matte = OnnxU2NetMatteProvider() + self._matte = make_matte_provider() if self._image is None: self._image = build_image_gateway(circuit=_CIRCUIT) # uploader 在这里注入而不是让 provider 自己去拿:framework 不认识 app 的对象存储。 @@ -974,7 +974,7 @@ def __init__( self, *, image=None, # None → 懒加载 ImageGateway - matte: MatteProvider | None = None, # None → 懒加载 OnnxU2NetMatteProvider + matte: MatteProvider | None = None, # None → 懒加载,按 WINDUP_MATTE_PROVIDER 选 upload: Callable[[bytes], str] | None = None, # None → 真实对象存储上传 fetch_ref: Callable[[str], bytes] | None = None, # None → 下载 reference_image_url @@ -1185,9 +1185,9 @@ def _get_matte(self): return self._matte with self._assembly_lock: if self._matte is None: - from windup_framework.providers import OnnxU2NetMatteProvider + from windup_framework.providers import make_matte_provider - self._matte = OnnxU2NetMatteProvider() + self._matte = make_matte_provider() return self._matte def _download(self, url: str) -> bytes: diff --git a/backend/packages/app/src/windup_app/server/orchestrator/view_sheet_executor.py b/backend/packages/app/src/windup_app/server/orchestrator/view_sheet_executor.py index 332cf302..ff446158 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/view_sheet_executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/view_sheet_executor.py @@ -338,9 +338,9 @@ def _get_matte(self): return self._matte with self._assembly_lock: if self._matte is None: - from windup_framework.providers import OnnxU2NetMatteProvider + from windup_framework.providers import make_matte_provider - self._matte = OnnxU2NetMatteProvider() + self._matte = make_matte_provider() return self._matte def _download(self, url: str) -> bytes: diff --git a/backend/packages/framework/src/windup_framework/providers/__init__.py b/backend/packages/framework/src/windup_framework/providers/__init__.py index cf6f2de7..4f56ba7b 100644 --- a/backend/packages/framework/src/windup_framework/providers/__init__.py +++ b/backend/packages/framework/src/windup_framework/providers/__init__.py @@ -13,6 +13,7 @@ ) from windup_framework.providers.matte import OnnxU2NetMatteProvider from windup_framework.providers.matte_birefnet import BiRefNetMatteProvider +from windup_framework.providers.matte_factory import make_matte_provider from windup_framework.providers.sufy import ( SufyImageProvider, SufyVideoProvider, @@ -34,6 +35,7 @@ # FAL 队列面的 i2v(现役接口形态);首帧要公网 URL,故与 uploader 成对出现 "SufyImageProvider", "BiRefNetMatteProvider", + "make_matte_provider", "OnnxU2NetMatteProvider", # Gateway 工厂(executor 从 windup_framework.gateway 取;此处再导出方便装配) "bind_call_context", diff --git a/backend/packages/framework/src/windup_framework/providers/matte_factory.py b/backend/packages/framework/src/windup_framework/providers/matte_factory.py new file mode 100644 index 00000000..456449c9 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/providers/matte_factory.py @@ -0,0 +1,42 @@ +"""按配置选抠图 provider。 + +为什么要这个开关:BiRefNet 在同一帧上把主体内部非实心从 5,541 px 降到 106 px(-98%), +但单帧峰值 6.85GB —— 生产 worker 容器上限 5GiB、宿主总共 7.7GB,**跑不了**,而组员 +本机 16GB 跑得很轻松。同一份代码两种装配,好过为它开一条分支:分支一定会漂,而漂出来 +的"更好的管线"产出的素材,产品复现不出来。 + +默认必须是 u2net:忘配等于用得起的那个,而不是忘配就把生产打 OOM。 +""" + +from __future__ import annotations + +import logging +import os + +from .interfaces import MatteProvider + +logger = logging.getLogger("windup.matte.factory") + +#: 环境变量名。取值 ``u2net``(默认) / ``birefnet``。 +ENV = "WINDUP_MATTE_PROVIDER" +_U2NET = "u2net" +_BIREFNET = "birefnet" + + +def make_matte_provider(name: str | None = None) -> MatteProvider: + """按名字造 provider;不认识的名字回落 u2net 并留一条 WARNING。 + + 不认识就抛错的话,一个拼错的环境变量会让整个 worker 起不来;而回落是安全方向 —— + u2net 在任何机器上都跑得起来,坏处只是抠图差一点,且这条 WARNING 说明了原因。 + """ + choice = (name or os.environ.get(ENV) or _U2NET).strip().lower() + if choice == _BIREFNET: + from .matte_birefnet import BiRefNetMatteProvider + + logger.info("抠图用 BiRefNet(与 u2net 取并集);单帧峰值约 6.85GB,别在小内存机器上开") + return BiRefNetMatteProvider() + if choice != _U2NET: + logger.warning("%s=%r 不认识,回落 u2net;可选:%s / %s", ENV, choice, _U2NET, _BIREFNET) + from .matte import OnnxU2NetMatteProvider + + return OnnxU2NetMatteProvider() diff --git a/backend/tests/test_custom_action.py b/backend/tests/test_custom_action.py index 662cc6c3..cdcaa8fa 100644 --- a/backend/tests/test_custom_action.py +++ b/backend/tests/test_custom_action.py @@ -279,7 +279,7 @@ def _factory(*_args, **_kwargs): return object() return _factory - monkeypatch.setattr(providers, "OnnxU2NetMatteProvider", _counting("matte")) + monkeypatch.setattr(providers, "make_matte_provider", _counting("matte")) monkeypatch.setattr(gateway, "build_image_gateway", _counting("image")) monkeypatch.setattr(gateway, "build_video_gateway", _counting("video")) @@ -319,9 +319,9 @@ def test_injected_matte_is_not_replaced_on_assemble(monkeypatch): sent = object() def _boom(*_a, **_k): - raise AssertionError("不该再构造 OnnxU2NetMatteProvider") + raise AssertionError("不该再构造抠图 provider") - monkeypatch.setattr(providers, "OnnxU2NetMatteProvider", _boom) + monkeypatch.setattr(providers, "make_matte_provider", _boom) monkeypatch.setattr(gateway, "build_image_gateway", lambda **_k: object()) monkeypatch.setattr(gateway, "build_video_gateway", lambda **_k: object()) diff --git a/backend/tests/test_matte_provider_choice.py b/backend/tests/test_matte_provider_choice.py new file mode 100644 index 00000000..89caf2fb --- /dev/null +++ b/backend/tests/test_matte_provider_choice.py @@ -0,0 +1,51 @@ +"""抠图 provider 的选择开关。 + +这个开关存在的理由:BiRefNet 单帧峰值 6.85GB,生产 worker 上限 5GiB —— 开错方向的代价 +不是"抠图差一点",是把 worker 打 OOM。所以默认值与回落方向都要有用例钉住。 +""" +from __future__ import annotations + +import pytest + +from windup_framework.providers import make_matte_provider +from windup_framework.providers.matte import OnnxU2NetMatteProvider +from windup_framework.providers.matte_factory import ENV + + +def test_default_is_u2net_so_a_missing_env_cannot_oom_the_worker(monkeypatch): + """拦的坏例:默认值给成 BiRefNet。 + + 忘配就该拿到跑得起来的那个。反过来的话,一台没设这个变量的机器会在第一帧抠图时 + 被 OOM kill,而表现是 worker 无声重启、任务卡在 RUNNING。 + """ + monkeypatch.delenv(ENV, raising=False) + assert isinstance(make_matte_provider(), OnnxU2NetMatteProvider) + + +def test_an_unknown_value_falls_back_instead_of_killing_the_worker(monkeypatch): + """拦的坏例:不认识的值直接抛错。 + + 一个拼错的环境变量(``bierfnet``)会让整个 worker 起不来,而回落只是抠图差一点。 + 两个方向的代价不对称。 + """ + monkeypatch.setenv(ENV, "bierfnet") + assert isinstance(make_matte_provider(), OnnxU2NetMatteProvider) + + +def test_explicit_argument_beats_the_environment(monkeypatch): + """显式传参优先于环境变量 —— 否则测试与本地脚本没法覆盖部署的设置。""" + monkeypatch.setenv(ENV, "birefnet") + assert isinstance(make_matte_provider("u2net"), OnnxU2NetMatteProvider) + + +def test_birefnet_is_reachable_by_name_not_dead_code(monkeypatch): + """拦的坏例:provider 合进仓里却没有任何路径能选到它(#686 就是这么变成死代码的)。 + + 只断言"造出来的是那个类",不真跑推理:权重 224MB,CI 上不该下载,而这条要证明的是 + **接线通了**,不是模型好不好。 + """ + pytest.importorskip("onnxruntime") + from windup_framework.providers.matte_birefnet import BiRefNetMatteProvider + + monkeypatch.setenv(ENV, "birefnet") + assert isinstance(make_matte_provider(), BiRefNetMatteProvider) diff --git a/backend/tests/test_mq_worker.py b/backend/tests/test_mq_worker.py index 86ef8a5b..c363dab3 100644 --- a/backend/tests/test_mq_worker.py +++ b/backend/tests/test_mq_worker.py @@ -1305,7 +1305,7 @@ def warmup(self): fake = _Fake() monkeypatch.setattr( - "windup_framework.providers.OnnxU2NetMatteProvider", lambda *a, **k: fake + "windup_framework.providers.make_matte_provider", lambda *a, **k: fake ) prev_a, prev_i = ex.executor._matte, ex.image_executor._matte try: From 3f52b16cf6fed7e14efed4a7920a06b5ae005c73 Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Thu, 27 Aug 2026 17:07:57 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(matte):=20=E7=BB=99=20BiRefNet=20?= =?UTF-8?q?=E8=A1=A5=20warmup,=E5=90=A6=E5=88=99=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF=E9=9D=99=E9=BB=98=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bootstrap.worker 是 matte.warmup() 然后 bind_matte(matte),两句包在同一个 except Exception 里。BiRefNet 没有 warmup,第一句抛 AttributeError,bind_matte 就到不了 —— 三个 executor 各自惰性 new 一份,而它默认与 u2net 取并集、每份内部 再 new 一个 u2net,进程里 6 个 ONNX 会话。生产 worker 容器上限 5GiB,而本模型 单帧峰值 6.85GB:**这个 PR 想防的 OOM,恰恰会因为它自己的接线漏洞发生。** 表面上只有一条 "ONNX 预热失败" 的 WARNING,开发机上完全跑得通。 原有那条用例只断言 isinstance,碰不到 warmup;test_mq_worker 那条把工厂换成一个 自带 warmup 的桩,断言的是桩的能力。新用例走 make_matte_provider 取真 provider、 断言协议齐全,已用定点变异验证:去掉 warmup 后它会红。 --- .../providers/matte_birefnet.py | 21 ++++++++++++++++ backend/tests/test_matte_provider_choice.py | 25 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/backend/packages/framework/src/windup_framework/providers/matte_birefnet.py b/backend/packages/framework/src/windup_framework/providers/matte_birefnet.py index f7650cdd..6c140d76 100644 --- a/backend/packages/framework/src/windup_framework/providers/matte_birefnet.py +++ b/backend/packages/framework/src/windup_framework/providers/matte_birefnet.py @@ -83,6 +83,27 @@ def _get_session(self): ) return self._session + def warmup(self) -> None: + """把会话装进内存。**必须有这个方法** —— 没有它整条接线会静默失效。 + + ``bootstrap.worker`` 是 ``matte.warmup()`` 然后 ``bind_matte(matte)``,两句包在 + 同一个 ``except Exception`` 里。缺 ``warmup`` 时第一句抛 AttributeError, + **``bind_matte`` 就到不了** —— 于是三个 executor 各自惰性 new 一份 provider, + 而本类默认 ``union_with_u2net=True``,每份内部再 new 一个 u2net,进程里就是 + 6 个 ONNX 会话。生产 worker 容器上限 5GiB,而本模型单帧峰值 6.85GB。 + 表面上只有一条 "ONNX 预热失败" 的 WARNING,开发机上完全跑得通。 + + 并集那一路的 u2net 也一并预热:它是每帧都要跑的,留到首帧再装等于把两次冷启动 + 叠在一起。 + """ + self._get_session() + if self._union: + if self._u2net is None: + from .matte import OnnxU2NetMatteProvider + + self._u2net = OnnxU2NetMatteProvider() + self._u2net.warmup() + def _predict_mask(self, img: Image.Image) -> Image.Image: session = self._get_session() arr = np.asarray( diff --git a/backend/tests/test_matte_provider_choice.py b/backend/tests/test_matte_provider_choice.py index 89caf2fb..606f3472 100644 --- a/backend/tests/test_matte_provider_choice.py +++ b/backend/tests/test_matte_provider_choice.py @@ -49,3 +49,28 @@ def test_birefnet_is_reachable_by_name_not_dead_code(monkeypatch): monkeypatch.setenv(ENV, "birefnet") assert isinstance(make_matte_provider(), BiRefNetMatteProvider) + + +def test_every_selectable_provider_survives_the_bootstrap_warmup_call(monkeypatch): + """拦的坏例:某个 provider 缺 ``warmup``,整条共享接线静默失效。 + + ``bootstrap.worker`` 是 ``matte.warmup()`` 然后 ``bind_matte(matte)``,两句包在同一个 + ``except Exception`` 里。缺 ``warmup`` 时第一句抛 AttributeError,``bind_matte`` + **就到不了** —— 三个 executor 各自惰性 new 一份,而 BiRefNet 默认与 u2net 取并集, + 每份内部再 new 一个 u2net,进程里 6 个 ONNX 会话。生产 worker 上限 5GiB, + BiRefNet 单帧峰值 6.85GB。表面上只有一条 "ONNX 预热失败" 的 WARNING。 + (FennoAI 式审查在 #823 上指出;本用例把它钉住。) + + 断言的是**协议齐全**,不真跑推理:权重 224MB + 176MB,CI 上不该下载。 + """ + from windup_framework.providers import make_matte_provider + from windup_framework.providers.matte_factory import ENV, _BIREFNET, _U2NET + + for choice in (_U2NET, _BIREFNET): + monkeypatch.setenv(ENV, choice) + provider = make_matte_provider() + assert callable(getattr(provider, "warmup", None)), ( + f"{type(provider).__name__} 缺 warmup —— bind_matte 会被跳过," + "共享实例失效,进程里会装多份 ONNX 会话" + ) + assert callable(getattr(provider, "cutout", None)) From fb2fe147d364690ef5e3f3acc1bfb29603f97c2e Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Thu, 27 Aug 2026 18:11:37 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs(env):=20.env.example=20=E8=A1=A5?= =?UTF-8?q?=E4=B8=8A=20WINDUP=5FMATTE=5FPROVIDER?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审指出新配置名没写进示范文件。写在既有的 WINDUP_MATTE_REFINE 旁边,并标明 "服务器上不要开" —— 单帧峰值 6.85GB 而 worker 容器上限 5GiB,这条不写清楚, 照着示范文件配的人会把 worker 打 OOM。 --- .env.example | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.env.example b/.env.example index 1c5cc16a..19e0f7dd 100644 --- a/.env.example +++ b/.env.example @@ -134,6 +134,12 @@ WINDUP_SSE_REDIS_CHANNEL=windup:pubsub:generation-task-events # WINDUP_MATTE_REFINE=0 # 浅肤色脸颊/小腿可能漏检。ORT CPU arena 已在代码里关闭,不必再配。 WINDUP_MATTE_REFINE=1 + +# 抠图 provider。u2net(默认)/ birefnet。 +# birefnet 在同一帧上把主体内部非实心从 5541px 降到 106px,但**单帧峰值 6.85GB** —— +# 生产 worker 容器上限 5GiB、宿主 7.7GB,**服务器上不要开**,开了会 OOM。 +# 它是给组员本机(16GB+)做高质量素材用的。取值拼错会回落 u2net 并留一条 WARNING。 +WINDUP_MATTE_PROVIDER=u2net # web / worker 各有一份连接池。生成已短 session,不必再按 handler 并发配满。 POSTGRES_POOL_SIZE=5 POSTGRES_MAX_OVERFLOW=10 From 46679b97d761445ef73fd2caf50ec5c224eba2ac Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Thu, 27 Aug 2026 18:24:30 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test(env):=20=E6=8A=8A=20WINDUP=5FMATTE=5FP?= =?UTF-8?q?ROVIDER=20=E7=99=BB=E8=AE=B0=E8=BF=9B=E8=B1=81=E5=85=8D?= =?UTF-8?q?=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仓里有条守卫测试拦"填了不生效"的配置键:.env.example 里的键必须真被某个 BaseSettings 读到。这个开关走 os.environ —— 与旁边的 WINDUP_MATTE_REFINE 同类, 按同一方式登记。走 BaseSettings 的话 framework 的配置层就要认识 provider 的名字, 而选哪个 provider 是装配决定,不是配置数据。 --- backend/tests/test_env_example_keys_are_live.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/tests/test_env_example_keys_are_live.py b/backend/tests/test_env_example_keys_are_live.py index 439fccf9..a0d183de 100644 --- a/backend/tests/test_env_example_keys_are_live.py +++ b/backend/tests/test_env_example_keys_are_live.py @@ -33,6 +33,10 @@ "WINDUP_SSE_REDIS_CHANNEL", # windup_framework.providers.matte._refine_enabled:os.environ,不走 BaseSettings "WINDUP_MATTE_REFINE", + # providers.matte_factory.make_matte_provider:同上,os.environ。 + # 走 BaseSettings 的话 framework 的配置层就要认识 provider 的名字,而选哪个 provider + # 是装配决定,不是配置数据。 + "WINDUP_MATTE_PROVIDER", # render3d._tc3.TencentCredentials.resolve:环境变量 → 加锁文件,不走 BaseSettings "TENCENT_SECRET_ID", "TENCENT_SECRET_KEY", "TENCENT_REGION", # orchestrator.client_bake / docker-compose 的构建目标,都是 os.getenv