Skip to content

Commit 0add7f5

Browse files
authored
created clustering module for lidar points (#40)
* created clustering module * init return type annotation * silence pylint * formatting * added result check
1 parent 84a2b6e commit 0add7f5

File tree

4 files changed

+318
-0
lines changed

4 files changed

+318
-0
lines changed

modules/clustering/clustering.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""
2+
Clusters together LiDAR detections.
3+
"""
4+
5+
import math
6+
7+
from .. import detection_cluster
8+
from .. import detection_point
9+
from .. import lidar_detection
10+
11+
12+
class Clustering:
13+
"""
14+
Groups together LiDAR detections into clusters.
15+
"""
16+
17+
def __init__(self, max_cluster_distance: float) -> None:
18+
"""
19+
Initialize max distance between points in the same cluster.
20+
"""
21+
self.max_cluster_distance = max_cluster_distance
22+
23+
self.__clockwise = False
24+
self.__last_point = None
25+
self.__last_angle = None
26+
self.cluster = []
27+
28+
def __calculate_distance_between_two_points(
29+
self, p1: detection_point.DetectionPoint, p2: detection_point.DetectionPoint
30+
) -> float:
31+
"""
32+
Distance calculation between two points.
33+
"""
34+
return math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2)
35+
36+
def run(
37+
self, detection: lidar_detection.LidarDetection
38+
) -> "tuple[bool, detection_cluster.DetectionCluster | None]":
39+
"""
40+
Returns a DetectionCluster consisting of LidarDetections.
41+
"""
42+
# convert to x, y coordinates
43+
detection_angle_in_radians = detection.angle * math.pi / 180
44+
x = math.cos(detection_angle_in_radians) * detection.distance
45+
y = math.sin(detection_angle_in_radians) * detection.distance
46+
47+
result, point = detection_point.DetectionPoint.create(x, y)
48+
if not result:
49+
return False, None
50+
51+
if self.__last_point is None:
52+
self.__last_point = point
53+
self.__last_angle = detection.angle
54+
self.cluster.append(point)
55+
return False, None
56+
57+
# if lidar direction changes, start a new cluster
58+
direction_switched = False
59+
current_direction = self.__clockwise
60+
if detection.angle < self.__last_angle:
61+
self.__clockwise = False
62+
elif detection.angle > self.__last_angle:
63+
self.__clockwise = True
64+
if current_direction != self.__clockwise:
65+
direction_switched = True
66+
self.__last_angle = detection.angle
67+
68+
# check distance from last point
69+
distance_from_last_point = self.__calculate_distance_between_two_points(
70+
point, self.__last_point
71+
)
72+
self.__last_point = point
73+
74+
# if far enough, send current cluster, initialize new one
75+
if distance_from_last_point > self.max_cluster_distance or direction_switched:
76+
result, new_cluster = detection_cluster.DetectionCluster.create(self.cluster)
77+
if not result:
78+
return False, None
79+
self.cluster = []
80+
self.cluster.append(point)
81+
return True, new_cluster
82+
83+
# if close enough, cluster together
84+
self.cluster.append(point)
85+
return False, None
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""
2+
Gets detection clusters.
3+
"""
4+
5+
import queue
6+
7+
from modules import lidar_detection
8+
from worker import queue_wrapper
9+
from worker import worker_controller
10+
from . import clustering
11+
12+
13+
def clustering_worker(
14+
max_cluster_distance: float,
15+
detection_in_queue: queue_wrapper.QueueWrapper,
16+
cluster_out_queue: queue_wrapper.QueueWrapper,
17+
controller: worker_controller.WorkerController,
18+
) -> None:
19+
"""
20+
Worker process.
21+
22+
max_cluster_distance: max distance between points in the same cluster in metres.
23+
"""
24+
clusterer = clustering.Clustering(max_cluster_distance)
25+
26+
while not controller.is_exit_requested():
27+
controller.check_pause()
28+
29+
try:
30+
detection: lidar_detection.LidarDetection = detection_in_queue.queue.get_nowait()
31+
if detection is None:
32+
break
33+
except queue.Empty:
34+
continue
35+
36+
result, value = clusterer.run(detection)
37+
if not result:
38+
continue
39+
40+
cluster_out_queue.queue.put(value)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""
2+
Clustering worker integration test.
3+
"""
4+
5+
import multiprocessing as mp
6+
import queue
7+
import random
8+
import time
9+
10+
from modules import detection_cluster
11+
from modules import lidar_detection
12+
from modules.clustering import clustering_worker
13+
from worker import queue_wrapper
14+
from worker import worker_controller
15+
16+
# Constants
17+
QUEUE_MAX_SIZE = 10
18+
DELAY = 0.1
19+
MAX_CLUSTER_DISTANCE = 3.0 # metres
20+
21+
22+
def simulate_detection_worker(in_queue: queue_wrapper.QueueWrapper) -> None:
23+
"""
24+
Place example lidar reading into the queue.
25+
"""
26+
random_distance = random.randint(0, 50)
27+
result, detection = lidar_detection.LidarDetection.create(random_distance, 0.0)
28+
assert result
29+
assert detection is not None
30+
31+
in_queue.queue.put(detection)
32+
33+
34+
def main() -> int:
35+
"""
36+
Main function.
37+
"""
38+
39+
# Setup
40+
controller = worker_controller.WorkerController()
41+
mp_manager = mp.Manager()
42+
43+
detection_in_queue = queue_wrapper.QueueWrapper(mp_manager, QUEUE_MAX_SIZE)
44+
cluster_out_queue = queue_wrapper.QueueWrapper(mp_manager, QUEUE_MAX_SIZE)
45+
46+
worker = mp.Process(
47+
target=clustering_worker.clustering_worker,
48+
args=(
49+
MAX_CLUSTER_DISTANCE,
50+
detection_in_queue,
51+
cluster_out_queue,
52+
controller,
53+
),
54+
)
55+
56+
# Run
57+
worker.start()
58+
59+
while True:
60+
simulate_detection_worker(detection_in_queue)
61+
try:
62+
input_data: detection_cluster.DetectionCluster = cluster_out_queue.queue.get_nowait()
63+
64+
assert input_data is not None
65+
assert str(type(input_data)) == "<class 'modules.detection_cluster.DetectionCluster'>"
66+
67+
print(input_data)
68+
69+
except queue.Empty:
70+
time.sleep(DELAY)
71+
continue
72+
73+
time.sleep(DELAY)
74+
75+
# Teardown
76+
controller.request_exit()
77+
78+
detection_in_queue.fill_and_drain_queue()
79+
80+
worker.join()
81+
82+
return 0
83+
84+
85+
if __name__ == "__main__":
86+
result_main = main()
87+
if result_main < 0:
88+
print(f"ERROR: Status code: {result_main}")
89+
90+
print("Done!")

tests/unit/test_clustering.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""
2+
Test for clustering module.
3+
"""
4+
5+
import pytest
6+
7+
from modules import lidar_detection
8+
from modules.clustering import clustering
9+
10+
MAX_CLUSTER_DISTANCE = 0.5 # metres
11+
12+
# pylint: disable=redefined-outer-name
13+
14+
15+
@pytest.fixture()
16+
def clustering_maker() -> clustering.Clustering: # type: ignore
17+
"""
18+
Construct a clustering instance with predefined max cluster distance limit.
19+
"""
20+
clustering_instance = clustering.Clustering(MAX_CLUSTER_DISTANCE)
21+
yield clustering_instance
22+
23+
24+
@pytest.fixture()
25+
def cluster_member_1() -> lidar_detection.LidarDetection: # type: ignore
26+
"""
27+
Creates a LidarDetection that should be clustered with cluster_member_2 and cluster_member_3.
28+
"""
29+
result, detection = lidar_detection.LidarDetection.create(1.0, -7.0)
30+
assert result
31+
assert detection is not None
32+
yield detection
33+
34+
35+
@pytest.fixture()
36+
def cluster_member_2() -> lidar_detection.LidarDetection: # type: ignore
37+
"""
38+
Creates a LidarDetection that should be clustered with cluster_member_1 and cluster_member_3.
39+
"""
40+
result, detection = lidar_detection.LidarDetection.create(1.0, -9.0)
41+
assert result
42+
assert detection is not None
43+
yield detection
44+
45+
46+
@pytest.fixture()
47+
def cluster_member_3() -> lidar_detection.LidarDetection: # type: ignore
48+
"""
49+
Creates a LidarDetection that should be clustered with cluster_member_1 and cluster_member_2.
50+
"""
51+
result, detection = lidar_detection.LidarDetection.create(1.0, -11.0)
52+
assert result
53+
assert detection is not None
54+
yield detection
55+
56+
57+
@pytest.fixture()
58+
def cluster_outsider() -> lidar_detection.LidarDetection: # type: ignore
59+
"""
60+
Creates a LidarDetection that should be clustered on its own.
61+
"""
62+
result, detection = lidar_detection.LidarDetection.create(3.0, -13.0)
63+
assert result
64+
assert detection is not None
65+
yield detection
66+
67+
68+
class TestClustering:
69+
"""
70+
Test for the Clustering.run() method.
71+
"""
72+
73+
def test_clustering(
74+
self,
75+
clustering_maker: clustering.Clustering,
76+
cluster_member_1: lidar_detection.LidarDetection,
77+
cluster_member_2: lidar_detection.LidarDetection,
78+
cluster_member_3: lidar_detection.LidarDetection,
79+
cluster_outsider: lidar_detection.LidarDetection,
80+
) -> None:
81+
"""
82+
Test clustering module.
83+
"""
84+
expected = None
85+
result, cluster = clustering_maker.run(cluster_member_1)
86+
assert not result
87+
assert cluster == expected
88+
89+
expected = None
90+
result, cluster = clustering_maker.run(cluster_member_2)
91+
assert not result
92+
assert cluster == expected
93+
94+
expected = None
95+
result, cluster = clustering_maker.run(cluster_member_3)
96+
assert not result
97+
assert cluster == expected
98+
99+
expected = 3
100+
result, cluster = clustering_maker.run(cluster_outsider)
101+
assert result
102+
assert cluster is not None
103+
assert len(cluster.detections) == expected

0 commit comments

Comments
 (0)