File size: 6,446 Bytes
9dedaf7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | """Visualize PP-DocLayoutV3 results served over HTTP.
Sends each image to a running server (serve_pp_doclayout_v3.py), draws the
returned boxes/polygons on top of it — a small numbered badge per box marks
its reading order, and a legend strip maps each label's color to its name —
and writes both the annotated image and the raw JSON response to disk (JSON
carries the per-element score; the image would get unreadable if it tried to
print full "order:label score" banners on every box — dense pages can have
30+ small, tightly packed regions where that text is bigger than the box
itself and blots out neighbours). Talks to the server only over HTTP — no
onnxruntime/torch import needed here.
Usage:
python serve_pp_doclayout_v3.py --onnx pp_doclayoutv3.onnx &
python visualize_layout.py --images "examples/inputs/*.jpg" --out-dir examples/outputs
python visualize_layout.py --images page.png --url http://localhost:8000/v1/layout --threshold 0.4
"""
from __future__ import annotations
import argparse
import colorsys
import glob
import json
import math
from pathlib import Path
from typing import Any
import cv2
import numpy as np
import requests
GOLDEN_RATIO_CONJUGATE = 0.618033988749895
FONT = cv2.FONT_HERSHEY_SIMPLEX
BADGE_FONT_SCALE = 0.35
BADGE_RADIUS_MIN = 9
LEGEND_ROW_H = 22
LEGEND_COL_W = 140
LEGEND_PAD = 8
def label_color(label_id: int) -> tuple[int, int, int]:
"""Deterministic, well-separated BGR color per label id (golden-angle hue spacing)."""
hue = (label_id * GOLDEN_RATIO_CONJUGATE) % 1.0
r, g, b = colorsys.hsv_to_rgb(hue, 0.65, 0.95)
return int(b * 255), int(g * 255), int(r * 255)
def draw_badge(img: np.ndarray, order: int, x: int, y: int, color: tuple[int, int, int]) -> None:
"""Small filled circle with the reading-order number, clamped so it stays on-canvas."""
h, w = img.shape[:2]
text = str(order)
(tw, th), _ = cv2.getTextSize(text, FONT, BADGE_FONT_SCALE, 1)
radius = max(BADGE_RADIUS_MIN, tw // 2 + 3)
cx, cy = min(max(x, radius), w - radius - 1), min(max(y, radius), h - radius - 1)
cv2.circle(img, (cx, cy), radius, color, -1, cv2.LINE_AA)
cv2.putText(img, text, (cx - tw // 2, cy + th // 2), FONT, BADGE_FONT_SCALE, (255, 255, 255), 1, cv2.LINE_AA)
def build_legend(elements: list[dict], width: int) -> np.ndarray | None:
"""White strip mapping each label present to its color, for appending below the image."""
entries = sorted({(el["label_id"], el["label"]) for el in elements})
if not entries:
return None
cols = max(1, width // LEGEND_COL_W)
rows = math.ceil(len(entries) / cols)
legend = np.full((LEGEND_PAD * 2 + rows * LEGEND_ROW_H, width, 3), 255, dtype=np.uint8)
for i, (label_id, label) in enumerate(entries):
row, col = divmod(i, cols)
x, y = LEGEND_PAD + col * LEGEND_COL_W, LEGEND_PAD + row * LEGEND_ROW_H
cv2.rectangle(legend, (x, y + 4), (x + 14, y + 18), label_color(label_id), -1)
cv2.putText(legend, label, (x + 20, y + 16), FONT, 0.42, (20, 20, 20), 1, cv2.LINE_AA)
return legend
def draw(image_path: Path, elements: list[dict], out_path: Path, show_polygons: bool) -> None:
img = cv2.imread(str(image_path))
if img is None:
raise FileNotFoundError(f"could not read image: {image_path}")
for el in elements:
color = label_color(el["label_id"])
if show_polygons and el.get("polygon"):
poly = np.asarray(el["polygon"], dtype=np.int32).reshape(-1, 1, 2)
cv2.polylines(img, [poly], True, color, 2, cv2.LINE_AA)
else:
x0, y0, x1, y1 = map(int, el["box"])
cv2.rectangle(img, (x0, y0), (x1, y1), color, 2)
x0, y0 = int(el["box"][0]), int(el["box"][1])
draw_badge(img, el["order"], x0, y0, color)
legend = build_legend(elements, img.shape[1])
if legend is not None:
img = np.vstack([img, legend])
out_path.parent.mkdir(parents=True, exist_ok=True)
cv2.imwrite(str(out_path), img)
def call_server(url: str, image_path: Path, threshold: float, polygons: bool, timeout: float) -> dict[str, Any]:
with open(image_path, "rb") as f:
files = {"file": (image_path.name, f, "application/octet-stream")}
params = {"threshold": threshold, "polygons": str(polygons).lower()}
resp = requests.post(url, files=files, params=params, timeout=timeout)
resp.raise_for_status()
return resp.json()
def resolve_images(patterns: list[str]) -> list[Path]:
paths: list[Path] = []
for pattern in patterns:
matched = sorted(glob.glob(pattern))
if matched:
paths.extend(Path(m) for m in matched)
else:
paths.append(Path(pattern))
return paths
def main() -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--images", nargs="+", required=True, help="image paths and/or glob patterns")
p.add_argument("--url", default="http://localhost:8000/v1/layout")
p.add_argument("--out-dir", type=Path, default=Path("examples/outputs"))
p.add_argument("--threshold", type=float, default=0.5)
p.add_argument("--no-polygons", dest="polygons", action="store_false")
p.add_argument("--timeout", type=float, default=60.0)
args = p.parse_args()
paths = resolve_images(args.images)
if not paths:
raise SystemExit(f"no images matched: {args.images}")
args.out_dir.mkdir(parents=True, exist_ok=True)
summary = []
for path in paths:
print(f"-> {path.name}")
result = call_server(args.url, path, args.threshold, args.polygons, args.timeout)
elements = result["results"][0]["elements"]
json_path = args.out_dir / f"{path.stem}.json"
json_path.write_text(json.dumps(result, indent=2))
vis_path = args.out_dir / f"{path.stem}_annotated.jpg"
draw(path, elements, vis_path, show_polygons=args.polygons)
print(f" {len(elements)} elements, {result['latency_ms']} ms -> {vis_path.name}, {json_path.name}")
summary.append(
{"image": path.name, "annotated": vis_path.name, "json": json_path.name, "count": len(elements)}
)
(args.out_dir / "summary.json").write_text(json.dumps(summary, indent=2))
print(f"\n{len(summary)} image(s) -> {args.out_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
|