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()