agent-l / pipelines /visualize_validation_comparison.py
zhou777's picture
Add files using upload-large-folder tool
cf0614b verified
Raw
History Blame Contribute Delete
13.8 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
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))