diff --git a/pyproject.toml b/pyproject.toml index 3dc57d9..d300a12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "pandas>=2.0", "numpy>=1.24", "matplotlib>=3.7", + "Pillow>=10.0", ] [project.scripts] diff --git a/tests/test_montage.py b/tests/test_montage.py new file mode 100644 index 0000000..6770d29 --- /dev/null +++ b/tests/test_montage.py @@ -0,0 +1,57 @@ +"""montage_pngs 单元测试。""" + +import tempfile +from pathlib import Path + +from PIL import Image + + +def _make_test_png(path, w=200, h=100, color="red"): + """创建测试用 PNG。""" + img = Image.new("RGB", (w, h), color) + img.save(path) + return str(path) + + +def test_montage_basic(): + """4 张图拼成 2x2 网格。""" + from ts_anomaly_td.visualize import montage_pngs + + with tempfile.TemporaryDirectory() as tmpdir: + paths = [] + for i, color in enumerate(["red", "green", "blue", "yellow"]): + p = Path(tmpdir) / f"img_{i}.png" + _make_test_png(p, color=color) + paths.append(str(p)) + + out = Path(tmpdir) / "montage.png" + result = montage_pngs(paths, str(out), cols=2, title="Test") + assert Path(result).exists() + assert Path(result).stat().st_size > 1000 + + +def test_montage_uneven(): + """3 张图拼成 2 列(第二行只 1 张)。""" + from ts_anomaly_td.visualize import montage_pngs + + with tempfile.TemporaryDirectory() as tmpdir: + paths = [] + for i in range(3): + p = Path(tmpdir) / f"img_{i}.png" + _make_test_png(p) + paths.append(str(p)) + + out = Path(tmpdir) / "montage_uneven.png" + result = montage_pngs(paths, str(out), cols=2) + assert Path(result).exists() + + +def test_montage_empty(): + """空列表应抛 ValueError。""" + from ts_anomaly_td.visualize import montage_pngs + + try: + montage_pngs([], "/dev/null") + assert False, "应抛 ValueError" + except ValueError: + pass diff --git a/ts_anomaly_td/cli.py b/ts_anomaly_td/cli.py index b4828fc..f3387f9 100644 --- a/ts_anomaly_td/cli.py +++ b/ts_anomaly_td/cli.py @@ -218,6 +218,13 @@ def build_parser() -> argparse.ArgumentParser: p_ra.add_argument("--value-col", default="value", help="数值列名 (default: value)") p_ra.add_argument("--title", default="", help="图表标题") + # montage + p_mt = sub.add_parser("montage", help="多 PNG 拼图") + p_mt.add_argument("--inputs", nargs="+", required=True, help="输入 PNG 文件列表") + p_mt.add_argument("--output", required=True, help="拼图输出路径") + p_mt.add_argument("--cols", type=int, default=2, help="列数 (default: 2)") + p_mt.add_argument("--title", default="", help="总标题") + return parser @@ -241,6 +248,16 @@ def cmd_render_algo(args): render_algo(ts, vals, labels, windows, args.algo, args.output, title=title) +def cmd_montage(args): + """montage 子命令:多 PNG 拼图。""" + logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") + + from ts_anomaly_td.visualize import montage_pngs + + montage_pngs(args.inputs, args.output, cols=args.cols, title=args.title) + logger.info("montage complete: %s", args.output) + + def main(): parser = build_parser() args = parser.parse_args() @@ -253,6 +270,7 @@ def main(): "visualize": cmd_visualize, "watch": cmd_watch, "render-algo": cmd_render_algo, + "montage": cmd_montage, } fn = dispatch.get(args.command)