Files
railway-client/tools/make_viewer.py
T
minsung 4e5173522d @
feat: SAM3.1 다중 프롬프트 배치 검출 및 라벨 병합 후처리

sam3_multi_prompt.py: 프롬프트 N개를 forward 1회에 배치 처리한다.
서버(프롬프트당 forward 1회) 대비 forward 횟수가 1/N로 줄고, 이미지
임베딩과 텍스트 인코딩을 타일 전체에서 재사용한다. RTX 3060 12GB
기준 배치 16이 최적(32 이상은 VRAM 압박으로 4배 이상 느려짐).

타일보다 큰 대상(숲, 도로, 주차장)은 타일 경계에서 잘려 사각형
마스크가 되므로 --wide-prompts 로 지정해 이미지 전체를 한 장으로
처리한다.

merge_labels.py: 검출 결과 후처리. 같은 병합 그룹에 속한 라벨끼리
외곽선이 gap px 이내로 인접하면 하나로 합치고 대표 라벨을 붙인다
(building + building rooftop + blue roof -> building). 병합 전
claim 규칙으로 소유권을 재배정해, 차량 위에 잡힌 *roof 폴리곤이
건물이 아니라 차량에 합쳐지도록 한다.

make_viewer.py: 라벨별 on/off 가능한 단독 HTML 뷰어 생성.
프롬프트 파일의 그룹 주석 기준으로 라벨을 묶어 나열하고, 줌/팬과
선택 상태 저장을 지원한다.

cut_tiles.py: R{행}C{열} 격자 오버레이 + 원본 배율 타일 저장.

전역 규칙 반영:
- CUDA 사용 불가 시 CPU로 폴백하지 않고 즉시 에러
- set_per_process_memory_fraction 으로 드라이버의 시스템 메모리
  폴백(10배 이상 느려짐) 전에 OOM 발생
- 체크포인트/BPE 경로 하드코딩 제거. CLI 인자 -> 환경변수 ->
  sam31server 설정파일 순으로 해결

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
2026-08-20 10:05:23 +09:00

