@
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>
@
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
세그멘테이션 결과 뷰어 생성 — 라벨별 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()
|
||||
Reference in New Issue
Block a user