summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--config.example.toml2
-rw-r--r--doc/Design.md15
-rw-r--r--doc/Specifications.md17
-rw-r--r--doc/config.md34
-rw-r--r--src/mcp_sd15/config.py6
-rw-r--r--src/mcp_sd15/pipeline_factory.py33
-rw-r--r--tests/test_config.py3
-rw-r--r--tests/test_pipeline_factory.py68
-rw-r--r--tests/test_server.py1
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: