226 lines
8.3 KiB
Python
226 lines
8.3 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)
|
|
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]] = []
|
|
for variant in ("full", "semantic"):
|
|
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()
|