Skip to content

Noise

Classes for adding noise into point cloud

AddOutlier

Add synthetic outlier points (and attributes) around a point cloud.

This transform expects a dictionary containing:

  • "coord": NumPy array of shape (N, 3) with point coordinates.
  • Optionally "norm": per-point normals of shape (N, 3).
  • Optionally "color": per-point colors of shape (N, C).
  • Optionally "noise_index": 1D array of length N indicating noise labels (existing noise index).

It augments the point cloud by sampling additional points in a spherical shell around the existing point cloud and appends them (and their attributes) to the existing arrays.

The N number of outliers is:

N = int(max_ratio * n_pts)

where n_pts is the original number of points. If this is 0, the transform does nothing.

Behavior of fixed:

  • If fixed=False:
  • Use all N generated outliers.
  • If fixed=True:
  • Randomly choose a subset of the outliers with size:

    N_used ∼ Uniform{ N // 2, ..., N }
    

    and only append this subset.

Outliers are sampled inside a spherical shell defined from the centroid and bounding radius:

  1. Compute the centroid:

    center = mean(coord, axis=0)

  2. Compute the maximum radius from the centroid:

    r_max = max(||coord[i] - center||)

  3. Define a spherical shell with inner radius:

    r_min = radius_min * r_max

and outer radius:

   r_max_shell = r_max
  1. Sample directions uniformly on the sphere and radii uniformly in volume within [r_min, r_max_shell], then map back to world coordinates.

For each outlier point, a random unit normal is generated (if "norm" exists), and random colors are generated (if "color" exists).

The "noise_index" field is updated as follows:

  • If "noise_index" does not exist:
  • A new array of length N + N_used is created, filled with 0 for original points and 3 (indication of outlier type noise) for new outliers.
  • If "noise_index" exists:
  • It is extended to length N + N_used, keeping existing values and setting all new entries to 3.

Parameters:

Name Type Description Default
max_ratio float

Maximum ratio of outliers to original points. The N number of generated outliers is:

N = int(max_ratio * n_pts)

where n_pts is the original point count. Defaults to 0.2.

0.2
fixed bool

Controls whether to subsample the generated outliers:

  • False → use all generated outliers (N).
  • True → randomly select a subset of size between N // 2 and N (inclusive). Defaults to False.
False
radius_min float

Fraction of the maximum radius used as the inner radius of the sampling shell. The shell is defined as [radius_min * r_max, r_max]. Defaults to 0.5.

0.5
range255 bool

Whether "color" values are in [0, 255]. If True, outlier colors are sampled as integers in [0, 255]. If False, they are sampled as floats in [0, 1]. Defaults to False.

False
apply_p float

Probability of applying the adding outliers. Defaults to 1.0.

1.0
Source code in src\augmentation_class.py
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
@TRANSFORMS.register()
class AddOutlier:
    """Add synthetic outlier points (and attributes) around a point cloud.

    This transform expects a dictionary containing:

    * `"coord"`: NumPy array of shape (N, 3) with point coordinates.
    * Optionally `"norm"`: per-point normals of shape (N, 3).
    * Optionally `"color"`: per-point colors of shape (N, C).
    * Optionally `"noise_index"`: 1D array of length N indicating noise labels (existing noise index).

    It augments the point cloud by sampling additional points in a spherical shell around the existing point cloud
    and appends them (and their attributes) to the existing arrays.

    The N number of outliers is:

        N = int(max_ratio * n_pts)

    where ``n_pts`` is the original number of points. If this is 0, the transform
    does nothing.

    Behavior of ``fixed``:

    * If ``fixed=False``:
      - Use **all** `N` generated outliers.
    * If ``fixed=True``:
      - Randomly choose a subset of the outliers with size:

            N_used ∼ Uniform{ N // 2, ..., N }

        and only append this subset.

    Outliers are sampled inside a spherical shell defined from the centroid
    and bounding radius:

    1. Compute the centroid:

           center = mean(coord, axis=0)

    2. Compute the maximum radius from the centroid:

           r_max = max(||coord[i] - center||)

    3. Define a spherical shell with inner radius:

           r_min = radius_min * r_max

       and outer radius:

           r_max_shell = r_max

    4. Sample directions uniformly on the sphere and radii uniformly in **volume** within `[r_min, r_max_shell]`,
       then map back to world coordinates.

    For each outlier point, a random unit normal is generated (if `"norm"` exists), and random colors are generated
    (if `"color"` exists).

    The `"noise_index"` field is updated as follows:

    * If `"noise_index"` does not exist:
      - A new array of length `N + N_used` is created, filled with 0 for original points and 3
      (indication of outlier type noise) for new outliers.
    * If `"noise_index"` exists:
      - It is extended to length `N + N_used`, keeping existing values and setting all new entries to 3.

    Args:
        max_ratio (float, optional):
            Maximum ratio of outliers to original points. The N number of generated outliers is:

                N = int(max_ratio * n_pts)

            where n_pts is the original point count.
            Defaults to 0.2.
        fixed (bool, optional):
            Controls whether to subsample the generated outliers:

            * False → use all generated outliers (`N`).
            * True → randomly select a subset of size between `N // 2` and `N` (inclusive).
            Defaults to False.
        radius_min (float, optional):
            Fraction of the maximum radius used as the inner radius of the sampling shell. The shell is defined as
            `[radius_min * r_max, r_max]`.
            Defaults to 0.5.
        range255 (bool, optional): Whether `"color"` values are in `[0, 255]`. If True, outlier colors are sampled as
            integers in `[0, 255]`. If False, they are sampled as floats in `[0, 1]`.
            Defaults to False.
        apply_p (float, optional):
            Probability of applying the adding outliers.
            Defaults to 1.0.
    """
    def __init__(self, max_ratio=0.2, fixed=False, radius_min=0.5, range255=False, apply_p=1.0):
        self.max_ratio = max_ratio
        self.fixed = fixed
        self.radius_min = radius_min
        self.range255 = range255
        self.apply_p = apply_p


    def __call__(self, data_dict: dict) -> dict:
        """Add outlier points and update aligned per-point attributes.

        Args:
            data_dict (dict): Input dictionary containing `"coord"` and optionally `"norm"`, `"color"`, and `"noise_index"`.

        Returns:
            dict: The same dictionary with additional outlier points and updated attributes, if adding outliers is applied.
        """
        if random.random() > self.apply_p:
            return data_dict

        if "coord" not in data_dict.keys():
            return data_dict

        coord = data_dict["coord"]
        if "noise_index" in data_dict:
            coord = coord[np.logical_not(data_dict["noise_index"])]
        n_pts = len(coord)

        # how many outliers to add
        N = int(self.max_ratio * n_pts)
        if N <= 0:
            return data_dict

        # --- define a bounding sphere from current coords ---
        center = coord.mean(axis=0, keepdims=True)  # (1,3)
        rel = coord - center  # (N,3)
        r_max = np.linalg.norm(rel, axis=1).max() + 1e-6  # avoid zero

        r_min = self.radius_min * r_max
        r_max_shell = r_max  # you can change to > r_max if you want truly outside
        # --- sample directions uniformly on sphere ---
        noise_dir = np.random.normal(size=(N, 3))  # Gaussian
        noise_dir /= np.linalg.norm(noise_dir, axis=-1, keepdims=True)  # normalize
        # --- sample radii uniformly in the volume shell [r_min, r_max_shell] ---
        u = np.random.uniform(low=0.0, high=1.0, size=N)
        # uniform in [r_min^3, r_max^3], then cube root → uniform in volume
        r = (r_min ** 3 + u * (r_max_shell ** 3 - r_min ** 3)) ** (1.0 / 3.0)
        noise = noise_dir * r[:, None]  # (N,3) in bounding shell
        noise_coord = center + noise  # map back to world coords

        # add normal
        rand_dir = np.random.normal(size=noise_coord.shape)  # gaussian noise
        # normalize to unit vectors
        norm_len = np.linalg.norm(rand_dir, axis=-1, keepdims=True) + 1e-8
        noise_normal = (rand_dir / norm_len).astype(np.float32)
        # noise_normal = noise_dir              # or use direction as "normal"


        if not self.fixed and N > 1:
            # choose subset size between N // 2 and N (inclusive)
            k_min = max(1, N // 2)
            k = np.random.randint(k_min, N + 1)
            idx_sel = np.random.choice(N, size=k, replace=False)
            noise_coord = noise_coord[idx_sel]
            noise_normal = noise_normal[idx_sel]
            N = k

        # ----- add coords -----
        data_dict["coord"] = np.concatenate([data_dict["coord"], noise_coord], axis=0)
        # ----- add normals (if present) -----
        if "norm" in data_dict:
            data_dict["norm"] = np.concatenate([data_dict["norm"], noise_normal], axis=0)
        # ----- add color for outliers (if present) -----
        if "color" in data_dict:
            orig_color = data_dict["color"]
            C = orig_color.shape[1]
            dtype = orig_color.dtype
            if self.range255:
                noise_color = np.random.randint(0, 256, size=(N, C)).astype(dtype)
            else:
                noise_color = np.random.rand(N, C).astype(dtype)
            data_dict["color"] = np.concatenate([data_dict["color"], noise_color], axis=0)

        # ----- update noise_index -----
        if "noise_index" not in data_dict:
            noise_index = np.zeros(len(data_dict["coord"]), dtype=int)
            noise_index[-N:] = 3  # mark new outliers as 3
            data_dict["noise_index"] = noise_index
        else:
            old_len = len(data_dict["noise_index"])
            new_len = len(data_dict["coord"])
            tmp = np.ones(new_len, dtype=int) * 3
            tmp[:old_len] = data_dict["noise_index"]
            data_dict["noise_index"] = tmp

        return data_dict

__call__(data_dict)

Add outlier points and update aligned per-point attributes.

Parameters:

Name Type Description Default
data_dict dict

Input dictionary containing "coord" and optionally "norm", "color", and "noise_index".

required

Returns:

Name Type Description
dict dict

The same dictionary with additional outlier points and updated attributes, if adding outliers is applied.

Source code in src\augmentation_class.py
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
def __call__(self, data_dict: dict) -> dict:
    """Add outlier points and update aligned per-point attributes.

    Args:
        data_dict (dict): Input dictionary containing `"coord"` and optionally `"norm"`, `"color"`, and `"noise_index"`.

    Returns:
        dict: The same dictionary with additional outlier points and updated attributes, if adding outliers is applied.
    """
    if random.random() > self.apply_p:
        return data_dict

    if "coord" not in data_dict.keys():
        return data_dict

    coord = data_dict["coord"]
    if "noise_index" in data_dict:
        coord = coord[np.logical_not(data_dict["noise_index"])]
    n_pts = len(coord)

    # how many outliers to add
    N = int(self.max_ratio * n_pts)
    if N <= 0:
        return data_dict

    # --- define a bounding sphere from current coords ---
    center = coord.mean(axis=0, keepdims=True)  # (1,3)
    rel = coord - center  # (N,3)
    r_max = np.linalg.norm(rel, axis=1).max() + 1e-6  # avoid zero

    r_min = self.radius_min * r_max
    r_max_shell = r_max  # you can change to > r_max if you want truly outside
    # --- sample directions uniformly on sphere ---
    noise_dir = np.random.normal(size=(N, 3))  # Gaussian
    noise_dir /= np.linalg.norm(noise_dir, axis=-1, keepdims=True)  # normalize
    # --- sample radii uniformly in the volume shell [r_min, r_max_shell] ---
    u = np.random.uniform(low=0.0, high=1.0, size=N)
    # uniform in [r_min^3, r_max^3], then cube root → uniform in volume
    r = (r_min ** 3 + u * (r_max_shell ** 3 - r_min ** 3)) ** (1.0 / 3.0)
    noise = noise_dir * r[:, None]  # (N,3) in bounding shell
    noise_coord = center + noise  # map back to world coords

    # add normal
    rand_dir = np.random.normal(size=noise_coord.shape)  # gaussian noise
    # normalize to unit vectors
    norm_len = np.linalg.norm(rand_dir, axis=-1, keepdims=True) + 1e-8
    noise_normal = (rand_dir / norm_len).astype(np.float32)
    # noise_normal = noise_dir              # or use direction as "normal"


    if not self.fixed and N > 1:
        # choose subset size between N // 2 and N (inclusive)
        k_min = max(1, N // 2)
        k = np.random.randint(k_min, N + 1)
        idx_sel = np.random.choice(N, size=k, replace=False)
        noise_coord = noise_coord[idx_sel]
        noise_normal = noise_normal[idx_sel]
        N = k

    # ----- add coords -----
    data_dict["coord"] = np.concatenate([data_dict["coord"], noise_coord], axis=0)
    # ----- add normals (if present) -----
    if "norm" in data_dict:
        data_dict["norm"] = np.concatenate([data_dict["norm"], noise_normal], axis=0)
    # ----- add color for outliers (if present) -----
    if "color" in data_dict:
        orig_color = data_dict["color"]
        C = orig_color.shape[1]
        dtype = orig_color.dtype
        if self.range255:
            noise_color = np.random.randint(0, 256, size=(N, C)).astype(dtype)
        else:
            noise_color = np.random.rand(N, C).astype(dtype)
        data_dict["color"] = np.concatenate([data_dict["color"], noise_color], axis=0)

    # ----- update noise_index -----
    if "noise_index" not in data_dict:
        noise_index = np.zeros(len(data_dict["coord"]), dtype=int)
        noise_index[-N:] = 3  # mark new outliers as 3
        data_dict["noise_index"] = noise_index
    else:
        old_len = len(data_dict["noise_index"])
        new_len = len(data_dict["coord"])
        tmp = np.ones(new_len, dtype=int) * 3
        tmp[:old_len] = data_dict["noise_index"]
        data_dict["noise_index"] = tmp

    return data_dict

Adding Outlier Noise

Before After

AddBackgroundNoise

Add structured background noise patches around a point cloud.

This transform expects a dictionary containing:

  • "coord": NumPy array of shape (N, 3) with point coordinates.
  • Optionally "norm": per-point normals of shape (N, 3).
  • Optionally "color": per-point colors of shape (N, C).
  • Optionally "noise_index": 1D array of length N indicating noise labels (existing noise index).

It generates several small 3D regions (up to max_regions), each containing up to region_max_k random points. These regions are anchored to the bounding box of the (non-noise) point cloud and then added as background clutter:

1. Compute the axis-aligned bounding box (AABB) from points with ``noise_index == 0`` (or all points if ``noise_index`` is absent).
2. Sample up to ``max_regions`` random centers inside the box (with behavior controlled by ``mode``).
3. For each region, sample points uniformly in a cube of side length ``region_size`` around the center.
4. Snap one coordinate of each region to one face of the bounding box, so that noise patches lie on or around the box surface.
5. Generate random unit normals for these noise points.
6. Optionally subsample a random subset of regions/points when ``fixed=False``.
7. Concatenate noise coordinates, normals, and colors (if present) to the existing arrays, and update ``noise_index`` to mark them asbackground noise (label 2).

Parameters:

Name Type Description Default
max_regions int

Maximum number of noise regions to generate. Each region is a local cube of sampled noise points. Defaults to 8.

8
region_max_k int

Maximum number of points per region before optional subsampling. Total initial noise points are roughly max_regions * region_max_k before any reduction. Defaults to 128.

128
region_size float

Side length of each cubic noise region. Points are sampled uniformly in a cube of side region_size centered at each region center. Defaults to 0.5.

0.5
fixed bool

Controls randomness and subsampling of the generated noise: * If False: - Randomly choose a number of regions between 1 and max_regions. - Flatten all noise points from those regions. - Randomly select a subset of points, with size between roughly 1/8 of all region points and the full set. * If True: - Use all max_regions * region_max_k points (no region or point subsampling).

Defaults to False.

False
range255 bool

Whether color values are in the range [0, 255]. If True, noise colors are sampled as integers in [0, 255]. If False, noise colors are sampled as floats in [0, 1]. Defaults to False.

False
mode {outside, inside}

Controls how the region centers are chosen relative to the bounding box:

  • "inside":
  • Region centers are chosen so that the entire noise cube (of side region_size) lies strictly inside the bounding box, as much as possible. This ensures all noise points stay on/within the box volume.
  • "outside":
  • Region centers are sampled anywhere inside the bounding box, and one coordinate of each region is snapped to a box face. Some noise points may lie slightly outside the box, mimicking more realistic background clutter.

Defaults to "outside".

'outside'
apply_p float

Probability of applying the background Defaults to 1.0.

1.0
Source code in src\augmentation_class.py
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
@TRANSFORMS.register()
class AddBackgroundNoise:
    """Add structured background noise patches around a point cloud.

    This transform expects a dictionary containing:

    * `"coord"`: NumPy array of shape (N, 3) with point coordinates.
    * Optionally `"norm"`: per-point normals of shape (N, 3).
    * Optionally `"color"`: per-point colors of shape (N, C).
    * Optionally `"noise_index"`: 1D array of length N indicating noise labels (existing noise index).

    It generates several small 3D regions (up to ``max_regions``), each containing up to ``region_max_k`` random points.
    These regions are anchored to the bounding box of the (non-noise) point cloud and then added as background clutter:

        1. Compute the axis-aligned bounding box (AABB) from points with ``noise_index == 0`` (or all points if ``noise_index`` is absent).
        2. Sample up to ``max_regions`` random centers inside the box (with behavior controlled by ``mode``).
        3. For each region, sample points uniformly in a cube of side length ``region_size`` around the center.
        4. Snap one coordinate of each region to one face of the bounding box, so that noise patches lie on or around the box surface.
        5. Generate random unit normals for these noise points.
        6. Optionally subsample a random subset of regions/points when ``fixed=False``.
        7. Concatenate noise coordinates, normals, and colors (if present) to the existing arrays, and update ``noise_index`` to mark them asbackground noise (label 2).

    Args:
        max_regions (int, optional):
            Maximum number of noise regions to generate. Each region is a local cube of sampled noise points.
            Defaults to 8.
        region_max_k (int, optional):
            Maximum number of points per region before optional subsampling. Total initial noise points are
            roughly ``max_regions * region_max_k`` before any reduction.
            Defaults to 128.
        region_size (float, optional):
            Side length of each cubic noise region. Points are sampled uniformly in a cube of side ``region_size``
            centered at each region center.
            Defaults to 0.5.
        fixed (bool, optional):
            Controls randomness and subsampling of the generated noise:
            * If ``False``:
              - Randomly choose a number of regions between 1 and ``max_regions``.
              - Flatten all noise points from those regions.
              - Randomly select a subset of points, with size between roughly 1/8 of all region points and the full set.
            * If ``True``:
              - Use all ``max_regions * region_max_k`` points (no region or point subsampling).

            Defaults to False.
        range255 (bool, optional):
            Whether color values are in the range ``[0, 255]``. If True, noise colors are sampled as integers in
            ``[0, 255]``. If False, noise colors are sampled as floats in ``[0, 1]``.
            Defaults to False.
        mode ({"outside", "inside"}, optional): Controls how the region
            centers are chosen relative to the bounding box:

            * ``"inside"``:
              - Region centers are chosen so that the entire noise cube (of side ``region_size``) lies strictly inside the bounding
                box, as much as possible. This ensures all noise points stay on/within the box volume.
            * ``"outside"``:
              - Region centers are sampled anywhere inside the bounding box, and one coordinate of each region is snapped to a box face.
                Some noise points may lie slightly outside the box, mimicking more realistic background clutter.

            Defaults to ``"outside"``.
        apply_p (float, optional):
            Probability of applying the background
            Defaults to 1.0.
    """
    def __init__(self, max_regions: int =8, region_max_k: int =128, region_size: float=0.5, fixed:bool=False, range255: bool=False, mode: str="outside", apply_p: float=1.0):
        self.max_regions = max_regions
        self.region_max_k = region_max_k
        self.fixed = fixed
        self.region_size = region_size
        self.range255 = range255
        self.apply_p = apply_p
        assert mode in ["outside", "inside"]
        # "inside" is to make sure all noise are on the bounding box, "outside" only guarantee the center is on the
        # bounding box, some noise might be outside the box, "inside" is for nice for segmentation / denoising. But
        # "outside" is more like real background clutter.
        self.mode = mode

    def __call__(self, data_dict: dict) -> dict:
        """Add background noise regions and update aligned per-point attributes.

        Args:
            data_dict (dict): Input dictionary containing at least `"coord"`. May also contain `"norm"`, `"color"`, and `"noise_index"`.
                Any existing `"noise_index" > 0` points are excluded when computing the bounding box used to place new regions.

        Returns:
            dict: The same dictionary with added background noise points (coords, normals, colors if present) and an updated
            `"noise_index"` marking these new points with label 2.
        """
        if random.random() > self.apply_p:
            return data_dict

        if "coord" not in data_dict.keys():
            return data_dict

        coord = data_dict["coord"]
        if "noise_index" in data_dict:
            coord = coord[np.logical_not(data_dict["noise_index"])]
        max_value, min_value = np.max(coord, axis=0), np.min(coord, axis=0)
        bounding_box = np.stack((min_value, max_value), axis=1)

        half = self.region_size / 2.0
        if self.mode == "inside":
            span = max_value - min_value
            inner_min = min_value + np.minimum(half, np.maximum(span / 2.0 - 1e-6, 0.0))
            inner_max = max_value - np.minimum(half, np.maximum(span / 2.0 - 1e-6, 0.0))
        else:
            inner_min = min_value
            inner_max = max_value

        random_x = np.random.uniform(inner_min[0], inner_max[0], self.max_regions)  # [max_regions]
        random_y = np.random.uniform(inner_min[1], inner_max[1], self.max_regions)  # [max_regions]
        random_z = np.random.uniform(inner_min[2], inner_max[2], self.max_regions)  # [max_regions]
        random_point_center = np.stack((random_x, random_y, random_z), axis=1)  # [max_regions, 3]

        # [max_regions, region_max_k, 3]
        noise = np.random.uniform(low=-half, high=half, size=(len(random_point_center), self.region_max_k, 3))
        random_point = random_point_center[:, np.newaxis, :] + noise  # [max_regions, 1, 3] + [max_regions, region_max_k, 3]
        # random_point_normal = np.zeros_like(random_point)  # [max_regions, region_max_k, 3]
        # tmp_normal = np.stack((np.eye(3), np.eye(3) * -1), axis=1)  # [3, 2, 3]

        random_face = np.random.choice(6, self.max_regions, replace=True)
        for i, val in enumerate(random_face):
            divide = val // 2
            mod = val % 2
            random_point[i, :, divide] = bounding_box[divide][mod]
            # random_point_normal[i, :] = tmp_normal[divide][mod]

        # ----- RANDOM NORMALS INSTEAD OF FACE NORMALS -----
        # shape (R, K, 3)
        rand_dir = np.random.normal(size=random_point.shape)  # gaussian noise
        # normalize to unit vectors
        norm_len = np.linalg.norm(rand_dir, axis=-1, keepdims=True) + 1e-8
        random_point_normal = (rand_dir / norm_len).astype(np.float32)

        if not self.fixed:
            region_size = np.random.randint(1, self.max_regions + 1)
            random_point = random_point[:region_size].reshape(-1, 3)
            random_point_normal = random_point_normal[:region_size].reshape(-1, 3)
            total_size = np.random.randint(max(1, len(random_point) // 8), len(random_point) + 1)

            shuffle_idx = np.arange(len(random_point))
            np.random.shuffle(shuffle_idx)
            shuffle_idx = shuffle_idx[:total_size]
            random_point = random_point[shuffle_idx]
            random_point_normal = random_point_normal[shuffle_idx]
        else:
            random_point = random_point.reshape(-1, 3)
            random_point_normal = random_point_normal.reshape(-1, 3)

        # ----- ADD COLOR FOR NOISE -----
        if "color" in data_dict:
            orig_color = data_dict["color"]
            C = orig_color.shape[1]
            dtype = orig_color.dtype
            if self.range255:
                noise_color = np.random.randint(0, 256, size=(random_point.shape[0], C)).astype(dtype)
            else:
                noise_color = np.random.rand(random_point.shape[0], C).astype(dtype)

        # ----- CONCATENATE -----
        data_dict["coord"] = np.concatenate((data_dict["coord"], random_point), axis=0)
        if "norm" in data_dict:
            data_dict["norm"] = np.concatenate((data_dict["norm"], random_point_normal), axis=0)

        if "color" in data_dict:
            data_dict["color"] = np.concatenate((data_dict["color"], noise_color), axis=0)

        # ----- UPDATE noise_index -----
        num_new = random_point.shape[0]
        if "noise_index" not in data_dict:
            noise_index = np.zeros(len(data_dict["coord"]), dtype=int)
            noise_index[-num_new:] = 2  # mark new as noise
            data_dict["noise_index"] = noise_index
        else:
            old_len = len(data_dict["noise_index"])
            new_len = len(data_dict["coord"])
            tmp = np.ones(new_len, dtype=int) * 2
            tmp[:old_len] = data_dict["noise_index"]
            data_dict["noise_index"] = tmp

        return data_dict

__call__(data_dict)

Add background noise regions and update aligned per-point attributes.

Parameters:

Name Type Description Default
data_dict dict

Input dictionary containing at least "coord". May also contain "norm", "color", and "noise_index". Any existing "noise_index" > 0 points are excluded when computing the bounding box used to place new regions.

required

Returns:

Name Type Description
dict dict

The same dictionary with added background noise points (coords, normals, colors if present) and an updated

dict

"noise_index" marking these new points with label 2.

Source code in src\augmentation_class.py
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
def __call__(self, data_dict: dict) -> dict:
    """Add background noise regions and update aligned per-point attributes.

    Args:
        data_dict (dict): Input dictionary containing at least `"coord"`. May also contain `"norm"`, `"color"`, and `"noise_index"`.
            Any existing `"noise_index" > 0` points are excluded when computing the bounding box used to place new regions.

    Returns:
        dict: The same dictionary with added background noise points (coords, normals, colors if present) and an updated
        `"noise_index"` marking these new points with label 2.
    """
    if random.random() > self.apply_p:
        return data_dict

    if "coord" not in data_dict.keys():
        return data_dict

    coord = data_dict["coord"]
    if "noise_index" in data_dict:
        coord = coord[np.logical_not(data_dict["noise_index"])]
    max_value, min_value = np.max(coord, axis=0), np.min(coord, axis=0)
    bounding_box = np.stack((min_value, max_value), axis=1)

    half = self.region_size / 2.0
    if self.mode == "inside":
        span = max_value - min_value
        inner_min = min_value + np.minimum(half, np.maximum(span / 2.0 - 1e-6, 0.0))
        inner_max = max_value - np.minimum(half, np.maximum(span / 2.0 - 1e-6, 0.0))
    else:
        inner_min = min_value
        inner_max = max_value

    random_x = np.random.uniform(inner_min[0], inner_max[0], self.max_regions)  # [max_regions]
    random_y = np.random.uniform(inner_min[1], inner_max[1], self.max_regions)  # [max_regions]
    random_z = np.random.uniform(inner_min[2], inner_max[2], self.max_regions)  # [max_regions]
    random_point_center = np.stack((random_x, random_y, random_z), axis=1)  # [max_regions, 3]

    # [max_regions, region_max_k, 3]
    noise = np.random.uniform(low=-half, high=half, size=(len(random_point_center), self.region_max_k, 3))
    random_point = random_point_center[:, np.newaxis, :] + noise  # [max_regions, 1, 3] + [max_regions, region_max_k, 3]
    # random_point_normal = np.zeros_like(random_point)  # [max_regions, region_max_k, 3]
    # tmp_normal = np.stack((np.eye(3), np.eye(3) * -1), axis=1)  # [3, 2, 3]

    random_face = np.random.choice(6, self.max_regions, replace=True)
    for i, val in enumerate(random_face):
        divide = val // 2
        mod = val % 2
        random_point[i, :, divide] = bounding_box[divide][mod]
        # random_point_normal[i, :] = tmp_normal[divide][mod]

    # ----- RANDOM NORMALS INSTEAD OF FACE NORMALS -----
    # shape (R, K, 3)
    rand_dir = np.random.normal(size=random_point.shape)  # gaussian noise
    # normalize to unit vectors
    norm_len = np.linalg.norm(rand_dir, axis=-1, keepdims=True) + 1e-8
    random_point_normal = (rand_dir / norm_len).astype(np.float32)

    if not self.fixed:
        region_size = np.random.randint(1, self.max_regions + 1)
        random_point = random_point[:region_size].reshape(-1, 3)
        random_point_normal = random_point_normal[:region_size].reshape(-1, 3)
        total_size = np.random.randint(max(1, len(random_point) // 8), len(random_point) + 1)

        shuffle_idx = np.arange(len(random_point))
        np.random.shuffle(shuffle_idx)
        shuffle_idx = shuffle_idx[:total_size]
        random_point = random_point[shuffle_idx]
        random_point_normal = random_point_normal[shuffle_idx]
    else:
        random_point = random_point.reshape(-1, 3)
        random_point_normal = random_point_normal.reshape(-1, 3)

    # ----- ADD COLOR FOR NOISE -----
    if "color" in data_dict:
        orig_color = data_dict["color"]
        C = orig_color.shape[1]
        dtype = orig_color.dtype
        if self.range255:
            noise_color = np.random.randint(0, 256, size=(random_point.shape[0], C)).astype(dtype)
        else:
            noise_color = np.random.rand(random_point.shape[0], C).astype(dtype)

    # ----- CONCATENATE -----
    data_dict["coord"] = np.concatenate((data_dict["coord"], random_point), axis=0)
    if "norm" in data_dict:
        data_dict["norm"] = np.concatenate((data_dict["norm"], random_point_normal), axis=0)

    if "color" in data_dict:
        data_dict["color"] = np.concatenate((data_dict["color"], noise_color), axis=0)

    # ----- UPDATE noise_index -----
    num_new = random_point.shape[0]
    if "noise_index" not in data_dict:
        noise_index = np.zeros(len(data_dict["coord"]), dtype=int)
        noise_index[-num_new:] = 2  # mark new as noise
        data_dict["noise_index"] = noise_index
    else:
        old_len = len(data_dict["noise_index"])
        new_len = len(data_dict["coord"])
        tmp = np.ones(new_len, dtype=int) * 2
        tmp[:old_len] = data_dict["noise_index"]
        data_dict["noise_index"] = tmp

    return data_dict

Adding Background Noise

Before After

AddNoise

Add local noise clusters around randomly chosen points.

This transform expects a dictionary containing:

  • "coord": NumPy array of shape (N, 3) with point coordinates.
  • Optionally "norm": per-point normals of shape (N, 3), required if method="custom".
  • Optionally "color": per-point colors of shape (N, C).
  • Optionally "noise_index": 1D array of length N indicating existing noise labels.

The transform works by:

  1. Selecting a subset of points as noise centers. The number of centers is:

    noise_center_count = int(N * noise_size_ratio)

(clamped to [1, N]). If this is 0, no noise is added. 2. For each center, generating up to noise_max_k noise points in its local neighborhood (cluster), according to the chosen method and boundary. 3. Optionally subsampling the generated noise when fixed=False. 4. Mapping the local offsets to world coordinates and appending them (and their normals/colors) to the existing arrays. 5. Updating noise_index to mark the new points as local noise (value 1).

Noise generation is controlled by two knobs:

  • method: defines how offsets are sampled:
    • "uniform":
    • If boundary="sphere": sample directions uniformly and radii uniformly in volume within a ball of radius ball_r.
    • If boundary="cube": sample coordinates uniformly in [low, high]^3.
    • "gaussian":
    • If boundary="sphere": Gaussian around the center, clamped to lie within a ball of radius ball_r.
    • If boundary="cube": Gaussian in a cube, then clipped to the interval [low, high] per axis.
    • "custom":
    • Requires a "norm" field.
    • For each center, uses its normal as a base direction and applies directional + radial jitter (using ball_r as scale) to create offsets in a “tube-like” pattern around the surface.
  • boundary: defines the shape of the local region:
    • "sphere": offsets lie in or near a ball of radius ball_r.
    • "cube": offsets lie in or near a cube with side approximately high - low (for uniform/gaussian).

Colors for noise points are derived from the center colors:

  • For "uniform" and "gaussian" methods:
  • Noise points inherit exactly the color of their center.
  • For "custom":
  • Optionally add Gaussian jitter in color space (scale depends on whether range255 is True or False), then clip to valid range.

The number of final noise points is controlled by fixed:

  • If fixed=True:
  • Use all generated noise: noise_center_count * noise_max_k points.
  • If fixed=False:
  • Randomly shuffle all noise points and keep a random subset of size between approximately one-eighth and the full generated count.

Parameters:

Name Type Description Default
noise_size_ratio float

Fraction of non-noise points to use as noise centers. The number of centers is:

noise_center_count = int(N * noise_size_ratio)

Defaults to 1./64.

1.0 / 64
noise_max_k int

Maximum number of noise points generated per center before optional subsampling. Defaults to 16.

16
fixed bool

Whether to keep all generated noise points or randomly subsample them:

  • True → keep all noise_center_count * noise_max_k noise points.
  • False → shuffle and randomly keep a subset of those points.

Defaults to True.

True
method {uniform, gaussian, custom}

Noise sampling strategy. See above for details. "custom" requires "norm" in data_dict. Defaults to "uniform".

'uniform'
boundary {sphere, cube}

Shape of the local region in which noise is sampled. Interacts with method as described above. Defaults to "sphere".

'sphere'
low float

Lower bound for cube-based offsets (used for boundary="cube" in "uniform" and "gaussian"). Defaults to -0.1.

-0.1
high float

Upper bound for cube-based offsets. Defaults to 0.1.

0.1
ball_r float

Radius of the spherical neighborhood for boundary="sphere" methods; also used as a scale for radial jitter in the "custom" method. Defaults to 0.1.

0.1
range255 bool

Whether colors are stored in [0, 255] (integer-like). If True, color jitter in the "custom" method is done in that range. If False, colors are assumed in [0, 1]. Defaults to False.

False
apply_p float

Probability of applying the noise Defaults to 1.0.

1.0
Source code in src\augmentation_class.py
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
@TRANSFORMS.register()
class AddNoise:
    """Add local noise clusters around randomly chosen points.

    This transform expects a dictionary containing:

    * ``"coord"``: NumPy array of shape (N, 3) with point coordinates.
    * Optionally ``"norm"``: per-point normals of shape (N, 3), required if
      ``method="custom"``.
    * Optionally ``"color"``: per-point colors of shape (N, C).
    * Optionally ``"noise_index"``: 1D array of length N indicating existing noise labels.

    The transform works by:

    1. Selecting a subset of points as **noise centers**.
       The number of centers is:

           noise_center_count = int(N * noise_size_ratio)

       (clamped to ``[1, N]``). If this is 0, no noise is added.
    2. For each center, generating up to ``noise_max_k`` noise points in its
       local neighborhood (cluster), according to the chosen ``method`` and ``boundary``.
    3. Optionally subsampling the generated noise when ``fixed=False``.
    4. Mapping the local offsets to world coordinates and appending them (and their normals/colors) to the existing arrays.
    5. Updating ``noise_index`` to mark the new points as local noise (value 1).

    Noise generation is controlled by two knobs:

    * ``method``: defines *how* offsets are sampled:
        - ``"uniform"``:
          * If ``boundary="sphere"``: sample directions uniformly and radii uniformly in volume within a ball of radius ``ball_r``.
          * If ``boundary="cube"``: sample coordinates uniformly in ``[low, high]^3``.
        - ``"gaussian"``:
          * If ``boundary="sphere"``: Gaussian around the center, clamped to lie within a ball of radius ``ball_r``.
          * If ``boundary="cube"``: Gaussian in a cube, then clipped to the interval ``[low, high]`` per axis.
        - ``"custom"``:
          * Requires a ``"norm"`` field.
          * For each center, uses its normal as a base direction and applies directional + radial jitter (using ``ball_r`` as scale) to create
            offsets in a “tube-like” pattern around the surface.
    * ``boundary``: defines the *shape* of the local region:
        - ``"sphere"``: offsets lie in or near a ball of radius ``ball_r``.
        - ``"cube"``: offsets lie in or near a cube with side approximately ``high - low`` (for uniform/gaussian).

    Colors for noise points are derived from the center colors:

    * For ``"uniform"`` and ``"gaussian"`` methods:
      - Noise points inherit exactly the color of their center.
    * For ``"custom"``:
      - Optionally add Gaussian jitter in color space (scale depends on whether ``range255`` is True or False),
      then clip to valid range.

    The number of **final** noise points is controlled by ``fixed``:

    * If ``fixed=True``:
      - Use all generated noise: ``noise_center_count * noise_max_k`` points.
    * If ``fixed=False``:
      - Randomly shuffle all noise points and keep a random subset of size
        between approximately one-eighth and the full generated count.

    Args:
        noise_size_ratio (float, optional):
            Fraction of non-noise points to use as noise centers. The number of centers is:

                noise_center_count = int(N * noise_size_ratio)

            Defaults to 1./64.
        noise_max_k (int, optional):
            Maximum number of noise points generated per center before optional subsampling.
            Defaults to 16.
        fixed (bool, optional):
            Whether to keep all generated noise points or randomly subsample them:

            * True  → keep all ``noise_center_count * noise_max_k`` noise points.
            * False → shuffle and randomly keep a subset of those points.

            Defaults to True.
        method ({"uniform", "gaussian", "custom"}, optional):
            Noise sampling strategy. See above for details. ``"custom"`` requires
            ``"norm"`` in ``data_dict``.
            Defaults to ``"uniform"``.
        boundary ({"sphere", "cube"}, optional):
            Shape of the local region in which noise is sampled. Interacts with ``method`` as described above.
            Defaults to ``"sphere"``.
        low (float, optional):
            Lower bound for cube-based offsets (used for ``boundary="cube"`` in ``"uniform"`` and ``"gaussian"``).
            Defaults to -0.1.
        high (float, optional):
            Upper bound for cube-based offsets.
            Defaults to 0.1.
        ball_r (float, optional):
            Radius of the spherical neighborhood for ``boundary="sphere"`` methods; also used as a scale for radial
            jitter in the ``"custom"`` method.
            Defaults to 0.1.
        range255 (bool, optional):
            Whether colors are stored in ``[0, 255]`` (integer-like). If True, color jitter in the ``"custom"`` method
            is done in that range. If False, colors are assumed in ``[0, 1]``.
            Defaults to False.
        apply_p (float, optional):
            Probability of applying the noise
            Defaults to 1.0.

    """
    def __init__(self, noise_size_ratio:float=1./64, noise_max_k:int=16, fixed:bool=True, method:str="uniform", boundary:str="sphere",
                 low:float=-0.1, high:float=0.1, ball_r:float=0.1, range255:bool=False, apply_p:float=1.0):
        self.noise_size_ratio = noise_size_ratio
        self.noise_max_k = noise_max_k
        self.fixed = fixed
        assert method in ["uniform", "gaussian", "custom"]
        self.method = method
        assert boundary in ["sphere", "cube"]
        self.boundary = boundary
        self.low, self.high = low, high
        self.ball_r = ball_r
        self.range255 = range255
        self.apply_p = apply_p

    def __call__(self, data_dict: dict) -> dict:
        """Apply local noise cluster augmentation to a point cloud dictionary.

        Args:
            data_dict (dict): Input dictionary containing at least ``"coord"`` (NumPy array of shape (N, 3)).
                May also contain ``"norm"``, ``"color"``, and ``"noise_index"``. For ``method="custom"``, ``"norm"`` must be present.

        Returns:
            dict: The same dictionary with additional noise points appended to the relevant per-point fields and an updated ``"noise_index"`` marking newly added noise points with label 1.
        """
        if random.random() > self.apply_p:
            return data_dict
        if "coord" not in data_dict.keys():
            return data_dict

        coord = data_dict["coord"]
        if "noise_index" in data_dict:
            coord = coord[np.logical_not(data_dict["noise_index"])]
        N = coord.shape[0]

        # how many centers
        noise_center_count = int(N * self.noise_size_ratio)
        if noise_center_count <= 0:
            return data_dict
        noise_center_count = min(noise_center_count, N)

        # choose centers
        center_idx = np.random.choice(N, noise_center_count, replace=False)
        centers = coord[center_idx]  # (C, 3)
        C = noise_center_count
        K = self.noise_max_k

        # ----------------- generate offsets (noise in local coords) -----------------
        noise_offsets = None  # (C, K, 3)
        noise_normals = None  # (C, K, 3) or None

        # helper: random unit directions
        def random_unit_vectors(shape):
            v = np.random.normal(size=shape)
            norm = np.linalg.norm(v, axis=-1, keepdims=True) + 1e-8
            return v / norm

        if self.method == "uniform":
            if self.boundary == "sphere":
                # uniform in ball of radius ball_r
                dirs = random_unit_vectors((C, K, 3))
                u = np.random.rand(C, K)
                radii = (u ** (1.0 / 3.0)) * self.ball_r  # uniform in volume
                noise_offsets = dirs * radii[..., None]
                noise_normals = dirs  # can use direction as "normal"
            elif self.boundary == "cube":
                noise_offsets = np.random.uniform(low=self.low, high=self.high, size=(C, K, 3))
                noise_normals = random_unit_vectors((C, K, 3))

        elif self.method == "gaussian":
            if self.boundary == "sphere":
                # sample Gaussian around center
                offsets = np.random.normal(loc=0.0, scale=self.ball_r / 3.0, size=(C, K, 3))
                # radii of those offsets
                radii = np.linalg.norm(offsets, axis=-1, keepdims=True) + 1e-8  # (C, K, 1)
                # clamp radius to ball_r by scaling each vector
                # scale = 1 if inside, ball_r / r if outside
                scale = np.minimum(1.0, self.ball_r / radii)  # (C, K, 1), broadcasts over last dim
                offsets = offsets * scale  # (C, K, 3)
                noise_offsets = offsets
                # recompute or reuse direction as normals
                new_radii = np.linalg.norm(offsets, axis=-1, keepdims=True) + 1e-8
                noise_normals = offsets / new_radii
            elif self.boundary == "cube":
                # Gaussian in a cube, then clipped to [low, high]
                scale = (self.high - self.low) / 6.0  # ~99.7% in [low, high]
                offsets = np.random.normal(loc=0.0, scale=scale, size=(C, K, 3))
                offsets = np.clip(offsets, self.low, self.high)
                noise_offsets = offsets
                noise_normals = random_unit_vectors((C, K, 3))

        elif self.method == "custom":
            assert "norm" in data_dict, "custom method requires 'norm' in data_dict."

            noise_size = len(center_idx)  # N' centers
            K = self.noise_max_k

            # ---- center normals ----
            normal_centers = np.asarray(data_dict["norm"], dtype=np.float32)[center_idx]  # (N', 3)
            # make sure they are unit vectors
            normal_centers /= (np.linalg.norm(normal_centers, axis=-1, keepdims=True) + 1e-8)

            # [N', K, 3] repeat normals per patch
            repeat_normals = np.repeat(normal_centers[:, np.newaxis, :], K, axis=1)  # (N', K, 3)

            # =====================================================
            # 1) JITTER FOR NORMAL DIRECTION (directional jitter)
            # =====================================================
            # small Gaussian noise added to the normal, then renormalized
            dir_sigma = 2  # strength of direction jitter (tune if needed)
            dir_jitter = np.random.normal(
                loc=0.0,
                scale=dir_sigma,
                size=(noise_size, K, 3)
            )

            perturbed_dir = repeat_normals + dir_jitter  # (N', K, 3)
            dir_norm = np.linalg.norm(perturbed_dir, axis=-1, keepdims=True) + 1e-8
            dir_unit = perturbed_dir / dir_norm  # (N', K, 3), unit direction per noise point

            # =====================================================
            # 2) JITTER FOR DISTANCE TO CENTER (radial jitter)
            # =====================================================
            # base radius in [0, ball_r], biased toward outer region (your original idea)
            u = np.random.rand(noise_size, K)  # (N', K)
            base_radius = (u ** (1.0 / 3.0)) * self.ball_r  # (N', K), >= 0

            # additive jitter around base_radius
            dist_sigma = 1.  # in *absolute* units of ball_r (tune this)
            jitter = np.random.normal(
                loc=0.0,
                scale=dist_sigma * self.ball_r,
                size=(noise_size, K)
            )  # (N', K)

            r = base_radius + jitter  # (N', K)
            r = np.clip(r, self.ball_r / 4.0, self.ball_r)  # keep in [0, ball_r]
            r = r[:, :, None]  # (N', K, 1)

            # =====================================================
            # FINAL OFFSETS & NORMALS (LOCAL COORDS)
            # =====================================================
            # offset from center = jittered direction * jittered distance
            noise_offsets = dir_unit * r  # (N', K, 3)
            noise_normals = dir_unit  # (N', K, 3)
        else:
            return data_dict

        # ----------------- COLOR: center-based -----------------
        noise_colors = None
        if "color" in data_dict:
            orig_color = np.asarray(data_dict["color"])
            center_colors = orig_color[center_idx]  # (C, Cc)
            base_colors = np.repeat(center_colors[:, None, :], K, axis=1)  # (C, K, Cc)

            if self.method in ("uniform", "gaussian"):
                # exactly same color as center
                noise_colors = base_colors.astype(orig_color.dtype)

            elif self.method == "custom":
                if self.range255:
                    color_jitter = np.random.normal(loc=0.0, scale=10.0, size=base_colors.shape)
                    noise_colors = base_colors + color_jitter
                    noise_colors = np.clip(noise_colors, 0, 255).round().astype(orig_color.dtype)
                else:
                    color_jitter = np.random.normal(loc=0.0, scale=0.05, size=base_colors.shape)
                    noise_colors = base_colors + color_jitter
                    noise_colors = np.clip(noise_colors, 0.0, 1.0).astype(orig_color.dtype)

        # ----------------- map to world coords -----------------
        noise_points = noise_offsets + centers[:, None, :]  # (C, K, 3)

        # flatten
        noise_points = noise_points.reshape(-1, 3)
        if noise_normals is not None:
            noise_normals = noise_normals.reshape(-1, 3)
        if noise_colors is not None:
            noise_colors = noise_colors.reshape(-1, noise_colors.shape[-1])

        # ----------------- optional subsampling -----------------
        if not self.fixed:
            idx = np.arange(len(noise_points))
            np.random.shuffle(idx)
            total = np.random.randint(max(1, len(noise_points) // 8), len(noise_points) + 1)
            idx = idx[:total]

            noise_points = noise_points[idx]
            if noise_normals is not None:
                noise_normals = noise_normals[idx]
            if noise_colors is not None:
                noise_colors = noise_colors[idx]

        # ----------------- append to data_dict -----------------
        data_dict["coord"] = np.concatenate([data_dict["coord"], noise_points], axis=0)

        if "norm" in data_dict and noise_normals is not None:
            data_dict["norm"] = np.concatenate([data_dict["norm"], noise_normals], axis=0)

        if "color" in data_dict and noise_colors is not None:
            data_dict["color"] = np.concatenate([data_dict["color"], noise_colors], axis=0)

        # ----------------- noise_index -----------------
        num_noise = noise_points.shape[0]
        if "noise_index" not in data_dict:
            ni = np.zeros(len(data_dict["coord"]), dtype=int)
            ni[-num_noise:] = 1
            data_dict["noise_index"] = ni
        else:
            old_len = len(data_dict["noise_index"])
            new_len = len(data_dict["coord"])
            ni = np.ones(new_len, dtype=int) * 1
            ni[:old_len] = data_dict["noise_index"]
            data_dict["noise_index"] = ni

        return data_dict

__call__(data_dict)

Apply local noise cluster augmentation to a point cloud dictionary.

Parameters:

Name Type Description Default
data_dict dict

Input dictionary containing at least "coord" (NumPy array of shape (N, 3)). May also contain "norm", "color", and "noise_index". For method="custom", "norm" must be present.

required

Returns:

Name Type Description
dict dict

The same dictionary with additional noise points appended to the relevant per-point fields and an updated "noise_index" marking newly added noise points with label 1.

Source code in src\augmentation_class.py
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
def __call__(self, data_dict: dict) -> dict:
    """Apply local noise cluster augmentation to a point cloud dictionary.

    Args:
        data_dict (dict): Input dictionary containing at least ``"coord"`` (NumPy array of shape (N, 3)).
            May also contain ``"norm"``, ``"color"``, and ``"noise_index"``. For ``method="custom"``, ``"norm"`` must be present.

    Returns:
        dict: The same dictionary with additional noise points appended to the relevant per-point fields and an updated ``"noise_index"`` marking newly added noise points with label 1.
    """
    if random.random() > self.apply_p:
        return data_dict
    if "coord" not in data_dict.keys():
        return data_dict

    coord = data_dict["coord"]
    if "noise_index" in data_dict:
        coord = coord[np.logical_not(data_dict["noise_index"])]
    N = coord.shape[0]

    # how many centers
    noise_center_count = int(N * self.noise_size_ratio)
    if noise_center_count <= 0:
        return data_dict
    noise_center_count = min(noise_center_count, N)

    # choose centers
    center_idx = np.random.choice(N, noise_center_count, replace=False)
    centers = coord[center_idx]  # (C, 3)
    C = noise_center_count
    K = self.noise_max_k

    # ----------------- generate offsets (noise in local coords) -----------------
    noise_offsets = None  # (C, K, 3)
    noise_normals = None  # (C, K, 3) or None

    # helper: random unit directions
    def random_unit_vectors(shape):
        v = np.random.normal(size=shape)
        norm = np.linalg.norm(v, axis=-1, keepdims=True) + 1e-8
        return v / norm

    if self.method == "uniform":
        if self.boundary == "sphere":
            # uniform in ball of radius ball_r
            dirs = random_unit_vectors((C, K, 3))
            u = np.random.rand(C, K)
            radii = (u ** (1.0 / 3.0)) * self.ball_r  # uniform in volume
            noise_offsets = dirs * radii[..., None]
            noise_normals = dirs  # can use direction as "normal"
        elif self.boundary == "cube":
            noise_offsets = np.random.uniform(low=self.low, high=self.high, size=(C, K, 3))
            noise_normals = random_unit_vectors((C, K, 3))

    elif self.method == "gaussian":
        if self.boundary == "sphere":
            # sample Gaussian around center
            offsets = np.random.normal(loc=0.0, scale=self.ball_r / 3.0, size=(C, K, 3))
            # radii of those offsets
            radii = np.linalg.norm(offsets, axis=-1, keepdims=True) + 1e-8  # (C, K, 1)
            # clamp radius to ball_r by scaling each vector
            # scale = 1 if inside, ball_r / r if outside
            scale = np.minimum(1.0, self.ball_r / radii)  # (C, K, 1), broadcasts over last dim
            offsets = offsets * scale  # (C, K, 3)
            noise_offsets = offsets
            # recompute or reuse direction as normals
            new_radii = np.linalg.norm(offsets, axis=-1, keepdims=True) + 1e-8
            noise_normals = offsets / new_radii
        elif self.boundary == "cube":
            # Gaussian in a cube, then clipped to [low, high]
            scale = (self.high - self.low) / 6.0  # ~99.7% in [low, high]
            offsets = np.random.normal(loc=0.0, scale=scale, size=(C, K, 3))
            offsets = np.clip(offsets, self.low, self.high)
            noise_offsets = offsets
            noise_normals = random_unit_vectors((C, K, 3))

    elif self.method == "custom":
        assert "norm" in data_dict, "custom method requires 'norm' in data_dict."

        noise_size = len(center_idx)  # N' centers
        K = self.noise_max_k

        # ---- center normals ----
        normal_centers = np.asarray(data_dict["norm"], dtype=np.float32)[center_idx]  # (N', 3)
        # make sure they are unit vectors
        normal_centers /= (np.linalg.norm(normal_centers, axis=-1, keepdims=True) + 1e-8)

        # [N', K, 3] repeat normals per patch
        repeat_normals = np.repeat(normal_centers[:, np.newaxis, :], K, axis=1)  # (N', K, 3)

        # =====================================================
        # 1) JITTER FOR NORMAL DIRECTION (directional jitter)
        # =====================================================
        # small Gaussian noise added to the normal, then renormalized
        dir_sigma = 2  # strength of direction jitter (tune if needed)
        dir_jitter = np.random.normal(
            loc=0.0,
            scale=dir_sigma,
            size=(noise_size, K, 3)
        )

        perturbed_dir = repeat_normals + dir_jitter  # (N', K, 3)
        dir_norm = np.linalg.norm(perturbed_dir, axis=-1, keepdims=True) + 1e-8
        dir_unit = perturbed_dir / dir_norm  # (N', K, 3), unit direction per noise point

        # =====================================================
        # 2) JITTER FOR DISTANCE TO CENTER (radial jitter)
        # =====================================================
        # base radius in [0, ball_r], biased toward outer region (your original idea)
        u = np.random.rand(noise_size, K)  # (N', K)
        base_radius = (u ** (1.0 / 3.0)) * self.ball_r  # (N', K), >= 0

        # additive jitter around base_radius
        dist_sigma = 1.  # in *absolute* units of ball_r (tune this)
        jitter = np.random.normal(
            loc=0.0,
            scale=dist_sigma * self.ball_r,
            size=(noise_size, K)
        )  # (N', K)

        r = base_radius + jitter  # (N', K)
        r = np.clip(r, self.ball_r / 4.0, self.ball_r)  # keep in [0, ball_r]
        r = r[:, :, None]  # (N', K, 1)

        # =====================================================
        # FINAL OFFSETS & NORMALS (LOCAL COORDS)
        # =====================================================
        # offset from center = jittered direction * jittered distance
        noise_offsets = dir_unit * r  # (N', K, 3)
        noise_normals = dir_unit  # (N', K, 3)
    else:
        return data_dict

    # ----------------- COLOR: center-based -----------------
    noise_colors = None
    if "color" in data_dict:
        orig_color = np.asarray(data_dict["color"])
        center_colors = orig_color[center_idx]  # (C, Cc)
        base_colors = np.repeat(center_colors[:, None, :], K, axis=1)  # (C, K, Cc)

        if self.method in ("uniform", "gaussian"):
            # exactly same color as center
            noise_colors = base_colors.astype(orig_color.dtype)

        elif self.method == "custom":
            if self.range255:
                color_jitter = np.random.normal(loc=0.0, scale=10.0, size=base_colors.shape)
                noise_colors = base_colors + color_jitter
                noise_colors = np.clip(noise_colors, 0, 255).round().astype(orig_color.dtype)
            else:
                color_jitter = np.random.normal(loc=0.0, scale=0.05, size=base_colors.shape)
                noise_colors = base_colors + color_jitter
                noise_colors = np.clip(noise_colors, 0.0, 1.0).astype(orig_color.dtype)

    # ----------------- map to world coords -----------------
    noise_points = noise_offsets + centers[:, None, :]  # (C, K, 3)

    # flatten
    noise_points = noise_points.reshape(-1, 3)
    if noise_normals is not None:
        noise_normals = noise_normals.reshape(-1, 3)
    if noise_colors is not None:
        noise_colors = noise_colors.reshape(-1, noise_colors.shape[-1])

    # ----------------- optional subsampling -----------------
    if not self.fixed:
        idx = np.arange(len(noise_points))
        np.random.shuffle(idx)
        total = np.random.randint(max(1, len(noise_points) // 8), len(noise_points) + 1)
        idx = idx[:total]

        noise_points = noise_points[idx]
        if noise_normals is not None:
            noise_normals = noise_normals[idx]
        if noise_colors is not None:
            noise_colors = noise_colors[idx]

    # ----------------- append to data_dict -----------------
    data_dict["coord"] = np.concatenate([data_dict["coord"], noise_points], axis=0)

    if "norm" in data_dict and noise_normals is not None:
        data_dict["norm"] = np.concatenate([data_dict["norm"], noise_normals], axis=0)

    if "color" in data_dict and noise_colors is not None:
        data_dict["color"] = np.concatenate([data_dict["color"], noise_colors], axis=0)

    # ----------------- noise_index -----------------
    num_noise = noise_points.shape[0]
    if "noise_index" not in data_dict:
        ni = np.zeros(len(data_dict["coord"]), dtype=int)
        ni[-num_noise:] = 1
        data_dict["noise_index"] = ni
    else:
        old_len = len(data_dict["noise_index"])
        new_len = len(data_dict["coord"])
        ni = np.ones(new_len, dtype=int) * 1
        ni[:old_len] = data_dict["noise_index"]
        data_dict["noise_index"] = ni

    return data_dict

Adding Noise

Before After