diff --git a/.env b/.env new file mode 100644 index 0000000000000000000000000000000000000000..a363ff7205584052db0e653874ad0c04a8a4ef5d --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +DISPLAY=:1 +UID=1000 +GID=1000 +DOCKER_ID=eroniki +DOCKER_TAG=0.0.1 +MPLCONFIGDIR=/tmp/matplotlib_cache \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..8b6a040eaaec438f1cee7e3e701df41f0542b622 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# Use the official Python image as the base image +FROM python:3.9-slim +RUN apt-get update -y +RUN apt-get install -y libx11-dev +RUN apt-get install -y python3-tk + +# Set the working directory in the container +WORKDIR /app + +RUN pip install pip --upgrade +# Copy the Python script into the container +COPY requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt + +# Run the Python script +# CMD ["python", "plot.py"] +CMD ["/bin/bash"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..b5279a6458f4e98003b8876d3d4085ff112ce20d --- /dev/null +++ b/Makefile @@ -0,0 +1,64 @@ +SHELL=/bin/bash +env_file = .env +include ${env_file} + +.PHONY: clean up down restart stop reload ps logs logsf push env + +all: reload + +default: all + +unsupervised-segmentation: + docker exec -it $@ bash + +clean: pyclean prune + +up: + @echo "Starting up containers for $(PROJECT_NAME)..." + docker-compose up --build --detach --remove-orphans + +down: + @echo "Removing containers." + docker-compose down --remove-orphans + +restart: + @echo "Restarting containers." + docker-compose restart + +stop: + @echo "Stopping containers for $(PROJECT_NAME)..." + docker-compose stop + +reload: reload_env reload_conts + +reload_conts: stop down up + +reload_env: + @echo "Reloading the environment variables." + @source ${env_file} + +ps: + @docker ps --filter name="$(PROJECT_NAME)*" + +logs: + @echo "Displaying past containers logs" + docker-compose logs + +logsf: + @echo "Follow containers logs output" + docker-compose logs -f + +push: + @docker login + @docker-compose push + +pyclean: + @sudo find . -regex '^.*\(__pycache__\|\.py[co]\)$$' -delete + +prune: prune_conts prune_vols + +prune_conts: + docker system prune --all --force + +prune_vols: + docker volume prune --force diff --git a/README.md b/README.md index 9d291a6781c7c38c0a5dccaa8276f686f94a4669..fad4371cafbe93fae9850e3b0cbc07b6e0bb1df0 100644 --- a/README.md +++ b/README.md @@ -91,3 +91,56 @@ For open source projects, say how it is licensed. ## Project status If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. + +# Unsupervised Segmentation + +## Problem Description + +You have a LiDAR scanning the environment and you received a pointcloud with each point having attributes (x, y, z) in sensor coordinates. +The goal is to segment the pointcloud so as to identify the objects in the scene. + +- Each segment should be assigned a unique ID. +- All points belonging to a segment should share the same ID as the segment. +- Each point should be associated with only one segment. + +## Things to Consider + +- Consider only the points that don't belong to the ground plane for the segmentation task. + +## Implementation Details + +- Folder `data` contains a pointcloud data. +- File `implementation.py` has been setup to load pointcloud from folder `data` into a numpy array and visualization has been provided to visualize how input 3D pointcloud looks like. + +## Expectation + +- It is expected that the core-parts of the algorithm to be written from scratch. +- 3rd-party libraries can be used for parts of the algorithm, like Linear Algebra routines, optimization routines, etc. +- Provide explanation as to why a specific algorithm has been selected over others methods. + +## Example + +Input: + +```python +pointcloud = [ + [0.0, 0.0, 0.0], # Point-1 + [2.0, 2.0, 2.0], # Point-2 + [2.16, 4.89, 5.78], # Point-3 + [0.01, 0.1, 0.03], # Point-4 + # ... + ] +``` + +Output: + +```python +segment_ids = [0, # id = 0 as Point-1 belongs to segment-0 + 1, # id = 1 as Point-2 belongs to segment-1 + 2, # id = 2 as Point-3 belongs to segment-2 + 0, # id = 0 as Point-4 belongs to segment-0 + #... + ] +``` + +`len(segment_ids)` is same as `len(pointcloud)` diff --git a/data/cyngn_interview_question_pointcloud_data.npy b/data/cyngn_interview_question_pointcloud_data.npy new file mode 100644 index 0000000000000000000000000000000000000000..5640bc14768e580dabc99447fcdfc3ea4dcb38f9 Binary files /dev/null and b/data/cyngn_interview_question_pointcloud_data.npy differ diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b88d9157c72baaae900a62d09ea788ef7bce824e --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,23 @@ +version: '3.8' +x-service: &service + init: true + tty: true + stdin_open: true + user: "${UID}:${GID}" + environment: + - DISPLAY=${DISPLAY} + - MPLCONFIGDIR=${MPLCONFIGDIR} + volumes: + - .:/app + - /tmp/.X11-unix:/tmp/.X11-unix:rw + - /etc/group:/etc/group:ro + - /etc/passwd:/etc/passwd:ro + - /etc/shadow:/etc/shadow:ro + - /etc/sudoers.d:/etc/sudoers.d:ro + +services: + unsupervised-segmentation: + <<: *service + build: . + image: "${DOCKER_ID}/unsupervised-segmentation:${DOCKER_TAG}" + container_name: "unsupervised-segmentation" diff --git a/implementation.py b/implementation.py new file mode 100644 index 0000000000000000000000000000000000000000..5e13add3e06188684f3a3d93c5832014b5ae8d41 --- /dev/null +++ b/implementation.py @@ -0,0 +1,92 @@ +#%% imports +import matplotlib +matplotlib.use('TkAgg') +import numpy as np +from enum import IntEnum +import matplotlib.pyplot as plt + +from segmentation.base import BaseSegmenter +from segmentation.segmentation import SurfaceSegmenter +#%% types +class Index(IntEnum): + X = 0 + Y = 1 + Z = 2 + +#%% helper functions +def visualize_pointcloud_downsampled(pc:np.ndarray, downsample_factor:int=10) -> None: + fig = plt.figure(figsize=(25, 25)) + ax = fig.add_subplot(111, projection='3d') + + ax.scatter(pc[::downsample_factor, Index.X], + pc[::downsample_factor, Index.Y], + pc[::downsample_factor, Index.Z], + color="red", s=0.1) + ax.set_xlabel("x (m)", fontsize=14) + ax.set_ylabel("y (m)", fontsize=14) + ax.set_zlabel("z (m)", fontsize=14) + ax.set_xlim(-20, 20) + ax.set_ylim(-20, 20) + ax.set_zlim(-20, 50) + ax.set_title("Pointcloud (3D)", fontsize=14) + plt.show() + + # make this plot occupy 30% of the figure's width and 100% of its height + plt.figure(figsize=(25, 25)) + plt.plot(pc[:, Index.X], pc[:, Index.Y], "rx", markersize=1, alpha=0.2) + plt.xlabel("x (m)", fontsize=14) + plt.ylabel("y (m)", fontsize=14) + plt.grid(True) + plt.gca().set_aspect('equal', adjustable='box') + plt.title("Pointcloud (Top View)", fontsize=14) + plt.show() + +def visualize_pointcloud_downsampled_with_segment_ids(pc: np.ndarray, segment_ids: np.ndarray, + downsample_factor:int=10) -> None: + fig = plt.figure(figsize=(25, 25)) + ax = fig.add_subplot(111, projection='3d') + + ax.scatter(pc[::downsample_factor, Index.X], + pc[::downsample_factor, Index.Y], + pc[::downsample_factor, Index.Z], + c=segment_ids[::downsample_factor], + cmap="tab20", + s=0.2) + ax.set_xlabel("x (m)", fontsize=14) + ax.set_ylabel("y (m)", fontsize=14) + ax.set_zlabel("z (m)", fontsize=14) + ax.set_xlim(-20, 20) + ax.set_ylim(-20, 20) + ax.set_zlim(-20, 50) + ax.set_title("Pointcloud (3D)", fontsize=14) + plt.show() + + # make this plot occupy 30% of the figure's width and 100% of its height + plt.figure(figsize=(25, 25)) + plt.scatter(pc[:, Index.X], pc[:, Index.Y], c=segment_ids, cmap="tab20", s=1, alpha=0.5) + plt.xlabel("x (m)", fontsize=14) + plt.ylabel("y (m)", fontsize=14) + plt.grid(True) + plt.gca().set_aspect('equal', adjustable='box') + plt.title("Pointcloud (Top View)", fontsize=14) + plt.show() + +#%% +pointcloud = np.load("data/cyngn_interview_question_pointcloud_data.npy") +visualize_pointcloud_downsampled(pointcloud, downsample_factor=5) # use 'downsample_factor=1' for no downsampling during visualization + +##### TODO: REQUIRES IMPLEMENTATION ############################## +################################################################## +def segment_pointcloud(pointcloud:np.ndarray) -> np.ndarray: + # input is a pointcloud of shape (N, 3) + awesome_segmenter = SurfaceSegmenter() + mask_surfaces = awesome_segmenter.predict(pointcloud) + mask = mask_surfaces + # output is a segmentation mask of shape (N,) + # where each element is an integer representing the segment id + return np.array(mask) + +segment_ids = segment_pointcloud(pointcloud) +visualize_pointcloud_downsampled_with_segment_ids(pointcloud, segment_ids, downsample_factor=5) # use 'downsample_factor=1' for no downsampling during visualization + +# %% diff --git a/murat.code-workspace b/murat.code-workspace new file mode 100644 index 0000000000000000000000000000000000000000..898b479d45b700bf5e191fd91900b5b1d325dc88 --- /dev/null +++ b/murat.code-workspace @@ -0,0 +1,74 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "terminal.integrated.gpuAcceleration": "off", + "editor.wordWrap": "on", + "latex-workshop.latex.recipe.default": "lastUsed", + "latex-workshop.latex.autoBuild.run": "never", + "latex-workshop.latex.recipes": [ + { + "name": "magic ðŸŸ", + "tools": [ + "pdflatex", + "bibtex", + "pdflatex", + "makeglossaries", + // "makeindex", + "pdflatex" + ] + } + ], + "files.associations": { + "*.tikz": "latex", + "*.table": "latex", + "*.figure": "latex" + }, + "latex-workshop.latex.tools": [ + { + "name": "makeindex", + "command": "makeindex", + "args": [ + "%DOCFILE%.nlo", + "-s", + "nomencl.ist", + "-o", + "%DOCFILE%.nls" + ] + }, + { + "name": "pdflatex", + "command": "pdflatex", + "args": [ + "-synctex=1", + "-interaction=nonstopmode", + "-file-line-error", + "%DOC%" + ], + "env": {} + }, + { + "name": "bibtex", + "command": "bibtex", + "args": [ + "%DOCFILE%" + ], + "env": {} + }, + { + "name": "makeglossaries", + "command": "makeglossaries", + "args": [ + "%DOCFILE%" + ] + } + ], + }, + "tasks": { + "version": "2.0.0", + "tasks": [] + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..21b52ac089518b2aa1f282c7e9b1ca4e9a87427c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +numpy==1.26.4 +scipy==1.12.0 +matplotlib==3.8.3 +seaborn==0.13.2 +scikit-learn==1.4.1.post1 +open3d \ No newline at end of file diff --git a/segmentation/__init__.py b/segmentation/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/segmentation/__pycache__/__init__.cpython-312.pyc b/segmentation/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3c6d2652cab58e9423df820474afb35c5ed76fe4 Binary files /dev/null and b/segmentation/__pycache__/__init__.cpython-312.pyc differ diff --git a/segmentation/__pycache__/base.cpython-312.pyc b/segmentation/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b3232ad56dc3c93b19e5ea7394af86be35db38df Binary files /dev/null and b/segmentation/__pycache__/base.cpython-312.pyc differ diff --git a/segmentation/__pycache__/segmentation.cpython-312.pyc b/segmentation/__pycache__/segmentation.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..58f99716fc4f72855ef16347dd0d61b613f12060 Binary files /dev/null and b/segmentation/__pycache__/segmentation.cpython-312.pyc differ diff --git a/segmentation/base.py b/segmentation/base.py new file mode 100644 index 0000000000000000000000000000000000000000..826d7cecaa2f6388a5c85d14d58ef7df466b69d7 --- /dev/null +++ b/segmentation/base.py @@ -0,0 +1,22 @@ +import numpy as np +import sklearn as sk +from sklearn.cluster import DBSCAN + + +class BaseSegmenter(sk.base.BaseEstimator): + def __init__(self, param=1): + super().__init__() + self.param = param + + def fit(self, X, y=None): + raise NotImplementedError("In case we want to try some supervised method") + + def predict(self, point_cloud): + dbscan = DBSCAN(eps=0.2, min_samples=5) + clusters = dbscan.fit_predict(point_cloud) + + return clusters + + +if __name__ == '__main__': + pass \ No newline at end of file diff --git a/segmentation/segmentation.py b/segmentation/segmentation.py new file mode 100644 index 0000000000000000000000000000000000000000..0e5be8ec41e7b407efe6b1be09aeecfe6ab90ab7 --- /dev/null +++ b/segmentation/segmentation.py @@ -0,0 +1,26 @@ +import numpy as np +from segmentation.base import BaseSegmenter +import open3d as o3d + + +class SurfaceSegmenter(BaseSegmenter): + def __init__(self, param=1): + super().__init__(param) + + def fit(self, X, y=None): + raise NotImplementedError("In case we want to try some supervised method") + + def predict(self, point_cloud): + pcd = o3d.geometry.PointCloud() + pcd.points = o3d.utility.Vector3dVector(point_cloud) + + # Use RANSAC to find a plane in the point cloud + plane_model, inliers = pcd.segment_plane(distance_threshold=0.01, + ransac_n=3, + num_iterations=1000) + + inlier_cloud = pcd.select_by_index(inliers) + + # Visualize the inlier points + o3d.visualization.draw_geometries([inlier_cloud]) + return inlier_cloud \ No newline at end of file