agent-l / pipelines /visualize_error_analysis.py
zhou777's picture
Add files using upload-large-folder tool
cf0614b verified
Raw
History Blame Contribute Delete
17.4 kB
from __future__ import annotations
import argparse
import json
import math
from datetime import datetime
from pathlib import Path
from typing import Any
from PIL import Image, ImageDraw, ImageFont
PROJECT_ROOT = Path(__file__).resolve().parents[1]
ANALYSIS_DIR = PROJECT_ROOT / "runs/analysis/validation_error_analysis_20260524"
DEFAULT_COMPARISON_PATH = ANALYSIS_DIR / "per_image_comparison.jsonl"
DEFAULT_OUTPUT_DIR = ANALYSIS_DIR / "visual_review_images"
DEFAULT_REFERENCE_PATH = (
PROJECT_ROOT
/ "runs/validation_eval/qwen3_5_35b_a3b_exp_a_official/reference/reference_subset.jsonl"
)
DEFAULT_RUNS = {
"A_official": PROJECT_ROOT / "runs/validation_eval/qwen3_5_35b_a3b_exp_a_official/predictions/predictions.jsonl",
"B_full_rerun": PROJECT_ROOT
/ "runs/validation_eval/qwen3_5_35b_a3b_exp_b_attr_rerun_20260501_174649_max12000_stream/predictions/predictions.jsonl",
"B_old_occlusion_only": PROJECT_ROOT
/ "runs/validation_eval/qwen3_5_35b_a3b_ablation_occlusion_only_tp4_20260501_2058/predictions/predictions.jsonl",
"B_lite_dataset": PROJECT_ROOT
/ "runs/validation_eval/qwen3_5_35b_a3b_exp_b_lite_v0_20260504_2206_dataset/predictions/predictions.jsonl",
}
DEFAULT_PANEL_WIDTH = 420
DEFAULT_PANEL_HEIGHT = 300
PANEL_SPACING = 16
MARGIN = 18
TITLE_HEIGHT = 96
PANEL_HEADER_HEIGHT = 68
FOOTER_HEIGHT = 36
BACKGROUND_COLOR = (246, 244, 239)
PANEL_BG_COLOR = (232, 230, 224)
TEXT_COLOR = (28, 27, 25)
SUBTEXT_COLOR = (78, 74, 68)
GT_COLOR = (28, 138, 68)
PRED_COLOR = (210, 58, 50)
FONT_PATH = Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc")
FONT_BOLD_PATH = Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc")
def main() -> None:
"""命令行入口。"""
parser = argparse.ArgumentParser(description="为统一错误分析导出人工复核对比图。")
parser.add_argument("--comparison-path", default=str(DEFAULT_COMPARISON_PATH), help="per_image_comparison.jsonl")
parser.add_argument("--reference-path", default=str(DEFAULT_REFERENCE_PATH), help="reference_subset.jsonl")
parser.add_argument("--output-dir", default=str(DEFAULT_OUTPUT_DIR), help="图像输出目录")
parser.add_argument("--top-k", type=int, default=30, help="导出最高风险样本数量")
parser.add_argument("--panel-width", type=int, default=DEFAULT_PANEL_WIDTH, help="单个面板宽度")
parser.add_argument("--panel-height", type=int, default=DEFAULT_PANEL_HEIGHT, help="单个面板高度")
parser.add_argument("--dpi", type=int, default=150, help="保存图片 DPI")
parser.add_argument(
"--run",
action="append",
default=[],
help="预测文件,格式为 name=predictions.jsonl。未传入时使用默认四组实验。",
)
args = parser.parse_args()
runs = _parse_runs(args.run)
comparison_rows = _read_jsonl(Path(args.comparison_path))[: args.top_k]
reference_by_key = _load_reference_by_key(Path(args.reference_path))
predictions_by_run = {
run_name: _load_predictions_by_key(prediction_path)
for run_name, prediction_path in runs.items()
}
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
manifest_rows: list[dict[str, Any]] = []
digits = max(3, int(math.log10(max(1, len(comparison_rows)))) + 1)
for index, comparison in enumerate(comparison_rows, start=1):
image_key = str(comparison["image_key"])
reference_row = reference_by_key.get(image_key)
if reference_row is None:
continue
image_path = Path(reference_row["image_path"])
if not image_path.exists():
continue
output_path = output_dir / f"{index:0{digits}d}__risk{comparison['max_risk_score']}__{image_path.stem}.jpg"
manifest_rows.append(
_build_review_image(
image_path=image_path,
reference_row=reference_row,
comparison=comparison,
predictions_by_run=predictions_by_run,
output_path=output_path,
panel_width=args.panel_width,
panel_height=args.panel_height,
dpi=args.dpi,
)
)
manifest_path = output_dir / "manifest.jsonl"
with manifest_path.open("w", encoding="utf-8") as file:
for row in manifest_rows:
file.write(json.dumps(row, ensure_ascii=False))
file.write("\n")
summary = {
"created_at": datetime.now().isoformat(timespec="seconds"),
"comparison_path": str(Path(args.comparison_path).resolve()),
"reference_path": str(Path(args.reference_path).resolve()),
"output_dir": str(output_dir.resolve()),
"image_count": len(manifest_rows),
"top_k": args.top_k,
"panel_width": args.panel_width,
"panel_height": args.panel_height,
"dpi": args.dpi,
"runs": {name: str(path.resolve()) for name, path in runs.items()},
"manifest_path": str(manifest_path.resolve()),
}
(output_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
_write_readme(output_dir / "README.md", summary, manifest_rows)
print(json.dumps(summary, ensure_ascii=False, indent=2))
def _parse_runs(raw_runs: list[str]) -> dict[str, Path]:
"""解析运行配置。"""
if not raw_runs:
return DEFAULT_RUNS
runs: dict[str, Path] = {}
for raw_run in raw_runs:
if "=" not in raw_run:
raise ValueError("--run 必须使用 name=predictions.jsonl 格式。")
name, path = raw_run.split("=", 1)
name = name.strip()
if not name:
raise ValueError("--run name 不能为空。")
runs[name] = Path(path).resolve()
return runs
def _build_review_image(
*,
image_path: Path,
reference_row: dict[str, Any],
comparison: dict[str, Any],
predictions_by_run: dict[str, dict[str, dict[str, Any]]],
output_path: Path,
panel_width: int,
panel_height: int,
dpi: int,
) -> dict[str, Any]:
"""生成单张复核对比图。"""
title_font = _load_font(24, bold=True)
header_font = _load_font(17, bold=True)
text_font = _load_font(14)
small_font = _load_font(12)
panel_names = ["GT", *predictions_by_run.keys()]
canvas_width = MARGIN * 2 + panel_width * len(panel_names) + PANEL_SPACING * (len(panel_names) - 1)
canvas_height = TITLE_HEIGHT + PANEL_HEADER_HEIGHT + panel_height + FOOTER_HEIGHT + MARGIN
with Image.open(image_path).convert("RGB") as image:
canvas = Image.new("RGB", (canvas_width, canvas_height), BACKGROUND_COLOR)
draw = ImageDraw.Draw(canvas)
_draw_page_header(draw, comparison, image_path, title_font, text_font)
gt_annotations = _extract_gt_annotations(reference_row)
panels: list[tuple[str, list[dict[str, Any]], dict[str, Any] | None, tuple[int, int, int]]] = [
("GT", gt_annotations, None, GT_COLOR)
]
for run_name, prediction_by_key in predictions_by_run.items():
prediction_row = prediction_by_key.get(str(comparison["image_key"]))
annotations = prediction_row.get("annotations", []) if prediction_row else []
run_metrics = comparison.get("runs", {}).get(run_name, {})
panels.append((run_name, annotations, run_metrics, PRED_COLOR))
for index, (panel_name, annotations, metrics, color) in enumerate(panels):
left = MARGIN + index * (panel_width + PANEL_SPACING)
_draw_panel(
canvas=canvas,
image=image,
left=left,
top=TITLE_HEIGHT,
panel_name=panel_name,
annotations=annotations,
metrics=metrics,
color=color,
panel_width=panel_width,
panel_height=panel_height,
header_font=header_font,
text_font=text_font,
small_font=small_font,
)
output_path.parent.mkdir(parents=True, exist_ok=True)
canvas.save(output_path, quality=96, dpi=(dpi, dpi))
return {
"image_key": comparison["image_key"],
"image_path": str(image_path.resolve()),
"review_image": str(output_path.resolve()),
"max_risk_score": comparison.get("max_risk_score"),
"risk_run_count": comparison.get("risk_run_count"),
"error_tags": comparison.get("error_tags", []),
"best_f1_run": comparison.get("best_f1_run"),
"worst_f1_run": comparison.get("worst_f1_run"),
}
def _draw_page_header(
draw: ImageDraw.ImageDraw,
comparison: dict[str, Any],
image_path: Path,
title_font: ImageFont.ImageFont,
text_font: ImageFont.ImageFont,
) -> None:
"""绘制页面标题。"""
title = f"Risk {comparison.get('max_risk_score')} | {image_path.name}"
tags = ", ".join(comparison.get("error_tags", [])) or "-"
meta = (
f"risk_runs={comparison.get('risk_run_count')} | "
f"best_f1={comparison.get('best_f1_run')} | worst_f1={comparison.get('worst_f1_run')}"
)
draw.text((MARGIN, 16), title, fill=TEXT_COLOR, font=title_font)
draw.text((MARGIN, 48), meta, fill=SUBTEXT_COLOR, font=text_font)
draw.text((MARGIN, 70), f"tags: {tags}", fill=SUBTEXT_COLOR, font=text_font)
def _draw_panel(
*,
canvas: Image.Image,
image: Image.Image,
left: int,
top: int,
panel_name: str,
annotations: list[dict[str, Any]],
metrics: dict[str, Any] | None,
color: tuple[int, int, int],
panel_width: int,
panel_height: int,
header_font: ImageFont.ImageFont,
text_font: ImageFont.ImageFont,
small_font: ImageFont.ImageFont,
) -> None:
"""绘制单个面板。"""
draw = ImageDraw.Draw(canvas)
draw.rectangle(
(left, top, left + panel_width, top + PANEL_HEADER_HEIGHT + panel_height),
fill=PANEL_BG_COLOR,
)
draw.text((left + 10, top + 8), panel_name, fill=TEXT_COLOR, font=header_font)
if metrics is None:
metric_text = f"targets={len(annotations)}"
metric_text_2 = "ground truth"
else:
metric_text = (
f"GT={metrics.get('reference_count', '-')} Pred={metrics.get('prediction_count', '-')} "
f"Match={metrics.get('matched_count', '-')}"
)
metric_text_2 = (
f"P={_fmt(metrics.get('precision'))} R={_fmt(metrics.get('recall'))} "
f"F1={_fmt(metrics.get('f1'))} M={_fmt(metrics.get('maturity_accuracy_on_matched'))} "
f"O={_fmt(metrics.get('occlusion_accuracy_on_matched'))}"
)
draw.text((left + 10, top + 34), metric_text, fill=SUBTEXT_COLOR, font=small_font)
draw.text((left + 10, top + 50), metric_text_2, fill=SUBTEXT_COLOR, font=small_font)
preview = _fit_image(_draw_boxes(image, annotations, color=color), panel_width, panel_height)
paste_x = left + (panel_width - preview.width) // 2
paste_y = top + PANEL_HEADER_HEIGHT + (panel_height - preview.height) // 2
canvas.paste(preview, (paste_x, paste_y))
def _draw_boxes(
image: Image.Image,
annotations: list[dict[str, Any]],
*,
color: tuple[int, int, int],
) -> Image.Image:
"""在图像上绘制 bbox。"""
preview = image.copy()
draw = ImageDraw.Draw(preview)
label_font = _load_font(max(16, int(min(preview.size) / 110)))
line_width = max(4, int(round(min(preview.size) / 150)))
for index, annotation in enumerate(annotations, start=1):
bbox = _normalize_bbox(annotation.get("bbox"))
if bbox is None:
continue
bbox = _clamp_bbox(bbox, preview.width, preview.height)
draw.rectangle(bbox, outline=color, width=line_width)
label = _build_box_label(annotation, index)
_draw_box_label(draw, bbox[0], bbox[1], label, color, label_font)
return preview
def _draw_box_label(
draw: ImageDraw.ImageDraw,
x1: float,
y1: float,
text: str,
color: tuple[int, int, int],
font: ImageFont.ImageFont,
) -> None:
"""绘制 bbox 标签。"""
text = text[:48]
bbox = draw.textbbox((0, 0), text, font=font)
padding_x = 6
padding_y = 3
label_height = bbox[3] - bbox[1] + 2 * padding_y
label_width = bbox[2] - bbox[0] + 2 * padding_x
label_box = (x1, max(0.0, y1 - label_height), x1 + label_width, max(label_height, y1))
draw.rectangle(label_box, fill=color)
draw.text((label_box[0] + padding_x, label_box[1] + padding_y), text, fill=(255, 255, 255), font=font)
def _build_box_label(annotation: dict[str, Any], fallback_index: int) -> str:
"""构建 bbox 标签文本。"""
target_index = annotation.get("target_index", fallback_index - 1)
maturity = annotation.get("maturity_level") or _label_to_maturity(annotation.get("label"))
occlusion = annotation.get("occlusion_degree")
parts = [f"#{target_index}"]
if maturity:
parts.append(str(maturity))
if occlusion:
parts.append(str(occlusion))
return " ".join(parts)
def _label_to_maturity(label: Any) -> str | None:
if not isinstance(label, str):
return None
for maturity in ["未成熟", "半成熟", "完熟"]:
if maturity in label:
return maturity
return label[:12] if label else None
def _fit_image(image: Image.Image, max_width: int, max_height: int) -> Image.Image:
"""等比例缩放图像。"""
scale = min(max_width / image.width, max_height / image.height)
new_width = max(1, int(round(image.width * scale)))
new_height = max(1, int(round(image.height * scale)))
return image.resize((new_width, new_height), Image.Resampling.LANCZOS)
def _load_reference_by_key(path: Path) -> dict[str, dict[str, Any]]:
"""按文件名加载 reference。"""
rows = _read_jsonl(path)
return {Path(str(row["image_path"])).name: row for row in rows}
def _load_predictions_by_key(path: Path) -> dict[str, dict[str, Any]]:
"""按文件名加载预测结果。"""
rows = _read_jsonl(path)
return {Path(str(row["image_path"])).name: row for row in rows}
def _extract_gt_annotations(reference_row: dict[str, Any]) -> list[dict[str, Any]]:
"""提取 GT annotation。"""
annotations: list[dict[str, Any]] = []
for record in reference_row.get("source_records", []):
if not isinstance(record, dict):
continue
annotations.append(
{
"target_index": record.get("target_index"),
"bbox": record.get("bbox"),
"maturity_level": record.get("maturity_level"),
"occlusion_degree": record.get("occlusion_degree"),
}
)
return annotations
def _normalize_bbox(values: Any) -> tuple[float, float, float, float] | None:
"""标准化 bbox。"""
if not isinstance(values, list) or len(values) != 4:
return None
try:
x1, y1, x2, y2 = [float(value) for value in values]
except (TypeError, ValueError):
return None
if x2 <= x1 or y2 <= y1:
return None
return x1, y1, x2, y2
def _clamp_bbox(
bbox: tuple[float, float, float, float],
image_width: int,
image_height: int,
) -> tuple[float, float, float, float]:
"""裁剪 bbox 到图像范围内。"""
x1, y1, x2, y2 = bbox
x1 = max(0.0, min(float(image_width - 1), x1))
y1 = max(0.0, min(float(image_height - 1), y1))
x2 = max(x1 + 1.0, min(float(image_width), x2))
y2 = max(y1 + 1.0, min(float(image_height), y2))
return x1, y1, x2, y2
def _write_readme(path: Path, summary: dict[str, Any], manifest_rows: list[dict[str, Any]]) -> None:
"""写人工查看说明。"""
lines = [
"# Visual Review Images",
"",
"本目录包含最高风险样本的五联对比图:GT、A official、B full rerun、B old + occlusion_only、B-lite dataset。",
"",
f"- image_count: `{summary['image_count']}`",
f"- generated_at: `{summary['created_at']}`",
"",
"建议优先查看前 10 张,它们在多个实验中都被标为高风险。",
"",
"| # | Image Key | Max Risk | Tags | File |",
"|---:|---|---:|---|---|",
]
for index, row in enumerate(manifest_rows, start=1):
file_name = Path(row["review_image"]).name
tags = ", ".join(row.get("error_tags", [])) or "-"
lines.append(f"| {index} | `{row['image_key']}` | {row['max_risk_score']} | {tags} | `{file_name}` |")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
with path.open("r", encoding="utf-8") as file:
for line in file:
line = line.strip()
if line:
rows.append(json.loads(line))
return rows
def _load_font(size: int, *, bold: bool = False) -> ImageFont.ImageFont:
"""加载支持中文的字体。"""
font_path = FONT_BOLD_PATH if bold else FONT_PATH
if font_path.exists():
return ImageFont.truetype(str(font_path), size=size)
return ImageFont.load_default()
def _fmt(value: Any) -> str:
if value is None:
return "-"
try:
return f"{float(value):.3f}"
except (TypeError, ValueError):
return str(value)
if __name__ == "__main__":
main()