From fd987cb31fd20483080f8d80ce35b53f101f243f Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sat, 12 Sep 2026 20:12:17 +0900 Subject: feat: SD 1.x向けScheduler対応を5種類に整理 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generation.schedulerの正式対応値をdpmpp_2m_karras、dpmpp_2m_sde_karras、euler_a、ddim、lcmに限定し、既定値をdpmpp_2m_karrasへ変更する。旧dpmpp_2m、eulerおよび未知値は起動時の設定validationで拒否する。 Scheduler生成処理をPipelineFactoryへ集約する。DPM++ 2M系はDPMSolverMultistepSchedulerを共用し、algorithm_type、solver_order=2、use_karras_sigmas=Trueで通常版とSDE版を切り替える。Euler Ancestral、DDIM、LCMは現在のpipeline scheduler configを引き継いで構築する。 LCM-LoRAは既存のmodel.lora設定から通常のLoRAとして読み込み、LCM選択時にもstepsとcfg_scaleを自動調整せずTOML値をそのまま使用する。MCP Tool schemaにはscheduler入力を追加しない。 Specifications.md、Design.md、config.mdおよびconfig.example.tomlのenum、既定値、対応表、設定例を新仕様へ統一する。5 schedulerの変換、DPM++ constructor option、旧値と未知値の拒否、省略時の既定値、MCP schema非公開をテストで検証する。 検証: ruff check、ruff format --check、pyright、pytest(39 passed)、インストール済みDiffusersで5 schedulerの実インスタンス構築を確認。 --- config.example.toml | 2 +- doc/Design.md | 15 ++++++++- doc/Specifications.md | 17 +++++++++- doc/config.md | 34 +++++++++++++++++--- src/mcp_sd15/config.py | 6 ++-- src/mcp_sd15/pipeline_factory.py | 33 +++++++++++++------ tests/test_config.py | 3 ++ tests/test_pipeline_factory.py | 68 ++++++++++++++++++++++++++++++++++++---- tests/test_server.py | 1 + 9 files changed, 153 insertions(+), 26 deletions(-) diff --git a/config.example.toml b/config.example.toml index ee8ec39..4226cee 100644 --- a/config.example.toml +++ b/config.example.toml @@ -10,7 +10,7 @@ weight = 0.8 [generation] steps = 25 cfg_scale = 7.0 -scheduler = "dpmpp_2m" +scheduler = "dpmpp_2m_karras" default_size = "square_512" [controlnet.depth] diff --git a/doc/Design.md b/doc/Design.md index fd4ac63..96eb505 100644 --- a/doc/Design.md +++ b/doc/Design.md @@ -181,6 +181,19 @@ Pydanticは必須とせず、`tomllib + dataclass + 明示的validation`を基 | images per prompt | 1 | | CPU offload | 初期値OFF | +Schedulerの文字列変換は`PipelineFactory`へ集約し、Tool層とService層には分岐を置かない。 + +| 設定値 | Diffusers実装 | 追加設定 | +|---|---|---| +| `dpmpp_2m_karras` | `DPMSolverMultistepScheduler` | `algorithm_type="dpmsolver++"`, `solver_order=2`, `use_karras_sigmas=True` | +| `dpmpp_2m_sde_karras` | `DPMSolverMultistepScheduler` | `algorithm_type="sde-dpmsolver++"`, `solver_order=2`, `use_karras_sigmas=True` | +| `euler_a` | `EulerAncestralDiscreteScheduler` | pipeline scheduler configを継承 | +| `ddim` | `DDIMScheduler` | pipeline scheduler configを継承 | +| `lcm` | `LCMScheduler` | pipeline scheduler configを継承 | + +LCM-LoRAも通常のLoRAと同じ`[[model.lora]]`でロードする。`lcm`選択時にstepsやCFGを +自動調整せず、TOMLの`generation.steps`と`generation.cfg_scale`をそのままpipelineへ渡す。 + --- ## 9. ControlNet設計 @@ -387,7 +400,7 @@ mcp-sd15 = "mcp_sd15.cli:main" | テスト種別 | GPU | 内容 | |---|---:|---| -| Config parsing | 不要 | TOML読込・型検証 | +| Config parsing | 不要 | TOML読込・型検証・scheduler enum検証 | | Path resolution | 不要 | モデルパス解決 | | MCP schema | 不要 | Tool schema検証 | | Request validation | 不要 | mode / size / image必須条件 | diff --git a/doc/Specifications.md b/doc/Specifications.md index 0fb6451..1b98f96 100644 --- a/doc/Specifications.md +++ b/doc/Specifications.md @@ -169,7 +169,7 @@ weight = 0.5 [generation] steps = 25 cfg_scale = 7.0 -scheduler = "dpmpp_2m" +scheduler = "dpmpp_2m_karras" default_size = "square_512" [controlnet.depth] @@ -189,6 +189,21 @@ model = "control_v11p_sd15_inpaint.safetensors" directory = "./outputs" ``` +### 7.1 Scheduler + +`generation.scheduler`は次の5種類のみを許可し、既定値は`dpmpp_2m_karras`とする。 + +| 値 | 動作 | +|---|---| +| `dpmpp_2m_karras` | DPM++ 2M + Karras sigmas | +| `dpmpp_2m_sde_karras` | DPM++ 2M SDE + Karras sigmas | +| `euler_a` | Euler Ancestral | +| `ddim` | DDIM | +| `lcm` | Latent Consistency Model scheduler | + +`lcm`選択時も`steps`と`cfg_scale`は設定値をそのまま使用する。LoRAファイル名から +LCM-LoRAを推測したり、生成設定を自動変更したりしない。 + --- ## 8. CLI要件 diff --git a/doc/config.md b/doc/config.md index c619d2d..804fbf8 100644 --- a/doc/config.md +++ b/doc/config.md @@ -45,7 +45,7 @@ uv run mcp-sd15 --config C:\path\to\config.toml | `model.lora[].weight` | number | No | `1.0` | | `generation.steps` | integer | No | `25` | | `generation.cfg_scale` | number | No | `7.0` | -| `generation.scheduler` | string enum | No | `dpmpp_2m` | +| `generation.scheduler` | string enum | No | `dpmpp_2m_karras` | | `generation.default_size` | string enum | No | `square_512` | | `controlnet.depth.enabled` | boolean | No | `false` | | `controlnet.depth.model` | string | 有効時 | なし | @@ -125,7 +125,7 @@ weight = 0.5 [generation] steps = 25 cfg_scale = 7.0 -scheduler = "dpmpp_2m" +scheduler = "dpmpp_2m_karras" default_size = "square_512" ``` @@ -147,10 +147,34 @@ default_size = "square_512" | 値 | Diffusers scheduler | |---|---| -| `dpmpp_2m` | DPM-Solver++、solver order 2 | +| `dpmpp_2m_karras` | `DPMSolverMultistepScheduler`。DPM++ 2M、Karras sigmas | +| `dpmpp_2m_sde_karras` | `DPMSolverMultistepScheduler`。DPM++ 2M SDE、Karras sigmas | | `ddim` | DDIM | -| `euler` | Euler Discrete | | `euler_a` | Euler Ancestral Discrete | +| `lcm` | Latent Consistency Model scheduler | + +`dpmpp_2m_karras`は`algorithm_type="dpmsolver++"`、`solver_order=2`、 +`use_karras_sigmas=true`で構築する。`dpmpp_2m_sde_karras`は`algorithm_type`だけを +`sde-dpmsolver++`へ変更する。他のschedulerはpipeline scheduler configを引き継ぐ。 + +### LCM-LoRA設定例 + +LCM-LoRAも通常のLoRAと同じ方法で指定する。 + +```toml +[[model.lora]] +file = "lcm-lora-sdv1-5.safetensors" +weight = 1.0 + +[generation] +steps = 4 +cfg_scale = 1.0 +scheduler = "lcm" +default_size = "square_512" +``` + +`lcm`選択時も`steps`と`cfg_scale`はTOML値をそのまま使用する。LCM-LoRAのファイル名を +自動判定して設定を書き換えることはない。 ### `default_size` @@ -259,7 +283,7 @@ weight = 0.5 [generation] steps = 25 cfg_scale = 7.0 -scheduler = "dpmpp_2m" +scheduler = "dpmpp_2m_karras" default_size = "square_512" [controlnet.depth] diff --git a/src/mcp_sd15/config.py b/src/mcp_sd15/config.py index 5bbe225..1a5a71d 100644 --- a/src/mcp_sd15/config.py +++ b/src/mcp_sd15/config.py @@ -11,7 +11,7 @@ from typing import Any from .errors import ConfigError VALID_SIZES = frozenset({"square_512", "landscape_512", "portrait_512"}) -VALID_SCHEDULERS = frozenset({"dpmpp_2m", "ddim", "euler", "euler_a"}) +VALID_SCHEDULERS = frozenset({"dpmpp_2m_karras", "dpmpp_2m_sde_karras", "euler_a", "ddim", "lcm"}) @dataclass(frozen=True) @@ -41,7 +41,7 @@ class ModelConfig: class GenerationConfig: steps: int = 25 cfg_scale: float = 7.0 - scheduler: str = "dpmpp_2m" + scheduler: str = "dpmpp_2m_karras" default_size: str = "square_512" @@ -189,7 +189,7 @@ def load_config(path: str | Path, *, validate_paths: bool = True) -> AppConfig: cfg_scale = _number(generation_raw, "cfg_scale", 7.0) if not 0 <= cfg_scale <= 30: raise ConfigError("generation.cfg_scale must be between 0 and 30") - scheduler = _string(generation_raw, "scheduler") or "dpmpp_2m" + scheduler = _string(generation_raw, "scheduler") or "dpmpp_2m_karras" if scheduler not in VALID_SCHEDULERS: raise ConfigError(f"unsupported scheduler: {scheduler}") default_size = _string(generation_raw, "default_size") or "square_512" diff --git a/src/mcp_sd15/pipeline_factory.py b/src/mcp_sd15/pipeline_factory.py index 12d95ba..b0525ea 100644 --- a/src/mcp_sd15/pipeline_factory.py +++ b/src/mcp_sd15/pipeline_factory.py @@ -64,7 +64,7 @@ class PipelineFactory: adapter_weights.append(item.weight) if adapter_names: pipeline.set_adapters(adapter_names, adapter_weights=adapter_weights) - pipeline.scheduler = self._scheduler(pipeline.scheduler, diffusers) + pipeline.scheduler = self._create_scheduler(pipeline.scheduler, diffusers) pipeline.set_progress_bar_config(disable=True) return pipeline.to("cuda") except ConfigError: @@ -130,14 +130,29 @@ class PipelineFactory: pipeline.set_progress_bar_config(disable=True) return pipeline.to("cuda") - def _scheduler(self, scheduler: Any, diffusers: Any) -> Any: + def _create_scheduler(self, current: Any, diffusers: Any) -> Any: + name = self.config.generation.scheduler + if name == "dpmpp_2m_karras": + return diffusers.DPMSolverMultistepScheduler.from_config( + current.config, + algorithm_type="dpmsolver++", + solver_order=2, + use_karras_sigmas=True, + ) + if name == "dpmpp_2m_sde_karras": + return diffusers.DPMSolverMultistepScheduler.from_config( + current.config, + algorithm_type="sde-dpmsolver++", + solver_order=2, + use_karras_sigmas=True, + ) classes = { - "dpmpp_2m": diffusers.DPMSolverMultistepScheduler, - "ddim": diffusers.DDIMScheduler, - "euler": diffusers.EulerDiscreteScheduler, "euler_a": diffusers.EulerAncestralDiscreteScheduler, + "ddim": diffusers.DDIMScheduler, + "lcm": diffusers.LCMScheduler, } - options = dict(scheduler.config) - if self.config.generation.scheduler == "dpmpp_2m": - options.update(algorithm_type="dpmsolver++", solver_order=2) - return classes[self.config.generation.scheduler].from_config(options) + try: + scheduler_class = classes[name] + except KeyError as exc: + raise ConfigError(f"unsupported scheduler: {name}") from exc + return scheduler_class.from_config(current.config) diff --git a/tests/test_config.py b/tests/test_config.py index 5e4341a..c24064b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -76,6 +76,8 @@ def test_loads_and_resolves_all_model_paths(tmp_path: Path) -> None: [ ('checkpoint = "../base.safetensors"', "file name"), ("steps = 0", "steps"), + ('scheduler = "dpmpp_2m"', "scheduler"), + ('scheduler = "euler"', "scheduler"), ('scheduler = "unknown"', "scheduler"), ('default_size = "1024"', "default_size"), ('preprocessor = "zoe"', "preprocessor"), @@ -120,3 +122,4 @@ def test_can_skip_path_checks_for_schema_only_use(tmp_path: Path) -> None: ) config = load_config(path, validate_paths=False) assert config.model.root == (tmp_path / "missing").resolve() + assert config.generation.scheduler == "dpmpp_2m_karras" diff --git a/tests/test_pipeline_factory.py b/tests/test_pipeline_factory.py index 454a5b1..408133b 100644 --- a/tests/test_pipeline_factory.py +++ b/tests/test_pipeline_factory.py @@ -34,8 +34,24 @@ class FakePipeline: class FakeScheduler: @classmethod - def from_config(cls, config: dict[str, object]) -> object: - return {"scheduler": cls.__name__, **config} + def from_config(cls, config: dict[str, object], **kwargs: object) -> object: + return SimpleNamespace(implementation=cls.__name__, config=config, options=kwargs) + + +class FakeDPMSolverMultistepScheduler(FakeScheduler): + pass + + +class FakeDDIMScheduler(FakeScheduler): + pass + + +class FakeEulerAncestralDiscreteScheduler(FakeScheduler): + pass + + +class FakeLCMScheduler(FakeScheduler): + pass def fake_diffusers(pipeline: FakePipeline, external_vae: object) -> SimpleNamespace: @@ -54,10 +70,10 @@ def fake_diffusers(pipeline: FakePipeline, external_vae: object) -> SimpleNamesp return SimpleNamespace( StableDiffusionPipeline=StableDiffusionPipeline, AutoencoderKL=AutoencoderKL, - DPMSolverMultistepScheduler=FakeScheduler, - DDIMScheduler=FakeScheduler, - EulerDiscreteScheduler=FakeScheduler, - EulerAncestralDiscreteScheduler=FakeScheduler, + DPMSolverMultistepScheduler=FakeDPMSolverMultistepScheduler, + DDIMScheduler=FakeDDIMScheduler, + EulerAncestralDiscreteScheduler=FakeEulerAncestralDiscreteScheduler, + LCMScheduler=FakeLCMScheduler, ) @@ -97,3 +113,43 @@ def test_checkpoint_without_vae_fails_pipeline_initialization(app_config, monkey with pytest.raises(ConfigError, match="does not provide a VAE"): factory.create_base() + + +@pytest.mark.parametrize( + ("name", "implementation", "options"), + [ + ( + "dpmpp_2m_karras", + "FakeDPMSolverMultistepScheduler", + { + "algorithm_type": "dpmsolver++", + "solver_order": 2, + "use_karras_sigmas": True, + }, + ), + ( + "dpmpp_2m_sde_karras", + "FakeDPMSolverMultistepScheduler", + { + "algorithm_type": "sde-dpmsolver++", + "solver_order": 2, + "use_karras_sigmas": True, + }, + ), + ("euler_a", "FakeEulerAncestralDiscreteScheduler", {}), + ("ddim", "FakeDDIMScheduler", {}), + ("lcm", "FakeLCMScheduler", {}), + ], +) +def test_scheduler_mapping(app_config, name, implementation, options) -> None: + generation = replace(app_config.generation, scheduler=name) + factory = PipelineFactory(replace(app_config, generation=generation)) + current_config = {"beta_start": 0.00085, "beta_end": 0.012} + current = SimpleNamespace(config=current_config) + diffusers = fake_diffusers(FakePipeline(), object()) + + result = factory._create_scheduler(current, diffusers) + + assert result.implementation == implementation + assert result.config is current_config + assert result.options == options diff --git a/tests/test_server.py b/tests/test_server.py index 4590049..a6c6af5 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -25,6 +25,7 @@ async def test_exposes_exactly_three_tools_with_closed_enums(app_config) -> None "openpose_full", ] assert "width" not in guided.input_schema["properties"] + assert all("scheduler" not in tool.input_schema["properties"] for tool in tools) async def test_mcp_call_returns_png_image_content(app_config) -> None: -- cgit v1.2.3