Files
samgeo3-lab/scripts/merge_multi_results.py
2026-07-16 11:07:20 +09:00

1223 lines
40 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Merge multi-prompt SamGeo3 results into one overview (overlay + grid + HTML).
Works on the output directory from multi_prompt_segment.py
(e.g. output/dji_0016_compact with *_mask.png files).
Usage (from project root, venv active):
python scripts/merge_multi_results.py --result-dir output/dji_0016_compact
python scripts/merge_multi_results.py --result-dir output/dji_0016_compact --image data/DJI_....JPG
python scripts/merge_multi_results.py --result-dir output/dji_0016_compact --max-side 2048
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
import numpy as np
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
# Distinct RGB colors for class overlays (cycled if more prompts)
PALETTE = [
(230, 25, 75), # red
(60, 180, 75), # green
(0, 130, 200), # blue
(245, 130, 48), # orange
(145, 30, 180), # purple
(70, 240, 240), # cyan
(240, 50, 230), # magenta
(210, 245, 60), # lime
(250, 190, 212), # pink
(0, 128, 128), # teal
(220, 190, 255), # lavender
(170, 110, 40), # brown
(255, 250, 200), # beige
(128, 0, 0), # maroon
(170, 255, 195), # mint
(128, 128, 0), # olive
(255, 215, 180), # apricot
(0, 0, 128), # navy
(128, 128, 128), # gray
(255, 225, 25), # yellow
]
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Merge multi-prompt segmentation results")
p.add_argument(
"--result-dir",
type=Path,
required=True,
help="Directory with *_mask.png (and optional summary.json)",
)
p.add_argument(
"--image",
type=Path,
default=None,
help="Original image (else from summary.json or data/ fallback)",
)
p.add_argument(
"--output-dir",
type=Path,
default=None,
help="Where to write merged outputs (default: <result-dir>/merged)",
)
p.add_argument(
"--max-side",
type=int,
default=2560,
help="Max width/height of overview images (keeps memory/file size down)",
)
p.add_argument(
"--alpha",
type=float,
default=0.45,
help="Mask overlay opacity 0~1",
)
p.add_argument(
"--grid-cols",
type=int,
default=4,
help="Columns in thumbnail grid",
)
p.add_argument(
"--thumb-size",
type=int,
default=480,
help="Thumbnail max side for grid cells",
)
p.add_argument(
"--no-grid",
action="store_true",
help="Skip per-class grid image",
)
p.add_argument(
"--no-html",
action="store_true",
help="Skip HTML report",
)
p.add_argument(
"--top-k",
type=int,
default=3,
help="Max classes shown per pixel click (rank = mean score + log area). Default 3.",
)
return p.parse_args()
def load_summary(result_dir: Path) -> dict[str, Any] | None:
path = result_dir / "summary.json"
if not path.is_file():
return None
with open(path, encoding="utf-8") as f:
return json.load(f)
def _common_prefix(strings: list[str]) -> str:
if not strings:
return ""
prefix = strings[0]
for s in strings[1:]:
while not s.startswith(prefix) and prefix:
prefix = prefix[:-1]
if not prefix:
break
return prefix
def discover_masks(result_dir: Path) -> list[dict[str, Any]]:
"""Return list of {prompt, mask_path, ann_path, n_from_scores} from files."""
masks = sorted(result_dir.glob("*_mask.png"))
bases = [mp.name[: -len("_mask.png")] for mp in masks]
# e.g. common "DJI_..._0016_" then remainder is prompt tag (may contain _)
cpref = _common_prefix(bases)
# prefer cut at last underscore of common prefix so prompt is clean
if cpref and not cpref.endswith("_"):
# trim to last underscore so we don't eat prompt chars
li = cpref.rfind("_")
cpref = cpref[: li + 1] if li >= 0 else ""
items: list[dict[str, Any]] = []
for mp, base in zip(masks, bases):
tag = base[len(cpref) :] if cpref and base.startswith(cpref) else base
if not tag:
# fallback: last underscore segment only
tag = base.rsplit("_", 1)[-1]
prompt = tag.replace("_", " ")
ann = result_dir / f"{base}_ann.png"
scores = result_dir / f"{base}_scores.npy"
n_obj = None
if scores.is_file():
try:
n_obj = int(np.load(scores).shape[0])
except Exception:
n_obj = None
items.append(
{
"prompt": prompt,
"tag": tag,
"base": base,
"mask_path": mp,
"ann_path": ann if ann.is_file() else None,
"scores_path": scores if scores.is_file() else None,
"n_objects": n_obj,
}
)
return items
def items_from_summary(summary: dict[str, Any], result_dir: Path) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
for r in summary.get("results", []):
n = int(r.get("n_objects") or 0)
if n <= 0 and not r.get("mask"):
continue
mask_s = r.get("mask")
mask_path = Path(mask_s) if mask_s else None
if mask_path is None or not mask_path.is_file():
# try local name
prompt = r.get("prompt", "")
tag = "".join(c if c.isalnum() or c in "-_" else "_" for c in prompt)
cands = list(result_dir.glob(f"*_{tag}_mask.png"))
mask_path = cands[0] if cands else None
if mask_path is None or not mask_path.is_file():
continue
ann_s = r.get("ann")
ann_path = Path(ann_s) if ann_s and Path(ann_s).is_file() else None
items.append(
{
"prompt": r.get("prompt", mask_path.stem),
"tag": mask_path.stem.replace("_mask", "").split("_")[-1],
"base": mask_path.name[: -len("_mask.png")],
"mask_path": mask_path,
"ann_path": ann_path,
"scores_path": Path(r["scores_npy"])
if r.get("scores_npy") and Path(r["scores_npy"]).is_file()
else None,
"n_objects": n,
"scores_min": r.get("scores_min"),
"scores_max": r.get("scores_max"),
}
)
return items
def resolve_base_image(
args_image: Path | None,
summary: dict[str, Any] | None,
result_dir: Path,
) -> Path:
cands: list[Path] = []
if args_image:
cands.append(args_image)
if summary and summary.get("image"):
cands.append(Path(summary["image"]))
# common lab defaults
cands.append(ROOT / "data" / "DJI_20260306100802_0016.JPG")
cands.append(
Path(r"D:\MYCLAUDE_PROJECT\segment-geospatial\sample\DJI_20260306100802_0016.JPG")
)
for c in cands:
if c and Path(c).is_file():
return Path(c).resolve()
raise FileNotFoundError(
"Base image not found. Pass --image. Tried:\n "
+ "\n ".join(str(c) for c in cands)
)
def resize_max(im: Image.Image, max_side: int) -> Image.Image:
w, h = im.size
m = max(w, h)
if m <= max_side:
return im
scale = max_side / m
nw, nh = int(w * scale), int(h * scale)
return im.resize((nw, nh), Image.Resampling.BILINEAR)
def load_mask_bool(path: Path, size: tuple[int, int]) -> np.ndarray:
m = Image.open(path)
if m.mode not in ("L", "I", "I;16", "P"):
m = m.convert("L")
if m.size != size:
m = m.resize(size, Image.Resampling.NEAREST)
arr = np.array(m)
return arr > 0
def try_font(size: int) -> ImageFont.ImageFont:
for name in (
"C:/Windows/Fonts/malgun.ttf",
"C:/Windows/Fonts/segoeui.ttf",
"C:/Windows/Fonts/arial.ttf",
):
if Path(name).is_file():
try:
return ImageFont.truetype(name, size)
except Exception:
pass
return ImageFont.load_default()
def build_overlay(
base_rgb: Image.Image,
items: list[dict[str, Any]],
alpha: float,
) -> tuple[Image.Image, list[dict[str, Any]]]:
base = np.asarray(base_rgb.convert("RGB"), dtype=np.float32)
h, w = base.shape[:2]
out = base.copy()
legend: list[dict[str, Any]] = []
for i, it in enumerate(items):
color = PALETTE[i % len(PALETTE)]
try:
mask = load_mask_bool(it["mask_path"], (w, h))
except Exception as e:
print(f" [WARN] skip mask {it['mask_path'].name}: {e}")
continue
pix = int(mask.sum())
if pix == 0:
continue
c = np.array(color, dtype=np.float32)
out[mask] = out[mask] * (1.0 - alpha) + c * alpha
legend.append(
{
"prompt": it["prompt"],
"color": color,
"n_objects": it.get("n_objects"),
"pixels": pix,
"scores_min": it.get("scores_min"),
"scores_max": it.get("scores_max"),
}
)
blended = Image.fromarray(np.clip(out, 0, 255).astype(np.uint8))
return blended, legend
def draw_legend_panel(
legend: list[dict[str, Any]],
width: int = 420,
row_h: int = 36,
) -> Image.Image:
font = try_font(16)
title_font = try_font(20)
n = max(len(legend), 1)
height = 56 + n * row_h + 16
panel = Image.new("RGB", (width, height), (24, 28, 36))
draw = ImageDraw.Draw(panel)
draw.text((16, 14), "Merged classes (prompt)", fill=(240, 244, 248), font=title_font)
y = 52
for ent in legend:
r, g, b = ent["color"]
draw.rectangle([16, y + 4, 40, y + 28], fill=(r, g, b))
n_obj = ent.get("n_objects")
n_txt = f"n={n_obj}" if n_obj is not None else f"px={ent['pixels']}"
smin, smax = ent.get("scores_min"), ent.get("scores_max")
if smin is not None and smax is not None:
score_txt = f" conf {smin:.2f}~{smax:.2f}"
else:
score_txt = ""
label = f"{ent['prompt']} ({n_txt}{score_txt})"
draw.text((52, y + 6), label, fill=(220, 226, 234), font=font)
y += row_h
return panel
def compose_with_legend(overlay: Image.Image, legend_panel: Image.Image) -> Image.Image:
gap = 16
w = overlay.width + gap + legend_panel.width
h = max(overlay.height, legend_panel.height)
canvas = Image.new("RGB", (w, h), (18, 20, 26))
canvas.paste(overlay, (0, 0))
canvas.paste(legend_panel, (overlay.width + gap, 0))
return canvas
def build_grid(
base: Image.Image,
items: list[dict[str, Any]],
colors: list[tuple[int, int, int]],
cols: int,
thumb: int,
alpha: float,
) -> Image.Image:
font = try_font(18)
cells: list[Image.Image] = []
for i, it in enumerate(items):
color = colors[i % len(colors)]
# small overlay per class
b = resize_max(base.copy(), thumb)
arr = np.asarray(b.convert("RGB"), dtype=np.float32)
h, w = arr.shape[:2]
try:
mask = load_mask_bool(it["mask_path"], (w, h))
except Exception:
mask = np.zeros((h, w), dtype=bool)
c = np.array(color, dtype=np.float32)
if mask.any():
arr[mask] = arr[mask] * (1.0 - alpha) + c * alpha
cell = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8))
# title bar
bar_h = 32
framed = Image.new("RGB", (cell.width, cell.height + bar_h), (30, 34, 42))
framed.paste(cell, (0, bar_h))
draw = ImageDraw.Draw(framed)
draw.rectangle([0, 0, framed.width, bar_h], fill=(30, 34, 42))
draw.rectangle([8, 8, 24, 24], fill=color)
n = it.get("n_objects")
title = f"{it['prompt']}" + (f" (n={n})" if n is not None else "")
draw.text((32, 6), title[:40], fill=(235, 240, 245), font=font)
cells.append(framed)
if not cells:
return Image.new("RGB", (thumb, thumb), (40, 40, 40))
cols = max(1, cols)
rows = (len(cells) + cols - 1) // cols
cw = max(c.width for c in cells)
ch = max(c.height for c in cells)
pad = 8
grid_w = cols * cw + (cols + 1) * pad
grid_h = rows * ch + (rows + 1) * pad
grid = Image.new("RGB", (grid_w, grid_h), (18, 20, 26))
for i, cell in enumerate(cells):
r, c = divmod(i, cols)
x = pad + c * (cw + pad)
y = pad + r * (ch + pad)
grid.paste(cell, (x, y))
return grid
def enrich_item_scores(items: list[dict[str, Any]]) -> None:
"""Fill scores_min/max/mean/n_objects from *_scores.npy when present."""
for it in items:
sp = it.get("scores_path")
if sp is None or not Path(sp).is_file():
continue
try:
arr = np.load(sp).astype(np.float64).ravel()
if arr.size == 0:
continue
it["n_objects"] = int(arr.size)
it["scores_min"] = float(arr.min())
it["scores_max"] = float(arr.max())
it["scores_mean"] = float(arr.mean())
except Exception:
continue
def mask_to_rle(mask: np.ndarray) -> list[int]:
"""Run-length encode binary mask (row-major). [start, length, start, length, ...].
Used so the browser can hit-test without canvas getImageData (file:// safe).
"""
flat = np.ascontiguousarray(mask.astype(bool).ravel())
n = int(flat.size)
if n == 0:
return []
# transitions where value changes
# find runs of True
rle: list[int] = []
i = 0
while i < n:
if flat[i]:
start = i
while i < n and flat[i]:
i += 1
rle.append(int(start))
rle.append(int(i - start))
else:
i += 1
return rle
def rank_score_mean_area(
scores_mean: float | None,
scores_max: float | None,
pixels: int,
image_pixels: int,
) -> float:
"""Priority for pixel label: mean confidence + log-normalized area.
rank = mean + log1p(pixels) / log1p(image_pixels)
- mean: SAM instance conf mean for that prompt (fallback: scores_max)
- area term in [0, 1]: larger masks rank higher, but log softens huge yards
Downstream 3D/point-cloud labeling can reuse the same formula for top-k.
"""
if scores_mean is not None:
mean = float(scores_mean)
elif scores_max is not None:
mean = float(scores_max)
else:
mean = 0.0
img_px = max(int(image_pixels), 1)
px = max(int(pixels), 0)
area_term = float(np.log1p(px) / np.log1p(img_px))
return mean + area_term
def export_hitmasks(
out_dir: Path,
ordered_items: list[dict[str, Any]],
legend: list[dict[str, Any]],
size: tuple[int, int],
top_k: int = 3,
) -> list[dict[str, Any]]:
"""Build interactive class meta with RLE hit data (file:// / no CORS needed)."""
w, h = size
image_pixels = int(w * h)
classes_meta: list[dict[str, Any]] = []
for i, ent in enumerate(legend):
it = None
for cand in ordered_items:
if cand["prompt"] == ent["prompt"] or cand["prompt"].replace(
"_", " "
) == ent["prompt"]:
it = cand
break
if it is None and i < len(ordered_items):
it = ordered_items[i]
if it is None:
continue
mask = load_mask_bool(it["mask_path"], (w, h))
rle = mask_to_rle(mask)
r, g, b = ent["color"]
smin = ent.get("scores_min")
if smin is None:
smin = it.get("scores_min")
smax = ent.get("scores_max")
if smax is None:
smax = it.get("scores_max")
smean = it.get("scores_mean")
if smean is None:
smean = ent.get("scores_mean")
if smean is None and smin is not None and smax is not None:
smean = (float(smin) + float(smax)) / 2.0
pixels = int(ent.get("pixels") or int(mask.sum()))
rank = rank_score_mean_area(smean, smax, pixels, image_pixels)
classes_meta.append(
{
"id": i,
"prompt": ent["prompt"],
"color": [int(r), int(g), int(b)],
"n_objects": ent.get("n_objects")
if ent.get("n_objects") is not None
else it.get("n_objects"),
"pixels": pixels,
"scores_min": smin,
"scores_max": smax,
"scores_mean": smean,
"rank_score": rank,
"rle": rle,
}
)
meta = {
"width": w,
"height": h,
"overlay": "combined_overlay.png",
"hit_mode": "rle",
"top_k": int(top_k),
"ranking": {
"formula": "mean + log1p(pixels)/log1p(W*H)",
"components": ["scores_mean", "log_area_norm"],
"purpose": "pixel prompt label for downstream point cloud",
"top_k": int(top_k),
},
"classes": classes_meta,
}
meta_path = out_dir / "interactive_meta.json"
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
return classes_meta
def write_html(
out_path: Path,
overlay_name: str,
grid_name: str | None,
legend: list[dict[str, Any]],
meta: dict[str, Any],
interactive_meta: dict[str, Any] | None = None,
top_k: int = 3,
) -> None:
"""Write interactive index.html (click → top-k prompts by mean+area)."""
# Embed meta so file:// works without fetch CORS issues
imeta = interactive_meta
if imeta is None:
imeta_path = out_path.parent / "interactive_meta.json"
if imeta_path.is_file():
with open(imeta_path, encoding="utf-8") as f:
imeta = json.load(f)
else:
imeta = {
"width": 0,
"height": 0,
"overlay": overlay_name,
"classes": [],
}
# Ensure ranking policy is present for the embedded viewer / 3D export
imeta.setdefault("top_k", int(top_k))
imeta.setdefault(
"ranking",
{
"formula": "mean + log1p(pixels)/log1p(W*H)",
"components": ["scores_mean", "log_area_norm"],
"purpose": "pixel prompt label for downstream point cloud",
"top_k": int(top_k),
},
)
imeta["top_k"] = int(top_k)
imeta["ranking"]["top_k"] = int(top_k)
imeta_js = json.dumps(imeta, ensure_ascii=False)
top_k = max(1, int(top_k))
# Static fallback rows still useful if JS fails
rows = []
for ent in legend:
r, g, b = ent["color"]
n = ent.get("n_objects")
smin, smax = ent.get("scores_min"), ent.get("scores_max")
score_cell = (
f"{smin:.3f}~{smax:.3f}" if smin is not None and smax is not None else "-"
)
rows.append(
f"<tr data-prompt=\"{ent['prompt']}\">"
f"<td><span class='swatch' style='background:rgb({r},{g},{b})'></span></td>"
f"<td class='prompt-cell'>{ent['prompt']}</td>"
f"<td>{n if n is not None else '-'}</td>"
f"<td>{score_cell}</td>"
f"<td>{ent['pixels']:,}</td></tr>"
)
grid_block = (
f'<details class="grid-details"><summary>Per-class grid</summary>'
f'<img src="{grid_name}" alt="grid" class="grid-img"/></details>'
if grid_name
else ""
)
n_cls = len(legend)
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Merged multi-prompt — interactive</title>
<style>
:root {{
--bg:#0f1419; --panel:#1a2332; --panel2:#243044; --text:#e7ecf3; --muted:#9aa8bc;
--accent:#3d9cf0; --accent2:#5eead4; --border:#2d3a4f; --hit:#fbbf24;
}}
* {{ box-sizing:border-box; }}
body {{
margin:0; font-family:Segoe UI, Malgun Gothic, sans-serif;
background:var(--bg); color:var(--text); min-height:100vh;
}}
header {{
padding:.85rem 1.25rem; border-bottom:1px solid var(--border);
background:linear-gradient(90deg,#1e3a5f,#0f766e);
}}
header h1 {{ margin:0; font-size:1.15rem; font-weight:600; }}
header p {{ margin:.25rem 0 0; font-size:.85rem; color:rgba(255,255,255,.88); }}
.layout {{
display:grid; grid-template-columns:1fr minmax(300px,380px);
gap:0; min-height:calc(100vh - 70px);
}}
@media (max-width:900px) {{
.layout {{ grid-template-columns:1fr; }}
}}
.stage {{
padding:1rem; overflow:auto; border-right:1px solid var(--border);
background:#0b1020;
}}
.canvas-wrap {{
position:relative; display:inline-block; max-width:100%;
border:1px solid var(--border); border-radius:8px; overflow:hidden;
cursor:crosshair; background:#000;
}}
.canvas-wrap canvas {{ display:block; max-width:100%; height:auto; vertical-align:top; }}
.hint {{
margin:.6rem 0 0; font-size:.82rem; color:var(--muted);
}}
.side {{
padding:1rem; background:var(--panel); overflow:auto;
display:flex; flex-direction:column; gap:.85rem;
}}
.side h2 {{
margin:0; font-size:.95rem; color:var(--accent2);
text-transform:uppercase; letter-spacing:.04em;
}}
.pick-meta {{
font-size:.82rem; color:var(--muted); font-family:Consolas, monospace;
}}
.hit-list {{ list-style:none; margin:0; padding:0; }}
.hit-list li {{
display:grid; grid-template-columns:18px 1fr auto;
gap:.5rem; align-items:center;
padding:.55rem .6rem; margin-bottom:.4rem;
background:var(--panel2); border:1px solid var(--border); border-radius:8px;
border-left:4px solid #666;
}}
.hit-list li.top {{
box-shadow:0 0 0 1px var(--hit);
}}
.hit-list .sw {{
width:14px; height:14px; border-radius:3px; display:inline-block;
}}
.hit-list .name {{ font-weight:600; font-size:.9rem; }}
.hit-list .score {{
font-family:Consolas, monospace; font-size:.8rem; color:var(--accent2);
text-align:right; white-space:nowrap;
}}
.hit-list .sub {{
grid-column:2 / -1; font-size:.75rem; color:var(--muted);
}}
.empty-hit {{ color:var(--muted); font-size:.9rem; padding:.5rem 0; }}
.rank-badge {{
font-size:.7rem; background:#0b1220; border:1px solid var(--border);
border-radius:999px; padding:.1rem .45rem; color:var(--hit); margin-left:.35rem;
}}
.all-table {{ width:100%; border-collapse:collapse; font-size:.78rem; }}
.all-table th, .all-table td {{
border:1px solid var(--border); padding:.35rem .4rem; text-align:left;
}}
.all-table th {{ background:#121a28; color:var(--accent2); position:sticky; top:0; }}
.all-table tr.hit-row {{ background:rgba(251,191,36,.12); }}
.all-table tr.hit-row.top {{ background:rgba(251,191,36,.22); }}
.swatch {{
display:inline-block; width:12px; height:12px; border-radius:2px; vertical-align:middle;
}}
.status {{ font-size:.8rem; color:var(--muted); }}
.status.ok {{ color:var(--accent2); }}
.status.err {{ color:#f87171; }}
.grid-details {{ margin-top:1rem; color:var(--muted); font-size:.85rem; }}
.grid-img {{ max-width:100%; border:1px solid var(--border); border-radius:8px; margin-top:.5rem; }}
.priority-note {{
font-size:.8rem; color:var(--muted); line-height:1.45;
border-left:3px solid var(--accent); padding-left:.6rem;
}}
</style>
</head>
<body>
<header>
<h1>Merged multi-prompt — top-{top_k} pixel labels</h1>
<p>
픽셀 클릭 → 그 위치에 겹친 프롬프트 중
<strong>rank = mean conf + log 면적</strong> 상위 <strong>{top_k}</strong>개만 표시합니다.
(3D 포인트 클라우드 라벨용: 픽셀 → prompt)
</p>
</header>
<div class="layout">
<div class="stage">
<div class="canvas-wrap" id="wrap">
<canvas id="view"></canvas>
</div>
<p class="hint">클릭: top-{top_k} 조회 · Shift+클릭: 누적 후 재순위 top-{top_k} · Esc: 초기화 · 클래스 {n_cls}개</p>
<p class="status" id="status">loading…</p>
{grid_block}
</div>
<aside class="side">
<div>
<h2>Top-{top_k} labels</h2>
<div class="pick-meta" id="pickMeta">클릭하여 조회</div>
<ul class="hit-list" id="hitList"></ul>
<div class="empty-hit" id="emptyHit" hidden>이 픽셀에 마스크 없음</div>
<p class="priority-note">
우선순위: <code>rank = scores_mean + log1p(pixels)/log1p(W×H)</code><br/>
mean 없을 때 scores_max 사용. 상위 <strong>{top_k}</strong>개만 표시 (나머지 중복 숨김).
이후 포인트클라우드에서 이 prompt를 픽셀 라벨로 씁니다.
</p>
</div>
<div>
<h2>All classes</h2>
<div style="max-height:45vh; overflow:auto;">
<table class="all-table" id="allTable">
<thead>
<tr><th></th><th>prompt</th><th>n</th><th>score max</th><th>px</th></tr>
</thead>
<tbody>
{''.join(rows)}
</tbody>
</table>
</div>
</div>
</aside>
</div>
<script>
(async function () {{
/* file:// safe: NO getImageData (avoids "canvas tainted by cross-origin data")
Hit test uses RLE from merge step, not mask PNGs. */
const statusEl = document.getElementById('status');
const canvas = document.getElementById('view');
const ctx = canvas.getContext('2d');
const hitList = document.getElementById('hitList');
const emptyHit = document.getElementById('emptyHit');
const pickMeta = document.getElementById('pickMeta');
let meta = null;
let overlayImg = null;
let classes = [];
let lastHits = [];
let lastAllCount = 0;
let marker = null;
const TOP_K = {top_k};
function setStatus(msg, ok) {{
statusEl.textContent = msg;
statusEl.className = 'status ' + (ok === true ? 'ok' : ok === false ? 'err' : '');
}}
function loadImage(src) {{
return new Promise((resolve, reject) => {{
const im = new Image();
im.onload = () => resolve(im);
im.onerror = () => reject(new Error('fail load ' + src));
im.src = src;
}});
}}
/** RLE: [start, length, ...] of True runs on flat row-major index */
function rleHas(rle, idx) {{
if (!rle || !rle.length) return false;
for (let j = 0; j < rle.length; j += 2) {{
const s = rle[j];
const len = rle[j + 1];
if (idx < s) return false;
if (idx < s + len) return true;
}}
return false;
}}
/** rank = mean + log1p(pixels)/log1p(W*H) — same as Python rank_score_mean_area */
function computeRankScore(cls) {{
if (cls.rank_score != null && Number.isFinite(Number(cls.rank_score))) {{
return Number(cls.rank_score);
}}
let mean = 0;
if (cls.scores_mean != null) mean = Number(cls.scores_mean);
else if (cls.scores_max != null) mean = Number(cls.scores_max);
const imgPx = Math.max(1, (meta.width || 1) * (meta.height || 1));
const px = Math.max(0, Number(cls.pixels) || 0);
const areaTerm = Math.log1p(px) / Math.log1p(imgPx);
return mean + areaTerm;
}}
function sortByRank(hits) {{
return hits.slice().sort((a, b) => {{
const d = b.rank_score - a.rank_score;
if (d !== 0) return d;
// tie-break: higher mean, then larger area
const ma = a.scores_mean != null ? Number(a.scores_mean) : -1;
const mb = b.scores_mean != null ? Number(b.scores_mean) : -1;
if (mb !== ma) return mb - ma;
return (b.pixels || 0) - (a.pixels || 0);
}});
}}
function draw() {{
if (!overlayImg || !meta) return;
const w = meta.width, h = meta.height;
if (canvas.width !== w || canvas.height !== h) {{
canvas.width = w;
canvas.height = h;
}}
canvas.style.width = '100%';
canvas.style.height = 'auto';
// drawImage only — never getImageData (taint-safe display)
ctx.drawImage(overlayImg, 0, 0, w, h);
if (marker) {{
const {{x, y}} = marker;
ctx.save();
ctx.strokeStyle = '#fbbf24';
ctx.fillStyle = 'rgba(251,191,36,0.35)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x + 0.5, y + 0.5, 8, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x - 16, y + 0.5); ctx.lineTo(x + 16, y + 0.5);
ctx.moveTo(x + 0.5, y - 16); ctx.lineTo(x + 0.5, y + 16);
ctx.stroke();
// stacked color dots for each hit
lastHits.forEach((h, i) => {{
ctx.beginPath();
ctx.fillStyle = `rgb(${{h.color[0]}},${{h.color[1]}},${{h.color[2]}})`;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.arc(x + 14 + i * 10, y - 14, 5, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}});
ctx.restore();
}}
}}
function eventToNatural(ev) {{
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
const x = Math.floor((ev.clientX - rect.left) * sx);
const y = Math.floor((ev.clientY - rect.top) * sy);
return {{
x: Math.max(0, Math.min(canvas.width - 1, x)),
y: Math.max(0, Math.min(canvas.height - 1, y)),
}};
}}
function queryPixel(x, y, accumulate) {{
const idx = y * meta.width + x;
const hits = [];
for (const cls of classes) {{
if (rleHas(cls.rle, idx)) {{
hits.push({{
id: cls.id,
prompt: cls.prompt,
color: cls.color,
scores_min: cls.scores_min,
scores_max: cls.scores_max,
scores_mean: cls.scores_mean,
n_objects: cls.n_objects,
pixels: cls.pixels,
rank_score: computeRankScore(cls),
}});
}}
}}
let ranked = sortByRank(hits);
lastAllCount = ranked.length;
if (accumulate && lastHits.length) {{
const map = new Map(lastHits.map(h => [h.id, h]));
for (const h of ranked) map.set(h.id, h);
ranked = sortByRank(Array.from(map.values()));
lastAllCount = ranked.length;
}}
// Only top-K labels for UI and for downstream "pixel → prompt" selection
lastHits = ranked.slice(0, TOP_K);
marker = {{ x, y }};
renderHits();
draw();
}}
function fmtScore(h) {{
if (h.scores_max == null && h.scores_min == null && h.scores_mean == null) {{
return 'score n/a';
}}
const m = h.scores_mean != null ? Number(h.scores_mean).toFixed(3) : null;
const b = h.scores_max != null ? Number(h.scores_max).toFixed(3) : null;
const r = h.rank_score != null ? Number(h.rank_score).toFixed(3) : null;
const parts = [];
if (m != null) parts.push('mean ' + m);
if (b != null) parts.push('max ' + b);
if (r != null) parts.push('rank ' + r);
return parts.join(' · ') || 'score n/a';
}}
function renderHits() {{
hitList.innerHTML = '';
document.querySelectorAll('#allTable tr').forEach(tr => tr.classList.remove('hit-row', 'top'));
if (!lastHits.length) {{
emptyHit.hidden = false;
pickMeta.textContent = marker
? `pixel (${{marker.x}}, ${{marker.y}}) — 0 classes`
: '클릭하여 조회';
return;
}}
emptyHit.hidden = true;
const hidden = Math.max(0, lastAllCount - lastHits.length);
pickMeta.textContent = hidden > 0
? `pixel (${{marker.x}}, ${{marker.y}}) — top ${{lastHits.length}} / ${{lastAllCount}} overlapping (hidden ${{hidden}})`
: `pixel (${{marker.x}}, ${{marker.y}}) — top ${{lastHits.length}} / ${{lastAllCount}} overlapping`;
lastHits.forEach((h, i) => {{
const li = document.createElement('li');
if (i === 0) li.classList.add('top');
li.style.borderLeftColor = `rgb(${{h.color[0]}},${{h.color[1]}},${{h.color[2]}})`;
const rank = `<span class="rank-badge">#${{i+1}}</span>`;
li.innerHTML = `
<span class="sw" style="background:rgb(${{h.color[0]}},${{h.color[1]}},${{h.color[2]}})"></span>
<div><div class="name">${{h.prompt}}${{rank}}</div></div>
<div class="score">${{fmtScore(h)}}</div>
<div class="sub">n_objects=${{h.n_objects ?? '-'}} · pixels=${{(h.pixels != null ? Number(h.pixels).toLocaleString() : '-')}} · rank=${{Number(h.rank_score).toFixed(3)}}</div>
`;
hitList.appendChild(li);
const tr = document.querySelector('#allTable tr[data-prompt="' + h.prompt.replace(/"/g, '') + '"]');
if (tr) {{
tr.classList.add('hit-row');
if (i === 0) tr.classList.add('top');
}}
}});
}}
try {{
meta = {imeta_js};
if (!meta || !meta.classes || !meta.classes.length) {{
throw new Error('interactive_meta empty — re-run merge_multi_results.py');
}}
classes = meta.classes.map(c => ({{
id: c.id,
prompt: c.prompt,
color: c.color,
rle: c.rle || [],
scores_min: c.scores_min,
scores_max: c.scores_max,
scores_mean: c.scores_mean,
n_objects: c.n_objects,
pixels: c.pixels,
rank_score: c.rank_score,
}}));
setStatus('loading overlay…');
overlayImg = await loadImage(meta.overlay || '{overlay_name}');
draw();
setStatus(
'ready — ' + classes.length + ' classes · click → top-' + TOP_K +
' by mean+log(area) (RLE)',
true
);
}} catch (e) {{
setStatus(String(e.message || e), false);
try {{
overlayImg = await loadImage('{overlay_name}');
meta = meta || {{ width: overlayImg.naturalWidth, height: overlayImg.naturalHeight }};
canvas.width = overlayImg.naturalWidth;
canvas.height = overlayImg.naturalHeight;
ctx.drawImage(overlayImg, 0, 0);
}} catch (_) {{}}
}}
canvas.addEventListener('click', (ev) => {{
if (!meta || !classes.length) return;
const pt = eventToNatural(ev);
queryPixel(pt.x, pt.y, ev.shiftKey);
}});
window.addEventListener('keydown', (ev) => {{
if (ev.key === 'Escape') {{
lastHits = [];
marker = null;
renderHits();
draw();
}}
}});
}})();
</script>
</body>
</html>
"""
out_path.write_text(html, encoding="utf-8")
def main() -> int:
args = parse_args()
result_dir = args.result_dir.resolve()
if not result_dir.is_dir():
print(f"ERROR: result-dir not found: {result_dir}", file=sys.stderr)
return 2
summary = load_summary(result_dir)
# Prefer all masks on disk (summary.json is only the *last* multi run)
disk_items = discover_masks(result_dir)
if summary:
meta_by_prompt = {
r.get("prompt"): r for r in summary.get("results", []) if r.get("prompt")
}
for it in disk_items:
# exact or underscore-normalized match
meta = meta_by_prompt.get(it["prompt"])
if meta is None:
meta = meta_by_prompt.get(it["prompt"].replace(" ", "_"))
if meta is None:
for k, v in meta_by_prompt.items():
if k.replace(" ", "_") == it["tag"] or k == it["tag"].replace(
"_", " "
):
meta = v
break
if meta:
if it.get("n_objects") is None and meta.get("n_objects") is not None:
it["n_objects"] = meta.get("n_objects")
it["scores_min"] = meta.get("scores_min")
it["scores_max"] = meta.get("scores_max")
items = disk_items
# also include any summary-only masks not found by discover
if not items:
items = items_from_summary(summary, result_dir)
else:
items = disk_items
# only keep masks that have content
filtered: list[dict[str, Any]] = []
for it in items:
try:
m = Image.open(it["mask_path"])
arr = np.array(m.convert("L") if m.mode != "L" else m)
if (arr > 0).any():
filtered.append(it)
except Exception:
continue
items = filtered
enrich_item_scores(items)
if not items:
print(f"ERROR: no non-empty mask files in {result_dir}", file=sys.stderr)
return 1
try:
image_path = resolve_base_image(args.image, summary, result_dir)
except FileNotFoundError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 2
out_dir = (args.output_dir or (result_dir / "merged")).resolve()
out_dir.mkdir(parents=True, exist_ok=True)
print("=== merge multi-prompt results ===")
print(f"result_dir: {result_dir}")
print(f"image: {image_path}")
print(f"classes: {len(items)}")
print(f"output: {out_dir}")
base_full = Image.open(image_path).convert("RGB")
base = resize_max(base_full, args.max_side)
print(f"base size: {base_full.size} -> overview {base.size}")
# resize masks via load_mask_bool to base.size
overlay, legend = build_overlay(base, items, alpha=args.alpha)
if not legend:
print("ERROR: all masks empty after resize", file=sys.stderr)
return 1
# propagate scores from items into legend
by_prompt = {it["prompt"]: it for it in items}
for ent in legend:
it = by_prompt.get(ent["prompt"])
if not it:
continue
for k in ("scores_min", "scores_max", "scores_mean", "n_objects"):
if ent.get(k) is None and it.get(k) is not None:
ent[k] = it[k]
legend_panel = draw_legend_panel(legend)
combined = compose_with_legend(overlay, legend_panel)
overlay_path = out_dir / "combined_overlay.png"
with_legend_path = out_dir / "combined_with_legend.png"
overlay.save(overlay_path, optimize=True)
combined.save(with_legend_path, optimize=True)
print(f"saved: {overlay_path}")
print(f"saved: {with_legend_path}")
# ordered items matching legend (for hitmasks + grid)
ordered = []
for ent in legend:
for it in items:
if it["prompt"] == ent["prompt"] or it["prompt"].replace(
"_", " "
) == ent["prompt"]:
ordered.append(it)
break
if not ordered:
ordered = items
grid_name = None
if not args.no_grid:
colors = [PALETTE[i % len(PALETTE)] for i in range(len(ordered))]
grid = build_grid(
base_full,
ordered,
colors,
cols=args.grid_cols,
thumb=args.thumb_size,
alpha=args.alpha,
)
grid_path = out_dir / "per_class_grid.png"
grid.save(grid_path, optimize=True)
grid_name = grid_path.name
print(f"saved: {grid_path}")
# RLE hit data for interactive click query (file:// safe, no getImageData)
top_k = max(1, int(args.top_k))
print(f"exporting RLE hit data for interactive HTML (top_k={top_k})...")
classes_meta = export_hitmasks(
out_dir, ordered, legend, base.size, top_k=top_k
)
print(f"saved: {out_dir / 'interactive_meta.json'} ({len(classes_meta)} classes, RLE)")
merge_summary = {
"result_dir": str(result_dir),
"image": str(image_path),
"n_classes": len(legend),
"max_side": args.max_side,
"alpha": args.alpha,
"top_k": top_k,
"ranking": {
"formula": "mean + log1p(pixels)/log1p(W*H)",
"components": ["scores_mean", "log_area_norm"],
"purpose": "pixel prompt label for downstream point cloud",
"top_k": top_k,
},
"overview_size": list(base.size),
"classes": legend,
"outputs": {
"combined_overlay": str(overlay_path),
"combined_with_legend": str(with_legend_path),
"per_class_grid": str(out_dir / "per_class_grid.png")
if grid_name
else None,
"interactive_meta": str(out_dir / "interactive_meta.json"),
"index_html": str(out_dir / "index.html"),
},
}
sum_path = out_dir / "merge_summary.json"
with open(sum_path, "w", encoding="utf-8") as f:
json.dump(merge_summary, f, ensure_ascii=False, indent=2)
print(f"saved: {sum_path}")
if not args.no_html:
html_path = out_dir / "index.html"
interactive_meta = {
"width": base.size[0],
"height": base.size[1],
"overlay": overlay_path.name,
"top_k": top_k,
"ranking": merge_summary["ranking"],
"classes": classes_meta,
}
# Use overlay without side legend so click coords map 1:1 to hitmasks
write_html(
html_path,
overlay_name=overlay_path.name,
grid_name=grid_name,
legend=legend,
meta=merge_summary,
interactive_meta=interactive_meta,
top_k=top_k,
)
print(f"saved: {html_path} (interactive top-{top_k} by mean+log area)")
print("\nClasses merged:")
for ent in legend:
n = ent.get("n_objects")
print(f" - {ent['prompt']}: n={n} px={ent['pixels']:,}")
return 0
if __name__ == "__main__":
raise SystemExit(main())