303 lines
12 KiB
Python
Raw 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.
"""
세그멘테이션 결과 뷰어 생성 — 라벨별 on/off, 줌/팬.
sam3_multi_prompt.py 가 만든 JSON과 원본 이미지를 받아 단독 HTML을 만든다.
라벨은 프롬프트 파일의 "# [그룹명]" 주석 기준으로 묶어서 나열한다.
사용법:
python tools/make_viewer.py \
--json "data/everyimage/output/0857_multi.json" \
--image "data/everyimage/DJI_20250805162831_0857.JPG" \
--prompts prompts/discovery_v1.txt
출력:
<json과 같은 폴더>/<이름>_viewer.html + <이름>_view.jpg
"""
import argparse
import json
from collections import Counter, OrderedDict
from pathlib import Path
import cv2
import numpy as np
def parse_groups(path: Path) -> "OrderedDict[str, list]":
"""프롬프트 파일의 '# [그룹명]' 주석으로 라벨을 묶는다."""
groups, current = OrderedDict(), "기타"
for line in path.read_text(encoding="utf-8").splitlines():
s = line.strip()
if s.startswith("#"):
body = s.lstrip("#").strip()
if body.startswith("[") and body.endswith("]"):
current = body[1:-1].strip()
groups.setdefault(current, [])
elif s:
groups.setdefault(current, []).append(s)
return groups
HTML = """<!doctype html>
<meta charset="utf-8">
<title>__TITLE__</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin:0; display:flex; height:100vh; font:13px/1.5 "Malgun Gothic",sans-serif;
background:#15181a; color:#e6e9ea; }
#side { width:320px; flex:none; overflow-y:auto; border-right:1px solid #2c3336;
background:#1a1e20; }
#side h1 { font-size:14px; margin:0; padding:12px 14px; border-bottom:1px solid #2c3336; }
#side .meta { padding:8px 14px; color:#8b969b; font-size:12px; border-bottom:1px solid #2c3336; }
.grp { border-bottom:1px solid #23292b; }
.grp > .hd { display:flex; align-items:center; gap:8px; padding:8px 12px;
background:#20262a; cursor:pointer; font-weight:700; font-size:12px; }
.grp > .hd .n { margin-left:auto; color:#7f8b90; font-weight:400; }
.grp > .hd .tw { color:#7f8b90; width:10px; }
.grp > .hd input { accent-color:#4da3ff; }
.grp.fold .row { display:none; }
.row { display:flex; align-items:center; gap:8px; padding:3px 12px 3px 26px; cursor:pointer; }
.row:hover { background:#232a2d; }
.row input { accent-color:#4da3ff; }
.sw { width:12px; height:12px; border-radius:2px; flex:none; }
.row .lb { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.row .ct { color:#7f8b90; font-variant-numeric:tabular-nums; }
.row.off .lb, .row.off .ct { color:#5b6568; }
#bar { padding:8px 12px; display:flex; gap:6px; flex-wrap:wrap;
border-bottom:1px solid #2c3336; }
button { background:#2b3336; color:#dfe4e6; border:1px solid #3a4448; border-radius:4px;
padding:4px 9px; cursor:pointer; font-size:12px; }
button:hover { background:#36403f; }
#view { flex:1; position:relative; overflow:hidden; background:#0d0f10; }
canvas { position:absolute; top:0; left:0; }
#hud { position:absolute; right:10px; bottom:10px; background:#000a; padding:4px 8px;
border-radius:4px; font-size:12px; color:#c9d1d4; }
</style>
<div id="side">
<h1>__TITLE__</h1>
<div class="meta">segment __TOTAL__개 · 라벨 __NLAB__종</div>
<div id="bar">
<button onclick="setAll(true)">전체 켜기</button>
<button onclick="setAll(false)">전체 끄기</button>
<button onclick="fit()">맞춤</button>
<button onclick="toggleFill()">채움 on/off</button>
<button onclick="exportSel()">선택 복사</button>
</div>
<div id="list"></div>
</div>
<div id="view"><canvas id="cv"></canvas><div id="hud"></div></div>
<script>
const DATA = __DATA__; // {groups:[{name,labels:[{label,color,count}]}], segs:[{l,p}]}
const IMG_SRC = "__IMG__";
const KEY = 'segview:' + document.title;
const ALL = DATA.groups.flatMap(g => g.labels.map(l => l.label));
const saved = localStorage.getItem(KEY);
const on = new Set(saved ? JSON.parse(saved).filter(l => ALL.includes(l)) : ALL);
let fill = true;
function save() { localStorage.setItem(KEY, JSON.stringify([...on])); }
function exportSel() {
const out = [];
for (const g of DATA.groups) {
const sel = g.labels.filter(l => on.has(l.label));
if (sel.length) out.push(`# [${g.name}]`, ...sel.map(l => l.label), '');
}
const t = out.join('\\n');
navigator.clipboard.writeText(t)
.then(() => alert(`선택 ${on.size}개 라벨 복사됨`))
.catch(() => prompt('복사 실패 — 직접 복사하라', t));
}
const img = new Image();
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const view = document.getElementById('view'), hud = document.getElementById('hud');
let scale = 1, ox = 0, oy = 0;
function buildList() {
const list = document.getElementById('list');
for (const g of DATA.groups) {
if (!g.labels.length) continue;
const box = document.createElement('div'); box.className = 'grp';
const hd = document.createElement('div'); hd.className = 'hd';
hd.innerHTML = `<input type="checkbox" class="gchk" checked>
<span class="tw">▾</span><span>${g.name}</span><span class="n">${
g.labels.reduce((a, l) => a + l.count, 0)}</span>`;
hd.querySelector('.gchk').onclick = e => {
e.stopPropagation();
const allOn = g.labels.every(l => on.has(l.label));
g.labels.forEach(l => allOn ? on.delete(l.label) : on.add(l.label));
sync(); draw();
};
hd.onclick = () => { // 헤더 클릭 = 접기/펴기
box.classList.toggle('fold');
hd.querySelector('.tw').textContent = box.classList.contains('fold') ? '▸' : '▾';
};
box.appendChild(hd);
for (const l of g.labels) {
const row = document.createElement('div'); row.className = 'row';
row.dataset.label = l.label;
row.innerHTML = `<input type="checkbox" checked>
<span class="sw" style="background:${l.color}"></span>
<span class="lb">${l.label}</span><span class="ct">${l.count}</span>`;
box.dataset.group = g.name;
row.onclick = e => {
if (e.target.tagName !== 'INPUT') e.preventDefault();
on.has(l.label) ? on.delete(l.label) : on.add(l.label);
sync(); draw();
};
box.appendChild(row);
}
list.appendChild(box);
}
sync();
}
function sync() {
document.querySelectorAll('.row').forEach(r => {
const v = on.has(r.dataset.label);
r.classList.toggle('off', !v);
r.querySelector('input').checked = v;
});
save();
document.querySelectorAll('.grp').forEach((box, i) => {
const g = DATA.groups.filter(x => x.labels.length)[i];
if (!g) return;
const n = g.labels.filter(l => on.has(l.label)).length;
const chk = box.querySelector('.gchk');
chk.checked = n > 0;
chk.indeterminate = n > 0 && n < g.labels.length;
});
}
function setAll(v) {
DATA.groups.forEach(g => g.labels.forEach(l => v ? on.add(l.label) : on.delete(l.label)));
sync(); draw();
}
function toggleFill() { fill = !fill; draw(); }
function resize() { cv.width = view.clientWidth; cv.height = view.clientHeight; draw(); }
function fit() {
scale = Math.min(cv.width / img.width, cv.height / img.height);
ox = (cv.width - img.width * scale) / 2;
oy = (cv.height - img.height * scale) / 2;
draw();
}
function draw() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cv.width, cv.height);
ctx.setTransform(scale, 0, 0, scale, ox, oy);
ctx.drawImage(img, 0, 0);
let shown = 0;
ctx.lineWidth = Math.max(0.7, 1.4 / scale);
for (const s of DATA.segs) {
if (!on.has(s.l)) continue;
shown++;
const col = DATA.colorOf[s.l];
ctx.beginPath();
ctx.moveTo(s.p[0][0], s.p[0][1]);
for (let i = 1; i < s.p.length; i++) ctx.lineTo(s.p[i][0], s.p[i][1]);
ctx.closePath();
if (fill) { ctx.fillStyle = col + '55'; ctx.fill(); }
ctx.strokeStyle = col; ctx.stroke();
}
hud.textContent = `${shown} / ${DATA.segs.length} segment · ${(scale * 100).toFixed(0)}%`;
}
view.addEventListener('wheel', e => {
e.preventDefault();
const k = e.deltaY < 0 ? 1.15 : 1 / 1.15;
const r = cv.getBoundingClientRect();
const mx = e.clientX - r.left, my = e.clientY - r.top;
ox = mx - (mx - ox) * k; oy = my - (my - oy) * k; scale *= k;
draw();
}, { passive: false });
let drag = null;
view.addEventListener('mousedown', e => drag = { x: e.clientX - ox, y: e.clientY - oy });
window.addEventListener('mouseup', () => drag = null);
window.addEventListener('mousemove', e => {
if (!drag) return;
ox = e.clientX - drag.x; oy = e.clientY - drag.y; draw();
});
window.addEventListener('resize', resize);
window.addEventListener('keydown', e => { if (e.key === 'f') fit(); });
img.onload = () => { buildList(); resize(); fit(); };
img.src = IMG_SRC;
</script>
"""
def color_for(label: str) -> str:
"""라벨 문자열 해시 → 고정 색상."""
h = 0
for ch in label:
h = (h * 131 + ord(ch)) & 0xFFFFFFFF
hsv = np.uint8([[[(h % 360) // 2, # OpenCV 색상은 0~179
190 + (h >> 9) % 60,
200 + (h >> 17) % 55]]])
r, g, b = cv2.cvtColor(hsv, cv2.COLOR_HSV2RGB)[0][0]
return "#%02x%02x%02x" % (int(r), int(g), int(b))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--json", required=True)
ap.add_argument("--image", required=True)
ap.add_argument("--prompts", default="prompts/discovery_v1.txt")
ap.add_argument("--output", default=None, help="기본: <json이름>_viewer.html")
ap.add_argument("--max-size", type=int, default=3000, help="뷰어용 이미지 최대 변 길이")
args = ap.parse_args()
jpath = Path(args.json)
data = json.loads(jpath.read_text(encoding="utf-8"))
segs = data.get("segments", [])
img = cv2.imdecode(np.fromfile(args.image, dtype=np.uint8), cv2.IMREAD_COLOR)
if img is None:
print(f"이미지 로드 실패: {args.image}")
return
H, W = img.shape[:2]
k = min(1.0, args.max_size / max(H, W))
if k < 1.0:
img = cv2.resize(img, (int(W * k), int(H * k)), interpolation=cv2.INTER_AREA)
out_html = Path(args.output) if args.output else jpath.with_name(jpath.stem + "_viewer.html")
img_name = jpath.stem + "_view.jpg"
cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 88])[1].tofile(
str(out_html.with_name(img_name)))
counts = Counter(s.get("label", "") for s in segs)
groups_src = parse_groups(Path(args.prompts))
seen, groups = set(), []
for name, labels in groups_src.items():
rows = [{"label": lb, "color": color_for(lb), "count": counts.get(lb, 0)}
for lb in labels if counts.get(lb, 0) > 0]
seen.update(r["label"] for r in rows)
if rows:
groups.append({"name": name, "labels": rows})
extra = [{"label": lb, "color": color_for(lb), "count": c}
for lb, c in counts.most_common() if lb not in seen and lb]
if extra:
groups.append({"name": "그룹 없음", "labels": extra})
payload = {
"groups": groups,
"colorOf": {lb: color_for(lb) for lb in counts if lb},
"segs": [{"l": s.get("label", ""),
"p": [[round(p[0] * k, 1), round(p[1] * k, 1)] for p in s["points"]]}
for s in segs if s.get("points")],
}
html = (HTML.replace("__TITLE__", jpath.stem)
.replace("__TOTAL__", str(len(segs)))
.replace("__NLAB__", str(len([c for c in counts if c])))
.replace("__IMG__", img_name)
.replace("__DATA__", json.dumps(payload, ensure_ascii=False)))
out_html.write_text(html, encoding="utf-8")
print(f"뷰어: {out_html}")
print(f"이미지: {out_html.with_name(img_name)} ({img.shape[1]}×{img.shape[0]})")
print(f"segment {len(segs)}개 · 라벨 {len([c for c in counts if c])}종 · 그룹 {len(groups)}개")
if __name__ == "__main__":
main()