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 PROJECT_ROOT = Path(__file__).resolve().parent.parent RUNS_ROOT = PROJECT_ROOT / "runs" / "validation_compare" DEFAULT_REFERENCE_PATH = ( PROJECT_ROOT / "runs" / "validation_eval" / "qwen3_5_35b_a3b_exp_a_official" / "reference" / "reference_subset.jsonl" ) DEFAULT_EXP_A_PATH = ( PROJECT_ROOT / "runs" / "validation_eval" / "qwen3_5_35b_a3b_exp_a_official" / "predictions" / "predictions.jsonl" ) DEFAULT_EXP_B_PATH = ( PROJECT_ROOT / "runs" / "validation_eval" / "qwen3_5_35b_a3b_exp_b_attr" / "predictions" / "predictions.jsonl" ) DEFAULT_MAX_IMAGES = 128 PANEL_SPACING = 24 HEADER_HEIGHT = 84 FOOTER_HEIGHT = 54 BACKGROUND_COLOR = (245, 243, 238) TEXT_COLOR = (28, 26, 24) SUBTEXT_COLOR = (88, 84, 78) GT_COLOR = (28, 138, 68) PRED_COLOR = (208, 52, 52) PALETTE = [ (212, 76, 54), (34, 117, 168), (48, 145, 84), (198, 138, 39), (135, 84, 196), (28, 151, 156), ] def _load_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 not line: continue rows.append(json.loads(line)) return rows def _group_reference_rows(reference_rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: grouped: dict[str, dict[str, Any]] = {} for row in reference_rows: image_path = str(Path(row["image_path"]).resolve()) source_records = sorted( row.get("source_records", []), key=lambda item: int(item.get("target_index", 0)), ) grouped[image_path] = { "image_path": image_path, "source_records": source_records, "image_width": int(source_records[0]["image_width"]) if source_records else None, "image_height": int(source_records[0]["image_height"]) if source_records else None, } return grouped def _group_prediction_rows(prediction_rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: grouped: dict[str, dict[str, Any]] = {} for row in prediction_rows: image_path = str(Path(row["image_path"]).resolve()) grouped[image_path] = row return grouped def _normalize_bbox(values: list[Any]) -> tuple[float, float, float, float] | None: if 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]: 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 _fit_image(image: Image.Image, max_width: int, max_height: int) -> Image.Image: scale = min(max_width / image.width, max_height / image.height) scale = min(scale, 1.0) new_width = max(1, int(round(image.width * scale))) new_height = max(1, int(round(image.height * scale))) if new_width == image.width and new_height == image.height: return image.copy() return image.resize((new_width, new_height), Image.Resampling.LANCZOS) def _draw_label(draw: ImageDraw.ImageDraw, x1: float, y1: float, text: str, color: tuple[int, int, int]) -> None: text = text[:72] bbox = draw.textbbox((0, 0), text) padding_x = 6 padding_y = 4 bg_box = ( x1, max(0.0, y1 - (bbox[3] - bbox[1]) - 2 * padding_y), x1 + (bbox[2] - bbox[0]) + 2 * padding_x, y1, ) draw.rectangle(bg_box, fill=color) draw.text((bg_box[0] + padding_x, bg_box[1] + padding_y), text, fill=(255, 255, 255)) def _draw_boxes( image: Image.Image, annotations: list[dict[str, Any]], *, default_color: tuple[int, int, int], use_palette: bool, ) -> Image.Image: preview = image.copy() draw = ImageDraw.Draw(preview) line_width = max(3, int(round(min(preview.size) / 170))) for index, annotation in enumerate(annotations, start=1): bbox_values = annotation.get("bbox") bbox = _normalize_bbox(bbox_values if isinstance(bbox_values, list) else []) if bbox is None: continue bbox = _clamp_bbox(bbox, preview.width, preview.height) color = PALETTE[(index - 1) % len(PALETTE)] if use_palette else default_color draw.rectangle(bbox, outline=color, width=line_width) target_index = annotation.get("target_index", index - 1) maturity = annotation.get("maturity_level") occlusion = annotation.get("occlusion_degree") label_parts = [f"#{target_index}"] if maturity: label_parts.append(str(maturity)) if occlusion: label_parts.append(f"遮挡:{occlusion}") _draw_label(draw, bbox[0], bbox[1], " | ".join(label_parts), color) return preview def _extract_gt_annotations(reference_row: dict[str, Any]) -> list[dict[str, Any]]: annotations: list[dict[str, Any]] = [] for record in reference_row.get("source_records", []): bbox = record.get("bbox") if not isinstance(bbox, list): continue annotations.append( { "target_index": record.get("target_index"), "bbox": bbox, "maturity_level": record.get("maturity_level"), "occlusion_degree": record.get("occlusion_degree"), } ) return annotations def _build_header( canvas: Image.Image, *, title: str, image_name: str, gt_count: int, exp_a_count: int, exp_b_count: int, exp_a_error: str | None, exp_b_error: str | None, ) -> None: draw = ImageDraw.Draw(canvas) draw.text((24, 18), title, fill=TEXT_COLOR) meta = f"{image_name} | GT={gt_count} | A={exp_a_count} | B={exp_b_count}" draw.text((24, 42), meta, fill=SUBTEXT_COLOR) if exp_a_error: draw.text((24, 62), f"A parse_error: {exp_a_error[:120]}", fill=(150, 58, 42)) if exp_b_error: draw.text((24, 80), f"B parse_error: {exp_b_error[:120]}", fill=(150, 58, 42)) def _build_footer(draw: ImageDraw.ImageDraw, top_y: int, labels: list[str], panel_width: int) -> None: for index, label in enumerate(labels): x = 24 + index * (panel_width + PANEL_SPACING) draw.text((x, top_y), label, fill=TEXT_COLOR) def _build_triptych( *, image_path: Path, gt_annotations: list[dict[str, Any]], exp_a_row: dict[str, Any] | None, exp_b_row: dict[str, Any] | None, output_path: Path, ) -> dict[str, Any]: with Image.open(image_path).convert("RGB") as image: display_height = max(320, min(720, image.height)) display_width = max(320, min(960, image.width)) gt_panel = _fit_image( _draw_boxes(image, gt_annotations, default_color=GT_COLOR, use_palette=True), display_width, display_height, ) exp_a_annotations = exp_a_row.get("annotations", []) if exp_a_row else [] exp_b_annotations = exp_b_row.get("annotations", []) if exp_b_row else [] exp_a_panel = _fit_image( _draw_boxes(image, exp_a_annotations, default_color=PRED_COLOR, use_palette=False), display_width, display_height, ) exp_b_panel = _fit_image( _draw_boxes(image, exp_b_annotations, default_color=PRED_COLOR, use_palette=False), display_width, display_height, ) panel_width = max(gt_panel.width, exp_a_panel.width, exp_b_panel.width) panel_height = max(gt_panel.height, exp_a_panel.height, exp_b_panel.height) canvas_width = 24 * 2 + panel_width * 3 + PANEL_SPACING * 2 canvas_height = HEADER_HEIGHT + panel_height + FOOTER_HEIGHT + 24 canvas = Image.new("RGB", (canvas_width, canvas_height), BACKGROUND_COLOR) _build_header( canvas, title="Validation Comparison: GT vs Exp A vs Exp B", image_name=image_path.name, gt_count=len(gt_annotations), exp_a_count=len(exp_a_annotations), exp_b_count=len(exp_b_annotations), exp_a_error=exp_a_row.get("parse_error") if exp_a_row else "missing prediction row", exp_b_error=exp_b_row.get("parse_error") if exp_b_row else "missing prediction row", ) panel_top = HEADER_HEIGHT panel_lefts = [ 24, 24 + panel_width + PANEL_SPACING, 24 + (panel_width + PANEL_SPACING) * 2, ] for left, panel in zip(panel_lefts, [gt_panel, exp_a_panel, exp_b_panel], strict=True): paste_x = left + (panel_width - panel.width) // 2 paste_y = panel_top + (panel_height - panel.height) // 2 canvas.paste(panel, (paste_x, paste_y)) footer_draw = ImageDraw.Draw(canvas) _build_footer( footer_draw, HEADER_HEIGHT + panel_height + 14, ["GT", "Experiment A", "Experiment B"], panel_width, ) output_path.parent.mkdir(parents=True, exist_ok=True) canvas.save(output_path, quality=95) return { "image_path": str(image_path), "comparison_image": str(output_path), "gt_count": len(gt_annotations), "exp_a_count": len(exp_a_annotations), "exp_b_count": len(exp_b_annotations), "exp_a_parse_error": exp_a_row.get("parse_error") if exp_a_row else "missing prediction row", "exp_b_parse_error": exp_b_row.get("parse_error") if exp_b_row else "missing prediction row", } def build_validation_comparison( *, reference_path: Path, exp_a_predictions_path: Path, exp_b_predictions_path: Path, output_dir: Path | None = None, max_images: int = DEFAULT_MAX_IMAGES, ) -> dict[str, Any]: reference_rows = _load_jsonl(reference_path) exp_a_rows = _group_prediction_rows(_load_jsonl(exp_a_predictions_path)) exp_b_rows = _group_prediction_rows(_load_jsonl(exp_b_predictions_path)) reference_grouped = _group_reference_rows(reference_rows) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") run_dir = output_dir or RUNS_ROOT / f"exp_a_vs_b_{timestamp}" run_dir.mkdir(parents=True, exist_ok=True) image_dir = run_dir / "images" image_dir.mkdir(parents=True, exist_ok=True) manifest_rows: list[dict[str, Any]] = [] sorted_image_paths = sorted(reference_grouped.keys())[:max_images] digits = max(3, int(math.log10(max(1, len(sorted_image_paths)))) + 1) for index, image_path_str in enumerate(sorted_image_paths, start=1): image_path = Path(image_path_str) reference_row = reference_grouped[image_path_str] gt_annotations = _extract_gt_annotations(reference_row) comparison_path = image_dir / f"{index:0{digits}d}__{image_path.stem}.jpg" manifest_rows.append( _build_triptych( image_path=image_path, gt_annotations=gt_annotations, exp_a_row=exp_a_rows.get(image_path_str), exp_b_row=exp_b_rows.get(image_path_str), output_path=comparison_path, ) ) manifest_path = run_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"), "reference_path": str(reference_path.resolve()), "exp_a_predictions_path": str(exp_a_predictions_path.resolve()), "exp_b_predictions_path": str(exp_b_predictions_path.resolve()), "image_count": len(manifest_rows), "run_dir": str(run_dir), "image_dir": str(image_dir), "manifest_path": str(manifest_path), } (run_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") return summary def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="生成实验 A / 实验 B / GT 的验证集可视化对比图。") parser.add_argument("--reference-path", default=str(DEFAULT_REFERENCE_PATH), help="reference_subset.jsonl 路径") parser.add_argument("--exp-a-predictions-path", default=str(DEFAULT_EXP_A_PATH), help="实验 A predictions.jsonl") parser.add_argument("--exp-b-predictions-path", default=str(DEFAULT_EXP_B_PATH), help="实验 B predictions.jsonl") parser.add_argument("--output-dir", default=None, help="输出目录;默认写入 runs/validation_compare") parser.add_argument("--max-images", type=int, default=DEFAULT_MAX_IMAGES, help="最多导出多少张图") return parser.parse_args() if __name__ == "__main__": args = parse_args() summary = build_validation_comparison( reference_path=Path(args.reference_path).expanduser().resolve(), exp_a_predictions_path=Path(args.exp_a_predictions_path).expanduser().resolve(), exp_b_predictions_path=Path(args.exp_b_predictions_path).expanduser().resolve(), output_dir=Path(args.output_dir).expanduser().resolve() if args.output_dir else None, max_images=args.max_images, ) print(json.dumps(summary, ensure_ascii=False, indent=2))