1. 단일 이미지에서 3D 공간으로
일상적인 실내 사진 하나를 컴퓨터에 입력하면 화면 속 소파와 테이블, 창문을 인식하는 것을 넘어 각 객체까지의 실제 거리를 알려준다면? 소파는 약 2m, 테이블은 3m, 창문은 8m 정도 떨어져 있다고. 이것이 깊이 추정(depth estimation) 모델이 수행하는 작업이다.
lingbot-depth-pretrain-vitl-14 컨테이너는 DINOv2 기반의 대규모 비전 모델을 활용해 두 가지 방식의 깊이 정보 획득을 지원한다. 첫째, 단일 RGB 이미지로부터 픽셀별 거리를 예측하는 단안(monocular) 깊이 추정. 둘째, 불완전한 깊이 데이터(예: LiDAR 스캔 결과)를 RGB 정보와 결합해 밀도 높은 깊이 맵을 복원하는 깊이 완성(depth completion)이다. 3.21억 개의 파라미터를 보유한 이 모델은 컨테이너 형태로 패키징되어 있어 모델 추론부터 웹 기반 인터랙션까지 일관된 환경에서 실행할 수 있다.
2. 아키텍처 개요: 세 구성 요소의 협업
해당 컨테이너는 PyTorch(연산), FastAPI(서비), Gradio(인터페이스)의 조합으로 구성된다. 각각 독립적인 역할을 수행하면서도 긴밀하게 연결되어 있다.
2.1 PyTorch 2.6: 추론 엔진
PyTorch 2.6은 전체 파이프라인의 계산 중추를 담당한다. CUDA 12.4와의 호환성을 바탕으로 최신 GPU 아키텍처를 효율적으로 활용하며, 개선된 메모리 관리 전략으로 321M 규모의 모델을 안정적으로 로드한다.
모델 가중치 관리에는 심볼릭 링(symbolic link) 전략이 적용된다. 실제 가중치 파일은 /root/assets/lingbot-depth/에 고정 배치되고, 애플리케이션은 이를 가리키는 심볼릭 링크를 통해 접근한다. 이러한 구조는 컨테이너 재배포나 경로 변경 시에도 모델 데이터의 무결성을 유지한다.
2.2 FastAPI: 프로그래매틱 접근 경로
8000번 포트에서 수신 대기하는 FastAPI 서버는 RESTful 엔드포인트를 통해 외부 시스템과의 통합을 지원한다. 핵심 엔드포인트 /predict의 요청-응답 흐름은 다음과 같다.
import base64, json, io
import numpy as np
import requests
def fetch_depth_estimation(img_filepath):
# 이미지 바이리를 Base64 문자열로 변환
with open(img_filepath, "rb") as f:
encoded = base64.b64encode(f.read()).decode("utf-8")
# 요청 페이로드 구성
req_body = {
"image": encoded,
"mode": "monocular", # 또는 "completion"
"sparse_depth": None,
"camera_intrinsics": {
"fx": 460.14, "fy": 460.20,
"cx": 319.66, "cy": 237.40
}
}
# API 호출
resp = requests.post(
"http://localhost:8000/predict",
json=req_body,
headers={"Content-Type": "application/json"}
)
# 응답 분해
payload = resp.json()
if payload.get("status") == "success":
depth_vis = base64.b64decode(payload["depth_image"]) # 가시화용 깊이 맵
depth_raw = np.load(io.BytesIO(base64.b64decode(payload["depth_npy"]))) # 실제 거리값
pts_3d = np.load(io.BytesIO(base64.b64decode(payload["point_cloud"]))) # 3D 포인트 클라우드
return depth_raw, depth_vis, pts_3d
return None
FastAPI의 특장은 Pydantic 기반의 자동 입력 검증, 비동기 요청 처리, 그리고 자동 생성되는 OpenAPI 문서(/docs)에 있다. 이를 통해 클라이언트는 별도의 추가 문서 없이도 인터페이스를 파악할 수 있다.
2.3 Gradio: 즉시 사용 가능한 시각화
7860번 포트를 통해 제공되는 Gradio 인터페이스는 기술적 진입 장벽 없이 모델을 탐색할 수 있게 한다. 이미지 업로드, 모드 선택, 카메라 내부 파라미터 조정, 결과 대비 시각화, 산출물 다운로드 등의 기능을 웹 브라우저에서 직접 수행할 수 있다.
중요한 점은 Gradio가 내부적으로 FastAPI 엔드포인트를 호출한다는 것이다. 이는 프레젠테이션 계층과 비즈니스 로직의 명확한 분리를 의미하며, UI 교체가 필요한 경우에도 API 계을 재사용할 수 있게 한다.
3. 모델 구조: Masked Depth Modeling
3.1 설계 철학
전통적인 접근법이 불완전한 깊이 정보를 노이즈로 처리하는 경향이 있다면, 해당 모델은 이를 유의미한 신호로 해석한다. 마치 퍼즐의 일부 조각만으로 전체 그림을 유추하듯, RGB 텍스처와 제한된 깊이 샘플을 조합해 전체 깊이 분포를 재구성한다.
- 인코더: DINOv2 ViT-L/14. 대량의 비지도 이미지로 사전 훈련되어 의미론적 특징(윤곽, 질감, 객체 경계)을 추출한다.
- 디코더: 커스텀 ConvStack. 추상화된 특징을 픽셀 단위 깊이 값으로 매핑한다.
- MDM: 훈련 시점에서 깊이 정보의 일부를 의도적으로 마스킹하여, RGB와 남은 깊이 신호로부터 복원하는 능력을 강제한다.
3.2 두 가지 용 모드
단안 깊이 추정
입력: RGB (H, W, 3)
처리:透시, 그림자, 크기-거리 관계 등 시각적 단서만으로 깊이 추론
출력: (H, W) 배열, 단위: 미터
깊이 완성
입력: RGB + 희소 깊이 맵 (부분 픽셀만 유효값 보유)
처리: 색상 정보를 가이드로 삼아 깊이 맵의 빈 영역을 보간
출력: 완전한 깊이 맵 (단안 대비 정확도 및 경계 선명도 향상)
3.3 카메라 내부 파라미터의 역할
카메라 내부 파라미터 (fx, fy, cx, cy)는 픽셀 좌표계와 물리적 3D 좌표계 간의 변환을 가능하게 한다.
- fx, fy: 초점 거리. 이미지 내 객체의 투영 크기 결정
- cx, cy: 주점(principal point). 일반적으로 이미지 중
내부 파라미터가 없으면 상대적 깊이(가까움/멂)만 판별할 수 있다. 이 정보가 제공되면 절대적 거리(정확한 미터 단위) 산출이 가능해지며, 이는 후속 3D 재구성 단계에서 필수적이다.
4. 실전 적용
4.1 배포 및 상태 확인
기본 이미지 insbase-cuda124-pt250-dual-v7를 기반으로 하며, 초기 구동 시 약 5-8초의 모델 로딩 시간이 소요된다. 서비스 정상 여부는 다음 엔드포인트로 확인할 수 있다.
http://<host>:7860— Gradio 대시보드http://<host>:8000/docs— FastAPI 자동 문서http://<host>:8000/health— 상태 확인 (응답:{"status": "healthy"})
4.2 단안 깊이 추정 구현
import io, base64
import cv2
import numpy as np
from PIL import Image
import requests
def run_monocular_depth(source_path, endpoint="http://localhost:8000/predict"):
# 이미지 로드 및 색상 공간 변환
bgr = cv2.imread(source_path)
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
# ViT 패치 정렬을 위한 14의 배수 크기 조정
h, w = rgb.shape[:2]
aligned_h = (h // 14) * 14
aligned_w = (w // 14) * 14
if aligned_h != h or aligned_w != w:
rgb = cv2.resize(rgb, (aligned_w, aligned_h))
print(f"크기 조정: {w}x{h} → {aligned_w}x{aligned_h}")
# Base64 인코딩
success, buf = cv2.imencode(".jpg", rgb)
img_b64 = base64.b64encode(buf).decode("utf-8")
# API 요청
response = requests.post(
endpoint,
json={"image": img_b64, "mode": "monocular", "sparse_depth": None, "camera_intrinsics": None}
)
payload = response.json()
if payload.get("status") != "success":
raise RuntimeError(payload.get("error", "추론 실패"))
# 결과 디코딩
depth_vis = Image.open(io.BytesIO(base64.b64decode(payload["depth_image"])))
depth_arr = np.load(io.BytesIO(base64.b64decode(payload["depth_npy"])))
print(f"깊이 범위: {payload['depth_range']}, 형태: {depth_arr.shape}")
return depth_arr, depth_vis
# 활용 예시
depth_values, depth_visual = run_monocular_depth("interior.jpg")
if depth_values is not None:
# 특정 관심 영역의 평균 거리
roi_mean = depth_values[200:300, 300:400].mean()
print(f"관 영역 평균 거리: {roi_mean:.2f}m")
4.3 깊이 완성 구현
def run_depth_completion(rgb_file, sparse_file, cam_intrinsics, endpoint="http://localhost:8000/predict"):
# RGB 처리
rgb = cv2.imread(rgb_file)
rgb = cv2.cvtColor(rgb, cv2.COLOR_BGR2RGB)
# 희소 깊이 맵 처리 (16-bit PNG, 단위: mm 가정)
sparse_mm = cv2.imread(sparse_file, cv2.IMREAD_UNCHANGED)
sparse_m = sparse_mm.astype(np.float32) / 1000.0
# 크기 동기화
if rgb.shape[:2] != sparse_m.shape:
sparse_m = cv2.resize(sparse_m, (rgb.shape[1], rgb.shape[0]))
# 희소 깊이를 PNG로 재인코딩 후 Base64
sparse_norm = (sparse_m - sparse_m.min()) / (sparse_m.max() - sparse_m.min() + 1e-7)
sparse_u8 = (sparse_norm * 255).astype(np.uint8)
_, sparse_buf = cv2.imencode(".png", sparse_u8)
sparse_b64 = base64.b64encode(sparse_buf).decode("utf-8")
# RGB Base64
_, rgb_buf = cv2.imencode(".jpg", rgb)
rgb_b64 = base64.b64encode(rgb_buf).decode("utf-8")
# API 호출
resp = requests.post(
endpoint,
json={
"image": rgb_b64,
"mode": "completion",
"sparse_depth": sparse_b64,
"camera_intrinsics": cam_intrinsics
}
)
result = resp.json()
if result.get("status") == "success":
completed = np.load(io.BytesIO(base64.b64decode(result["depth_npy"])))
# 희소 점에서의 복원 정확도 검증
valid_mask = sparse_m > 0
if valid_mask.any():
mae = np.abs(sparse_m[valid_mask] - completed[valid_mask]).mean()
print(f"희소 점 MAE: {mae:.4f}m")
return completed
return None
# 실행
intrinsics = {"fx": 460.14, "fy": 460.20, "cx": 319.66, "cy": 237.40}
dense_depth = run_depth_completion("scene.jpg", "lidar_sparse.png", intrinsics)
4.4 포인트 클라우드 생성
def reconstruct_pointcloud(depth, K):
"""깊이 맵에서 3D 포인트 클라우드 복원"""
fx, fy = K["fx"], K["fy"]
cx, cy = K["cx"], K["cy"]
rows, cols = depth.shape
u, v = np.meshgrid(np.arange(cols), np.arange(rows))
z = depth
x = (u - cx) * z / fx
y = (v - cy) * z / fy
points = np.stack([x, y, z], axis=-1)
# 유효 점 필터링
valid = (z > 0.1) & (z < 50.0)
return points[valid].reshape(-1, 3)
def export_ply(points, filename="scene.ply"):
"""ASCII PLY 형식으로 저장"""
with open(filename, "w") as f:
f.write("ply\nformat ascii 1.0\n")
f.write(f"element vertex {len(points)}\n")
f.write("property float x\nproperty float y\nproperty float z\nend_header\n")
for pt in points:
f.write(f"{pt[0]} {pt[1]} {pt[2]}\n")
# 사용
cloud = reconstruct_pointcloud(dense_depth, intrinsics)
export_ply(cloud, "output.ply")
5. 최적화 전략
5.1 입력 전처리
def align_for_vit(image_path, base_dim=448):
"""ViT 패치 정렬 및 기본 전처리"""
img = cv2.imread(image_path)
if img is None:
raise ValueError(f"이미지 로드 실패: {image_path}")
rgb = cv2.COLOR_BGR2RGB
h, w = rgb.shape[:2]
# 짧은 변 기준 스케일링
scale = base_dim / min(h, w)
resized = cv2.resize(rgb, (int(w * scale), int(h * scale)))
# 14의 배수로 정렬
new_h = (resized.shape[0] // 14) * 14
new_w = (resized.shape[1] // 14) * 14
aligned = cv2.resize(resized, (new_w, new_h))
# [-1, 1] 범위 정규화
normalized = aligned.astype(np.float32) / 255.0
normalized = (normalized - 0.5) / 0.5
return normalized, (h, w), (new_h, new_w)
5.2 병렬 배치 처리
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
class DepthPipeline:
def __init__(self, api_url, workers=4):
self.api_url = api_url
self.executor = ThreadPoolExecutor(max_workers=workers)
def dispatch(self, image_paths):
futures = {self.executor.submit(self._infer, p): p for p in image_paths}
outcomes = []
for future in as_completed(futures):
path = futures[future]
try:
outcomes.append(future.result(timeout=30))
except Exception as exc:
outcomes.append({"source": path, "error": str(exc)})
return outcomes
def _infer(self, path):
# preprocess, encode, request, decode
# (구현 생략, 앞선 예시 참조)
pass
6. 응용 시나리오
6.1 로봇 내비게이션
class ObstacleAnalyzer:
def __init__(self, K, danger_m=1.0, safe_m=2.0):
self.intrinsics = K
self.danger = danger_m
self.safe = safe_m
def plan_from_depth(self, depth_map):
h, w = depth_map.shape
sectors = {
"left": depth_map[:, :w//3],
"center": depth_map[:, w//3:2*w//3],
"right": depth_map[:, 2*w//3:]
}
medians = {k: np.median(v[v > 0]) for k, v in sectors.items()}
advice = []
if medians["center"] > self.safe:
advice.append("전방 통과 가능")
elif median["center"] < self.danger:
advice.append("전방 장애물, 회전 필요")
# 좌우 우선순위 판단
if medians["left"] > medians["right"] and medians["left"] > self.safe:
advice.append("좌측 회전 권장")
elif medians["right"] > medians["left"] and medians["right"] > self.safe:
advice.append("우측 회전 권장")
return {"medians": medians, "advice": advice}
6.2 단일 카메라 3D 재구성
class SparseReconstructor:
def __init__(self, api_endpoint, K):
self.endpoint = api_endpoint
self.K = K
self.frames = []
def ingest(self, frame_bgr, pose_matrix=None):
"""비디오 프레임을 포인트 클라우드로 변환"""
# (API 호출 및 깊이 추정 로직)
depth = self._estimate(frame_bgr)
cloud = reconstruct_pointcloud(depth, self.K)
if pose_matrix is not None:
cloud = self._transform(cloud, pose_matrix)
self.frames.append(cloud)
return cloud
def _transform(self, pts, T):
"""동차 좌표 변환"""
h = np.hstack([pts, np.ones((pts.shape[0], 1))])
return (T @ h.T).T[:, :3]
def merge(self, max_pts=500000):
"""누적된 모든 프레임 병합"""
if not self.frames:
return None
combined = np.vstack(self.frames)
if len(combined) > max_pts:
idx = np.random.choice(len(combined), max_pts, replace=False)
combined = combined[idx]
return combined
6.3 AR 공간 이해
class SpatialUnderstanding:
def __init__(self, api_url):
self.api_url = api_url
def find_surfaces(self, depth_map, grad_thresh=0.05):
"""평면 후보 영역 검출"""
gx = np.gradient(depth_map, axis=1)
gy = np.gradient(depth_map, axis=0)
grad_mag = np.sqrt(gx**2 + gy**2)
flat = grad_mag < grad_thresh
kernel = np.ones((3, 3), np.uint8)
closed = cv2.morphologyEx(flat.astype(np.uint8), cv2.MORPH_CLOSE, kernel)
return closed.astype(bool)
def suggest_placement(self, depth_map, min_px=50):
"""가상 객체 배치 위치 제안"""
flat_mask = self.find_surfaces(depth_map)
n_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(
flat_mask.astype(np.uint8), 8
)
candidates = []
for i in range(1, n_labels):
if stats[i, cv2.CC_STAT_AREA] >= min_px:
cx, cy = int(centroids[i][0]), int(centroids[i][1])
candidates.append({
"pixel": (cx, cy),
"depth": depth_map[cy, cx],
"area": stats[i, cv2.CC_STAT_AREA]
})
candidates.sort(key=lambda x: x["area"], reverse=True)
return candidates[:5]
7. 마무리
lingbot-depth-pretrain-vitl-14 컨테이너는 현대적인 AI 서빙 아키텍처의 특징을 잘 보여준다. 계산 계층(PyTorch), 서비스 계층(FastAPI), 상호작용 계층(Gradio)의 분리는 유지보수성과 확장성을 높이며, 심볼릭 링크 기반의 자산 관리는 배포 안정성을 확보한다. 단안 추정과 깊이 완성이라는 이중 모드 지원, 그리고 표준화된 API를 통한 손쉬운 통합은 연구 환경부터 프로덕션 시스템까지 폭넓은 활용을 가능하게 한다.
실제 적용 시에는 입력 해상도의 14 배수 정렬, 카메라 내부 파라미터의 정확한 설정, 그리고 희소 깊이 입력의 적절한 전처리가 결과 품질에 결정적 영향을 미침을 유의해야 한다.