summaryrefslogtreecommitdiff
path: root/script
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-09-12 22:26:13 +0900
committertanaka takashi <isazaka@gmail.com>2026-09-12 22:26:13 +0900
commit546eca3591515b26516922860fe2cf1b573a5f82 (patch)
tree639cb25c732ca096f576b6e56a9027562b885f24 /script
parent796251e5b0da524584c9e81b8ad6e1dcc0931aa3 (diff)
transformersを4.x系に固定し、text encoder付きLoDA読み込み時の
IndexErrorを解消。あわせて外部VAEロードの既存バグも修正した。 問題: kohya形式LoRA(pixelartV3, pixel_f2, pixel sprites等)のロード時に Diffusers内部でIndexError: list index out of rangeが発生し、サーバが 起動失敗していた。LCM LoRAはtext encoderキーを含まないため成功していた。 原因: Transformers 5.x系のCLIPTextModel.named_modules()が返すモジュール名 (encoder.layers.0.self_attn.k_proj)と、Diffusersがkohya形式LoRAを PEFT形式へ変換後のキー(text_model.encoder.layers.0...lora_B.weight)の 間でtext_model.プレフィックスの有無が不一致となり、rank_dictが空になって get_peft_kwargsでlist(rank_dict.values())[0]がIndexErrorになる。 Transformers 4.x系ではnamed_modules()がtext_model.プレフィックス付きの モジュール名を返すため、PEFTキーと一致し正常にロードできる。 対応: transformers>=4.45,<5 に固定。4.x系最新版の4.57.6を採用。 これに伴いdiffusers 0.40.0→0.39.0、huggingface-hub 1.31.0→0.36.2、 tokenizers 0.23.2→0.22.2に変更された。peft 0.20.0は変更なし。 LoDAロード処理のコードは変更せず、標準APIのpipeline.load_lora_weights() をそのまま使用。モンキーパッチやキー書き換え等の互換性パッチは追加していない。 あわせて外部VAEロードの既存バグも修正: AutoencoderKL.from_single_fileがネットワークからconfig.jsonを取得 しようとして失敗する問題を、_vae_config_dir()でローカルhuggingface cache内のvae/config.jsonディレクトリを動的に見つけてconfigパラメータ に渡すことで解消。 変更内容: - pyproject.toml: transformers>=4.45 を transformers>=4.45,<5 に変更 - uv.lock: transformers 5.17.0→4.57.6, diffusers 0.40.0→0.39.0, huggingface-hub 1.31.0→0.36.2, tokenizers 0.23.2→0.22.2 - pipeline_factory.py: _vae_config_dir()メソッド追加、VAEロード時に configパラメータでローカルconfigディレクトリを指定 - tests/test_pipeline_factory.py: _vae_config_dirをモンキーパッチ、 VAE config引数の検証追加 - script/smoke_matrix.py: 10パターン網羅検証スクリプト追加 (4 checkpoint × VAE × 4種LoRA × scheduler) 検証: - ruff format/check、pyright 0 errors、pytest 61 passed - 実GPU・実モデルで10パターン全て成功(10 ok / 0 fail) - 4種checkpoint単体: 全て成功 - 外部VAE差し替え: 成功 - pixel sprites / pixelartV3 / pixel_f2: 全て成功(IndexError解消) - LCM LoRA + lcm scheduler: 成功 - animekawa + 外部VAE + pixelartV3 + euler_a: 成功
Diffstat (limited to 'script')
-rw-r--r--script/smoke_matrix.py304
1 files changed, 304 insertions, 0 deletions
diff --git a/script/smoke_matrix.py b/script/smoke_matrix.py
new file mode 100644
index 0000000..7fc3ec2
--- /dev/null
+++ b/script/smoke_matrix.py
@@ -0,0 +1,304 @@
+"""Run generate_image across all checkpoint/VAE/LoRA/scheduler combinations.
+
+For each pattern, this script writes a temporary TOML config, starts the
+``mcp-sd15`` server with ``streamable-http`` transport, calls ``generate_image``
+through the HTTP endpoint, saves the PNG, and records the result.
+
+Usage::
+
+ uv run python script/smoke_matrix.py --output-dir data/outputs/matrix
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import base64
+import json
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+from mcp import Client
+from mcp.types import ImageContent
+
+DEFAULT_PROMPT = "a small red cube on a white table, studio photograph, detailed"
+DEFAULT_NEGATIVE = "blurry, low quality"
+
+PATTERNS: list[dict[str, object]] = [
+ {
+ "name": "01_v15_base",
+ "checkpoint": "v1-5-pruned-emaonly.safetensors",
+ "vae": None,
+ "loras": [],
+ "scheduler": "dpmpp_2m_karras",
+ "steps": 20,
+ "cfg_scale": 7.0,
+ "prompt": DEFAULT_PROMPT,
+ "negative": DEFAULT_NEGATIVE,
+ },
+ {
+ "name": "02_animekawa_base",
+ "checkpoint": "animekawa_v10.safetensors",
+ "vae": None,
+ "loras": [],
+ "scheduler": "dpmpp_2m_karras",
+ "steps": 20,
+ "cfg_scale": 7.0,
+ "prompt": DEFAULT_PROMPT,
+ "negative": DEFAULT_NEGATIVE,
+ },
+ {
+ "name": "03_amore_base",
+ "checkpoint": "animeModel_amore.safetensors",
+ "vae": None,
+ "loras": [],
+ "scheduler": "dpmpp_2m_karras",
+ "steps": 20,
+ "cfg_scale": 7.0,
+ "prompt": DEFAULT_PROMPT,
+ "negative": DEFAULT_NEGATIVE,
+ },
+ {
+ "name": "04_animepastel_base",
+ "checkpoint": "animePastelDream_softBakedVae_full_fp16.safetensors",
+ "vae": None,
+ "loras": [],
+ "scheduler": "dpmpp_2m_karras",
+ "steps": 20,
+ "cfg_scale": 7.0,
+ "prompt": DEFAULT_PROMPT,
+ "negative": DEFAULT_NEGATIVE,
+ },
+ {
+ "name": "05_v15_external_vae",
+ "checkpoint": "v1-5-pruned-emaonly.safetensors",
+ "vae": "vaeKlF8Anime2_klF8Anime2VAE.safetensors",
+ "loras": [],
+ "scheduler": "dpmpp_2m_karras",
+ "steps": 20,
+ "cfg_scale": 7.0,
+ "prompt": DEFAULT_PROMPT,
+ "negative": DEFAULT_NEGATIVE,
+ },
+ {
+ "name": "06_v15_lora_pixel_sprites",
+ "checkpoint": "v1-5-pruned-emaonly.safetensors",
+ "vae": None,
+ "loras": [{"file": "pixel sprites.safetensors", "weight": 0.8}],
+ "scheduler": "dpmpp_2m_karras",
+ "steps": 20,
+ "cfg_scale": 7.0,
+ "prompt": "pixel art, a small red cube on a white table, detailed",
+ "negative": DEFAULT_NEGATIVE,
+ },
+ {
+ "name": "07_v15_lora_pixelartV3",
+ "checkpoint": "v1-5-pruned-emaonly.safetensors",
+ "vae": None,
+ "loras": [{"file": "pixelartV3.safetensors", "weight": 0.8}],
+ "scheduler": "dpmpp_2m_karras",
+ "steps": 20,
+ "cfg_scale": 7.0,
+ "prompt": "pixelart, a small red cube on a white table, detailed",
+ "negative": DEFAULT_NEGATIVE,
+ },
+ {
+ "name": "08_v15_lora_pixel_f2",
+ "checkpoint": "v1-5-pruned-emaonly.safetensors",
+ "vae": None,
+ "loras": [{"file": "pixel_f2.safetensors", "weight": 0.8}],
+ "scheduler": "dpmpp_2m_karras",
+ "steps": 20,
+ "cfg_scale": 7.0,
+ "prompt": "pixel, a small red cube on a white table, detailed",
+ "negative": DEFAULT_NEGATIVE,
+ },
+ {
+ "name": "09_v15_lcm_lora",
+ "checkpoint": "v1-5-pruned-emaonly.safetensors",
+ "vae": None,
+ "loras": [{"file": "LCM_LoRA_Weights_SD15.safetensors", "weight": 1.0}],
+ "scheduler": "lcm",
+ "steps": 4,
+ "cfg_scale": 1.0,
+ "prompt": DEFAULT_PROMPT,
+ "negative": DEFAULT_NEGATIVE,
+ },
+ {
+ "name": "10_animekawa_vae_lora",
+ "checkpoint": "animekawa_v10.safetensors",
+ "vae": "vaeKlF8Anime2_klF8Anime2VAE.safetensors",
+ "loras": [{"file": "pixelartV3.safetensors", "weight": 0.7}],
+ "scheduler": "euler_a",
+ "steps": 20,
+ "cfg_scale": 7.0,
+ "prompt": "pixelart, a small red cube on a white table, detailed",
+ "negative": DEFAULT_NEGATIVE,
+ },
+]
+
+
+def make_toml(pattern: dict[str, object], port: int) -> str:
+ lines: list[str] = []
+ lines.append("[model]")
+ lines.append('root = "."')
+ lines.append(f'checkpoint = "{pattern["checkpoint"]}"')
+ if pattern["vae"]:
+ lines.append(f'vae = "{pattern["vae"]}"')
+ for lora in pattern["loras"]: # type: ignore[union-attr]
+ lines.append("[[model.lora]]")
+ lines.append(f'file = "{lora["file"]}"') # type: ignore[index]
+ lines.append(f"weight = {lora['weight']}") # type: ignore[index]
+ lines.append("[generation]")
+ lines.append(f"steps = {pattern['steps']}")
+ lines.append(f"cfg_scale = {pattern['cfg_scale']}")
+ lines.append(f'scheduler = "{pattern["scheduler"]}"')
+ lines.append('default_size = "square_512"')
+ lines.append("[output]")
+ lines.append("enabled = false")
+ lines.append("[server]")
+ lines.append('transport = "streamable-http"')
+ lines.append('host = "127.0.0.1"')
+ lines.append(f"port = {port}")
+ return "\n".join(lines) + "\n"
+
+
+async def run_one(
+ pattern: dict[str, object],
+ config_path: Path,
+ output_path: Path,
+ port: int,
+) -> dict[str, object]:
+ toml = make_toml(pattern, port)
+ config_path.write_text(toml, encoding="utf-8")
+
+ proc = subprocess.Popen(
+ [sys.executable, "-m", "mcp_sd15", "--config", str(config_path)],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1,
+ )
+
+ result: dict[str, object] = {"name": pattern["name"], "status": "unknown"}
+ started = time.monotonic()
+
+ try:
+ # Wait for server to be ready (up to 120s for model loading)
+ url = f"http://127.0.0.1:{port}/mcp"
+ for _wait in range(120):
+ if proc.poll() is not None:
+ # Process died; drain stdout
+ dead_output = ""
+ if proc.stdout:
+ dead_output = proc.stdout.read()
+ result["status"] = "server_died"
+ result["output"] = dead_output[-3000:]
+ return result
+ try:
+ import httpx
+
+ resp = httpx.get(url, timeout=2.0)
+ if resp.status_code == 400:
+ break
+ except Exception:
+ pass
+ await asyncio.sleep(1)
+ else:
+ result["status"] = "server_timeout"
+ return result
+
+ async with Client(url, raise_exceptions=True) as client:
+ tools = (await client.list_tools()).tools
+ tool_names = {tool.name for tool in tools}
+ if tool_names != {"generate_image", "generate_guided_image", "inpaint_image"}:
+ result["status"] = "wrong_tools"
+ result["tools"] = list(tool_names)
+ return result
+
+ call_result = await client.call_tool(
+ "generate_image",
+ {
+ "positive_prompt": pattern["prompt"], # type: ignore[index]
+ "negative_prompt": pattern["negative"], # type: ignore[index]
+ "size": "square_512",
+ },
+ )
+
+ if call_result.is_error or len(call_result.content) != 1:
+ result["status"] = "tool_error"
+ result["detail"] = str(call_result)
+ return result
+
+ content = call_result.content[0]
+ if not isinstance(content, ImageContent) or content.mime_type != "image/png":
+ result["status"] = "wrong_content"
+ result["detail"] = str(content)
+ return result
+
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_bytes(base64.b64decode(content.data, validate=True))
+ elapsed = round(time.monotonic() - started, 1)
+ result["status"] = "ok"
+ result["elapsed"] = elapsed
+ result["output_file"] = str(output_path)
+ return result
+ except Exception as exc:
+ result["status"] = "exception"
+ result["detail"] = str(exc)
+ return result
+ finally:
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+
+
+async def main(args: argparse.Namespace) -> None:
+ config_path = args.data_dir / "config.matrix.toml"
+ results: list[dict[str, object]] = []
+
+ for i, pattern in enumerate(PATTERNS):
+ name = pattern["name"]
+ print(f"[{i + 1}/{len(PATTERNS)}] {name} ...", flush=True)
+ output_path = args.output_dir / f"{name}.png"
+ result = await run_one(pattern, config_path, output_path, args.port + i)
+ status = result["status"]
+ if status == "ok":
+ print(
+ f" -> OK elapsed={result.get('elapsed')}s file={result.get('output_file')}",
+ flush=True,
+ )
+ else:
+ print(f" -> FAIL status={status} detail={result.get('detail', '')}", flush=True)
+ results.append(result)
+
+ config_path.unlink(missing_ok=True)
+
+ summary_path = args.output_dir / "matrix_results.json"
+ summary_path.parent.mkdir(parents=True, exist_ok=True)
+ summary_path.write_text(json.dumps(results, indent=2, ensure_ascii=False), encoding="utf-8")
+
+ ok = sum(1 for r in results if r["status"] == "ok")
+ fail = len(results) - ok
+ print(f"\n=== Summary: {ok} ok / {fail} fail / {len(results)} total ===", flush=True)
+ if fail:
+ print("Failures:", flush=True)
+ for r in results:
+ if r["status"] != "ok":
+ print(f" {r['name']}: {r['status']} {r.get('detail', '')}", flush=True)
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--output-dir", type=Path, default=Path("data/outputs/matrix"))
+ parser.add_argument("--data-dir", type=Path, default=Path("data"))
+ parser.add_argument("--port", type=int, default=8130)
+ return parser.parse_args()
+
+
+if __name__ == "__main__":
+ asyncio.run(main(parse_args()))