S23DR_solution_2026 / training /fast_pointnet_class.py
donquicode's picture
Upload folder using huggingface_hub
f2e158e verified
Raw
History Blame Contribute Delete
11.2 kB
"""PointNet binary classifier over 6D (xyz+rgb) point-cloud patches: model,
dataset, training loop, and a single-patch predictor."""
import os
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
import pickle
from torch.utils.data import Dataset, DataLoader
from typing import List, Dict, Tuple, Optional
import json
class ClassificationPointNet(nn.Module):
"""
PointNet implementation for binary classification from 6D point cloud patches.
Takes 6D point clouds (x,y,z,r,g,b) and predicts binary classification (edge/not edge).
"""
def __init__(self, input_dim=6, max_points=1024):
super(ClassificationPointNet, self).__init__()
self.max_points = max_points
# Point-wise feature extraction.
self.conv1 = nn.Conv1d(input_dim, 64, 1)
self.conv2 = nn.Conv1d(64, 128, 1)
self.conv3 = nn.Conv1d(128, 256, 1)
self.conv4 = nn.Conv1d(256, 512, 1)
self.conv5 = nn.Conv1d(512, 1024, 1)
self.conv6 = nn.Conv1d(1024, 2048, 1)
# Classification head.
self.fc1 = nn.Linear(2048, 1024)
self.fc2 = nn.Linear(1024, 512)
self.fc3 = nn.Linear(512, 256)
self.fc4 = nn.Linear(256, 128)
self.fc5 = nn.Linear(128, 64)
self.fc6 = nn.Linear(64, 1)
self.bn1 = nn.BatchNorm1d(64)
self.bn2 = nn.BatchNorm1d(128)
self.bn3 = nn.BatchNorm1d(256)
self.bn4 = nn.BatchNorm1d(512)
self.bn5 = nn.BatchNorm1d(1024)
self.bn6 = nn.BatchNorm1d(2048)
self.dropout1 = nn.Dropout(0.3)
self.dropout2 = nn.Dropout(0.4)
self.dropout3 = nn.Dropout(0.5)
self.dropout4 = nn.Dropout(0.4)
self.dropout5 = nn.Dropout(0.3)
def forward(self, x):
"""x: (B, input_dim, max_points) -> (B, 1) logits."""
batch_size = x.size(0)
x1 = F.relu(self.bn1(self.conv1(x)))
x2 = F.relu(self.bn2(self.conv2(x1)))
x3 = F.relu(self.bn3(self.conv3(x2)))
x4 = F.relu(self.bn4(self.conv4(x3)))
x5 = F.relu(self.bn5(self.conv5(x4)))
x6 = F.relu(self.bn6(self.conv6(x5)))
global_features = torch.max(x6, 2)[0] # (B, 2048)
x = F.relu(self.fc1(global_features))
x = self.dropout1(x)
x = F.relu(self.fc2(x))
x = self.dropout2(x)
x = F.relu(self.fc3(x))
x = self.dropout3(x)
x = F.relu(self.fc4(x))
x = self.dropout4(x)
x = F.relu(self.fc5(x))
x = self.dropout5(x)
classification = self.fc6(x)
return classification
class PatchClassificationDataset(Dataset):
"""Loads saved .pkl patches for PointNet classification training."""
def __init__(self, dataset_dir: str, max_points: int = 1024, augment: bool = True):
self.dataset_dir = dataset_dir
self.max_points = max_points
self.augment = augment
self.patch_files = []
for file in os.listdir(dataset_dir):
if file.endswith('.pkl'):
self.patch_files.append(os.path.join(dataset_dir, file))
print(f"Found {len(self.patch_files)} patch files in {dataset_dir}")
def __len__(self):
return len(self.patch_files)
def __getitem__(self, idx):
"""Returns (patch (6, max_points), label scalar, valid_mask (max_points,))."""
patch_file = self.patch_files[idx]
with open(patch_file, 'rb') as f:
patch_info = pickle.load(f)
patch_6d = patch_info['patch_6d'] # (N, 6)
label = patch_info.get('label', 0)
num_points = patch_6d.shape[0]
if num_points >= self.max_points:
indices = np.random.choice(num_points, self.max_points, replace=False)
patch_sampled = patch_6d[indices]
valid_mask = np.ones(self.max_points, dtype=bool)
else:
patch_sampled = np.zeros((self.max_points, 6))
patch_sampled[:num_points] = patch_6d
valid_mask = np.zeros(self.max_points, dtype=bool)
valid_mask[:num_points] = True
if self.augment:
patch_sampled = self._augment_patch(patch_sampled, valid_mask)
# conv1d wants channels first.
patch_tensor = torch.from_numpy(patch_sampled.T).float() # (6, max_points)
label_tensor = torch.tensor(label, dtype=torch.float32)
valid_mask_tensor = torch.from_numpy(valid_mask)
return patch_tensor, label_tensor, valid_mask_tensor
def _augment_patch(self, patch, valid_mask):
"""Random z-rotation, jitter, and scale on the xyz channels."""
valid_points = patch[valid_mask]
if len(valid_points) == 0:
return patch
angle = np.random.uniform(0, 2 * np.pi)
cos_angle = np.cos(angle)
sin_angle = np.sin(angle)
rotation_matrix = np.array([
[cos_angle, -sin_angle, 0],
[sin_angle, cos_angle, 0],
[0, 0, 1]
])
valid_points[:, :3] = valid_points[:, :3] @ rotation_matrix.T
noise = np.random.normal(0, 0.01, valid_points[:, :3].shape)
valid_points[:, :3] += noise
scale = np.random.uniform(0.9, 1.1)
valid_points[:, :3] *= scale
patch[valid_mask] = valid_points
return patch
def save_patches_dataset(patches: List[Dict], dataset_dir: str, entry_id: str):
"""Pickle each patch to dataset_dir as {entry_id}_patch_{i}.pkl (skips existing)."""
os.makedirs(dataset_dir, exist_ok=True)
for i, patch in enumerate(patches):
filename = f"{entry_id}_patch_{i}.pkl"
filepath = os.path.join(dataset_dir, filename)
if os.path.exists(filepath):
continue
with open(filepath, 'wb') as f:
pickle.dump(patch, f)
print(f"Saved {len(patches)} patches for entry {entry_id}")
def collate_fn(batch):
"""Drop samples with no valid points; return None if the whole batch is empty."""
valid_batch = []
for patch_data, label, valid_mask in batch:
if valid_mask.sum() > 0:
valid_batch.append((patch_data, label, valid_mask))
if len(valid_batch) == 0:
return None
patch_data = torch.stack([item[0] for item in valid_batch])
labels = torch.stack([item[1] for item in valid_batch])
valid_masks = torch.stack([item[2] for item in valid_batch])
return patch_data, labels, valid_masks
def init_weights(m):
if isinstance(m, nn.Conv1d):
nn.init.xavier_uniform_(m.weight)
if m.bias is not None:
nn.init.zeros_(m.bias)
elif isinstance(m, nn.Linear):
nn.init.xavier_uniform_(m.weight)
if m.bias is not None:
nn.init.zeros_(m.bias)
elif isinstance(m, nn.BatchNorm1d):
nn.init.ones_(m.weight)
nn.init.zeros_(m.bias)
def train_pointnet(dataset_dir: str, model_save_path: str, epochs: int = 100, batch_size: int = 32,
lr: float = 0.001):
"""Train ClassificationPointNet on the pickled patches in dataset_dir."""
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Training on device: {device}")
dataset = PatchClassificationDataset(dataset_dir, max_points=1024, augment=True)
print(f"Dataset loaded with {len(dataset)} samples")
dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True, num_workers=8,
collate_fn=collate_fn, drop_last=True)
model = ClassificationPointNet(input_dim=6, max_points=1024)
model.apply(init_weights)
model.to(device)
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-4)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.5)
model.train()
for epoch in range(epochs):
total_loss = 0.0
correct = 0
total = 0
num_batches = 0
for batch_idx, batch_data in enumerate(dataloader):
if batch_data is None:
continue
patch_data, labels, valid_masks = batch_data
patch_data = patch_data.to(device)
labels = labels.to(device).unsqueeze(1)
optimizer.zero_grad()
outputs = model(patch_data)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
total_loss += loss.item()
predicted = (torch.sigmoid(outputs) > 0.5).float()
total += labels.size(0)
correct += (predicted == labels).sum().item()
num_batches += 1
if batch_idx % 50 == 0:
print(f"Epoch {epoch+1}/{epochs}, Batch {batch_idx}, "
f"Loss: {loss.item():.6f}, "
f"Accuracy: {100 * correct / total:.2f}%")
avg_loss = total_loss / num_batches if num_batches > 0 else 0
accuracy = 100 * correct / total if total > 0 else 0
print(f"Epoch {epoch+1}/{epochs} completed, "
f"Avg Loss: {avg_loss:.6f}, "
f"Accuracy: {accuracy:.2f}%")
scheduler.step()
checkpoint_path = model_save_path.replace('.pth', f'_epoch_{epoch+1}.pth')
torch.save({
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'epoch': epoch + 1,
'loss': avg_loss,
'accuracy': accuracy,
}, checkpoint_path)
torch.save({
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'epoch': epochs,
}, model_save_path)
print(f"Model saved to {model_save_path}")
return model
def load_pointnet_model(model_path: str, device: torch.device = None) -> ClassificationPointNet:
"""Load a trained ClassificationPointNet in eval mode."""
if device is None:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = ClassificationPointNet(input_dim=6, max_points=1024)
checkpoint = torch.load(model_path, map_location=device)
model.load_state_dict(checkpoint['model_state_dict'])
model.to(device)
model.eval()
return model
def predict_class_from_patch(model: ClassificationPointNet, patch: Dict, device: torch.device = None) -> Tuple[int, float]:
"""Score one patch (dict with 'patch_6d'). Returns (predicted_class, probability)."""
if device is None:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
patch_6d = patch['patch_6d'] # (N, 6)
max_points = 1024
num_points = patch_6d.shape[0]
if num_points >= max_points:
indices = np.random.choice(num_points, max_points, replace=False)
patch_sampled = patch_6d[indices]
else:
patch_sampled = np.zeros((max_points, 6))
patch_sampled[:num_points] = patch_6d
patch_tensor = torch.from_numpy(patch_sampled.T).float().unsqueeze(0) # (1, 6, max_points)
patch_tensor = patch_tensor.to(device)
with torch.no_grad():
outputs = model(patch_tensor)
probability = torch.sigmoid(outputs).item()
predicted_class = int(probability > 0.5)
return predicted_class, probability