feat: add render-algo subcommand for single-algorithm GT+detection PNG

This commit is contained in:
张宗平
2026-06-11 21:19:09 +08:00
parent 45fc062627
commit ab31b11fb5
3 changed files with 226 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
"""render_algo 单元测试。"""
import json
import tempfile
from pathlib import Path
from ts_anomaly_td.visualize import render_algo
def _make_ts_vals_labels(n=200):
"""生成测试用时间序列。"""
ts = [i * 3600_000 for i in range(n)] # 每小时
vals = [float(i % 20) for i in range(n)]
labels = [1 if 50 <= i <= 60 else 0 for i in range(n)]
return ts, vals, labels
def test_render_algo_basic():
"""render_algo 应生成有效 PNG。"""
ts, vals, labels = _make_ts_vals_labels()
windows = [[ts[50], ts[60]]]
with tempfile.TemporaryDirectory() as tmpdir:
out = Path(tmpdir) / "test_algo.png"
result = render_algo(ts, vals, labels, windows, "ksigma", str(out), title="test")
assert Path(result).exists()
assert Path(result).stat().st_size > 5000
def test_render_algo_empty_windows():
"""空检测窗口应正常渲染。"""
ts, vals, labels = _make_ts_vals_labels(50)
with tempfile.TemporaryDirectory() as tmpdir:
out = Path(tmpdir) / "empty.png"
result = render_algo(ts, vals, labels, [], "iqr", str(out))
assert Path(result).exists()
+30
View File
@@ -209,9 +209,38 @@ def build_parser() -> argparse.ArgumentParser:
p_watch.add_argument("--window-sec", type=int, default=30, help="检测间隔秒数 (default: 30)")
p_watch.add_argument("--url", default="ws://root:taosdata@localhost:6041", help="TDengine WebSocket URL")
# render-algo
p_ra = sub.add_parser("render-algo", help="单算法 GT+检测渲染 PNG")
p_ra.add_argument("--gt-csv", required=True, help="GT CSV 文件路径")
p_ra.add_argument("--det-json", required=True, help="detect-batch --json 输出的 JSON 文件")
p_ra.add_argument("--algo", required=True, help="算法名称")
p_ra.add_argument("--output", required=True, help="PNG 输出路径")
p_ra.add_argument("--value-col", default="value", help="数值列名 (default: value)")
p_ra.add_argument("--title", default="", help="图表标题")
return parser
def cmd_render_algo(args):
"""render-algo 子命令:单算法 GT+检测渲染 PNG。"""
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
import json
from ts_anomaly_td.io_csv import read_csv
from ts_anomaly_td.visualize import render_algo
ts, vals, labels = read_csv(args.gt_csv, value_col=args.value_col)
with open(args.det_json) as f:
det_data = json.load(f)
# det_json 格式: {"algo": "ksigma", "windows": [[start, end], ...], "error": null}
windows = det_data.get("windows", [])
title = args.title or f"{Path(args.gt_csv).stem} / {args.algo}"
render_algo(ts, vals, labels, windows, args.algo, args.output, title=title)
def main():
parser = build_parser()
args = parser.parse_args()
@@ -223,6 +252,7 @@ def main():
"e2e": cmd_e2e,
"visualize": cmd_visualize,
"watch": cmd_watch,
"render-algo": cmd_render_algo,
}
fn = dispatch.get(args.command)
+159
View File
@@ -153,3 +153,162 @@ def render_contrast(
plt.close(fig)
logger.info("chart saved: %s", output_path)
return output_path
def render_algo(
timestamps: list[int],
values: list[float],
gt_labels: list[int],
det_windows: list[list[int]],
algo: str,
output_path: str,
title: str = "",
) -> str:
"""单算法渲染:GT(红)+ 该算法检测窗口(算法色)→ PNG。
Args:
timestamps: 毫秒时间戳列表
values: 数值列表
gt_labels: GT 标签(0/1
det_windows: [[start_ts, end_ts], ...] 该算法检测窗口
algo: 算法名称
output_path: PNG 输出路径
title: 图表标题
Returns:
output_path
"""
font_name = _find_cjk_font()
plt.rcParams["font.family"] = font_name
plt.rcParams["font.size"] = 9
fig, ax = plt.subplots(1, 1, figsize=(14, 5))
t0 = timestamps[0]
hours = [(t - t0) / 3_600_000 for t in timestamps]
# 原始序列
ax.plot(hours, values, color="black", linewidth=0.6, alpha=0.8)
# GT 红色 axvspan
in_anomaly = False
anom_start = None
gt_windows = []
for i, label in enumerate(gt_labels):
if label == 1 and not in_anomaly:
in_anomaly = True
anom_start = hours[i]
elif label == 0 and in_anomaly:
in_anomaly = False
ax.axvspan(anom_start, hours[i], alpha=0.15, color="red", label="GT" if len(gt_windows) == 0 else None)
gt_windows.append((anom_start, hours[i]))
if in_anomaly:
ax.axvspan(anom_start, hours[-1], alpha=0.15, color="red", label="GT" if len(gt_windows) == 0 else None)
gt_windows.append((anom_start, hours[-1]))
# 该算法检测窗口
color = ALGO_COLORS.get(algo, "#888888")
det_hours = []
for i, (wstart, wend) in enumerate(det_windows):
ws_h = (wstart - t0) / 3_600_000
we_h = (wend - t0) / 3_600_000
ax.axvspan(ws_h, we_h, alpha=0.2, color=color, label=algo if i == 0 else None)
det_hours.append((ws_h, we_h))
# IoU + 统计
iou = _compute_iou(gt_windows, det_hours)
stats_text = f"GT: {len(gt_windows)} Det: {len(det_windows)} IoU: {iou:.3f}"
ax.set_title(f"{title}{stats_text}" if title else stats_text)
ax.set_ylabel("Value")
ax.set_xlabel("Time (hours)")
if gt_windows or det_windows:
ax.legend(loc="upper right", fontsize=7)
plt.tight_layout()
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
fig.savefig(output_path, dpi=150, bbox_inches="tight")
plt.close(fig)
logger.info("render_algo saved: %s", output_path)
return output_path
def montage_pngs(
image_paths: list[str],
output_path: str,
cols: int = 2,
title: str = "",
) -> str:
"""将多张等宽 PNG 按网格拼成一张对比图。
Args:
image_paths: PNG 文件路径列表
output_path: 拼图输出路径
cols: 列数
title: 总标题
Returns:
output_path
"""
from PIL import Image, ImageDraw, ImageFont
if not image_paths:
raise ValueError("image_paths 为空")
images = []
for p in image_paths:
img = Image.open(p)
images.append(img)
# 统一宽度为第一张图的宽度
target_w = images[0].width
resized = []
for img in images:
if img.width != target_w:
ratio = target_w / img.width
new_h = int(img.height * ratio)
img = img.resize((target_w, new_h), Image.LANCZOS)
resized.append(img)
n = len(resized)
rows_count = (n + cols - 1) // cols
# 计算每行高度
row_heights = []
for r in range(rows_count):
max_h = max(resized[r * cols + c].height for c in range(cols) if r * cols + c < n)
row_heights.append(max_h)
padding = 10
title_h = 40 if title else 0
total_w = target_w * cols + padding * (cols + 1)
total_h = sum(row_heights) + padding * (rows_count + 1) + title_h
canvas = Image.new("RGB", (total_w, total_h), "white")
draw = ImageDraw.Draw(canvas)
if title:
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 24)
except Exception:
font = ImageFont.load_default()
draw.text((total_w // 2, 15), title, fill="black", anchor="mm", font=font)
y_offset = title_h + padding
for r in range(rows_count):
x_offset = padding
for c in range(cols):
idx = r * cols + c
if idx >= n:
break
img = resized[idx]
# 垂直居中
y_pad = (row_heights[r] - img.height) // 2
canvas.paste(img, (x_offset, y_offset + y_pad))
x_offset += target_w + padding
y_offset += row_heights[r] + padding
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
canvas.save(output_path)
logger.info("montage saved: %s (%d images, %dx%d)", output_path, n, cols, rows_count)
return output_path