점수 추가

This commit is contained in:
minsung
2026-07-16 11:07:20 +09:00
parent 34a885bcf8
commit a9d066d47e
+149 -27
View File
@@ -101,6 +101,12 @@ def parse_args() -> argparse.Namespace:
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()
@@ -433,14 +439,42 @@ def mask_to_rle(mask: np.ndarray) -> list[int]:
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
@@ -466,9 +500,14 @@ def export_hitmasks(
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,
@@ -477,10 +516,11 @@ def export_hitmasks(
"n_objects": ent.get("n_objects")
if ent.get("n_objects") is not None
else it.get("n_objects"),
"pixels": int(ent.get("pixels") or int(mask.sum())),
"pixels": pixels,
"scores_min": smin,
"scores_max": smax,
"scores_mean": smean,
"rank_score": rank,
"rle": rle,
}
)
@@ -490,6 +530,13 @@ def export_hitmasks(
"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"
@@ -505,8 +552,9 @@ def write_html(
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 image → class list + scores)."""
"""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:
@@ -521,7 +569,21 @@ def write_html(
"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 = []
@@ -650,27 +712,32 @@ header p {{ margin:.25rem 0 0; font-size:.85rem; color:rgba(255,255,255,.88); }}
</head>
<body>
<header>
<h1>Merged multi-prompt — click to inspect</h1>
<p>이미지를 클릭하면 해당 픽셀에 겹친 클래스(프롬프트)와 confidence 점수가 오른쪽에 표시됩니다. 겹침 시 점수 높은 순으로 정렬됩니다.</p>
<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">클릭: 픽셀 조회 · Shift+클릭: 선택 유지(누적) · Esc: 초기화 · 클래스 {n_cls}개</p>
<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>Selection</h2>
<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">
겹침 우선순위 참고: 목록은 <strong>scores_max</strong> 내림차순입니다.
점수가 비슷하면 픽셀 면적(n_objects)과 장면 문맥으로 판단하세요.
우선순위: <code>rank = scores_mean + log1p(pixels)/log1p(W×H)</code><br/>
mean 없을 때 scores_max 사용. 상위 <strong>{top_k}</strong>개만 표시 (나머지 중복 숨김).
이후 포인트클라우드에서 이 prompt를 픽셀 라벨로 씁니다.
</p>
</div>
<div>
@@ -703,7 +770,9 @@ header p {{ margin:.25rem 0 0; font-size:.85rem; color:rgba(255,255,255,.88); }}
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;
@@ -731,6 +800,32 @@ header p {{ margin:.25rem 0 0; font-size:.85rem; color:rgba(255,255,255,.88); }}
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;
@@ -797,32 +892,38 @@ header p {{ margin:.25rem 0 0; font-size:.85rem; color:rgba(255,255,255,.88); }}
scores_mean: cls.scores_mean,
n_objects: cls.n_objects,
pixels: cls.pixels,
rank_score: (cls.scores_max != null ? Number(cls.scores_max) : -1),
rank_score: computeRankScore(cls),
}});
}}
}}
hits.sort((a, b) => b.rank_score - a.rank_score || (b.pixels || 0) - (a.pixels || 0));
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 hits) map.set(h.id, h);
lastHits = Array.from(map.values()).sort(
(a, b) => b.rank_score - a.rank_score || (b.pixels || 0) - (a.pixels || 0)
);
}} else {{
lastHits = hits;
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) return 'score n/a';
const a = h.scores_min != null ? Number(h.scores_min).toFixed(3) : '?';
const b = h.scores_max != null ? Number(h.scores_max).toFixed(3) : '?';
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;
return m != null ? `max ${{b}} · mean ${{m}}` : `${{a}} ~ ${{b}}`;
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() {{
@@ -837,18 +938,21 @@ header p {{ margin:.25rem 0 0; font-size:.85rem; color:rgba(255,255,255,.88); }}
return;
}}
emptyHit.hidden = true;
pickMeta.textContent = `pixel (${{marker.x}}, ${{marker.y}}) — ${{lastHits.length}} class(es) overlapping`;
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 = i === 0 ? '<span class="rank-badge">priority #1</span>' : `<span class="rank-badge">#${{i+1}}</span>`;
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() : '-')}}</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);
@@ -875,11 +979,16 @@ header p {{ margin:.25rem 0 0; font-size:.85rem; color:rgba(255,255,255,.88); }}
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 image (RLE hit-test)', true);
setStatus(
'ready — ' + classes.length + ' classes · click → top-' + TOP_K +
' by mean+log(area) (RLE)',
true
);
}} catch (e) {{
setStatus(String(e.message || e), false);
try {{
@@ -1042,8 +1151,11 @@ def main() -> int:
print(f"saved: {grid_path}")
# RLE hit data for interactive click query (file:// safe, no getImageData)
print("exporting RLE hit data for interactive HTML...")
classes_meta = export_hitmasks(out_dir, ordered, legend, base.size)
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 = {
@@ -1052,6 +1164,13 @@ def main() -> int:
"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": {
@@ -1075,6 +1194,8 @@ def main() -> int:
"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
@@ -1085,8 +1206,9 @@ def main() -> int:
legend=legend,
meta=merge_summary,
interactive_meta=interactive_meta,
top_k=top_k,
)
print(f"saved: {html_path} (interactive click inspect)")
print(f"saved: {html_path} (interactive top-{top_k} by mean+log area)")
print("\nClasses merged:")
for ent in legend: