228 lines
8.4 KiB
Python
228 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import subprocess
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.parse import urlencode
|
||
|
||
from PIL import Image, ImageChops, ImageStat
|
||
|
||
|
||
DEFAULT_COMPARE_URL = "http://192.168.200.184/newpec/navsea-compare-full-audit.html"
|
||
DEFAULT_AOI_JSON = Path("/root/sourceserver/pbf/tasks/pbf/NavSea_Full_AOI_Visual_Audit_2026-04-18.json")
|
||
DEFAULT_OUTPUT_DIR = Path("/root/sourceserver/pbf/report/full_aoi_visual_audit_2026-04-18")
|
||
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(description="对全国 full / semantic 做 AOI 截图 + 图像 diff 审计。")
|
||
parser.add_argument("--compare-url", default=DEFAULT_COMPARE_URL)
|
||
parser.add_argument("--aoi-json", type=Path, default=DEFAULT_AOI_JSON)
|
||
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
|
||
parser.add_argument("--width", type=int, default=1280)
|
||
parser.add_argument("--height", type=int, default=720)
|
||
parser.add_argument("--toolbar-height", type=int, default=120)
|
||
parser.add_argument("--bottom-trim", type=int, default=30)
|
||
parser.add_argument("--timeout-sec", type=int, default=120)
|
||
parser.add_argument("--virtual-time-budget-ms", type=int, default=30000)
|
||
parser.add_argument("--variant", action="append", choices=("full", "semantic"), help="只跑指定 variant,可重复传入")
|
||
return parser.parse_args()
|
||
|
||
|
||
def run_browser_screenshot(
|
||
compare_url: str,
|
||
output_png: Path,
|
||
width: int,
|
||
height: int,
|
||
timeout_sec: int,
|
||
virtual_time_budget_ms: int,
|
||
) -> None:
|
||
cmd = [
|
||
"timeout",
|
||
f"{timeout_sec}s",
|
||
"google-chrome",
|
||
"--headless=new",
|
||
"--disable-gpu",
|
||
"--enable-unsafe-swiftshader",
|
||
"--no-sandbox",
|
||
f"--virtual-time-budget={virtual_time_budget_ms}",
|
||
f"--window-size={width},{height}",
|
||
f"--screenshot={output_png}",
|
||
compare_url,
|
||
]
|
||
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||
|
||
|
||
def compute_visual_metrics(left_img: Image.Image, right_img: Image.Image) -> dict[str, Any]:
|
||
diff = ImageChops.difference(left_img, right_img)
|
||
diff_rgb = diff.convert("RGB")
|
||
diff_gray = diff.convert("L")
|
||
stat = ImageStat.Stat(diff_rgb)
|
||
mean_abs = [round(v, 4) for v in stat.mean]
|
||
rms = [round(v, 4) for v in stat.rms]
|
||
bbox = diff_gray.getbbox()
|
||
nonzero_pixels = 0
|
||
if bbox:
|
||
mask = diff_gray.point(lambda value: 255 if value else 0)
|
||
histogram = mask.histogram()
|
||
nonzero_pixels = histogram[255] if len(histogram) > 255 else 0
|
||
total_pixels = left_img.size[0] * left_img.size[1]
|
||
changed_ratio = round(nonzero_pixels / total_pixels, 6) if total_pixels else 0.0
|
||
return {
|
||
"changed_pixels": nonzero_pixels,
|
||
"total_pixels": total_pixels,
|
||
"changed_ratio": changed_ratio,
|
||
"mean_abs_rgb": mean_abs,
|
||
"rms_rgb": rms,
|
||
"diff_bbox": list(bbox) if bbox else None,
|
||
"diff_image": diff,
|
||
}
|
||
|
||
|
||
def crop_compare_panes(full_png: Path, left_png: Path, right_png: Path, diff_png: Path, toolbar_height: int, bottom_trim: int) -> dict[str, Any]:
|
||
image = Image.open(full_png).convert("RGBA")
|
||
usable_top = toolbar_height
|
||
usable_bottom = max(usable_top + 1, image.height - bottom_trim)
|
||
mid_x = image.width // 2
|
||
left = image.crop((0, usable_top, mid_x, usable_bottom))
|
||
right = image.crop((mid_x, usable_top, image.width, usable_bottom))
|
||
left.save(left_png)
|
||
right.save(right_png)
|
||
|
||
metrics = compute_visual_metrics(left, right)
|
||
diff_image = metrics.pop("diff_image")
|
||
diff_image.convert("RGB").point(lambda value: min(255, value * 4)).save(diff_png)
|
||
metrics["left_crop"] = [0, usable_top, mid_x, usable_bottom]
|
||
metrics["right_crop"] = [mid_x, usable_top, image.width, usable_bottom]
|
||
return metrics
|
||
|
||
|
||
def build_case_url(compare_url: str, variant: str, center: list[float], zoom: int | float) -> str:
|
||
query = urlencode(
|
||
{
|
||
"variant": variant,
|
||
"audit": "1",
|
||
"center": f"{center[0]},{center[1]}",
|
||
"zoom": zoom,
|
||
}
|
||
)
|
||
separator = "&" if "?" in compare_url else "?"
|
||
return f"{compare_url}{separator}{query}"
|
||
|
||
|
||
def write_report(output_dir: Path, summary: dict[str, Any]) -> tuple[Path, Path]:
|
||
report_json = output_dir / "full_aoi_visual_audit.json"
|
||
report_md = output_dir / "full_aoi_visual_audit.md"
|
||
report_json.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
|
||
lines = [
|
||
"# 全国 AOI 截图对比审计",
|
||
"",
|
||
"## 总览",
|
||
"",
|
||
f"- compare page: `{summary['compare_url']}`",
|
||
f"- cases: `{len(summary['cases'])}`",
|
||
"",
|
||
"## 差异排序",
|
||
"",
|
||
]
|
||
for case in summary["cases"]:
|
||
lines.extend(
|
||
[
|
||
f"### {case['label']} · {case['variant']} · z{case['zoom']}",
|
||
"",
|
||
f"- changed ratio: `{case['changed_ratio']}`",
|
||
f"- changed pixels: `{case['changed_pixels']}` / `{case['total_pixels']}`",
|
||
f"- mean abs rgb: `{case['mean_abs_rgb']}`",
|
||
f"- compare url: `{case['compare_url']}`",
|
||
f"- left image: `{case['left_image']}`",
|
||
f"- right image: `{case['right_image']}`",
|
||
f"- diff image: `{case['diff_image']}`",
|
||
"",
|
||
]
|
||
)
|
||
report_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
return report_md, report_json
|
||
|
||
|
||
def main() -> None:
|
||
args = parse_args()
|
||
output_dir = args.output_dir
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
aois = json.loads(args.aoi_json.read_text(encoding="utf-8"))
|
||
|
||
cases: list[dict[str, Any]] = []
|
||
variants = args.variant or ["full", "semantic"]
|
||
for variant in variants:
|
||
for aoi in aois:
|
||
for zoom in aoi["zoom_levels"]:
|
||
case_id = f"{aoi['id']}_{variant}_z{zoom}"
|
||
case_dir = output_dir / case_id
|
||
case_dir.mkdir(parents=True, exist_ok=True)
|
||
compare_url = build_case_url(args.compare_url, variant, aoi["center"], zoom)
|
||
full_png = case_dir / "compare_full.png"
|
||
left_png = case_dir / "compare_left.png"
|
||
right_png = case_dir / "compare_right.png"
|
||
diff_png = case_dir / "compare_diff.png"
|
||
|
||
run_browser_screenshot(
|
||
compare_url=compare_url,
|
||
output_png=full_png,
|
||
width=args.width,
|
||
height=args.height,
|
||
timeout_sec=args.timeout_sec,
|
||
virtual_time_budget_ms=args.virtual_time_budget_ms,
|
||
)
|
||
metrics = crop_compare_panes(
|
||
full_png=full_png,
|
||
left_png=left_png,
|
||
right_png=right_png,
|
||
diff_png=diff_png,
|
||
toolbar_height=args.toolbar_height,
|
||
bottom_trim=args.bottom_trim,
|
||
)
|
||
cases.append(
|
||
{
|
||
"id": case_id,
|
||
"label": aoi["label"],
|
||
"variant": variant,
|
||
"zoom": zoom,
|
||
"center": aoi["center"],
|
||
"radius_nm": aoi["radius_nm"],
|
||
"compare_url": compare_url,
|
||
"changed_pixels": metrics["changed_pixels"],
|
||
"total_pixels": metrics["total_pixels"],
|
||
"changed_ratio": metrics["changed_ratio"],
|
||
"mean_abs_rgb": metrics["mean_abs_rgb"],
|
||
"rms_rgb": metrics["rms_rgb"],
|
||
"diff_bbox": metrics["diff_bbox"],
|
||
"left_image": str(left_png),
|
||
"right_image": str(right_png),
|
||
"diff_image": str(diff_png),
|
||
}
|
||
)
|
||
|
||
cases.sort(key=lambda item: item["changed_ratio"], reverse=True)
|
||
summary = {
|
||
"compare_url": args.compare_url,
|
||
"cases": cases,
|
||
}
|
||
report_md, report_json = write_report(output_dir, summary)
|
||
print(
|
||
json.dumps(
|
||
{
|
||
"report_md": str(report_md),
|
||
"report_json": str(report_json),
|
||
"top_cases": cases[:5],
|
||
},
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
)
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|