Skip to content

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에서 이를 학습시키려면 다음과 같은 준비가 필요합니다.

  1. 데이터 어노테이션: LabelCloud나 CloudCompare 같은 툴을 사용하여 .ply 파일 내의 각 포인트에 결함 종류별 라벨을 부여해야 합니다.
  2. 데이터셋 클래스 생성: 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): 결함 라벨이 포함된 데이터를 사용하여 모델을 학습시킵니다.

python tools/train.py --config-file configs/weld/semseg-ptv3-weld.py

2. 추론 및 검사 (Inference): 새로운 .ply 파일을 입력으로 넣어 결함 영역을 예측합니다.

python tools/test.py --config-file configs/weld/semseg-ptv3-weld.py --model-path model_best.pth

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 파일에 새로운 데이터셋을 인식할 수 있도록 등록해야 합니다:

# pointcept/datasets/__init__.py 에 추가
from .weld import WeldDataset

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

See also