Pointcept:Examples:Weld:LVS
LVS 로 취득한 데이터로 훈련하는 샘플.
Pointcept는 주로 3D 포인트 클라우드의 Semantic Segmentation(의미론적 분할)과 Instance Segmentation(개별 객체 분할)에 특화된 프레임워크입니다. 용접면 품질 검사(기공, 스패터 검출)를 위해 Pointcept를 활용하는 구체적인 방법은 다음과 같습니다.
샘플 데이터 취득
자세한 내용은 Welding:SurfaceSampleDataGenerator 항목 참조.
LVS로 부터 (x, z) 축만 있는 csv 데이터 1-Line을 y축으로 여러 라인을 추가한, "면 (Surface)" 가상 데이터로 ply로 생성한다.
ply로 만들어진 파일을 압축하였다: 20260415-butt_bead_profile_sample-all.zip
포인트 클라우드 라벨링
CloudCompare 를 사용하여 3D Semantic Segmentation 하였다: 20260415-butt_bead_profile_sample-all-segs.zip
- bead.ply - 용접 비드
- face1.ply - 표면1
- face1.ply - 표면2
문제 정의 (Task Formulation)
용접 결함 검출은 보통 Semantic Segmentation 문제로 정의합니다. 각 포인트(Point)를 다음 중 하나로 분류하도록 모델을 학습시킵니다.
- Class 0: 정상 용접 비드 (Normal Bead)
- Class 1: 기공 (Porosity) - 오목하게 파인 형태적 특징
- Class 2: 스패터 (Spatter) - 돌출된 구형태의 특징
- Class 3: 모재 (Base Metal)
데이터 준비 및 커스텀 데이터셋 구축
제공해주신 .ply 파일은 형태 정보($x, y, z$)만 포함하고 있습니다. Pointcept에서 이를 학습시키려면 다음과 같은 준비가 필요합니다.
- 데이터 어노테이션: LabelCloud나 CloudCompare 같은 툴을 사용하여
.ply파일 내의 각 포인트에 결함 종류별 라벨을 부여해야 합니다. - 데이터셋 클래스 생성:
pointcept/datasets디렉토리에 용접 데이터를 로드하는 커스텀 Dataset 클래스를 작성합니다. (예:WeldDataset)-
configs/_base_/dataset/하위에 데이터 경로와 전처리 설정을 담은 커스텀 설정을 추가합니다.
-
모델 선택 (Model Selection)
용접 데이터는 정밀한 기하학적 특징(곡률, 높이 변화)이 중요하므로 다음과 같은 모델을 추천합니다.
- PTv3 (Point Transformer v3) - 현재 Pointcept의 주력 모델로,
configs/scannet/semseg-ptv3-base-v1m1-0-base.py설정을 참고하여 용접 데이터에 맞춰 수정 후 사용 가능합니다. 컨텍스트 이해 능력이 뛰어나 미세 결함 검출에 유리합니다. - SpUNet (Sparse UNet) - 계산 효율이 좋고 격자 형태의 특징 추출에 강점이 있어, LVS로 찍은 정형화된 스캔 데이터에 적합합니다.
품질 검사 프로세스 (Workflow)
1. 학습 (Train): 결함 라벨이 포함된 데이터를 사용하여 모델을 학습시킵니다.
2. 추론 및 검사 (Inference): 새로운 .ply 파일을 입력으로 넣어 결함 영역을 예측합니다.
3. 결과 분석: 모델이 출력한 Segmentation Map을 통해 기공이나 스패터로 분류된 포인트들의 클러스터 크기를 계산하여, 품질 합격/불합격 판정을 내립니다.
이 프로젝트에서의 접근 팁
- 정규화 (Normalization): 용접 비드는
$z$축 높이 변화가 핵심입니다. Pointcept 내의 GridSample이나 Normalization 전처리를 사용하여 데이터의 스케일을 맞추는 것이 중요합니다. - RPE (Relative Position Encoding): PointTransformerV3에서 RPE 옵션을 켜면 미세한 굴곡(기공)을 더 잘 잡아낼 수 있습니다.
WeldDataset 클래스 작성
제공해주신 face1.ply, face2.ply, bead.ply 파일을 클래스별로 라벨링하여 로드할 수 있는 WeldDataset 클래스를 작성하겠습니다.
Pointcept의 구조에 맞춰 pointcept/datasets/weld.py 파일을 생성하고, 각 포인트 클라우드를 합쳐서 하나의 씬(Scene)으로 구성하며 라벨을 부여하는 로직을 포함합니다.
pointcept/datasets/weld.py 작성
이 클래스는 각 .ply 파일을 읽어 (N, 3) 좌표와 (N,) 라벨을 생성합니다.
import os
import glob
import numpy as np
from torch.utils.data import Dataset
from plyfile import PlyData
from .builder import DATASETS
from .transform import Compose
def read_ply(path):
plydata = PlyData.read(path)
x = np.array(plydata['vertex']['x'])
y = np.array(plydata['vertex']['y'])
z = np.array(plydata['vertex']['z'])
coords = np.stack([x, y, z], axis=1)
return coords
@DATASETS.register_module()
class WeldDataset(Dataset):
def __init__(self,
split='train',
data_root='data/weld',
transform=None,
test_mode=False,
loop=1):
super(WeldDataset, self).__init__()
self.split = split
self.data_root = data_root
self.transform = Compose(transform)
self.loop = loop
self.test_mode = test_mode
# 클래스 매핑 정의
self.class_map = {
"face1": 0,
"face2": 1,
"bead": 2,
}
self.data_list = self.get_data_list()
def get_data_list(self):
split_dir = os.path.join(self.data_root, self.split)
if not os.path.exists(split_dir):
return []
return sorted(glob.glob(os.path.join(split_dir, "*")))
def __getitem__(self, idx):
scene_path = self.data_list[idx % len(self.data_list)]
all_coords = []
all_labels = []
for file_name, label in self.class_map.items():
file_path = os.path.join(scene_path, f"{file_name}.ply")
if os.path.exists(file_path):
coords = read_ply(file_path)
all_coords.append(coords)
all_labels.append(np.full(len(coords), label, dtype=np.int64))
if len(all_coords) == 0:
# 빈 데이터 처리 (에러 방지)
coords = np.zeros((0, 3), dtype=np.float32)
labels = np.zeros((0,), dtype=np.int64)
else:
coords = np.concatenate(all_coords, axis=0).astype(np.float32)
labels = np.concatenate(all_labels, axis=0).astype(np.int64)
# Pointcept standard keys: coord, segment (instead of label)
data_dict = dict(coord=coords, segment=labels, name=os.path.basename(scene_path))
if self.transform is not None:
data_dict = self.transform(data_dict)
return data_dict
def __len__(self):
return len(self.data_list) * self.loop
위 코드가 정상 작동하려면 훈련 데이터를 다음과 같이 배치해야 합니다.
data/weld/
├── train/
│ ├── scene_001/
│ │ ├── face1.ply
│ │ ├── face2.ply
│ │ └── bead.ply
│ └── scene_002/ ...
└── val/
└── scene_003/ ...
그리고 pointcept/datasets/__init__.py 파일에 새로운 데이터셋을 인식할 수 있도록 등록해야 합니다:
Config 파일 설정 (configs/base/dataset/weld.py)
모델 학습 시 이 데이터셋을 사용하도록 설정을 추가합니다.
dataset_type = "WeldDataset"
data_root = "data/weld"
data = dict(
train=dict(
type=dataset_type,
split="train",
data_root=data_root,
transform=[
dict(type="CenterShift", apply_z=True),
dict(type="RandomRotate", angle=[-1, 1], axis="z", center=[0, 0, 0], p=0.5),
dict(type="RandomScale", scale=[0.9, 1.1]),
dict(type="DefaultPredictDoNothing"), # placeholder
dict(type="Collect", keys=("coord", "label"), feat_keys=("coord")),
dict(type="ToTensor"),
],
loop=10, # 데이터가 적을 경우 반복 학습
),
val=dict(dataset_type,
split="val",
data_root=data_root,
transform=[
dict(type="CenterShift", apply_z=True),
dict(type="Collect", keys=("coord", "label"), feat_keys=("coord")),
dict(type="ToTensor"),
],
),
)
configs/weld/semseg-pt-v3m1-0-base.py 파일 작성:
_base_ = ["../_base_/default_runtime.py"]
# misc custom setting
batch_size = 1 # bs: total bs in all gpus
num_worker = 4
mix_prob = 0.0
empty_cache = False
enable_amp = True
# model settings
model = dict(
type="DefaultSegmentorV2",
num_classes=3,
backbone_out_channels=64,
backbone=dict(
type="PT-v3m1",
in_channels=3,
order=("z", "z-trans", "hilbert", "hilbert-trans"),
stride=(2, 2, 2, 2),
enc_depths=(2, 2, 2, 6, 2),
enc_channels=(32, 64, 128, 256, 512),
enc_num_head=(2, 4, 8, 16, 32),
enc_patch_size=(1024, 1024, 1024, 1024, 1024),
dec_depths=(2, 2, 2, 2),
dec_channels=(64, 64, 128, 256),
dec_num_head=(4, 4, 8, 16),
dec_patch_size=(1024, 1024, 1024, 1024),
mlp_ratio=4,
qkv_bias=True,
qk_scale=None,
attn_drop=0.0,
proj_drop=0.0,
drop_path=0.3,
shuffle_orders=True,
pre_norm=True,
enable_rpe=False,
enable_flash=False,
upcast_attention=True,
upcast_softmax=True,
enc_mode=False,
pdnorm_bn=False,
pdnorm_ln=False,
pdnorm_decouple=True,
pdnorm_adaptive=False,
pdnorm_affine=True,
pdnorm_conditions=("ScanNet", "S3DIS", "Structured3D"),
),
criteria=[
dict(type="CrossEntropyLoss", loss_weight=1.0, ignore_index=-1),
],
)
# scheduler settings
epoch = 100
optimizer = dict(type="AdamW", lr=0.006, weight_decay=0.05)
scheduler = dict(
type="OneCycleLR",
max_lr=0.006,
pct_start=0.05,
anneal_strategy="cos",
div_factor=10.0,
final_div_factor=1000.0,
)
# dataset settings
dataset_type = "WeldDataset"
data_root = "data/weld"
data = dict(
num_classes=3,
ignore_index=-1,
names=["face1", "face2", "bead"],
train=dict(
type=dataset_type,
split="train",
data_root=data_root,
transform=[
dict(type="CenterShift", apply_z=True),
dict(type="RandomRotate", angle=[-1, 1], axis="z", center=[0, 0, 0], p=0.5),
dict(type="RandomScale", scale=[0.9, 1.1]),
dict(
type="GridSample",
grid_size=0.02,
hash_type="fnv",
mode="train",
return_grid_coord=True,
),
dict(type="SphereCrop", point_max=102400, mode="random"),
dict(type="ToTensor"),
dict(
type="Collect",
keys=("coord", "grid_coord", "segment"),
feat_keys=["coord"],
),
],
loop=10,
),
val=dict(
type=dataset_type,
split="train", # Only have one scene for now
data_root=data_root,
transform=[
dict(type="CenterShift", apply_z=True),
dict(type="Copy", keys_dict={"segment": "origin_segment"}),
dict(
type="GridSample",
grid_size=0.02,
hash_type="fnv",
mode="train",
return_grid_coord=True,
return_inverse=True,
),
dict(type="ToTensor"),
dict(
type="Collect",
keys=("coord", "grid_coord", "segment", "origin_segment", "inverse"),
feat_keys=["coord"],
),
],
),
)
훈련 시작
PYTHONPATH=$PWD:$PYTHONPATH uv run --with open3d --no-sync python tools/train.py --config-file configs/weld/semseg-pt-v3m1-0-base.py --num-gpus 1
반복적으로 사용하기 위한 train.sh 스크립트 파일:
#!/usr/bin/env bash
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" || exit; pwd)
export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
export PYTHONPATH=$PWD:$PYTHONPATH
PYTHON_EXE="$ROOT_DIR/.venv/bin/python"
TRAIN_FILE="$ROOT_DIR/tools/train.py"
CONFIG_FILE="$ROOT_DIR/configs/weld/semseg-pt-v3m1-0-base.py"
ARGS=(
"$TRAIN_FILE"
--config-file "$CONFIG_FILE"
--num-gpus 1
)
function print_error
{
# shellcheck disable=SC2145
echo -e "\033[31m$@\033[0m" 1>&2
}
function print_message
{
# shellcheck disable=SC2145
echo -e "\033[32m$@\033[0m"
}
function on_interrupt_trap
{
print_error "An interrupt signal was detected."
exit 1
}
trap on_interrupt_trap INT
print_message "$PYTHON_EXE" "${ARGS[*]}"
"$PYTHON_EXE" "${ARGS[@]}"
모델 경량화 방법
configs/weld/semseg-pt-v3m1-0-base.py 는 PTv3 base (채널 32 → 512, 6.5M+ params)라서 8GB 에 부담될 수 있습니다.
경량 대안 (Pointcept 내장):
- LitePT-v1 (pointcept/models/litept) — PTv3의 경량 버전. configs/scannet/semseg-litept-v1m1-0-small.py 참고. Conv+Attn 하이브리드로 메모리·속도 모두 유리.
- SpUNet / MinkUNet (sparse_unet) — 가장 가벼운 baseline. configs/scannet/semseg-spunet-v1m1-0-base.py, semseg-minkunet34c-0-base.py.
- SPVCNN, OACNNs — 중간 규모.
가장 손쉬운 방법
현재 PTv3 config을 유지하되 다음을 줄이기:
- enc_channels=(32,64,128,256,512) → (16,32,64,128,256)
- enc_depths=(2,2,2,6,2) → (1,1,1,3,1)
- SphereCrop point_max=102400 → 40960
- grid_size=0.02 → 0.03 (voxel 키우면 점 수 급감)
성능 손실이 가장 적은 방향은?
정확도 손실 크기 순으로 보면:
그래서 성능 손실 최소 전략은 "비용 없는 것부터 점진적으로" 적용하는 단계적 접근입니다:
0순위 — 공짜 (정확도 손실 거의 없음)
실행 시:
- upcast_*: AMP(fp16) 하에서 attention을 fp32로 승격하는 안전장치인데, 3-class 용접 seg처럼 수치적으로 순한 태스크엔 거의 영향 없음
- expandable_segments: 메모리 단편화 완화만 함, 정확도 무관
- 에러 메시지가 "1.56 GiB at attn.float()"로 정확히 upcast 지점을 지목 — 이것만으로 통과할 가능성 높음
1순위 — 매우 작음 (수용체 크기 살짝 축소)
- PTv3는 serialized patch 안에서만 attention 계산. 용접 scene은 작아서 512 포인트 context도 충분
- 1024는 사실상 scene 전체를 보던 값이라 512로 내려도 실질적 수용체 손실 미미
2순위 — 작음 (학습 속도 느려짐, 최종 정확도는 복구 가능)
- 학습 중 random crop이라 epoch이 돌수록 다 커버됨
- loop=10 이미 있으니 오히려 다양성↑
3순위 — 중간 (마지막 수단)
- bead 두께·경계 정밀도에 영향. 실측 정밀도 요구면 피하기.
절대 마지막 — 큰 손실
- 채널/depth 축소, 모델 교체
권장 플로우
- 먼저 0순위만 적용해서 돌려보기 → 통과하면 끝 (정확도 손실 ≈ 0)
- 그래도 OOM이면 1순위 추가 (손실 미미)
- 그래도 OOM이면 2순위 추가
- 여기까진 정확도 손실 거의 없음. 이후는 trade-off 실화.
Weld 용 경량 config file
(예: semseg-litept-v1m1-0-weld.py 또는 semseg-spunet-v1m1-0-weld.py)을 새로 만들어 드릴까요? 어느 모델이 좋으실지 골라주세요.
Troubleshooting
ImportError torch/lib/libtorch_cuda.so: undefined symbol: ncclCommWindowDeregister
$ PYTHONPATH=$PWD:$PYTHONPATH ./.venv/bin/python tools/train.py --config-file configs/weld/semseg-pt-v3m1-0-base.py --num-gpus 1
Traceback (most recent call last):
File "/home/user/Projects/Pointcept/tools/train.py", line 8, in <module>
from pointcept.engines.defaults import (
File "/home/user/Projects/Pointcept/pointcept/engines/defaults.py", line 14, in <module>
from torch.nn.parallel import DistributedDataParallel
File "/home/user/Projects/Pointcept/.venv/lib/python3.10/site-packages/torch/__init__.py", line 442, in <module>
from torch._C import * # noqa: F403
ImportError: /home/user/Projects/Pointcept/.venv/lib/python3.10/site-packages/torch/lib/libtorch_cuda.so: undefined symbol: ncclCommWindowDeregister
라이브러리 버전이 잘못되었다. 다시 설치해라.
torch.OutOfMemoryError: CUDA out of memory
[2026-04-20 11:12:58,053 INFO train.py line 141 221563] => Building model ...
[2026-04-20 11:12:58,241 INFO train.py line 261 221563] Num params: 46154467
[2026-04-20 11:12:58,430 INFO train.py line 143 221563] => Building writer ...
[2026-04-20 11:12:58,431 INFO train.py line 271 221563] Tensorboard writer logging dir: exp/default
wandb: [wandb.login()] Loaded credentials for https://api.wandb.ai from /home/zer0/.netrc.
wandb: Currently logged in as: osom8979 (osom8979-blackhole) to https://api.wandb.ai. Use `wandb login --relogin` to force relogin
wandb: Tracking run with wandb version 0.26.0
wandb: Run data is saved locally in exp/default/wandb/run-20260420_111259-oibzqe5r
wandb: Run `wandb offline` to turn off syncing.
wandb: Syncing run exp/default
wandb: ⭐️ View project at https://wandb.ai/osom8979-blackhole/pointcept
wandb: 🚀 View run at https://wandb.ai/osom8979-blackhole/pointcept/runs/oibzqe5r
[2026-04-20 11:13:00,771 INFO train.py line 145 221563] => Building train dataset & dataloader ...
[2026-04-20 11:13:00,771 INFO train.py line 147 221563] => Building val dataset & dataloader ...
[2026-04-20 11:13:00,772 INFO train.py line 149 221563] => Building optimize, scheduler, scaler(amp) ...
[2026-04-20 11:13:00,773 INFO train.py line 153 221563] => Building hooks ...
[2026-04-20 11:13:00,773 INFO misc.py line 237 221563] => Loading checkpoint & weight ...
[2026-04-20 11:13:00,773 INFO misc.py line 274 221563] No weight found at: None
[2026-04-20 11:13:00,774 INFO train.py line 161 221563] >>>>>>>>>>>>>>>> Start Training >>>>>>>>>>>>>>>>
[2026-04-20 11:13:03,543 ERROR events.py line 611 221563] Traceback (most recent call last):
File "/home/zer0/Projects/Pointcept/pointcept/engines/train.py", line 177, in train
self.run_step()
File "/home/zer0/Projects/Pointcept/pointcept/engines/train.py", line 205, in run_step
output_dict = self.model(input_dict)
File "/home/zer0/Projects/Pointcept/.venv/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1751, in _wrapped_call_impl
return self._call_impl(*args, **kwargs)
File "/home/zer0/Projects/Pointcept/.venv/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1762, in _call_impl
return forward_call(*args, **kwargs)
File "/home/zer0/Projects/Pointcept/pointcept/models/default.py", line 65, in forward
point = self.backbone(point)
File "/home/zer0/Projects/Pointcept/.venv/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1751, in _wrapped_call_impl
return self._call_impl(*args, **kwargs)
File "/home/zer0/Projects/Pointcept/.venv/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1762, in _call_impl
return forward_call(*args, **kwargs)
File "/home/zer0/Projects/Pointcept/pointcept/models/point_transformer_v3/point_transformer_v3m1_base.py", line 705, in forward
point = self.enc(point)
File "/home/zer0/Projects/Pointcept/.venv/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1751, in _wrapped_call_impl
return self._call_impl(*args, **kwargs)
File "/home/zer0/Projects/Pointcept/.venv/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1762, in _call_impl
return forward_call(*args, **kwargs)
File "/home/zer0/Projects/Pointcept/pointcept/models/modules.py", line 82, in forward
input = module(input)
File "/home/zer0/Projects/Pointcept/.venv/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1751, in _wrapped_call_impl
return self._call_impl(*args, **kwargs)
File "/home/zer0/Projects/Pointcept/.venv/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1762, in _call_impl
return forward_call(*args, **kwargs)
File "/home/zer0/Projects/Pointcept/pointcept/models/modules.py", line 82, in forward
input = module(input)
File "/home/zer0/Projects/Pointcept/.venv/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1751, in _wrapped_call_impl
return self._call_impl(*args, **kwargs)
File "/home/zer0/Projects/Pointcept/.venv/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1762, in _call_impl
return forward_call(*args, **kwargs)
File "/home/zer0/Projects/Pointcept/pointcept/models/point_transformer_v3/point_transformer_v3m1_base.py", line 325, in forward
point = self.drop_path(self.attn(point))
File "/home/zer0/Projects/Pointcept/.venv/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1751, in _wrapped_call_impl
return self._call_impl(*args, **kwargs)
File "/home/zer0/Projects/Pointcept/.venv/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1762, in _call_impl
return forward_call(*args, **kwargs)
File "/home/zer0/Projects/Pointcept/pointcept/models/point_transformer_v3/point_transformer_v3m1_base.py", line 203, in forward
attn = attn.float()
torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 1.56 GiB. GPU 0 has a total capacity of 7.53 GiB of which 91.12 MiB is free. Including non-PyTorch memory, this process has 7.28 GiB memory in use. Of the allocated memory 6.71 GiB is allocated by PyTorch, and 457.70 MiB is reserved by PyTorch but unallocated. If reserved but unallocated memory is large try setting PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True to avoid fragmentation. See documentation for Memory Management (https://pytorch.org/docs/stable/notes/cuda.html#environment-variables)
wandb:
wandb: 🚀 View run exp/default at: https://wandb.ai/osom8979-blackhole/pointcept/runs/oibzqe5r
순수 GPU 메모리 부족 문제입니다. PTv3 base (46M params) + attn.float() upcast 때문에 8GB로는 빠듯함.
빠르게 시도할 수 있는 3가지 (강도 순)
① 최소 수정
configs/weld/semseg-pt-v3m1-0-base.py에서 2곳만 변경:
# 메모리 주범: attention upcast 해제 + patch 축소
upcast_attention=False, # True → False (line 38)
upcast_softmax=False, # True → False (line 39)
enc_patch_size=(256, 256, 256, 256, 256), # 1024 → 256
dec_patch_size=(256, 256, 256, 256),
...
# 그리고 transform에서:
dict(type="GridSample", grid_size=0.03, ...), # 0.02 → 0.03
dict(type="SphereCrop", point_max=40960, ...), # 102400 → 40960
환경 변수:
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
PYTHONPATH=$PWD:$PYTHONPATH ./.venv/bin/python tools/train.py \
--config-file configs/weld/semseg-pt-v3m1-0-base.py --num-gpus 1
② 채널·depth 절반 축소 (본격 slim PTv3)
enc_depths=(1, 1, 1, 3, 1),
enc_channels=(16, 32, 64, 128, 256),
enc_num_head=(1, 2, 4, 8, 16),
dec_depths=(1, 1, 1, 1),
dec_channels=(32, 32, 64, 128),
dec_num_head=(2, 2, 4, 8),
backbone_out_channels=32,
params 약 11M로 감소.
③ 모델 교체
SpUNet(~39M이지만 sparse conv라 메모리 훨씬 적게 씀) 또는 LitePT
모델 경량화 결과
훈련은 성공했지만 정상적인 결과로 보긴 어렵다.
하지만 전체 과정은 공유한다: Pointcept:Examples:Weld:LVS:SetupAndOOM