점수 추가
This commit is contained in:
+149
-27
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user