CMalone-Jupiter commited on
Commit
a37f5d3
·
verified ·
1 Parent(s): 048ed57

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +219 -0
  2. FoL/reranking.py +96 -0
  3. LICENSE +29 -0
  4. README.md +67 -14
  5. __init__.py +0 -0
  6. assets/.gitkeep +1 -0
  7. calib.txt +4 -0
  8. camera_calib.txt +5 -0
  9. configs/dry/cambogan_20250812_122101.yaml +6 -0
  10. configs/dry/cambogan_20250812_122339.yaml +6 -0
  11. configs/dry/cambogan_20250812_122621.yaml +6 -0
  12. configs/dry/dairycreek_20250812_122954.yaml +6 -0
  13. configs/dry/dairycreek_20250812_123312.yaml +6 -0
  14. configs/dry/holmview_20250812_120100.yaml +6 -0
  15. configs/dry/holmview_20250812_120856.yaml +6 -0
  16. configs/dry/pullenvale_20250812_134316.yaml +6 -0
  17. configs/dry/pullenvale_20250812_134524.yaml +6 -0
  18. configs/flooded/cambogan.yaml +6 -0
  19. configs/flooded/dairycreek.yaml +6 -0
  20. configs/flooded/holmview.yaml +6 -0
  21. configs/flooded/mountcotton.yaml +6 -0
  22. configs/flooded/pullenvale.yaml +6 -0
  23. lidar-camera-calibration.py +526 -0
  24. lidar_postprocessing/create_pointcloud_labels.py +143 -0
  25. lidar_postprocessing/fill_pointcloud_gaps.py +98 -0
  26. lidar_postprocessing/project_water_label.py +122 -0
  27. localisation/.gitkeep +0 -0
  28. localisation/VPR_eval-all-v2.py +215 -0
  29. localisation/VPR_eval-all.py +247 -0
  30. localisation/VPR_eval.ipynb +383 -0
  31. localisation/VPR_eval.py +199 -0
  32. localisation/__init__.py +0 -0
  33. localisation/create_VPR_eval_dataset.ipynb +132 -0
  34. localisation/groundtruth_utm-single.ipynb +0 -0
  35. localisation/groundtruth_utm_checker-all.py +112 -0
  36. localisation/plot_utm_traj.ipynb +122 -0
  37. segmentation/.gitkeep +0 -0
  38. segmentation/__init__.py +0 -0
  39. segmentation/evaluate-predictions.py +191 -0
  40. segmentation/show_labels-all.py +133 -0
  41. segmentation/show_labels-single.ipynb +0 -0
  42. utils/.gitkeep +0 -0
  43. utils/__init__.py +0 -0
  44. utils/camera.py +143 -0
  45. utils/lidar.py +163 -0
  46. utils/utils.py +494 -0
  47. visualisation/.gitkeep +0 -0
  48. visualisation/__init__.py +0 -0
  49. visualisation/convert_imgs2video.py +106 -0
  50. visualisation/points2image-all.py +88 -0
.gitignore ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # All others
2
+ *.png
3
+ *.svg
4
+ *.pdf
5
+ *.profile_default
6
+ *.json
7
+
8
+ # Byte-compiled / optimized / DLL files
9
+ __pycache__/
10
+ *.py[codz]
11
+ *$py.class
12
+
13
+ # C extensions
14
+ *.so
15
+
16
+ # Distribution / packaging
17
+ ss/
18
+ .Python
19
+ build/
20
+ develop-eggs/
21
+ dist/
22
+ downloads/
23
+ eggs/
24
+ .eggs/
25
+ lib/
26
+ lib64/
27
+ parts/
28
+ sdist/
29
+ var/
30
+ wheels/
31
+ share/python-wheels/
32
+ *.egg-info/
33
+ .installed.cfg
34
+ *.egg
35
+ MANIFEST
36
+
37
+ # PyInstaller
38
+ # Usually these files are written by a python script from a template
39
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
40
+ *.manifest
41
+ *.spec
42
+
43
+ # Installer logs
44
+ pip-log.txt
45
+ pip-delete-this-directory.txt
46
+
47
+ # Unit test / coverage reports
48
+ htmlcov/
49
+ .tox/
50
+ .nox/
51
+ .coverage
52
+ .coverage.*
53
+ .cache
54
+ nosetests.xml
55
+ coverage.xml
56
+ *.cover
57
+ *.py.cover
58
+ .hypothesis/
59
+ .pytest_cache/
60
+ cover/
61
+
62
+ # Translations
63
+ *.mo
64
+ *.pot
65
+
66
+ # Django stuff:
67
+ *.log
68
+ local_settings.py
69
+ db.sqlite3
70
+ db.sqlite3-journal
71
+
72
+ # Flask stuff:
73
+ instance/
74
+ .webassets-cache
75
+
76
+ # Scrapy stuff:
77
+ .scrapy
78
+
79
+ # Sphinx documentation
80
+ docs/_build/
81
+
82
+ # PyBuilder
83
+ .pybuilder/
84
+ target/
85
+
86
+ # Jupyter Notebook
87
+ .ipynb_checkpoints
88
+
89
+ # IPython
90
+ profile_default/
91
+ ipython_config.py
92
+
93
+ # pyenv
94
+ # For a library or package, you might want to ignore these files since the code is
95
+ # intended to run in multiple environments; otherwise, check them in:
96
+ # .python-version
97
+
98
+ # pipenv
99
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
100
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
101
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
102
+ # install all needed dependencies.
103
+ #Pipfile.lock
104
+
105
+ # UV
106
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
107
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
108
+ # commonly ignored for libraries.
109
+ #uv.lock
110
+
111
+ # poetry
112
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
113
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
114
+ # commonly ignored for libraries.
115
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
116
+ #poetry.lock
117
+ #poetry.toml
118
+
119
+ # pdm
120
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
121
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
122
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
123
+ #pdm.lock
124
+ #pdm.toml
125
+ .pdm-python
126
+ .pdm-build/
127
+
128
+ # pixi
129
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
130
+ #pixi.lock
131
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
132
+ # in the .venv directory. It is recommended not to include this directory in version control.
133
+ .pixi
134
+
135
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
136
+ __pypackages__/
137
+
138
+ # Celery stuff
139
+ celerybeat-schedule
140
+ celerybeat.pid
141
+
142
+ # SageMath parsed files
143
+ *.sage.py
144
+
145
+ # Environments
146
+ .env
147
+ .envrc
148
+ .venv
149
+ env/
150
+ venv/
151
+ ENV/
152
+ env.bak/
153
+ venv.bak/
154
+
155
+ # Spyder project settings
156
+ .spyderproject
157
+ .spyproject
158
+
159
+ # Rope project settings
160
+ .ropeproject
161
+
162
+ # mkdocs documentation
163
+ /site
164
+
165
+ # mypy
166
+ .mypy_cache/
167
+ .dmypy.json
168
+ dmypy.json
169
+
170
+ # Pyre type checker
171
+ .pyre/
172
+
173
+ # pytype static type analyzer
174
+ .pytype/
175
+
176
+ # Cython debug symbols
177
+ cython_debug/
178
+
179
+ # PyCharm
180
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
181
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
182
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
183
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
184
+ #.idea/
185
+
186
+ # Abstra
187
+ # Abstra is an AI-powered process automation framework.
188
+ # Ignore directories containing user credentials, local state, and settings.
189
+ # Learn more at https://abstra.io/docs
190
+ .abstra/
191
+
192
+ # Visual Studio Code
193
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
194
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
195
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
196
+ # you could uncomment the following to ignore the entire vscode folder
197
+ # .vscode/
198
+
199
+ # Ruff stuff:
200
+ .ruff_cache/
201
+
202
+ # PyPI configuration file
203
+ .pypirc
204
+
205
+ # Cursor
206
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
207
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
208
+ # refer to https://docs.cursor.com/context/ignore-files
209
+ .cursorignore
210
+ .cursorindexingignore
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Other
218
+ hazard_detection*
219
+ results*
FoL/reranking.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: UTF-8 -*-
2
+ import faiss
3
+ import torch
4
+ from torch import nn
5
+ import logging
6
+ import numpy as np
7
+ from tqdm import tqdm
8
+ from torch.utils.data import DataLoader
9
+ from torch.utils.data.dataset import Subset
10
+ # from prettytable import PrettyTable
11
+ import warnings
12
+ import cv2
13
+ from os.path import join
14
+ device = 'cuda' if torch.cuda.is_available() else 'cpu'
15
+ # import matplotlib.pyplot as plt
16
+
17
+ def match_batch_tensor(fm1, fm2, trainflag, grid_size, T2=0.7):
18
+ '''
19
+ fm1: (l,D) 529,768
20
+ fm2: (N,l,D) 100,529,768
21
+ mask1: (l)
22
+ mask2: (N,l)
23
+ '''
24
+ M = torch.matmul(fm2, fm1.T)
25
+
26
+ max1 = torch.argmax(M, dim=1)
27
+ max2 = torch.argmax(M, dim=2)
28
+ m = max2[torch.arange(M.shape[0]).reshape((-1, 1)), max1]
29
+ valid = torch.arange(M.shape[-1]).repeat((M.shape[0], 1)).cuda() == m
30
+ scores = torch.zeros(fm2.shape[0]).cuda()
31
+
32
+ for i in range(fm2.shape[0]):
33
+ idx1 = torch.nonzero(valid[i, :]).squeeze()
34
+ idx2 = max1[i, :][idx1]
35
+ assert idx1.shape == idx2.shape
36
+
37
+ if len(idx1.shape) > 0:
38
+ # Calculate cosine similarity and apply threshold
39
+ cos_similarity = torch.sum(fm1[idx1] * fm2[i][idx2], dim=1)
40
+ valid_pairs = cos_similarity > T2
41
+ idx1 = idx1[valid_pairs]
42
+ idx2 = idx2[valid_pairs]
43
+
44
+ if trainflag:
45
+ if len(idx1.shape) > 0:
46
+ similarity = torch.mean(torch.sum(fm1[idx1] * fm2[i][idx2], dim=1), dim=0)
47
+ else:
48
+ print("No mutual nearest neighbors!")
49
+ similarity = torch.mean(torch.sum(fm1 * fm2[i], dim=1), dim=0)
50
+ return similarity
51
+
52
+ else:
53
+ if len(idx1.shape) < 1:
54
+ scores[i] = 0
55
+ else:
56
+ scores[i] = len(idx1)
57
+ return scores
58
+
59
+ def local_sim(features_1, features_2, trainflag=False):
60
+ B, Num, C = features_2.shape
61
+ if trainflag:
62
+ queries = features_1
63
+ preds = features_2
64
+ similarity = torch.zeros(B).cuda()
65
+ for i in range(B):
66
+ query,pred = queries[i],preds[i].unsqueeze(0)
67
+ similarity[i] = match_batch_tensor(query, pred, trainflag, grid_size=(61,61))
68
+ return similarity
69
+ else:
70
+ query = features_1
71
+ preds = features_2
72
+ scores = match_batch_tensor(query, preds,trainflag, grid_size=(61,61))
73
+ return scores
74
+
75
+ def rerank(predictions, queries_local_features, database_local_features):
76
+ pred2 = []
77
+ print("reranking...")
78
+ for query_index, pred in enumerate(tqdm(predictions)):
79
+ query_local_features = torch.tensor(queries_local_features[query_index]).cuda()
80
+ positives_local_features = torch.tensor(database_local_features[pred]).cuda()
81
+ rerank_index = local_sim(query_local_features, positives_local_features, trainflag=False)
82
+ rerank_index_sorted = rerank_index.cpu().numpy().argsort()[::-1]
83
+ pred2.append(predictions[query_index][rerank_index_sorted])
84
+ return np.array(pred2)
85
+
86
+ def run_rerank(queries_features, database_features, q_local_list, r_local_list, recall_values = [1,]):
87
+
88
+ faiss_index = faiss.IndexFlatL2(8448)
89
+ faiss_index.add(database_features)
90
+
91
+ distances, predictions = faiss_index.search(queries_features, max(recall_values))
92
+
93
+ # rerank
94
+ predictions2 = rerank(predictions, q_local_list, r_local_list)
95
+
96
+ return predictions2
LICENSE ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2025, Queensland University of Technology (QUT)
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
README.md CHANGED
@@ -1,14 +1,67 @@
1
- ---
2
- title: Python FRED
3
- emoji: 💻🐳
4
- colorFrom: gray
5
- colorTo: green
6
- sdk: docker
7
- pinned: false
8
- tags:
9
- - jupyterlab
10
- suggested_storage: small
11
- license: cc-by-nc-sa-4.0
12
- ---
13
-
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # python-FRED
2
+ <!-- ![Zoe 2 img](assets/Zoe2-FRED.svg) -->
3
+ <p align="center">
4
+ <img src="assets/Zoe2-FRED.svg" alt="Zoe 2 img">
5
+ </p>
6
+ This repository provides the devkit tools for working with the Flooded Road Environments Dataset (FRED). This autonomous vehicle dataset has been developed to enable research into the detection of flooded roads during on-road deployment. The dataset was collected using a Renault Zoe with custom modifications to enable autonomy, including front and rear Blackfly cameras, an Ouster OS1 LiDAR, and a GNSS-corrected IMU. Data has been collected using the vehicle's sensor stack from 5 separate locations around Brisbane, Australia, both during and after flooding events. Semantic labels are provided for images to enable the development of detection methods, and corresponding position information from the GNSS-corrected IMU has been provided across sequences to additionally enable localization research for these scenarios.
7
+
8
+ ## Dataset Structure
9
+ We adopt the following structure for FRED to include a KITTI-style format for the dataset and the native recording format using RTmaps.
10
+ ```
11
+ ├── flooded # Location sequences captured during flooding events
12
+ │ ├── KITTI-style # Sequences in a KITTI-style format
13
+ │ | ├── Cambogan_20250811_113017 # Sequence by location
14
+ │ | | ├── back-imgs
15
+ │ | | | └── <timestamp>.png # Images in 'png' format
16
+ │ | | ├── back-labels
17
+ │ | | | └── <timestamp>.png # Semantic labels in 'png' format
18
+ │ | | ├── front-imgs
19
+ │ | | | └── <timestamp>.png # Images in 'png' format
20
+ │ | | ├── front-labels
21
+ │ | | | └── <timestamp>.png # Semantic labels in 'png' format
22
+ │ | | ├── imu
23
+ │ | | | └── <timestamp>.txt # IMU data formatted as a 'txt' file
24
+ │ | | ├── ouster
25
+ │ | | | └── <timestamp>.bin # Point clouds formatted as a binary file
26
+ │ | | └── utm
27
+ │ | | └── <timestamp>.txt # UTM locations formatted as a 'txt' file
28
+ │ | ├── ...
29
+ │ | └── ...
30
+ │ └── native-RTmaps # Sequences in native recording format
31
+ │ ├── Cambogan_20250811_113017 # Sequence by location
32
+ │ | ├── Camera_Rec # Recording files for image playback
33
+ │ | ├── IMU_Info_Rec # Recording files for IMU playback
34
+ │ | └── Ouster_Rec # Recording files for LiDAR playback
35
+ │ ├── ...
36
+ │ └── ...
37
+
38
+ └── dry # Location sequences captured while 'dry'
39
+ ├── KITTI-style
40
+ └── native-RTmaps
41
+ ```
42
+
43
+ ## Data Formats
44
+ ### Image Format
45
+ Images are stored in PNG format.
46
+
47
+ ### Point Cloud Format
48
+ Point clouds are stored in binary format (.bin), with each point containing x, y, z positions, as well as reflectivity values. Reflectivity values are surface normalized signal intensity measurements that range from 0 to 255. 3D coordinates are captured in the right-hand coordinate frame with the positive x-axis in the vehicle's direction of travel.
49
+
50
+ ### UTM Format
51
+ UTM data is stored in text file format (.txt), with UTM x and y values being stored as space separated values in the file.
52
+
53
+ ### IMU Format
54
+ Additional IMU information is also stored in text file format (.txt). A space delimiter is again used to separate values. The additional IMU data is stored in the following order:
55
+ ```
56
+ [ Latitude, Longitude, Altitude,
57
+ Roll, Pitch, Yaw,
58
+ North Velocity, East Velocity,
59
+ x Velocity, y Velocity, z Velocity,
60
+ x Angular Velocity, y Angular Velocity, z Angular Velocity,
61
+ x Angular Velocity, y Angular Velocity, z Angular Velocity,
62
+ x Angular Accel, y Angular Accel, z Angular Accel,
63
+ x Angular Accel, y Angular Accel, z Angular Accel,
64
+ Position Accuracy, Velocity Accuracy,
65
+ Navstate Value, Numstat Value,
66
+ Position Mode, Velocity Mode, Orientation Mode ]
67
+ ```
__init__.py ADDED
File without changes
assets/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+
calib.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ P2: 2.047776e+03 0.000000e+00 9.600000e+02 0.000000e+00 0.000000e+00 2.047776e+03 6.000000e+02 0.000000e+00 0.000000e+00 0.000000e+00 1.000000e+00 0.000000e+00
2
+ R0_rect: 1.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 1.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 1.000000e+00
3
+ Tr_ouster_to_cam: 1.13360350e-02 -9.99935650e-01 4.35486538e-04 1.00000000e-01 3.83878091e-02 0.00000000e+00 -9.99262916e-01 -5.00000000e-01 9.99198614e-01 1.13443968e-02 3.83853388e-02 -4.74000000e-01
4
+ Tr_ouster_to_cam_old: 9.59208808e-03 -9.99953927e-01 3.68490854e-04 1.00000000e-01 3.83878091e-02 0.00000000e+00 -9.99262916e-01 -5.00000000e-01 9.99216877e-01 9.59916346e-03 3.83860404e-02 -1.25600000e+00
camera_calib.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ focal_len: 12.00
2
+ principal_x: 960.00
3
+ principal_y: 600.00
4
+ pp_mm_x: 170.648
5
+ pp_mm_y: 170.648
configs/dry/cambogan_20250812_122101.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ location: "Cambogan"
2
+ sequence: "20250812_122101"
3
+ condition: "dry"
4
+ camera_pos: "front"
5
+ root: "../Datasets/FRED/"
6
+ img_calib_file: "./camera_calib.txt"
configs/dry/cambogan_20250812_122339.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ location: "Cambogan"
2
+ sequence: "20250812_122339"
3
+ condition: "dry"
4
+ camera_pos: "front"
5
+ root: "../Datasets/FRED/"
6
+ img_calib_file: "./camera_calib.txt"
configs/dry/cambogan_20250812_122621.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ location: "Cambogan"
2
+ sequence: "20250812_122621"
3
+ condition: "dry"
4
+ camera_pos: "front"
5
+ root: "../Datasets/FRED/"
6
+ img_calib_file: "./camera_calib.txt"
configs/dry/dairycreek_20250812_122954.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ location: "Dairy-Creek"
2
+ sequence: "20250812_122954"
3
+ condition: "dry"
4
+ camera_pos: "front"
5
+ root: "../Datasets/FRED/"
6
+ img_calib_file: "./camera_calib.txt"
configs/dry/dairycreek_20250812_123312.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ location: "Dairy-Creek"
2
+ sequence: "20250812_120856"
3
+ condition: "dry"
4
+ camera_pos: "front"
5
+ root: "../Datasets/FRED/"
6
+ img_calib_file: "./camera_calib.txt"
configs/dry/holmview_20250812_120100.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ location: "Holmview"
2
+ sequence: "20250812_120100"
3
+ condition: "dry"
4
+ camera_pos: "front"
5
+ root: "../Datasets/FRED/"
6
+ img_calib_file: "./camera_calib.txt"
configs/dry/holmview_20250812_120856.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ location: "Holmview"
2
+ sequence: "20250812_120100"
3
+ condition: "dry"
4
+ camera_pos: "front"
5
+ root: "../Datasets/FRED/"
6
+ img_calib_file: "./camera_calib.txt"
configs/dry/pullenvale_20250812_134316.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ location: "Pullenvale"
2
+ sequence: "20250812_134316"
3
+ condition: "dry"
4
+ camera_pos: "front"
5
+ root: "../Datasets/FRED/"
6
+ img_calib_file: "./camera_calib.txt"
configs/dry/pullenvale_20250812_134524.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ location: "Pullenvale"
2
+ sequence: "20250812_134524"
3
+ condition: "dry"
4
+ camera_pos: "front"
5
+ root: "../Datasets/FRED/"
6
+ img_calib_file: "./camera_calib.txt"
configs/flooded/cambogan.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ location: "Cambogan"
2
+ sequence: "20250811_113017"
3
+ condition: "flooded"
4
+ camera_pos: "front"
5
+ root: "../Datasets/FRED/"
6
+ img_calib_file: "./camera_calib.txt"
configs/flooded/dairycreek.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ location: "DairyCreek"
2
+ sequence: "20250811_103318"
3
+ condition: "flooded"
4
+ camera_pos: "front"
5
+ root: "../Datasets/FRED/"
6
+ img_calib_file: "./camera_calib.txt"
configs/flooded/holmview.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ location: "Holmview"
2
+ sequence: "20250820_130327"
3
+ condition: "flooded"
4
+ camera_pos: "front"
5
+ root: "../Datasets/FRED/"
6
+ img_calib_file: "./camera_calib.txt"
configs/flooded/mountcotton.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ location: "Mount-Cotton"
2
+ sequence: "20241217_113410"
3
+ condition: "flooded"
4
+ camera_pos: "front"
5
+ root: "../Datasets/FRED/"
6
+ img_calib_file: "./camera_calib.txt"
configs/flooded/pullenvale.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ location: "Pullenvale"
2
+ sequence: "20250916_124105"
3
+ condition: "flooded"
4
+ camera_pos: "front"
5
+ root: "../Datasets/FRED/"
6
+ img_calib_file: "./camera_calib.txt"
lidar-camera-calibration.py ADDED
@@ -0,0 +1,526 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import cv2
3
+ import open3d as o3d
4
+ from scipy.spatial.transform import Rotation
5
+ import argparse
6
+ import os
7
+
8
+ class LiDARCameraCalibrator:
9
+ def __init__(self, point_cloud_path, image_path, camera_matrix, dist_coeffs):
10
+ """
11
+ Initialize the calibrator with point cloud and image data
12
+
13
+ Args:
14
+ point_cloud_path (str): Path to LiDAR point cloud file (.pcd, .ply, .bin, etc.)
15
+ image_path (str): Path to camera image
16
+ camera_matrix (np.array): 3x3 camera intrinsic matrix
17
+ dist_coeffs (np.array): Camera distortion coefficients
18
+ """
19
+ # Load point cloud
20
+ self.points_3d = self.load_point_cloud(point_cloud_path)
21
+
22
+ # Load image
23
+ self.image = cv2.imread(image_path)
24
+ self.original_image = self.image.copy()
25
+ self.height, self.width = self.image.shape[:2]
26
+
27
+ # Camera parameters
28
+ self.camera_matrix = camera_matrix
29
+ self.dist_coeffs = dist_coeffs
30
+
31
+ # self.fixed_translation = np.array([0.676, 0.0, 1.486])
32
+ # self.fixed_transform = np.eye(4)
33
+ # self.fixed_transform[:3, 3] = self.fixed_translation
34
+
35
+ # Initial transformation parameters (adjustable via trackbars)
36
+ # tx=1.932+0.676, ty=0.25+0, tz=1.4+1.486, rx=0, ry=-2.0, rz=1.5
37
+ self.tx = -1.16 # Translation X
38
+ self.ty = -0.23 # Translation Y
39
+ self.tz = 0.42 # Translation Z
40
+ self.rx = 0.0 # Rotation X (degrees)
41
+ self.ry = 2.0 # Rotation Y (degrees)
42
+ self.rz = -0.55 # Rotation Z (degrees)
43
+
44
+ # Trackbar ranges (scaled by 1000 for precision)
45
+ self.t_range = 50000 # ±50m in mm
46
+ self.r_range = 18000 # ±180 degrees in 0.01 degree units
47
+
48
+ # Setup OpenCV window and trackbars
49
+ self.setup_gui()
50
+
51
+ def load_point_cloud(self, file_path):
52
+ """
53
+ Load point cloud from various formats including .bin files
54
+
55
+ Args:
56
+ file_path (str): Path to point cloud file
57
+
58
+ Returns:
59
+ np.array: Nx3 array of 3D points
60
+ """
61
+ file_ext = os.path.splitext(file_path)[1].lower()
62
+
63
+ if file_ext == '.bin':
64
+ # Load binary point cloud (common format for KITTI, nuScenes, etc.)
65
+ # Assumes format: [x, y, z, intensity] or [x, y, z, intensity, ring, time, etc.]
66
+ points = np.fromfile(file_path, dtype=np.float32)
67
+
68
+ # Determine the number of fields per point
69
+ # Common formats: 4 (x,y,z,intensity), 5 (x,y,z,intensity,ring), 6+ (additional fields)
70
+ if len(points) % 4 == 0:
71
+ points = points.reshape(-1, 4)
72
+ points_3d = points[:, :3] # Take only x, y, z
73
+ self.intensities = points[:, 3] # Store intensity for potential use
74
+ # print(self.intensities.max())
75
+ # print(self.intensities.min())
76
+ print(f"Loaded {len(points_3d)} points from .bin file (4 fields per point)")
77
+ elif len(points) % 6 == 0:
78
+ points = points.reshape(-1, 6)
79
+ points_3d = points[:, :3] # Take only x, y, z
80
+ self.intensities = points[:, 3] # Store intensity
81
+ print(f"Loaded {len(points_3d)} points from .bin file (6 fields per point)")
82
+ else:
83
+ # Try to infer the structure or default to 3D points only
84
+ # Assume the most common case where points are stored as [x,y,z,...]
85
+ n_fields = 4 # Default assumption
86
+ if len(points) % 3 == 0 and len(points) % 4 != 0:
87
+ n_fields = 3
88
+
89
+ points = points.reshape(-1, n_fields)
90
+ points_3d = points[:, :3]
91
+ if n_fields > 3:
92
+ self.intensities = points[:, 3]
93
+ else:
94
+ self.intensities = None
95
+ print(f"Loaded {len(points_3d)} points from .bin file ({n_fields} fields per point)")
96
+
97
+ return points_3d
98
+
99
+ else:
100
+ # Use Open3D for standard formats (.pcd, .ply, etc.)
101
+ try:
102
+ pcd = o3d.io.read_point_cloud(file_path)
103
+ points_3d = np.asarray(pcd.points)
104
+ self.intensities = None # Open3D formats don't always have intensity
105
+ print(f"Loaded {len(points_3d)} points using Open3D")
106
+ return points_3d
107
+ except Exception as e:
108
+ raise ValueError(f"Could not load point cloud {file_path}: {str(e)}")
109
+
110
+
111
+ def setup_gui(self):
112
+ """Setup OpenCV window and trackbars for real-time adjustment"""
113
+ cv2.namedWindow('LiDAR-Camera Calibration', cv2.WINDOW_NORMAL)
114
+ cv2.resizeWindow('LiDAR-Camera Calibration', 1200, 800)
115
+
116
+ # Create trackbars for translation (in mm for precision)
117
+ cv2.createTrackbar('TX (mm)', 'LiDAR-Camera Calibration',
118
+ int(self.tx * 1000) + self.t_range, 2 * self.t_range, self.update_transform)
119
+ cv2.createTrackbar('TY (mm)', 'LiDAR-Camera Calibration',
120
+ int(self.ty * 1000) + self.t_range, 2 * self.t_range, self.update_transform)
121
+ cv2.createTrackbar('TZ (mm)', 'LiDAR-Camera Calibration',
122
+ int(self.tz * 1000) + self.t_range, 2 * self.t_range, self.update_transform)
123
+
124
+ # Create trackbars for rotation (in 0.01 degree units)
125
+ cv2.createTrackbar('RX (0.01°)', 'LiDAR-Camera Calibration',
126
+ int(self.rx * 100) + self.r_range, 2 * self.r_range, self.update_transform)
127
+ cv2.createTrackbar('RY (0.01°)', 'LiDAR-Camera Calibration',
128
+ int(self.ry * 100) + self.r_range, 2 * self.r_range, self.update_transform)
129
+ cv2.createTrackbar('RZ (0.01°)', 'LiDAR-Camera Calibration',
130
+ int(self.rz * 100) + self.r_range, 2 * self.r_range, self.update_transform)
131
+
132
+ # Additional controls
133
+ cv2.createTrackbar('Point Size', 'LiDAR-Camera Calibration', 2, 10, self.update_transform)
134
+ cv2.createTrackbar('Max Distance (m)', 'LiDAR-Camera Calibration', 100, 200, self.update_transform)
135
+ cv2.createTrackbar('Min Distance (m)', 'LiDAR-Camera Calibration', 0, 50, self.update_transform)
136
+ cv2.createTrackbar('Intensity Filter', 'LiDAR-Camera Calibration', 0, 100, self.update_transform)
137
+
138
+ def set_initial_values(self, tx=0, ty=0, tz=0, rx=0, ry=0, rz=0):
139
+ """Set initial transformation values"""
140
+ self.tx, self.ty, self.tz = tx, ty, tz
141
+ self.rx, self.ry, self.rz = rx, ry, rz
142
+
143
+ # Update trackbars
144
+ cv2.setTrackbarPos('TX (mm)', 'LiDAR-Camera Calibration', int(tx * 1000) + self.t_range)
145
+ cv2.setTrackbarPos('TY (mm)', 'LiDAR-Camera Calibration', int(ty * 1000) + self.t_range)
146
+ cv2.setTrackbarPos('TZ (mm)', 'LiDAR-Camera Calibration', int(tz * 1000) + self.t_range)
147
+ cv2.setTrackbarPos('RX (0.01°)', 'LiDAR-Camera Calibration', int(rx * 100) + self.r_range)
148
+ cv2.setTrackbarPos('RY (0.01°)', 'LiDAR-Camera Calibration', int(ry * 100) + self.r_range)
149
+ cv2.setTrackbarPos('RZ (0.01°)', 'LiDAR-Camera Calibration', int(rz * 100) + self.r_range)
150
+
151
+ def update_transform(self, val):
152
+ """Callback function for trackbar updates"""
153
+ # Get current trackbar values
154
+ self.tx = (cv2.getTrackbarPos('TX (mm)', 'LiDAR-Camera Calibration') - self.t_range) / 1000.0
155
+ self.ty = (cv2.getTrackbarPos('TY (mm)', 'LiDAR-Camera Calibration') - self.t_range) / 1000.0
156
+ self.tz = (cv2.getTrackbarPos('TZ (mm)', 'LiDAR-Camera Calibration') - self.t_range) / 1000.0
157
+ self.rx = (cv2.getTrackbarPos('RX (0.01°)', 'LiDAR-Camera Calibration') - self.r_range) / 100.0
158
+ self.ry = (cv2.getTrackbarPos('RY (0.01°)', 'LiDAR-Camera Calibration') - self.r_range) / 100.0
159
+ self.rz = (cv2.getTrackbarPos('RZ (0.01°)', 'LiDAR-Camera Calibration') - self.r_range) / 100.0
160
+
161
+ # Update visualization
162
+ self.visualize_projection()
163
+
164
+ def get_transformation_matrix(self):
165
+ """Calculate 4x4 transformation matrix from current parameters"""
166
+ # Create rotation matrix from Euler angles (XYZ order)
167
+ rotation = Rotation.from_euler('xyz', [self.rx, self.ry, self.rz], degrees=True)
168
+ # R = rotation.as_matrix()
169
+
170
+ coord_transform = np.array([
171
+ [0, -1, 0],
172
+ [0, 0, -1],
173
+ [1, 0, 0]
174
+ ])
175
+
176
+ R = rotation.as_matrix() #@ coord_transform
177
+
178
+ # Create translation vector
179
+ t = np.array([self.tx, self.ty, self.tz])
180
+
181
+ # Build 4x4 transformation matrix
182
+ variable_T = np.eye(4)
183
+ variable_T[:3, :3] = R
184
+ variable_T[:3, 3] = t
185
+
186
+ # T = variable_T @ self.fixed_transform
187
+ T = variable_T
188
+
189
+ # Convert combined transform to camera coordinate frame
190
+ C4 = np.eye(4)
191
+ C4[:3, :3] = coord_transform # maps robot -> camera
192
+ T_camera = C4 @ T
193
+
194
+ return T_camera
195
+
196
+ def project_points(self):
197
+ """Project 3D LiDAR points to image coordinates"""
198
+ if len(self.points_3d) == 0:
199
+ return np.array([]), np.array([])
200
+
201
+ # Apply transformation
202
+ T = self.get_transformation_matrix()
203
+ points_homo = np.column_stack([self.points_3d, np.ones(len(self.points_3d))])
204
+ transformed_points = (T @ points_homo.T).T[:, :3]
205
+
206
+ # Filter points that are in front of camera
207
+ valid_mask = transformed_points[:, 2] > 0
208
+ valid_points = transformed_points[valid_mask]
209
+ valid_inten = self.intensities[valid_mask]
210
+
211
+ if len(valid_points) == 0:
212
+ return np.array([]), np.array([])
213
+
214
+ # Filter by distance
215
+ max_dist = cv2.getTrackbarPos('Max Distance (m)', 'LiDAR-Camera Calibration')
216
+ min_dist = cv2.getTrackbarPos('Min Distance (m)', 'LiDAR-Camera Calibration')
217
+ distances = np.linalg.norm(valid_points, axis=1)
218
+ dist_mask = (distances >= min_dist) & (distances <= max_dist)
219
+ valid_points = valid_points[dist_mask]
220
+ valid_inten = valid_inten[dist_mask]
221
+ valid_distances = distances[dist_mask]
222
+
223
+ # Filter by intensity if available
224
+ if hasattr(self, 'intensities') and self.intensities is not None:
225
+ intensity_threshold = cv2.getTrackbarPos('Intensity Filter', 'LiDAR-Camera Calibration') / 100.0
226
+ # Apply original valid_mask and dist_mask to intensities
227
+ original_valid_mask = np.arange(len(self.points_3d))[valid_mask[:len(valid_mask)]][:len(valid_points)]
228
+ if len(original_valid_mask) > 0 and len(original_valid_mask) <= len(self.intensities):
229
+ valid_intensities = self.intensities[original_valid_mask]
230
+ # Normalize intensities to 0-1 range
231
+ if len(valid_intensities) > 0:
232
+ intensity_norm = (valid_intensities - np.min(valid_intensities)) / (np.max(valid_intensities) - np.min(valid_intensities) + 1e-8)
233
+ intensity_mask = intensity_norm >= intensity_threshold
234
+ valid_points = valid_points[intensity_mask]
235
+ valid_inten = valid_inten[intensity_mask]
236
+ valid_distances = valid_distances[intensity_mask]
237
+
238
+ if len(valid_points) == 0:
239
+ return np.array([]), np.array([])
240
+
241
+ # Project to image coordinates
242
+ rvec = np.zeros(3) # No additional rotation
243
+ tvec = np.zeros(3) # No additional translation
244
+
245
+ image_points, _ = cv2.projectPoints(
246
+ valid_points, rvec, tvec, self.camera_matrix, self.dist_coeffs
247
+ )
248
+
249
+ image_points = image_points.reshape(-1, 2)
250
+
251
+ # Filter points within image bounds
252
+ h, w = self.height, self.width
253
+ valid_img_mask = ((image_points[:, 0] >= 0) & (image_points[:, 0] < w) &
254
+ (image_points[:, 1] >= 0) & (image_points[:, 1] < h))
255
+
256
+ return image_points[valid_img_mask], valid_distances[valid_img_mask], valid_inten[valid_img_mask]
257
+
258
+ def visualize_projection(self):
259
+ """Update the visualization with current transformation"""
260
+ # Reset image
261
+ self.image = self.original_image.copy()
262
+
263
+ # Project points
264
+ projected_points, distances, intensities = self.project_points()
265
+
266
+ if len(projected_points) == 0:
267
+ cv2.putText(self.image, "No valid points to display", (50, 50),
268
+ cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2)
269
+ else:
270
+ # Color points by distance (closer = red, farther = blue)
271
+ max_dist = cv2.getTrackbarPos('Max Distance (m)', 'LiDAR-Camera Calibration')
272
+ normalized_dist = distances.clip(0, 100) / min(max_dist,distances.max())
273
+ normalized_intensity = intensities/255
274
+
275
+ point_size = cv2.getTrackbarPos('Point Size', 'LiDAR-Camera Calibration')
276
+ if point_size == 0:
277
+ point_size = 1
278
+
279
+ # for i, (point, dist) in enumerate(zip(projected_points, normalized_dist)):
280
+ for i, (point, dist) in enumerate(zip(projected_points, normalized_intensity)):
281
+ # Color from red (close) to blue (far)
282
+ color = (255 * (1 - dist), 0, 255 * dist)
283
+ cv2.circle(self.image, (int(point[0]), int(point[1])),
284
+ point_size, color, -1)
285
+
286
+ # Add parameter information
287
+ info_text = [
288
+ f"TX: {self.tx:.3f}m TY: {self.ty:.3f}m TZ: {self.tz:.3f}m",
289
+ f"RX: {self.rx:.2f}° RY: {self.ry:.2f}° RZ: {self.rz:.2f}°",
290
+ f"Projected points: {len(projected_points)}",
291
+ "Press 'q' to quit, 's' to save parameters, 'r' to reset"
292
+ ]
293
+
294
+ for i, text in enumerate(info_text):
295
+ cv2.putText(self.image, text, (10, 30 + i * 25),
296
+ cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2, cv2.LINE_AA)
297
+ cv2.putText(self.image, text, (10, 30 + i * 25),
298
+ cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 1, cv2.LINE_AA)
299
+
300
+ cv2.imshow('LiDAR-Camera Calibration', self.image)
301
+
302
+ def save_parameters(self, filename="calibration_params.txt"):
303
+ """Save current transformation parameters to file"""
304
+ T = self.get_transformation_matrix()
305
+
306
+ with open(filename, 'w') as f:
307
+ f.write("LiDAR-Camera Calibration Parameters\n")
308
+ f.write("=" * 40 + "\n\n")
309
+ f.write(f"Translation (meters):\n")
310
+ f.write(f" TX: {self.tx:.6f}\n")
311
+ f.write(f" TY: {self.ty:.6f}\n")
312
+ f.write(f" TZ: {self.tz:.6f}\n\n")
313
+ f.write(f"Rotation (degrees):\n")
314
+ f.write(f" RX: {self.rx:.6f}\n")
315
+ f.write(f" RY: {self.ry:.6f}\n")
316
+ f.write(f" RZ: {self.rz:.6f}\n\n")
317
+ f.write("4x4 Transformation Matrix:\n")
318
+ f.write(str(T) + "\n")
319
+
320
+ print(f"Parameters saved to {filename}")
321
+
322
+ def run(self):
323
+ """Start the interactive calibration tool"""
324
+ print("LiDAR-Camera Calibration Tool")
325
+ print("Use trackbars to adjust transformation parameters")
326
+ print("Controls:")
327
+ print(" 'q' - Quit")
328
+ print(" 's' - Save current parameters")
329
+ print(" 'r' - Reset to initial values")
330
+
331
+ # Initial visualization
332
+ self.visualize_projection()
333
+
334
+ while True:
335
+ key = cv2.waitKey(30) & 0xFF
336
+
337
+ if key == ord('q'):
338
+ break
339
+ elif key == ord('s'):
340
+ self.save_parameters()
341
+ elif key == ord('r'):
342
+ self.set_initial_values()
343
+ self.visualize_projection()
344
+
345
+ cv2.destroyAllWindows()
346
+
347
+ def create_camera_matrix(focal_length_mm, principal_point_x_pixels, principal_point_y_pixels,
348
+ pixels_per_mm_x, pixels_per_mm_y):
349
+ """
350
+ Create camera matrix from physical camera parameters.
351
+
352
+ Args:
353
+ focal_length_mm: Focal length in millimeters
354
+ principal_point_x_pixels: Principal point X coordinate in pixels
355
+ principal_point_y_pixels: Principal point Y coordinate in pixels
356
+ pixels_per_mm_x: Pixels per millimeter in X direction
357
+ pixels_per_mm_y: Pixels per millimeter in Y direction
358
+
359
+ Returns:
360
+ camera_matrix: 3x3 camera intrinsic matrix
361
+ """
362
+ # Debug: Check inputs
363
+ # print(f"Input values:")
364
+ # print(f" focal_length_mm: {focal_length_mm}")
365
+ # print(f" principal_point_x_pixels: {principal_point_x_pixels}")
366
+ # print(f" principal_point_y_pixels: {principal_point_y_pixels}")
367
+ # print(f" pixels_per_mm_x: {pixels_per_mm_x}")
368
+ # print(f" pixels_per_mm_y: {pixels_per_mm_y}")
369
+
370
+ # Check for None or invalid values
371
+ if any(x is None for x in [focal_length_mm, principal_point_x_pixels, principal_point_y_pixels,
372
+ pixels_per_mm_x, pixels_per_mm_y]):
373
+ print("ERROR: One or more input parameters is None!")
374
+ return None
375
+
376
+ if pixels_per_mm_x == 0 or pixels_per_mm_y == 0:
377
+ print("ERROR: pixels_per_mm cannot be zero!")
378
+ return None
379
+
380
+ try:
381
+ # Convert focal length from mm to pixels
382
+ fx = focal_length_mm * pixels_per_mm_x
383
+ fy = focal_length_mm * pixels_per_mm_y
384
+
385
+ # Principal point is already in pixels
386
+ cx = principal_point_x_pixels
387
+ cy = principal_point_y_pixels
388
+
389
+ # print(f"DEBUG: Camera matrix calculation:")
390
+ # print(f" fx = {focal_length_mm} * {pixels_per_mm_x} = {fx}")
391
+ # print(f" fy = {focal_length_mm} * {pixels_per_mm_y} = {fy}")
392
+ # print(f" cx = {cx}")
393
+ # print(f" cy = {cy}")
394
+
395
+ camera_matrix = np.array([
396
+ [fx, 0, cx],
397
+ [0, fy, cy],
398
+ [0, 0, 1]
399
+ ], dtype=np.float64)
400
+
401
+ # print(f"DEBUG: Final camera matrix:\n{camera_matrix}")
402
+
403
+ return camera_matrix
404
+
405
+ except Exception as e:
406
+ print(f"ERROR in create_camera_matrix: {e}")
407
+ return None
408
+
409
+ def create_sample_data():
410
+ """Create sample point cloud and camera parameters for testing"""
411
+ # Generate sample point cloud (random points in a cube)
412
+ np.random.seed(42)
413
+ n_points = 1000
414
+ points = np.random.uniform(-10, 10, (n_points, 3))
415
+ points[:, 2] += 20 # Move points in front of camera
416
+
417
+ # Add intensity values
418
+ intensities = np.random.uniform(0, 255, n_points)
419
+
420
+ # Create .bin file (KITTI format: x, y, z, intensity)
421
+ bin_data = np.column_stack([points, intensities]).astype(np.float32)
422
+ bin_data.tofile("sample_pointcloud.bin")
423
+
424
+ # Also create .pcd for compatibility
425
+ pcd = o3d.geometry.PointCloud()
426
+ pcd.points = o3d.utility.Vector3dVector(points)
427
+ o3d.io.write_point_cloud("sample_pointcloud.pcd", pcd)
428
+
429
+ # Create sample camera matrix (typical values)
430
+
431
+ focal_length_mm = 12.0 # Your focal length in mm
432
+ principal_point_x_pixels = 960 # Principal point X in pixels
433
+ principal_point_y_pixels = 600 # Principal point Y in pixels
434
+ pixels_per_mm_x = 170.648 # Your pixels per mm in X
435
+ pixels_per_mm_y = 170.648 # Your pixels per mm in Y
436
+
437
+ # Create camera matrix
438
+ camera_matrix = create_camera_matrix(
439
+ focal_length_mm, principal_point_x_pixels, principal_point_y_pixels,
440
+ pixels_per_mm_x, pixels_per_mm_y
441
+ )
442
+ # camera_matrix = np.array([
443
+ # [800, 0, 320],
444
+ # [0, 800, 240],
445
+ # [0, 0, 1]
446
+ # ], dtype=np.float32)
447
+
448
+ # Sample distortion coefficients
449
+ # dist_coeffs = np.array([0.1, -0.2, 0, 0, 0.1], dtype=np.float32)
450
+ dist_coeffs = np.array([0.0, 0.0, 0.0, 0.0, 0.0])
451
+
452
+ # Create a simple test image
453
+ test_image = np.zeros((1200, 1920, 3), dtype=np.uint8)
454
+ cv2.putText(test_image, "Sample Camera Image", (150, 240),
455
+ cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
456
+ cv2.imwrite("sample_image.jpg", test_image)
457
+
458
+ return camera_matrix, dist_coeffs
459
+
460
+
461
+ def main():
462
+ parser = argparse.ArgumentParser(description='LiDAR-Camera Calibration Tool')
463
+ parser.add_argument('--pcd', type=str, help='Path to point cloud file (.pcd, .ply, .bin, etc.)')
464
+ parser.add_argument('--image', type=str, help='Path to camera image')
465
+ parser.add_argument('--demo', action='store_true', help='Run with sample data')
466
+
467
+ args = parser.parse_args()
468
+
469
+ if args.demo or (not args.pcd or not args.image):
470
+ print("Creating sample data for demonstration...")
471
+ camera_matrix, dist_coeffs = create_sample_data()
472
+ pcd_path = "sample_pointcloud.bin" # Use .bin file for demo
473
+ img_path = "sample_image.jpg"
474
+ else:
475
+ pcd_path = args.pcd
476
+ img_path = args.image
477
+
478
+ # You'll need to provide your camera calibration parameters here
479
+ focal_length_mm = 12.0 # Your focal length in mm
480
+ principal_point_x_pixels = 960 # Principal point X in pixels
481
+ principal_point_y_pixels = 600 # Principal point Y in pixels
482
+ pixels_per_mm_x = 170.648 # Your pixels per mm in X
483
+ pixels_per_mm_y = 170.648 # Your pixels per mm in Y
484
+
485
+ # Create camera matrix
486
+ camera_matrix = create_camera_matrix(
487
+ focal_length_mm, principal_point_x_pixels, principal_point_y_pixels,
488
+ pixels_per_mm_x, pixels_per_mm_y
489
+ )
490
+ # camera_matrix = np.array([
491
+ # [800, 0, 320],
492
+ # [0, 800, 240],
493
+ # [0, 0, 1]
494
+ # ], dtype=np.float32)
495
+
496
+ # Sample distortion coefficients
497
+ # dist_coeffs = np.array([0.1, -0.2, 0, 0, 0.1], dtype=np.float32)
498
+ dist_coeffs = np.array([0.0, 0.0, 0.0, 0.0, 0.0])
499
+
500
+ # Check if files exist
501
+ if not os.path.exists(pcd_path):
502
+ print(f"Error: Point cloud file {pcd_path} not found")
503
+ return
504
+
505
+ if not os.path.exists(img_path):
506
+ print(f"Error: Image file {img_path} not found")
507
+ return
508
+
509
+ # Initialize calibrator
510
+ calibrator = LiDARCameraCalibrator(pcd_path, img_path, camera_matrix, dist_coeffs)
511
+
512
+ # Set initial transformation values (modify these as needed)
513
+ # calibrator.set_initial_values(tx=1.932, ty=0, tz=1.4, rx=0, ry=-2.0, rz=1.5)
514
+ # calibrator.set_initial_values(tx=1.932, ty=0.25, tz=1.4, rx=0.0, ry=-2.0, rz=1.5)
515
+ calibrator.set_initial_values(tx=-1.256, ty=-0.1, tz=0.5, rx=0.0, ry=2.2, rz=-0.55)
516
+ # calibrator.set_initial_values(tx=-1.16, ty=-0.23, tz=0.42, rx=0.0, ry=2.0, rz=-0.55)
517
+ # calibrator.set_initial_values(tx=1.932, ty=0.25, tz=1.486, rx=0, ry=-2.0, rz=1.5)
518
+ # calibrator.set_initial_values(tx=0.23, ty=-0.42, tz=-1.16, rx=-2.5, ry=0.46, rz=-0.7)
519
+ # calibrator.set_initial_values(tx=0.192, ty=-0.641, tz=-1.474, rx=-2.51, ry=0.46, rz=-0.69)
520
+
521
+ # Run the calibration tool
522
+ calibrator.run()
523
+
524
+
525
+ if __name__ == "__main__":
526
+ main(
lidar_postprocessing/create_pointcloud_labels.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import matplotlib.pyplot as plt
3
+ import matplotlib.image as mpimg
4
+ import sys
5
+ sys.path.append(os.path.abspath(".")) # one level up
6
+ import numpy as np
7
+ import cv2
8
+ import open3d as o3d
9
+ from scipy.spatial.transform import Rotation
10
+ from utils.lidar import PointCloud
11
+ from utils.camera import ImageData
12
+ import utils.utils as utils
13
+ from natsort import natsorted
14
+
15
+ cmap = plt.get_cmap("jet")
16
+ LABEL_UNKNOWN = -1
17
+
18
+ # User parameters
19
+ location = 'Cambogan'
20
+ sequence = '20250811_113017'
21
+ # location = 'Holmview'
22
+ # sequence = '20250820_130327'
23
+ # location = 'Mount-Cotton'
24
+ # sequence = '20241217_113410'
25
+ condition = 'flooded'
26
+ camera_pos = 'front'
27
+ root_directory = f"../Datasets/FRED/{condition}/KITTI-style"
28
+ # 01000000
29
+
30
+ ############ Define filenames and directories ####################################
31
+
32
+ image_dir = f"{root_directory}/{location}_{sequence}/{camera_pos}-imgs/"
33
+ label_dir = f"{root_directory}/{location}_{sequence}/{camera_pos}-labels/"
34
+ lidar_dir = f"{root_directory}/{location}_{sequence}/ouster/"
35
+ utm_dir = f"{root_directory}/{location}_{sequence}/utm/"
36
+
37
+ img_calib_file = f"./camera_calib.txt"
38
+ lidar_calib_file = f"./calib.txt"
39
+
40
+ timestamps = [filename.split('.png')[0] for filename in natsorted(os.listdir(image_dir)) if os.path.isfile(image_dir+filename)]
41
+ groundplane_eqn = tuple(np.loadtxt(f"{root_directory}/{location}_{sequence}/ground_plane_eqn.txt"))
42
+ a, b, c, d = groundplane_eqn
43
+
44
+ # timestamps.sort()
45
+
46
+ fig, ax = plt.subplots(figsize=(12.8, 8))
47
+ # idx = [0] # mutable index
48
+ idx = [183]
49
+
50
+ def show_image(i):
51
+ ax.clear()
52
+ if i >= len(timestamps):
53
+ plt.close(fig)
54
+ return
55
+ image_timestamp = timestamps[i]
56
+ try:
57
+ image_filename = f"{image_dir}/{image_timestamp}.png"
58
+ label_filename = f"{label_dir}/{image_timestamp}.png"
59
+ lidar_filename, utm_filename = utils.get_corr_files(image_timestamp, [lidar_dir, utm_dir])
60
+
61
+ image = ImageData(image_filename, img_calib_file, label_filename)
62
+ pointcloud = PointCloud(lidar_filename, lidar_calib_file)
63
+
64
+ point_cam, distances_cam, intensities_cam, all_points_cam, valid_cam = pointcloud.points_ouster_to_cam() #, beam_id, azimuth
65
+ img_vis, uv, valid_img = image.project_points(all_points_cam, intensities_cam, cmap, valid_cam) #, beam_id, azimuth
66
+
67
+ valid_semantic = valid_cam & valid_img
68
+
69
+ # Assign semantics
70
+ semantic_labels = utils.assign_semantic_labels(
71
+ pointcloud.points[:, :3],
72
+ uv,
73
+ valid_semantic,
74
+ image.label_img,
75
+ interp_flags=None,
76
+ unknown_label=3
77
+ )
78
+
79
+ print(f"Max x distance: {pointcloud.points[semantic_labels==0,0].max()}")
80
+
81
+ ground_filter = pointcloud.ground_semantic == 0
82
+ inlier_filter = pointcloud.ground_inlier == 1
83
+
84
+ img_vis, uv, valid_img = image.project_points(all_points_cam, semantic_labels, cmap, valid_cam) #, beam_id, azimuth
85
+
86
+ # filtered_points = pointcloud.points[(semantic_labels==0) & (abs(pointcloud.points[:,1]) < 1),:]
87
+ # max_lookahead = filtered_points[:,0].max()
88
+ # far_points = filtered_points[filtered_points[:,0]==max_lookahead,:]
89
+
90
+ # if far_points.shape[0] > 1:
91
+ # far_point = far_points[abs(far_points[:,1]) == abs(far_points[:,1]).min(),:]
92
+ # else:
93
+ # far_point = far_points
94
+ # far_point_cam, far_point_distnace, far_point_intensity = pointcloud.select_points_ouster_to_cam(far_point)
95
+ # far_pixel = image.get_image_coords(far_point_cam)
96
+
97
+ # if far_pixel is not None and len(far_pixel) > 0:
98
+ # u, v = far_pixel[0] # pixel coordinates
99
+
100
+ # h, w = img_vis.shape[:2]
101
+ # bottom_center = (w // 2, h)
102
+
103
+ # ax.plot(
104
+ # [bottom_center[0], u],
105
+ # [bottom_center[1], v],
106
+ # color="lime",
107
+ # linewidth=2
108
+ # )
109
+ # ax.text(
110
+ # u,
111
+ # v - 10,
112
+ # f"{far_point[0,0]:.2f}",
113
+ # color="lime",
114
+ # fontsize=12,
115
+ # ha="center",
116
+ # bbox=dict(facecolor="black", alpha=0.6, edgecolor="none")
117
+ # )
118
+
119
+ ax.imshow(img_vis[:, :, ::-1])
120
+ ax.set_title(f"{image_timestamp}.png")
121
+ ax.axis("off")
122
+ # plt.savefig('paper_figures/labelled_pointcloud.pdf', format="pdf", bbox_inches='tight')
123
+ fig.canvas.draw()
124
+
125
+ except Exception as e:
126
+ print(f"Could not project pointcloud onto {image_timestamp}.png: {e}")
127
+ idx[0] += 1
128
+ show_image(idx[0]) # skip bad one
129
+
130
+ def on_key(event):
131
+ if event.key in [' ', 'right']: # space or right arrow
132
+ idx[0] += 1
133
+ show_image(idx[0])
134
+ elif event.key in [' ', 'left']: # space or right arrow
135
+ if idx[0] > 0:
136
+ idx[0] -= 1
137
+ show_image(idx[0])
138
+ elif event.key in ['q', 'escape']: # q or Esc → quit
139
+ plt.close(fig)
140
+
141
+ fig.canvas.mpl_connect('key_press_event', on_key)
142
+ show_image(idx[0])
143
+ plt.show()
lidar_postprocessing/fill_pointcloud_gaps.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import matplotlib.pyplot as plt
3
+ import matplotlib.image as mpimg
4
+ import sys
5
+ sys.path.append(os.path.abspath(".")) # one level up
6
+ import numpy as np
7
+ import cv2
8
+ import open3d as o3d
9
+ from scipy.spatial.transform import Rotation
10
+ from utils.lidar import PointCloud
11
+ from utils.camera import ImageData
12
+ import utils.utils as utils
13
+ from natsort import natsorted
14
+
15
+ cmap = plt.get_cmap("jet")
16
+ LABEL_UNKNOWN = -1
17
+
18
+ # User parameters
19
+ location = 'Cambogan'
20
+ sequence = '20250811_113017'
21
+ # location = 'Holmview'
22
+ # sequence = '20250820_130327'
23
+ # location = 'Mount-Cotton'
24
+ # sequence = '20241217_113410'
25
+ condition = 'flooded'
26
+ camera_pos = 'front'
27
+ root_directory = f"../Datasets/FRED/{condition}/KITTI-style"
28
+ # 01000000
29
+
30
+ ############ Define filenames and directories ####################################
31
+
32
+ image_dir = f"{root_directory}/{location}_{sequence}/{camera_pos}-imgs/"
33
+ label_dir = f"{root_directory}/{location}_{sequence}/{camera_pos}-labels/"
34
+ lidar_dir = f"{root_directory}/{location}_{sequence}/ouster/"
35
+ utm_dir = f"{root_directory}/{location}_{sequence}/utm/"
36
+
37
+ img_calib_file = f"./camera_calib.txt"
38
+ lidar_calib_file = f"./calib.txt"
39
+
40
+ timestamps = [filename.split('.png')[0] for filename in natsorted(os.listdir(image_dir)) if os.path.isfile(image_dir+filename)]
41
+ groundplane_eqn = tuple(np.loadtxt(f"{root_directory}/{location}_{sequence}/ground_plane_eqn.txt"))
42
+ a, b, c, d = groundplane_eqn
43
+
44
+ # timestamps.sort()
45
+
46
+ # idx = [0] # mutable index
47
+ # idx = [0]
48
+ idx = [183]
49
+
50
+ def show_image(i):
51
+ # ax.clear()
52
+ if i >= len(timestamps):
53
+ return
54
+ image_timestamp = timestamps[i]
55
+ try:
56
+ image_filename = f"{image_dir}/{image_timestamp}.png"
57
+ label_filename = f"{label_dir}/{image_timestamp}.png"
58
+ lidar_filename, utm_filename = utils.get_corr_files(image_timestamp, [lidar_dir, utm_dir])
59
+
60
+ image = ImageData(image_filename, img_calib_file, label_filename)
61
+ pointcloud = PointCloud(lidar_filename, lidar_calib_file)
62
+ # print(f"Number of points in pointcloud: {pointcloud.points.shape}")
63
+ # # print(f"Nan's in pointcloud? {np.any( == np.nan)}")
64
+ # print(f"Number of zeroed points: {np.sum(np.all(pointcloud.points == 0, axis=1))}")
65
+ # print(f"invalid points in ground plane: {np.sum(pointcloud.ground_semantic[np.all(pointcloud.points == 0, axis=1)]==0)}")
66
+ pointcloud.points, pointcloud.ground_semantic, pointcloud.ground_inlier = pointcloud.destagger(pointcloud.points, pointcloud.ground_semantic, pointcloud.ground_inlier)
67
+ groundplane_eqn = utils.fit_height_field_linear(pointcloud.points[pointcloud.ground_semantic==0,:3])
68
+ pointcloud.points, interp_flags = utils.complete_cloud(pointcloud.points, groundplane_eqn)
69
+
70
+ point_cam, distances_cam, intensities_cam, all_points_cam, valid_cam = pointcloud.points_ouster_to_cam() #, beam_id, azimuth
71
+ img_vis, uv, valid_img = image.project_points(all_points_cam, intensities_cam, cmap, valid_cam, colour_norm=255) #, beam_id, azimuth
72
+ semantic_labels = interp_flags.astype(int) + 1
73
+
74
+ labels_norm = semantic_labels.astype(np.float64) / semantic_labels.max()
75
+
76
+ colors = np.stack(
77
+ (labels_norm, np.zeros(labels_norm.shape[0]), np.zeros(labels_norm.shape[0])),
78
+ axis=1
79
+ ) # shape (N, 3)
80
+
81
+ pcd = o3d.geometry.PointCloud()
82
+ pcd.points = o3d.utility.Vector3dVector(pointcloud.points[:,:3])
83
+
84
+ pcd.colors = o3d.utility.Vector3dVector(colors)
85
+ o3d.visualization.draw_geometries([pcd,])
86
+
87
+ idx[0] += 1
88
+
89
+
90
+ except Exception as e:
91
+ print(f"Could not project pointcloud onto {image_timestamp}.png: {e}")
92
+ idx[0] += 1
93
+ show_image(idx[0]) # skip bad one
94
+
95
+
96
+ while idx[0] < len(timestamps):
97
+ show_image(idx[0])
98
+ print(f"Finished all pointclouds")
lidar_postprocessing/project_water_label.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import matplotlib.pyplot as plt
3
+ import matplotlib.image as mpimg
4
+ import sys
5
+ sys.path.append(os.path.abspath(".")) # one level up
6
+ import numpy as np
7
+ import cv2
8
+ import open3d as o3d
9
+ from scipy.spatial.transform import Rotation
10
+ from utils.lidar import PointCloud
11
+ from utils.camera import ImageData
12
+ import utils.utils as utils
13
+ from natsort import natsorted
14
+
15
+ cmap = plt.get_cmap("jet")
16
+
17
+ # User parameters
18
+ location = 'Cambogan'
19
+ sequence = '20250811_113017'
20
+ # location = 'Holmview'
21
+ # sequence = '20250820_130327'
22
+ # location = 'Mount-Cotton'
23
+ # sequence = '20241217_113410'
24
+ condition = 'flooded'
25
+ camera_pos = 'front'
26
+ root_directory = f"D:/Datasets/FRED/{condition}/KITTI-style"
27
+ # 01000000
28
+
29
+ ############ Define filenames and directories ####################################
30
+
31
+ image_dir = f"{root_directory}/{location}_{sequence}/{camera_pos}-imgs/"
32
+ lidar_dir = f"{root_directory}/{location}_{sequence}/ouster/"
33
+ utm_dir = f"{root_directory}/{location}_{sequence}/utm/"
34
+
35
+ img_calib_file = f"./camera_calib.txt"
36
+ lidar_calib_file = f"./calib.txt"
37
+
38
+ timestamps = [filename.split('.png')[0] for filename in natsorted(os.listdir(image_dir)) if os.path.isfile(image_dir+filename)]
39
+
40
+ # timestamps.sort()
41
+
42
+ fig, ax = plt.subplots(figsize=(12.8, 8))
43
+ idx = [0] # mutable index
44
+
45
+ def show_image(i):
46
+ ax.clear()
47
+ if i >= len(timestamps):
48
+ plt.close(fig)
49
+ return
50
+ image_timestamp = timestamps[i]
51
+ try:
52
+ image_filename = f"{image_dir}/{image_timestamp}.png"
53
+ lidar_filename, utm_filename = utils.get_corr_files(image_timestamp, [lidar_dir, utm_dir])
54
+
55
+ image = ImageData(image_filename, img_calib_file)
56
+ pointcloud = PointCloud(lidar_filename, lidar_calib_file)
57
+
58
+ lateral_filter = np.logical_and(-4 < pointcloud.points[:,1], pointcloud.points[:,1] < 1.5)
59
+ ground_filter = pointcloud.ground_semantic == 0
60
+ inlier_filter = pointcloud.ground_inlier == 1
61
+ points_filter = np.logical_and(np.logical_and(lateral_filter, ground_filter), inlier_filter)
62
+ filtered_points = pointcloud.points[points_filter]
63
+ pointcloud.points = filtered_points
64
+ max_lookahead = pointcloud.points[:,0].max()
65
+ far_points = pointcloud.points[pointcloud.points[:,0]==max_lookahead,:]
66
+
67
+ if far_points.shape[0] > 1:
68
+ # pointcloud.points = far_points[abs(far_points[:,1]) == abs(far_points[:,1]).min(),:]
69
+ far_point = far_points[abs(far_points[:,1]) == abs(far_points[:,1]).min(),:]
70
+ else:
71
+ # pointcloud.points = far_points
72
+ far_point = far_points
73
+
74
+ points_cam, distances_cam, intensities_cam, beam_id, azimuth = pointcloud.points_ouster_to_cam()
75
+ far_point_cam, far_point_distnace, far_point_intensity = pointcloud.select_points_ouster_to_cam(far_point)
76
+ img_vis = image.project_points(np.vstack((points_cam, far_point_cam)), np.append(intensities_cam, 128), beam_id, azimuth, cmap)
77
+ far_pixel = image.get_image_coords(far_point_cam)
78
+
79
+ if far_pixel is not None and len(far_pixel) > 0:
80
+ u, v = far_pixel[0] # pixel coordinates
81
+
82
+ h, w = img_vis.shape[:2]
83
+ bottom_center = (w // 2, h)
84
+
85
+ ax.imshow(img_vis[:, :, ::-1])
86
+ ax.plot(
87
+ [bottom_center[0], u],
88
+ [bottom_center[1], v],
89
+ color="lime",
90
+ linewidth=2
91
+ )
92
+ ax.text(
93
+ u,
94
+ v - 10,
95
+ f"{far_point[0,0]:.2f}",
96
+ color="lime",
97
+ fontsize=12,
98
+ ha="center",
99
+ bbox=dict(facecolor="black", alpha=0.6, edgecolor="none")
100
+ )
101
+ ax.set_title(f"{image_timestamp}.png")
102
+ ax.axis("off")
103
+ fig.canvas.draw()
104
+ except Exception as e:
105
+ print(f"Could not project pointcloud onto {image_timestamp}.png: {e}")
106
+ idx[0] += 1
107
+ show_image(idx[0]) # skip bad one
108
+
109
+ def on_key(event):
110
+ if event.key in [' ', 'right']: # space or right arrow
111
+ idx[0] += 1
112
+ show_image(idx[0])
113
+ elif event.key in [' ', 'left']: # space or right arrow
114
+ if idx[0] > 0:
115
+ idx[0] -= 1
116
+ show_image(idx[0])
117
+ elif event.key in ['q', 'escape']: # q or Esc → quit
118
+ plt.close(fig)
119
+
120
+ fig.canvas.mpl_connect('key_press_event', on_key)
121
+ show_image(idx[0])
122
+ plt.show()
localisation/.gitkeep ADDED
File without changes
localisation/VPR_eval-all-v2.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import matplotlib.pyplot as plt
3
+ import matplotlib.image as mpimg
4
+ import sys
5
+ sys.path.append(os.path.abspath(".")) # one level up
6
+ import numpy as np
7
+ import cv2
8
+ import open3d as o3d
9
+ from scipy.spatial.transform import Rotation
10
+ from utils.lidar import PointCloud
11
+ from utils.camera import ImageData
12
+ import utils.utils as utils
13
+ from natsort import natsorted, index_natsorted
14
+ import torch
15
+ from tqdm import tqdm
16
+
17
+ ################## set device based on cuda availability #################
18
+ device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
19
+
20
+ print('CUDA availability: ' + str(torch.cuda.is_available()))
21
+
22
+ ####################### Functions for matching using numpy on CPU or Pytorch on GPU ###################
23
+ def getMatchIndsCPU(ft_ref,ft_qry,topK=20,metric='cosine'):
24
+ """
25
+ metric: 'euclidean' or 'cosine'
26
+ """
27
+ # dMat = cdist(ft_ref,ft_qry,metric)
28
+
29
+ ft_qry_norm = ft_qry / np.linalg.norm(ft_qry, axis=1, keepdims=True) # Shape (M, N)
30
+ ft_ref_norm = ft_ref / np.linalg.norm(ft_ref, axis=1, keepdims=True) # Shape (C, N)
31
+
32
+ # Step 2: Compute cosine similarity
33
+ dMat = 1 - (ft_ref_norm @ ft_qry_norm.T)
34
+ mInds = np.argsort(dMat,axis=0)[:topK].squeeze() # shape: K x ft_qry.shape[0]
35
+ return mInds, dMat
36
+
37
+
38
+ def getMatchIndsGPU(ft_ref, ft_qry,topK=20, metric='cosine'):
39
+ # metric: 'euclidean' or 'cosine'
40
+ ft_qry_tensor = torch.Tensor(ft_qry).to(device)
41
+ ft_ref_tensor = torch.Tensor(ft_ref).to(device)
42
+
43
+ if metric == 'euclidean':
44
+ # Use torch's cdist for Euclidean distance
45
+ dMat = torch.cdist(ft_ref, ft_qry)
46
+
47
+ elif metric == 'cosine':
48
+ # # Normalize both the query and reference tensors
49
+ ft_qry_norm = ft_qry_tensor / ft_qry_tensor.norm(dim=1, keepdim=True)
50
+ ft_ref_norm = ft_ref_tensor / ft_ref_tensor.norm(dim=1, keepdim=True)
51
+ # Compute cosine similarity (1 - cosine similarity for distance)
52
+ dMat = 1 - ft_ref_norm @ ft_qry_norm.t()
53
+
54
+ # Get the indices of the top 5 closest matches
55
+ mInds = torch.argsort(dMat, dim=0)[:topK].squeeze()
56
+
57
+ return mInds, dMat
58
+
59
+ qry_sets = [
60
+ '20210909_124816_v2',
61
+ ]
62
+
63
+ ref_sets = [
64
+ '20230509_115540_v2',
65
+ ]
66
+
67
+ vpr_descs = [
68
+ 'cosplace',
69
+ 'boq',
70
+ 'clique-mining',
71
+ 'cricavpr',
72
+ 'eigenplaces',
73
+ 'mixvpr',
74
+ 'megaloc',
75
+ 'salad',
76
+ 'supervlad',
77
+ ]
78
+
79
+
80
+ img_calib_file = f"./camera_calib.txt"
81
+
82
+ dist_tolerance = 10 # metres
83
+ # qry_idx = 4
84
+
85
+ # User parameters
86
+ location = 'dalby-to-brigalow'
87
+
88
+ ################ Reference filenames and directories #################################
89
+ ref_condition = ''
90
+ ref_camera_pos = 'front'
91
+
92
+ ref_timestamps = []
93
+ ref_utms = []
94
+ ref_img_filenames = []
95
+ ref_utm_filenames = []
96
+
97
+ for ref_set in ref_sets:
98
+ print(f"Loading {ref_set}")
99
+
100
+ ref_root_directory = f"../Datasets/dalby/KITTI-style/{location}"
101
+ ref_vpr_root = f"../Datasets/dalby/KITTI-style/{location}/vpr_ftrs/"
102
+ ref_image_dir = f"{ref_root_directory}/{ref_set}/{ref_camera_pos}-imgs/"
103
+ ref_utm_dir = f"{ref_root_directory}/{ref_set}/utm/"
104
+
105
+
106
+ this_ref_timestamp = [filename.split('.png')[0] for filename in natsorted(os.listdir(ref_image_dir)) if os.path.isfile(ref_image_dir+filename)]
107
+ ref_utms = ref_utms+[np.loadtxt(ref_utm_dir+filename) for filename in natsorted(os.listdir(ref_utm_dir)) if os.path.isfile(ref_utm_dir+filename)][55::]
108
+ ref_img_filenames = [filename for filename in natsorted(os.listdir(ref_image_dir)) if os.path.isfile(ref_image_dir+filename)]
109
+ ref_utm_filenames = np.array([filename for filename in natsorted(os.listdir(ref_utm_dir)) if os.path.isfile(ref_utm_dir+filename)])[:len(os.listdir(ref_utm_dir))-55]
110
+ ref_timestamps = ref_timestamps+this_ref_timestamp
111
+
112
+ ref_utms = np.array(ref_utms)
113
+
114
+ for vpr_desc in vpr_descs:
115
+
116
+ all_results = []
117
+
118
+ first = True
119
+
120
+ print(f"Loading references")
121
+
122
+ for ref_set in ref_sets:
123
+ print(f"Loading {ref_set} {vpr_desc} descriptors")
124
+ ref_root_directory = f"../Datasets/dalby/KITTI-style/{location}"
125
+ ref_vpr_root = f"../Datasets/dalby/KITTI-style/{location}/vpr_ftrs/"
126
+
127
+ ref_image_dir = f"{ref_root_directory}/{ref_set}/{ref_camera_pos}-imgs/"
128
+
129
+ ref_name_sort_idx = index_natsorted(os.listdir(ref_image_dir))
130
+ ref_ftr = np.load(f"{ref_vpr_root}/{ref_set}/{vpr_desc}/queries_descriptors.npy")
131
+ if first:
132
+ ref_ftrs = ref_ftr[ref_name_sort_idx]
133
+ first = False
134
+ else:
135
+ ref_ftrs = np.vstack((ref_ftrs, ref_ftr[ref_name_sort_idx]))
136
+
137
+
138
+ for qry_set in qry_sets:
139
+
140
+ ################ Query filenames and directories #################################
141
+ qry_condition = ''
142
+ qry_camera_pos = 'front'
143
+
144
+ qry_root_directory = f"../Datasets/dalby/KITTI-style/{location}"
145
+ qry_vpr_root = f"../Datasets/dalby/KITTI-style/{location}/vpr_ftrs/"
146
+ qry_image_dir = f"{qry_root_directory}/{qry_set}/{qry_camera_pos}-imgs/"
147
+ qry_utm_dir = f"{qry_root_directory}/{qry_set}/utm/"
148
+
149
+
150
+ qry_timestamps = [filename.split('.png')[0] for filename in natsorted(os.listdir(qry_image_dir)) if os.path.isfile(qry_image_dir+filename)]
151
+ qry_utms = np.array([np.loadtxt(qry_utm_dir+filename) for filename in natsorted(os.listdir(qry_utm_dir)) if os.path.isfile(qry_utm_dir+filename)])
152
+ qry_name_sort_idx = index_natsorted(os.listdir(qry_image_dir))
153
+ qry_ftrs = np.load(f"{qry_vpr_root}/{qry_set}/{vpr_desc}/queries_descriptors.npy")
154
+ qry_ftrs = qry_ftrs[qry_name_sort_idx]
155
+
156
+ mInds, dMat = getMatchIndsGPU(ref_ftrs,qry_ftrs,topK=1)
157
+ mInds = mInds.cpu().numpy()
158
+ in_tol = []
159
+ dists = []
160
+ valid_qry = 0
161
+
162
+ qry_utm_timestamps, qry_utm_idxs = utils.get_all_corr_files(qry_timestamps, [qry_utm_dir,])
163
+ ref_utm_timestamp, ref_utm_idxs = utils.get_all_corr_files(ref_timestamps, [ref_utm_dir,])
164
+
165
+ for qry_idx in tqdm(range(len(qry_timestamps))):
166
+
167
+ qry_image_timestamp = qry_timestamps[qry_idx]
168
+ qry_image_filename = f"{qry_image_dir}/{qry_image_timestamp}.png"
169
+ qry_utm = qry_utms[qry_utm_idxs[qry_idx]]
170
+
171
+
172
+ diffs = ref_utms - qry_utm # shape (N, 2)
173
+ qry_dists = np.linalg.norm(diffs, axis=1) # shape (N,)
174
+ if qry_dists.min() > dist_tolerance:
175
+ continue
176
+ else:
177
+ valid_qry += 1
178
+
179
+ ref_utm = ref_utms[ref_utm_idxs[int(mInds[qry_idx])]]
180
+
181
+ diff = ref_utm - qry_utm # shape (N, 2)
182
+ dist = np.linalg.norm(diff) # shape (N,)
183
+ dists.append(dist)
184
+ if dist < dist_tolerance:
185
+ in_tol.append(1)
186
+ else:
187
+ in_tol.append(0)
188
+
189
+ # qry_image = ImageData(qry_image_filename, img_calib_file)
190
+
191
+ # fig, ax = plt.subplots(1, 2, figsize=(19.4, 6))
192
+ # ax[0].clear()
193
+ # ax[1].clear()
194
+
195
+ # ax[0].imshow(qry_image.image[:, :, ::-1])
196
+ # ax[0].set_title(f"{qry_image_timestamp}.png")
197
+ # ax[0].axis("off")
198
+
199
+ # # Show matching reference image
200
+ # # ref_img_timestamp = utils.get_corr_files(ref_timestamps[int(mInds[qry_idx])], [ref_image_dir,])
201
+ # ref_image = ImageData(f"{ref_image_dir}/{ref_timestamps[int(mInds[qry_idx])]}.png", img_calib_file)
202
+ # ax[1].imshow(ref_image.image[:, :, ::-1])
203
+ # ax[1].set_title(f"{ref_timestamps[int(mInds[qry_idx])]}\nDist={dist:.2f}m")
204
+
205
+ # ax[1].axis("off")
206
+ # fig.canvas.draw()
207
+
208
+ print(f"Recall for {qry_set} using {vpr_desc}: {np.sum(np.array(in_tol))/valid_qry:.02%}")
209
+ all_results.append(np.sum(np.array(in_tol))/valid_qry)
210
+ # plt.figure()
211
+ # plt.plot(np.clip(dists, 0, 30))
212
+ # plt.ylim((0,35))
213
+
214
+ print(f"All {vpr_desc} results:")
215
+ print(all_results)
localisation/VPR_eval-all.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import matplotlib.pyplot as plt
3
+ import matplotlib.image as mpimg
4
+ import sys
5
+ sys.path.append(os.path.abspath(".")) # one level up
6
+ import numpy as np
7
+ import cv2
8
+ import open3d as o3d
9
+ from scipy.spatial.transform import Rotation
10
+ from utils.lidar import PointCloud
11
+ from utils.camera import ImageData
12
+ import utils.utils as utils
13
+ from natsort import natsorted, index_natsorted
14
+ import torch
15
+ from tqdm import tqdm
16
+
17
+ ################## set device based on cuda availability #################
18
+ device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
19
+
20
+ print('CUDA availability: ' + str(torch.cuda.is_available()))
21
+
22
+ ####################### Functions for matching using numpy on CPU or Pytorch on GPU ###################
23
+ def getMatchIndsCPU(ft_ref,ft_qry,topK=20,metric='cosine'):
24
+ """
25
+ metric: 'euclidean' or 'cosine'
26
+ """
27
+ # dMat = cdist(ft_ref,ft_qry,metric)
28
+
29
+ ft_qry_norm = ft_qry / np.linalg.norm(ft_qry, axis=1, keepdims=True) # Shape (M, N)
30
+ ft_ref_norm = ft_ref / np.linalg.norm(ft_ref, axis=1, keepdims=True) # Shape (C, N)
31
+
32
+ # Step 2: Compute cosine similarity
33
+ dMat = 1 - (ft_ref_norm @ ft_qry_norm.T)
34
+ mInds = np.argsort(dMat,axis=0)[:topK].squeeze() # shape: K x ft_qry.shape[0]
35
+ return mInds, dMat
36
+
37
+
38
+ def getMatchIndsGPU(ft_ref, ft_qry,topK=20, metric='cosine'):
39
+ # metric: 'euclidean' or 'cosine'
40
+ ft_qry_tensor = torch.Tensor(ft_qry).to(device)
41
+ ft_ref_tensor = torch.Tensor(ft_ref).to(device)
42
+
43
+ if metric == 'euclidean':
44
+ # Use torch's cdist for Euclidean distance
45
+ dMat = torch.cdist(ft_ref, ft_qry)
46
+
47
+ elif metric == 'cosine':
48
+ # # Normalize both the query and reference tensors
49
+ ft_qry_norm = ft_qry_tensor / ft_qry_tensor.norm(dim=1, keepdim=True)
50
+ ft_ref_norm = ft_ref_tensor / ft_ref_tensor.norm(dim=1, keepdim=True)
51
+ # Compute cosine similarity (1 - cosine similarity for distance)
52
+ dMat = 1 - ft_ref_norm @ ft_qry_norm.t()
53
+
54
+ # Get the indices of the top 5 closest matches
55
+ mInds = torch.argsort(dMat, dim=0)[:topK].squeeze()
56
+
57
+ return mInds, dMat
58
+
59
+ qry_sets = [
60
+ 'Cambogan_20250811_113017',
61
+ 'DairyCreek_20250811_103318',
62
+ 'Holmview_20250820_130327',
63
+ 'Pullenvale_20250916_124105',
64
+ ]
65
+
66
+ # qry_sets = [
67
+ # 'Cambogan_20250812_122101',
68
+ # 'Dairy-Creek_20250812_123312',
69
+ # 'Holmview_20250812_120856',
70
+ # 'Pullenvale_20250812_134524',
71
+ # ]
72
+
73
+ ref_sets = [
74
+ 'Cambogan_20250812_122339',
75
+ 'Dairy-Creek_20250812_122954',
76
+ 'Holmview_20250812_120100',
77
+ 'Pullenvale_20250812_134316',
78
+ ]
79
+
80
+ vpr_descs = [
81
+ 'cosplace',
82
+ 'boq',
83
+ 'clique-mining',
84
+ 'cricavpr',
85
+ 'eigenplaces',
86
+ 'mixvpr',
87
+ 'salad',
88
+ 'supervlad',
89
+ # 'anyloc-urban',
90
+ ]
91
+
92
+ # vpr_descs = [
93
+ # 'cosplace',
94
+ # ]
95
+
96
+ img_calib_file = f"./camera_calib.txt"
97
+
98
+ dist_tolerance = 10 # metres
99
+ # qry_idx = 4
100
+
101
+ # User parameters
102
+ # vpr_desc = 'cosplace'
103
+ # location = 'Holmview'
104
+ # location = 'Cambogan'
105
+
106
+ ################ Reference filenames and directories #################################
107
+ # ref_sequence = '20250812_120100'
108
+ # 20250812_120856
109
+ # ref_sequence = '20250812_122339'
110
+ # 20250812_122621
111
+ ref_condition = 'dry'
112
+ ref_camera_pos = 'front'
113
+
114
+ ref_timestamps = []
115
+ ref_utms = []
116
+ ref_img_filenames = []
117
+ ref_utm_filenames = []
118
+
119
+ for ref_set in ref_sets:
120
+ print(f"Loading {ref_set}")
121
+ ref_root_directory = f"../Datasets/FRED/{ref_condition}/KITTI-style"
122
+ ref_vpr_root = f"../Datasets/FRED/vpr_ftrs/{ref_condition}/KITTI-style"
123
+
124
+ ref_image_dir = f"{ref_root_directory}/{ref_set}/{ref_camera_pos}-imgs/"
125
+ ref_utm_dir = f"{ref_root_directory}/{ref_set}/utm/"
126
+
127
+
128
+ this_ref_timestamp = [filename.split('.png')[0] for filename in natsorted(os.listdir(ref_image_dir)) if os.path.isfile(ref_image_dir+filename)]
129
+ ref_timestamps = ref_timestamps+this_ref_timestamp
130
+ ref_utms = ref_utms+[np.loadtxt(ref_utm_dir+filename) for filename in natsorted(os.listdir(ref_utm_dir)) if os.path.isfile(ref_utm_dir+filename)]
131
+ ref_utm_filenames = ref_utm_filenames+[utils.get_corr_files(ref_timestamp, [ref_utm_dir,]) for ref_timestamp in this_ref_timestamp]
132
+ ref_img_filenames = ref_img_filenames+[filename for filename in natsorted(os.listdir(ref_image_dir)) if os.path.isfile(ref_image_dir+filename)]
133
+
134
+ ref_utms = np.array(ref_utms)
135
+
136
+ for vpr_desc in vpr_descs:
137
+
138
+ all_results = []
139
+
140
+ first = True
141
+
142
+ print(f"Loading references")
143
+
144
+ for ref_set in ref_sets:
145
+ print(f"Loading {ref_set} {vpr_desc} descriptors")
146
+ ref_root_directory = f"../Datasets/FRED/{ref_condition}/KITTI-style"
147
+ ref_vpr_root = f"../Datasets/FRED/vpr_ftrs/{ref_condition}/KITTI-style"
148
+
149
+ ref_image_dir = f"{ref_root_directory}/{ref_set}/{ref_camera_pos}-imgs/"
150
+
151
+ ref_name_sort_idx = index_natsorted(os.listdir(ref_image_dir))
152
+ ref_ftr = np.load(f"{ref_vpr_root}/{ref_set}/{vpr_desc}/queries_descriptors.npy")
153
+ if first:
154
+ ref_ftrs = ref_ftr[ref_name_sort_idx]
155
+ first = False
156
+ else:
157
+ ref_ftrs = np.vstack((ref_ftrs, ref_ftr[ref_name_sort_idx]))
158
+
159
+
160
+ for qry_set in qry_sets:
161
+
162
+ ################ Query filenames and directories #################################
163
+ # qry_sequence = '20250820_130327'
164
+ # qry_sequence = '20250812_120856'
165
+ ###
166
+ # qry_sequence = '20250811_113017'
167
+ # qry_sequence = '20250812_122621'
168
+ qry_condition = 'flooded'
169
+ # qry_sequence = '20250812_122339'
170
+ # qry_condition = 'dry'
171
+ qry_camera_pos = 'front'
172
+ qry_root_directory = f"../Datasets/FRED/{qry_condition}/KITTI-style"
173
+ qry_vpr_root = f"../Datasets/FRED/vpr_ftrs/{qry_condition}/KITTI-style"
174
+
175
+ qry_image_dir = f"{qry_root_directory}/{qry_set}/{qry_camera_pos}-imgs/"
176
+ qry_utm_dir = f"{qry_root_directory}/{qry_set}/utm/"
177
+ qry_timestamps = [filename.split('.png')[0] for filename in natsorted(os.listdir(qry_image_dir)) if os.path.isfile(qry_image_dir+filename)]
178
+ qry_name_sort_idx = index_natsorted(os.listdir(qry_image_dir))
179
+ qry_ftrs = np.load(f"{qry_vpr_root}/{qry_set}/{vpr_desc}/queries_descriptors.npy")
180
+ qry_ftrs = qry_ftrs[qry_name_sort_idx]
181
+
182
+ mInds, dMat = getMatchIndsGPU(ref_ftrs,qry_ftrs,topK=1)
183
+ mInds = mInds.cpu().numpy()
184
+ in_tol = []
185
+ dists = []
186
+ valid_qry = 0
187
+
188
+ for qry_idx in tqdm(range(len(qry_timestamps))):
189
+
190
+ qry_image_timestamp = qry_timestamps[qry_idx]
191
+ qry_image_filename = f"{qry_image_dir}/{qry_image_timestamp}.png"
192
+ qry_utm_timestamp = utils.get_corr_files(qry_image_timestamp, [qry_utm_dir,])
193
+ qry_utm = np.loadtxt(qry_utm_timestamp)
194
+
195
+ # print(f"Number of queries: {len(qry_timestamps)}")
196
+ # print(f"Number of references{ {len(ref_timestamps)}}")
197
+ # print(f"Matche {mInds[qry_idx]}")
198
+ # print(natsorted(os.listdir(qry_image_dir)))
199
+ # print(os.listdir(qry_image_dir))
200
+
201
+ diffs = ref_utms - qry_utm # shape (N, 2)
202
+ qry_dists = np.linalg.norm(diffs, axis=1) # shape (N,)
203
+ if qry_dists.min() > dist_tolerance:
204
+ continue
205
+ else:
206
+ valid_qry += 1
207
+
208
+
209
+ # ref_utm_timestamp = utils.get_corr_files(ref_timestamps[int(mInds[qry_idx])], [ref_utm_dir,])
210
+ # ref_utm = np.loadtxt(ref_utm_timestamp)
211
+ ref_utm = np.loadtxt(ref_utm_filenames[int(mInds[qry_idx])])
212
+
213
+ diff = ref_utm - qry_utm # shape (N, 2)
214
+ dist = np.linalg.norm(diff) # shape (N,)
215
+ dists.append(dist)
216
+ if dist < dist_tolerance:
217
+ in_tol.append(1)
218
+ else:
219
+ in_tol.append(0)
220
+
221
+ # qry_image = ImageData(qry_image_filename, img_calib_file)
222
+
223
+ # fig, ax = plt.subplots(1, 2, figsize=(19.4, 6))
224
+ # ax[0].clear()
225
+ # ax[1].clear()
226
+
227
+ # ax[0].imshow(qry_image.image[:, :, ::-1])
228
+ # ax[0].set_title(f"{qry_image_timestamp}.png")
229
+ # ax[0].axis("off")
230
+
231
+ # # Show matching reference image
232
+ # # ref_img_timestamp = utils.get_corr_files(ref_timestamps[int(mInds[qry_idx])], [ref_image_dir,])
233
+ # ref_image = ImageData(f"{ref_image_dir}/{ref_timestamps[int(mInds[qry_idx])]}.png", img_calib_file)
234
+ # ax[1].imshow(ref_image.image[:, :, ::-1])
235
+ # ax[1].set_title(f"{ref_timestamps[int(mInds[qry_idx])]}\nDist={dist:.2f}m")
236
+
237
+ # ax[1].axis("off")
238
+ # fig.canvas.draw()
239
+
240
+ print(f"Recall for {qry_set} using {vpr_desc}: {np.sum(np.array(in_tol))/valid_qry:.02%}")
241
+ all_results.append(np.sum(np.array(in_tol))/valid_qry)
242
+ # plt.figure()
243
+ # plt.plot(np.clip(dists, 0, 30))
244
+ # plt.ylim((0,35))
245
+
246
+ print(f"All {vpr_desc} results:")
247
+ print(all_results)
localisation/VPR_eval.ipynb ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "code",
5
+ "execution_count": 26,
6
+ "metadata": {},
7
+ "outputs": [
8
+ {
9
+ "name": "stdout",
10
+ "output_type": "stream",
11
+ "text": [
12
+ "CUDA availability: True\n"
13
+ ]
14
+ }
15
+ ],
16
+ "source": [
17
+ "import os\n",
18
+ "import matplotlib.pyplot as plt\n",
19
+ "import matplotlib.image as mpimg\n",
20
+ "import sys\n",
21
+ "sys.path.append(os.path.abspath(\"..\")) # one level up\n",
22
+ "import numpy as np\n",
23
+ "import cv2\n",
24
+ "import open3d as o3d\n",
25
+ "from scipy.spatial.transform import Rotation\n",
26
+ "from utils.lidar import PointCloud\n",
27
+ "from utils.camera import ImageData\n",
28
+ "import utils.utils as utils\n",
29
+ "from natsort import natsorted, index_natsorted\n",
30
+ "import torch\n",
31
+ "from tqdm.notebook import tqdm\n",
32
+ "\n",
33
+ "################## set device based on cuda availability #################\n",
34
+ "device = torch.device(\"cuda:0\" if torch.cuda.is_available() else \"cpu\")\n",
35
+ "\n",
36
+ "print('CUDA availability: ' + str(torch.cuda.is_available()))"
37
+ ]
38
+ },
39
+ {
40
+ "cell_type": "code",
41
+ "execution_count": 27,
42
+ "metadata": {},
43
+ "outputs": [],
44
+ "source": [
45
+ "####################### Functions for matching using numpy on CPU or Pytorch on GPU ###################\n",
46
+ "def getMatchIndsCPU(ft_ref,ft_qry,topK=20,metric='cosine'):\n",
47
+ " \"\"\"\n",
48
+ " metric: 'euclidean' or 'cosine'\n",
49
+ " \"\"\"\n",
50
+ " # dMat = cdist(ft_ref,ft_qry,metric)\n",
51
+ "\n",
52
+ " ft_qry_norm = ft_qry / np.linalg.norm(ft_qry, axis=1, keepdims=True) # Shape (M, N)\n",
53
+ " ft_ref_norm = ft_ref / np.linalg.norm(ft_ref, axis=1, keepdims=True) # Shape (C, N)\n",
54
+ "\n",
55
+ " # Step 2: Compute cosine similarity\n",
56
+ " dMat = 1 - (ft_ref_norm @ ft_qry_norm.T)\n",
57
+ " mInds = np.argsort(dMat,axis=0)[:topK].squeeze() # shape: K x ft_qry.shape[0]\n",
58
+ " return mInds, dMat\n",
59
+ "\n",
60
+ "\n",
61
+ "def getMatchIndsGPU(ft_ref, ft_qry,topK=20, metric='cosine'):\n",
62
+ " # metric: 'euclidean' or 'cosine'\n",
63
+ " ft_qry_tensor = torch.Tensor(ft_qry).to(device)\n",
64
+ " ft_ref_tensor = torch.Tensor(ft_ref).to(device)\n",
65
+ "\n",
66
+ " if metric == 'euclidean':\n",
67
+ " # Use torch's cdist for Euclidean distance\n",
68
+ " dMat = torch.cdist(ft_ref, ft_qry)\n",
69
+ " \n",
70
+ " elif metric == 'cosine':\n",
71
+ " # # Normalize both the query and reference tensors\n",
72
+ " ft_qry_norm = ft_qry_tensor / ft_qry_tensor.norm(dim=1, keepdim=True)\n",
73
+ " ft_ref_norm = ft_ref_tensor / ft_ref_tensor.norm(dim=1, keepdim=True)\n",
74
+ " # Compute cosine similarity (1 - cosine similarity for distance)\n",
75
+ " dMat = 1 - ft_ref_norm @ ft_qry_norm.t()\n",
76
+ "\n",
77
+ " # Get the indices of the top 5 closest matches\n",
78
+ " mInds = torch.argsort(dMat, dim=0)[:topK].squeeze()\n",
79
+ " \n",
80
+ " return mInds, dMat"
81
+ ]
82
+ },
83
+ {
84
+ "cell_type": "code",
85
+ "execution_count": 28,
86
+ "metadata": {},
87
+ "outputs": [
88
+ {
89
+ "name": "stdout",
90
+ "output_type": "stream",
91
+ "text": [
92
+ "(18009, 2)\n"
93
+ ]
94
+ }
95
+ ],
96
+ "source": [
97
+ "# User parameters\n",
98
+ "# vpr_desc = 'boq'\n",
99
+ "# location = 'Holmview'\n",
100
+ "# # location = 'Cambogan'\n",
101
+ "\n",
102
+ "# ################ Query filenames and directories #################################\n",
103
+ "# # qry_sequence = '20250820_130327'\n",
104
+ "# qry_sequence = '20250812_120856'\n",
105
+ "# # qry_sequence = '20250811_113017'\n",
106
+ "# # qry_sequence = '20250812_122621'\n",
107
+ "# # qry_condition = 'flooded'\n",
108
+ "# # qry_sequence = '20250812_122339'\n",
109
+ "# qry_condition = 'dry'\n",
110
+ "# qry_camera_pos = 'front'\n",
111
+ "# qry_root_directory = f\"../../Datasets/FRED/{qry_condition}/KITTI-style\"\n",
112
+ "# qry_vpr_root = f\"../../Datasets/FRED/vpr_ftrs/{qry_condition}/KITTI-style\"\n",
113
+ "\n",
114
+ "vpr_desc = 'salad'\n",
115
+ "location = 'dalby-to-brigalow'\n",
116
+ "# location = 'Cambogan'\n",
117
+ "\n",
118
+ "################ Query filenames and directories #################################\n",
119
+ "qry_sequence = '20210909_124816_v2'\n",
120
+ "qry_condition = ''\n",
121
+ "qry_camera_pos = 'front'\n",
122
+ "qry_root_directory = f\"../../Datasets/dalby/KITTI-style/{location}\"\n",
123
+ "qry_vpr_root = f\"../../Datasets/dalby/KITTI-style/{location}/vpr_ftrs/\"\n",
124
+ "\n",
125
+ "qry_image_dir = f\"{qry_root_directory}/{qry_sequence}/{qry_camera_pos}-imgs/\"\n",
126
+ "qry_utm_dir = f\"{qry_root_directory}/{qry_sequence}/utm/\"\n",
127
+ "qry_utms = np.array([np.loadtxt(qry_utm_dir+filename) for filename in natsorted(os.listdir(qry_utm_dir)) if os.path.isfile(qry_utm_dir+filename)])\n",
128
+ "qry_timestamps = [filename.split('.png')[0] for filename in natsorted(os.listdir(qry_image_dir)) if os.path.isfile(qry_image_dir+filename)]\n",
129
+ "qry_name_sort_idx = index_natsorted(os.listdir(qry_image_dir))\n",
130
+ "qry_ftrs = np.load(f\"{qry_vpr_root}/{qry_sequence}/{vpr_desc}/queries_descriptors.npy\")\n",
131
+ "qry_ftrs = qry_ftrs[qry_name_sort_idx]\n",
132
+ "\n",
133
+ "# qry_ftrs = qry_ftrs[::2]\n",
134
+ "# qry_timestamps = qry_timestamps[::2]\n",
135
+ "\n",
136
+ "\n",
137
+ "################ Reference filenames and directories #################################\n",
138
+ "# ref_sequence = '20250812_120100'\n",
139
+ "# # 20250812_120856\n",
140
+ "# # ref_sequence = '20250812_122339'\n",
141
+ "# # 20250812_122621\n",
142
+ "# ref_condition = 'dry'\n",
143
+ "# ref_camera_pos = 'front'\n",
144
+ "# ref_root_directory = f\"../../Datasets/FRED/{ref_condition}/KITTI-style\"\n",
145
+ "# ref_vpr_root = f\"../../Datasets/FRED/vpr_ftrs/{ref_condition}/KITTI-style\"\n",
146
+ "\n",
147
+ "ref_sequence = '20230509_115540_v2'\n",
148
+ "ref_condition = ''\n",
149
+ "ref_camera_pos = 'front'\n",
150
+ "ref_root_directory = f\"../../Datasets/dalby/KITTI-style/{location}\"\n",
151
+ "ref_vpr_root = f\"../../Datasets/dalby/KITTI-style/{location}/vpr_ftrs/\"\n",
152
+ "\n",
153
+ "ref_image_dir = f\"{ref_root_directory}/{ref_sequence}/{ref_camera_pos}-imgs/\"\n",
154
+ "ref_utm_dir = f\"{ref_root_directory}/{ref_sequence}/utm/\"\n",
155
+ "ref_timestamps = [filename.split('.png')[0] for filename in natsorted(os.listdir(ref_image_dir)) if os.path.isfile(ref_image_dir+filename)]\n",
156
+ "ref_name_sort_idx = index_natsorted(os.listdir(ref_image_dir))\n",
157
+ "ref_utms = np.array([np.loadtxt(ref_utm_dir+filename) for filename in natsorted(os.listdir(ref_utm_dir)) if os.path.isfile(ref_utm_dir+filename)])[55::]\n",
158
+ "print(ref_utms.shape)\n",
159
+ "ref_img_filenames = [filename for filename in natsorted(os.listdir(ref_image_dir)) if os.path.isfile(ref_image_dir+filename)]\n",
160
+ "ref_utm_filenames = np.array([filename for filename in natsorted(os.listdir(ref_utm_dir)) if os.path.isfile(ref_utm_dir+filename)])[:len(os.listdir(ref_utm_dir))-55]\n",
161
+ "ref_ftrs = np.load(f\"{ref_vpr_root}/{ref_sequence}/{vpr_desc}/queries_descriptors.npy\")\n",
162
+ "ref_ftrs = ref_ftrs[ref_name_sort_idx]\n",
163
+ "\n",
164
+ "# ref_timestamps = ref_timestamps[::2]\n",
165
+ "# ref_img_filenames = ref_img_filenames[::2]\n",
166
+ "# ref_ftrs = ref_ftrs[::2]\n",
167
+ "\n",
168
+ "img_calib_file = f\"../camera_calib.txt\"\n",
169
+ "\n",
170
+ "dist_tolerance = 10 # metres\n",
171
+ "# qry_idx = 4\n",
172
+ "\n",
173
+ "qry_utm_timestamps, qry_utm_idxs = utils.get_all_corr_files(qry_timestamps, [qry_utm_dir,])\n",
174
+ "ref_utm_timestamp, ref_utm_idxs = utils.get_all_corr_files(ref_timestamps, [ref_utm_dir,])"
175
+ ]
176
+ },
177
+ {
178
+ "cell_type": "code",
179
+ "execution_count": 29,
180
+ "metadata": {},
181
+ "outputs": [
182
+ {
183
+ "data": {
184
+ "application/vnd.jupyter.widget-view+json": {
185
+ "model_id": "fe0f5b498d8f437b9e761d3747cfddc3",
186
+ "version_major": 2,
187
+ "version_minor": 0
188
+ },
189
+ "text/plain": [
190
+ " 0%| | 0/24299 [00:00<?, ?it/s]"
191
+ ]
192
+ },
193
+ "metadata": {},
194
+ "output_type": "display_data"
195
+ },
196
+ {
197
+ "name": "stdout",
198
+ "output_type": "stream",
199
+ "text": [
200
+ "Recall: 52.49%\n",
201
+ "Valid queries: 23438\n"
202
+ ]
203
+ }
204
+ ],
205
+ "source": [
206
+ "mInds, dMat = getMatchIndsGPU(ref_ftrs,qry_ftrs,topK=1)\n",
207
+ "mInds = mInds.cpu().numpy()\n",
208
+ "in_tol = []\n",
209
+ "dists = []\n",
210
+ "\n",
211
+ "valid_qry = []\n",
212
+ "colors = []\n",
213
+ "plot_utms = []\n",
214
+ "\n",
215
+ "for qry_idx in tqdm(range(len(qry_timestamps))):\n",
216
+ "\n",
217
+ " qry_image_timestamp = qry_timestamps[qry_idx]\n",
218
+ " qry_image_filename = f\"{qry_image_dir}/{qry_image_timestamp}.png\"\n",
219
+ " # qry_utm_timestamp = utils.get_corr_files(qry_image_timestamp, [qry_utm_dir,])\n",
220
+ " qry_utm = qry_utms[qry_utm_idxs[qry_idx]]\n",
221
+ " # qry_utm = np.loadtxt(qry_utm_timestamp)\n",
222
+ "\n",
223
+ " # print(f\"Number of queries: {len(qry_timestamps)}\")\n",
224
+ " # print(f\"Number of references{ {len(ref_timestamps)}}\")\n",
225
+ " # print(f\"Matche {mInds[qry_idx]}\")\n",
226
+ " # print(natsorted(os.listdir(qry_image_dir)))\n",
227
+ " # print(os.listdir(qry_image_dir))\n",
228
+ "\n",
229
+ " # ref_utm_timestamp = utils.get_corr_files(ref_timestamps[int(mInds[qry_idx])], [ref_utm_dir,])\n",
230
+ " # ref_utm = np.loadtxt(ref_utm_timestamp)\n",
231
+ " ref_utm = ref_utms[ref_utm_idxs[int(mInds[qry_idx])]]\n",
232
+ "\n",
233
+ " diff = ref_utm - qry_utm # shape (N, 2)\n",
234
+ " dist = np.linalg.norm(diff) # shape (N,)\n",
235
+ " dists.append(dist)\n",
236
+ " if dist < dist_tolerance:\n",
237
+ " in_tol.append(1)\n",
238
+ " else:\n",
239
+ " in_tol.append(0)\n",
240
+ "\n",
241
+ " diffs = ref_utms - qry_utm # shape (N, 2)\n",
242
+ " qry_dists = np.linalg.norm(diffs, axis=1) # shape (N,)\n",
243
+ " if qry_dists.min() > dist_tolerance:\n",
244
+ " # continue\n",
245
+ " valid_qry.append(0)\n",
246
+ " else:\n",
247
+ " valid_qry.append(1)\n",
248
+ " plot_utms.append(qry_utm)\n",
249
+ " if in_tol[-1]:\n",
250
+ " colors.append('g')\n",
251
+ " else:\n",
252
+ " colors.append('r')\n",
253
+ "\n",
254
+ "\n",
255
+ " # if qry_idx%1000 == 0:\n",
256
+ "\n",
257
+ " # fig, ax = plt.subplots(1, 2, figsize=(9.5, 3))\n",
258
+ " # ax[0].clear()\n",
259
+ " # ax[1].clear()\n",
260
+ " # qry_image = ImageData(qry_image_filename, img_calib_file)\n",
261
+ "\n",
262
+ " # ax[0].imshow(qry_image.image[:, :, ::-1])\n",
263
+ " # ax[0].set_title(f\"{qry_image_timestamp}.png\")\n",
264
+ " # ax[0].axis(\"off\")\n",
265
+ "\n",
266
+ " # ref_img_timestamp = utils.get_corr_files(ref_timestamps[int(mInds[qry_idx])], [ref_image_dir,])\n",
267
+ "\n",
268
+ " # ref_image = ImageData(ref_img_timestamp, img_calib_file)\n",
269
+ " # ax[1].imshow(ref_image.image[:, :, ::-1]) #cv2.flip(,1)\n",
270
+ " # ax[1].set_title(f\"{ref_img_timestamp.split('/')[-1]}\\nDist={dist:.2f}m\")\n",
271
+ "\n",
272
+ " # qry_image = ImageData(qry_image_filename, img_calib_file)\n",
273
+ "\n",
274
+ " # fig, ax = plt.subplots(1, 2, figsize=(19.4, 6))\n",
275
+ " # ax[0].clear()\n",
276
+ " # ax[1].clear()\n",
277
+ "\n",
278
+ " # ax[0].imshow(qry_image.image[:, :, ::-1])\n",
279
+ " # ax[0].set_title(f\"{qry_image_timestamp}.png\")\n",
280
+ " # ax[0].axis(\"off\")\n",
281
+ "\n",
282
+ " # # Show matching reference image\n",
283
+ " # # ref_img_timestamp = utils.get_corr_files(ref_timestamps[int(mInds[qry_idx])], [ref_image_dir,])\n",
284
+ " # ref_image = ImageData(f\"{ref_image_dir}/{ref_timestamps[int(mInds[qry_idx])]}.png\", img_calib_file)\n",
285
+ " # ax[1].imshow(ref_image.image[:, :, ::-1])\n",
286
+ " # ax[1].set_title(f\"{ref_timestamps[int(mInds[qry_idx])]}\\nDist={dist:.2f}m\")\n",
287
+ "\n",
288
+ " # ax[1].axis(\"off\")\n",
289
+ " # fig.canvas.draw()\n",
290
+ "\n",
291
+ "# print(f\"Recall: {np.sum(np.array(in_tol))/len(qry_timestamps):.02%}\")\n",
292
+ "print(f\"Recall: {np.sum(np.array(in_tol))/np.sum(valid_qry):.02%}\")\n",
293
+ "print(f\"Valid queries: {np.sum(valid_qry)}\")\n",
294
+ "# plt.figure()\n",
295
+ "# plt.plot(np.clip(dists, 0, 30))\n",
296
+ "# plt.ylim((0,35))"
297
+ ]
298
+ },
299
+ {
300
+ "cell_type": "code",
301
+ "execution_count": 33,
302
+ "metadata": {},
303
+ "outputs": [
304
+ {
305
+ "data": {
306
+ "text/plain": [
307
+ "<matplotlib.collections.PathCollection at 0x120f23ef5e0>"
308
+ ]
309
+ },
310
+ "execution_count": 33,
311
+ "metadata": {},
312
+ "output_type": "execute_result"
313
+ },
314
+ {
315
+ "data": {
316
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAAB9cAAAM8CAYAAADtLSDqAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/GU6VOAAAACXBIWXMAAA9hAAAPYQGoP6dpAADK5klEQVR4nOzdeZjd89k/8PeZmeybJAhRkcQaeyR5rKm1idjV3taW2kqrqit9+qtuD1VVVUUpIbSoWopqiSWqxBJi36KWRBYEySAkmTnn98epGcckSGRmsrxe13Wumc/nc3+/c89zuZ5L3XPfn0KpVCoFAAAAAAAAAFigqtZOAAAAAAAAAACWdIrrAAAAAAAAAPAJFNcBAAAAAAAA4BMorgMAAAAAAADAJ1BcBwAAAAAAAIBPoLgOAAAAAAAAAJ9AcR0AAAAAAAAAPoHiOgAAAAAAAAB8AsV1AAAAAAAAAPgEiusAAAAAAAAA8AmWy+L6v/71r+y+++7p3bt3CoVCrr/++oV+R6lUyhlnnJF11lkn7dq1y+qrr57/+7//W/zJAgAAAAAAANDqalo7gdbw7rvvZpNNNsnhhx+effbZZ5He8c1vfjO33nprzjjjjGy00UaZNWtWZsyYsZgzBQAAAAAAAGBJUCiVSqXWTqI1FQqFXHfdddlrr70a9ubOnZv//d//zZ/+9KfMnDkzG264YX75y19mu+22S5I8/fTT2XjjjfPEE09k3XXXbZ3EAQAAAAAAAGgxy+VY+E9y+OGH55577smVV16Zxx57LPvtt1923nnnTJw4MUly4403pn///rnpppvSr1+/9O3bN0cccUTefPPNVs4cAAAAAAAAgOaguP4R//nPf3LFFVfk6quvztChQ7PmmmvmO9/5TrbZZpuMGjUqSfLCCy/k5ZdfztVXX53Ro0fnkksuyUMPPZR99923lbMHAAAAAAAAoDksl3euf5yHH344pVIp66yzTsX+nDlz0rNnzyRJsVjMnDlzMnr06Ia4iy66KIMGDcqzzz5rVDwAAAAAAADAMkZx/SOKxWKqq6vz0EMPpbq6uuKsc+fOSZJVV101NTU1FQX4AQMGJEkmTZqkuA4AAAAAAACwjFFc/4iBAwemvr4+r732WoYOHTrfmK233jp1dXX5z3/+kzXXXDNJ8txzzyVJ1lhjjRbLFQAAAAAAAICWUSiVSqXWTqKlvfPOO3n++eeTlIvpZ555Zrbffvv06NEjffr0yVe+8pXcc889+fWvf52BAwdmxowZueOOO7LRRhtll112SbFYzJAhQ9K5c+ecddZZKRaLOe6449K1a9fceuutrfzbAQAAAAAAALC4LZfF9bFjx2b77bdvsn/ooYfmkksuybx58/Lzn/88o0ePzpQpU9KzZ89sueWW+clPfpKNNtooSTJ16tR84xvfyK233ppOnTplxIgR+fWvf50ePXq09K8DAAAAAAAAQDNbLovrAAAAAAAAALAwqlo7AQAAAAAAAABY0tW0dgItqVgsZurUqenSpUsKhUJrpwMAAAAAAABAKyqVSnn77bfTu3fvVFV9fG/6clVcnzp1alZfffXWTgMAAAAAAACAJcjkyZPzuc997mNjlqviepcuXZKU/w/TtWvXVs4GAAAAAAAAgNZUW1ub1VdfvaGW/HGWq+L6B6Pgu3btqrgOAAAAAAAAQJJ8qmvFP35oPAAAAAAAAACguA4AAAAAAAAAn0RxHQAAAAAAAAA+geI6AAAAAAAAAHwCxXUAAAAAAAAA+ASK6wAAAAAAAADwCRTXAQAAAAAAAOATKK4DAAAAAAAAwCdQXAcAAAAAAACAT7BQxfW+ffumUCg0+Rx33HELfOauu+7KoEGD0r59+/Tv3z/nn39+xfm1116bwYMHZ4UVVkinTp2y6aab5rLLLmvynnPPPTf9+vVL+/btM2jQoNx9990LkzoAAAAAAAAALLKFKq4/+OCDmTZtWsNnzJgxSZL99ttvvvEvvvhidtlllwwdOjQTJkzIySefnOOPPz7XXHNNQ0yPHj3ywx/+MOPGjctjjz2Www8/PIcffnhuueWWhpirrroqJ5xwQn74wx9mwoQJGTp0aEaMGJFJkyYtyu8MAAAAAAAAAAulUCqVSov68AknnJCbbropEydOTKFQaHL+/e9/PzfccEOefvrphr1jjjkmjz76aMaNG7fA92622WbZdddd87Of/SxJsvnmm2ezzTbLeeed1xAzYMCA7LXXXjn11FM/db61tbXp1q1bZs2ala5du37q5wAAAAAAAABY9ixMDXmR71yfO3duLr/88owcOXK+hfUkGTduXIYNG1axN3z48IwfPz7z5s1rEl8qlXL77bfn2Wefzec///mGn/PQQw81ec+wYcNy7733fmyOc+bMSW1tbcUHAAAA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dGgmTJiQk08+Occff3yuueaahpixY8fmoIMOyp133plx48alT58+GTZsWKZMabxj9KqrrsoJJ5yQH/7wh5kwYUKGDh2aESNGZNKkSYvyOwMALHG27rN1nj/++cz70bw8fPTDWXfFdZMkD097uGIMfH2pPuOnjm+tNAEAAAAAlluFUqlUWtSHTzjhhNx0002ZOHFiCoVCk/Pvf//7ueGGG/L000837B1zzDF59NFHM27cuPm+s76+Pt27d88555yTQw45JEmy+eabZ7PNNst5553XEDdgwIDstddeOfXUUz91vrW1tenWrVtmzZqVrl27furnAABay8//9fP8eOyPUywVk5RHxg9ZbUjGfXVcrn362vz0rp/m7TlvZ/8N9s9Pt/9p2lS3aeWMAQAAAACWHgtTQ17kO9fnzp2byy+/PCNHjpxvYT1Jxo0bl2HDhlXsDR8+POPHj8+8efPm+8zs2bMzb9689OjRo+HnPPTQQ03eM2zYsNx7770fm+OcOXNSW1tb8QEAWJp8a4tvZdCqgxrWXdt1zfm7np+xL43Nvn/ZN4+9+lhemPlCfnnPL/PDO37YipkCAAAAACzbFrm4fv3112fmzJk57LDDFhgzffr09OrVq2KvV69eqaury4wZM+b7zA9+8IOsttpq2WmnnZIkM2bMSH19/XzfM3369I/N8dRTT023bt0aPquvvvqn+M0AAJYcndp2yr9H/jv/+PI/cs3+1+TZrz+bTVbZJFc/eXWqq6pTSnkIUSmlXP7Y5UmSybMm58onrswtz9+SumJda6YPAAAAALDMqFnUBy+66KKMGDEivXv3/ti4j3a1fzCFfn7d7qeffnquuOKKjB07Nu3bt//E9yyoY/4DJ510Uk488cSGdW1trQI7ALDUaVvdNjuvtXPFXvua9k3i2te0z79e/ld2vnznvFf3XpJk+77b559f+WfaVrdtkVwBAAAAAJZVi9S5/vLLL+e2227LEUcc8bFxq6yySpPu8tdeey01NTXp2bNnxf4ZZ5yR//u//8utt96ajTfeuGF/xRVXTHV19Xzf89Fu9o9q165dunbtWvEBAFgWHD346LSrbpeaqppUF6qTJCcPPTkj/zYyc+rnNMSNfWlsLnr4otQV6zJ+6viMnzr+E7vZn3su+c53ku22SwYOLH/9znfK+wAAAAAAy6tF6lwfNWpUVl555ey6664fG7flllvmxhtvrNi79dZbM3jw4LRp06Zh71e/+lV+/vOf55ZbbsngwYMr4tu2bZtBgwZlzJgx2XvvvRv2x4wZkz333HNR0gcAWOqt03OdjD9qfH53/+/yzrx3svd6e2ev9fbKsX8/NsVSsSGupqomz7zxTDa/cPM8PP3hJMmgVQdlzMFj0r1D94p3PvpocuKJyR13JNXVSX1949m//538+tfJjjuWv26ySYv8mgAAAAAAS4xC6YM57Z9SsVhMv379ctBBB+W0006rODvppJMyZcqUjB49Okny4osvZsMNN8zRRx+dI488MuPGjcsxxxyTK664Ivvss0+S8ij4H/3oR/nzn/+crbfeuuFdnTt3TufOnZMkV111VQ4++OCcf/752XLLLXPBBRfkwgsvzJNPPpk11ljjU+deW1ubbt26ZdasWbrYAYBl0hZ/3CIPTX0odaXG7vSd+u+UO1+8M/WlcrW8ulCdYwYfk98M/00enlYuuM98alD23qsmc+dWFtU/qro6ads2ufHGcqEdAAAAAGBptjA15IXuXL/tttsyadKkjBw5ssnZtGnTMmnSpIZ1v379cvPNN+db3/pWfv/736d37945++yzGwrrSXLuuedm7ty52XfffSve9eMf/zinnHJKkuSAAw7IG2+8kZ/+9KeZNm1aNtxww9x8880LVVgHAFge/HmfP2f45cPz/JvPJ0m+s+V3cs+kexoK60lSX6rPw9MezpALh+TRVx9Npm+cwkX3JXXVKZUKH/v++vpkzpxk992TceN0sAMAAAAAy4+F7lxfmulcBwCWB3XFurw88+V0a98tK3ZcMUfdeFQunnBxRef6uj3XzbNvPFveu3RM8tJ2SenT/91ldXX5Lvbbbmue3wEAAAAAoCUsTA25qoVyAgCghdRU1WTNHmtmxY4rJkn+b8f/y3orrtdwvv5K66dLuy7lwvqMtZMXd1qownpS7mC//fZk4sTFmjoAAAAAwBJLcR0AYBm3YscV8/DRD+ffh/87/z783xl/1PgMXGVgqgvVyUNHJYW6T37JfFRXJ3/4w2JOFgAAAABgCbXQd64DALD0aVvdNlv32bph/Ysdf5F7X7k3j00dvNBd6x+or08eemhxZQgAAAAAsGTTuQ4AsBzq0aFHxh85Put0HvKZ3jNzZqlxceONyde+lpx0UjJlymfMEAAAAABgyaJz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mzbJpZeW71qfOTO57rqkQ4ckSalUyv+78/8lSeqKdakv1Wfq21Nz+WOXt2LCAAAAfByd6wAAAADNqUuXJlullPJ+3fsVe4UU8vbct1sqKwAAABbSQnWu9+3bN4VCocnnuOOOW+Azd911VwYNGpT27dunf//+Of/88yvOn3zyyeyzzz4N7z7rrLPm+55zzz03/fr1S/v27TNo0KDcfffdC5M6AAAAwBKjqlCV3dfZPdWF6iTlwnqS7Lr2ro1BU6YkX/pSMmhQcsQRyZtvtkaqAAAA/NdCFdcffPDBTJs2reEzZsyYJMl+++033/gXX3wxu+yyS4YOHZoJEybk5JNPzvHHH59rrrmmIWb27Nnp379/TjvttKyyyirzfc9VV12VE044IT/84Q8zYcKEDB06NCNGjMikSZMWJn0AAACAJcale1+aAzc8MD079Mw6PdfJ9Qden4GrDiwfvvtuMnRo8pe/JA8/nFxySbLzzkl9favmDAAAsDwrlEql0qI+fMIJJ+Smm27KxIkTUygUmpx///vfzw033JCnn366Ye+YY47Jo48+mnHjxjWJ79u3b0444YSccMIJFfubb755Nttss5x33nkNewMGDMhee+2VU0899VPnW1tbm27dumXWrFnp2rXrp34OAAAAoEXdemsyfHjT/SeeSDbYoOXzAQAAWEYtTA15oTrXP2zu3Lm5/PLLM3LkyPkW1pNk3LhxGTZsWMXe8OHDM378+MybN+9T/5yHHnqoyXuGDRuWe++992OfnTNnTmprays+AAAAAEu86ur571d95D/l1NcndXXNnw8AAACLXly//vrrM3PmzBx22GELjJk+fXp69epVsderV6/U1dVlxowZn+rnzJgxI/X19fN9z/Tp0z/22VNPPfX/t3fnYVpX5f/A388sDIvMiCgggogLKm6Z5oJbqWnmrpWpoWVftzRT81tRWlaWWmq2qbmR5i+xb+6ZC5Zr4q6I+4oL4oYwgwswy+f3x+QzjqCAwswAr9d1PZdzzrmfz9wPXR1x7rnPSV1dXfk1ePDgefqeAAAAAJ1qs82S4cPbiuyVlclWWyWrr946bmpKDj886d699fXNbyazZnVevgAAAEuAj11cP++887LDDjtk4MCBHxn3wa72906h/7Bu9/l5ztyeMWrUqNTX15dfL7744nx9TwAAAIBO0b17cuutySGHJNttl3z3u8k117R1rp9ySnLGGa1F9ubmZPTo5IQTOjdnAACAxVzVx3nT888/nxtvvDGXXXbZR8YNGDBgtu7y1157LVVVVenbt+88fa9ll102lZWVc3zOB7vZP6impiY1NTXz9H0AAAAAupS+fZM//GHOa//6V/LfBoYkrV/fcEPys591TG4AAABLoI/VuT569Oj069cvO+6440fGbbrpphk7dmy7uRtuuCEbbrhhqqur5+l7devWLRtssMFszxk7dmxGjBgxf4kDAAAALA76929/L3tlZTJgQPuYmTOTCRMSJ/kBAAAsEPNdXG9pacno0aOz//77p6qqfeP7qFGjst9++5XHhxxySJ5//vkcffTReeyxx3L++efnvPPOyzHHHFOOmTVrVh588ME8+OCDmTVrViZNmpQHH3wwTz/9dDnm6KOPzrnnnpvzzz8/jz32WI466qi88MILOeSQQz7OZwYAAABYtP34x0nv3q3HxFdUJD16JD//edv6448nq66arLtusuKKybe/3b7THQAAgPlWKor5+y+rG264Idtvv32eeOKJDBs2rN3a17/+9UycODE333xzee6WW27JUUcdlUceeSQDBw7M97///XZF8YkTJ2bo0KGzfZ+tttqq3XPOOOOM/OpXv8rkyZOz9tpr5ze/+U223HLL+Uk9DQ0NqaurS319fWpra+frvQAAAABdyqRJyf/9X9LSkuy5ZzJkSNva+uu3dq03N7fNXXJJ8pWvdHyeAAAAXdj81JDnu7i+KFNcBwAAAJYIVVXtC+vV1cn//m/yi190Xk4AAABd0PzUkD/WnesAAAAAdGErrdR6XPx7mpqSlVduHzNhQjJmTHL//R2aGgAAwKJKcR0AAABgcXPBBUnPnm3jHXdM9t+/bfyb37Tex7733skGGyQ/+1nH5wgAALCIcSw8AAAAwOLo1VeTu+5K+vRJNtusrZN90qRkxRVb72p/v8ceS9ZYo+PzBAAA6ETzU0Ou6qCcAAAAAOhI/fsnu+wy+/wLL8xeWE+S559XXAcAAPgIiusAAAAAS5Jhw5IePZIZM5L3DjSsrk6GD2+Lefvt1qPl33gj+dznki226JxcAQAAuhB3rgMAAAAsSfr2Tf72t9YCe5LU1CQXXZQMHtw6fvvtZNNNk8MPT37+82TLLZPzzuu8fAEAALoId64DAAAALInefrv1iPhBg5Le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zfW/mT3W3KMt4C9/SdZfPznooGSnnZI991RgBwCgQ32iO9ePPPLI/OMf/8hTTz2VUqk02/r3v//9XHXVVXnssbbjmw455JCMHz8+48aNS5LstddeaWhoyLXXXluO+cIXvpA+ffrk4osvTtLauX7FFVfkwQcfnK/8Zs6cmZkzZ5bHDQ0NGTx4sDvXAQAAAKALmvru1Ix/dXyW7bls1lpurbafOc6aldTVJTNmtH/DFVcku+7a4XkCALD4mJ871+erc/39Zs2alYsuuigHHHDAHAvrSWtX+nbbbddubvvtt8+9996bxsbGj4y544472s099dRTGThwYIYOHZqvfvWrefbZZ+ea44knnpi6urrya/DgwfPzEQEAAACADtSnR598dqXPZu1+a7f/mWN9/eyF9VIpmTRptmfMaJqRlkJHOwAAC97HLq5fccUVmTZtWr7+9a9/aMwrr7yS/v37t5vr379/mpqa8sYbb3xkzCuvvFIeb7zxxrnwwgtz/fXX55xzzskrr7ySESNGZMqUKR+Z46hRo1JfX19+vfjii/P5KQEAAACATte3bzJkSFJZ2X5+o43KX06ePjkjzhuRHr/okd4n9s6f7v1TBycJAMDi7mMX188777zssMMOGThw4EfGfbCr/b1T6N8/P6eY98/tsMMO2XPPPbPOOutk2223zTXXXJMkueCCCz7ye9fU1KS2trbdCwAAAABYxFRUJP/4R7LCCq3jbt2Ss85KNtywHPLVS7+ae16+J0nyTuM7OeSaQ3LLxFs6I1sAABZTVR/nTc8//3xuvPHGXHbZZR8ZN2DAgHYd6Eny2muvpaqqKn379v3ImA92s79fr169ss466+Spp576OOkDAAAAAIuatddOnn02efXVpE+fpEeP8lJL0ZLbX7i93XHwVRVVuXnizdlqpa06I1sAABZDH6tzffTo0enXr1923HHHj4zbdNNNM3bs2HZzN9xwQzbccMNUV1d/ZMyIESM+9LkzZ87MY489luWXX/7jpA8AAAAALIoqK5OBA9sV1pOkolSRpbsv3W6uuaU5y/VarjxuamnKMTcck2V/tWyWP3X5/Gbcb8qnbAIAwLyY7+J6S0tLRo8enf333z9VVe0b30eNGpX99tuvPD7kkEPy/PPP5+ijj85jjz2W888/P+edd16OOeaYcsx3vvOd3HDDDTn55JPz+OOP5+ST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kSMzfvz4jBw5Ml/5yldy1113lWMuueSSHHnkkfnRj36UBx54IFtssUV22GGHvPDCCx/nMwMAAAAAAAtbnz7JT3+anH12st9+SanUbvmdxndSpO3o+KIo8k7jO7M/p74+ufPOZOLEhZwwAHy0UlF88NKTeXfkkUfmH//4R5566qmUPvAvxST5/ve/n6uuuiqPPfZYee6QQw7J+PHjM27cuCTJXnvtlYaGhlx77bXlmC984Qvp06dPLr744iTJxhtvnE9/+tM588wzyzFrrrlmdtttt5x44onznG9DQ0Pq6upSX1+f2tra+f68AAAAAADAgnHcv4/LL277RbsC+9V7X52dhu3UFnT77clOO7UW2JPWTvgTTujgTAFYnM1PDflj37k+a9asXHTRRTnggAPmWFhPWrvSt9tuu3Zz22+/fe699940NjZ+ZMwdd9xR/j733XffbDHbbbddOebDzJw5Mw0NDe1eAAAAAABA5/vJZ3+SH27xwwypG5LV+66eC3a7oH1hvaWl9e726dPb5n7xi+Tmmzs8VwBIPkFx/Yorrsi0adPy9a9//UNjXnnllfTv37/dXP/+/dPU1JQ33njjI2NeeeWVJMkbb7yR5ubmj4z5MCeeeGLq6urKr8GDB8/rxwMAAAAAABaiqoqqnLD1CZl45MQ8fvjj2W+9/doHvPlm8vrrrUX295RKyYQJHZsoAPzXxy6un3feedlhhx0ycODAj4z7YFf7e6fQv39+TjEfnJuXmA8aNWpU6uvry68XX3zxI+MBAAAAAIAuok+fZOml29/VXhTJsGHt4yZMSEaMSPr3T3bYIXnppQ5NE4AlR9XHedPzzz+fG2+8MZdddtlHxg0YMGC27vLXXnstVVVV6du370fGvNepvuyyy6aysvIjYz5MTU1Nampq5ukzAQAAAAAAXUhlZTJmTLL77sm777bOHX548v5rZKdMST73uWTatKS5ObnxxuTzn28tuFd9rBIIAHyoj9W5Pnr06PTr1y877rjjR8ZtuummGTt2bLu5G264IRtuuGGqq6s/MmbEiBFJkm7dumWDDTaYLWbs2LHlGAAAAAAAYDG0/fbJs88m112XjB+f/P737TvZ77ijtcDe3Nw6bmpKHn88efLJzskXgMXafP/aVktLS0aPHp39998/VR/4ra9Ro0Zl0qRJufDCC5MkhxxySP7whz/k6KOPzoEHHphx48blvPPOy8UXX1x+z3e+851sueWWOfnkk7PrrrvmyiuvzI033pjbb7+9HHP00Udn5MiR2XDDDbPpppvm7LPPzgsvvJBDDjnk435uAAAAAABgUTBgQOtrTnr2nKf5N955I2ffd3amzZiW7VfZPtusvM0CThKAJcF8F9dvvPHGvPDCCznggANmW5s8eXJeeOGF8njo0KH55z//maOOOip//OMfM3DgwPzud7/LnnvuWY4ZMWJExowZk2OPPTbHHXdcVllllVxyySXZeOONyzF77bVXpkyZkp/97GeZPHly1l577fzzn//MkCFD5jd9AAAAAABgcbHllsmmmyZ33ZVUVLR2sH/1q8lKK5VDprwzJZ/+06fz8vSXUyqV8us7fp1zdz433/z0NzsvbwAWSaWiKIrOTqKjNDQ0pK6uLvX19amtre3sdAAAAAAAgE/qnXeS009vPT7+U59KDj209b72//rVf36VUf8alZaipTy3bM9l8/r/vt7xuQLQ5cxPDXm+O9cBAAAAAAC6jJ49kx/+8EOX62fUp6JU0a64/tast2YPbGpKJkxo/XrdddsV6AEgSSo6OwEAAAAAAICFZYfVdkhzS3N5XFmqzI6r7dg+aOrUZJNNkk9/uvU1YkRSX9/BmQLQ1SmuAwAAAAAAi63NV9w8F+1xUVbovUJ6VffKHmvukfN2Oa990A9/mDz4YNv4vvuSY4/t0DwB6PocCw8AAAAAACzW9llnn+yzzj4fHjB+fNLc1t2e5ubkoYcWfmIALFJ0rgMAAAAAAEu24cPb37FeWdk69z4zm2bmqOuOypDfDMm6Z66bKx+/soOTBKCzlYqiKDo7iY7S0NCQurq61NfXp7a2trPTAQAAAAAAuoLXX0+22ip57LHW8VprJbfckvTtWw751jXfyp/u+1NaipaUUkqS3PaN27LZipt1RsYALCDzU0N2LDwAAAAAALBkW2655IEHkrvuah1vvHFSU9MuZMzDY9JStCRJihSpqqjKZY9dprgOsARRXAcAAAAAAKipSbbc8kOXe1T1yNRMLY+LokiP6h7tg1pakj//OZkwIVltteTAA5Pq6oWUMAAdTXEdAAAAAABgLo7d8th865/fSlWpKiklvap75cBPH9gWUBTJN76RXHhha0G9qSm55prk6quTiorOSxyABcad6wAAAAAAAPPg8scuz9VP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M0lSpMhfJ/w1jx/+eIb1HdbJ2QFdiV+zAQAAAAAAYIn13NTnMqNpRru5IkUeff3RTsoI6KoU1wEAAAAAAFhirVi3YqorqmebX22Z1TohG6ArU1wHAAAAAABgidW3uVvO7bN/Kt9XNjtxmxOzVr+1OjEroCua7+L6pEmT8rWvfS19+/ZNz54986lPfSr33XffR77nj3/8Y9Zcc8306NEjq6++ei688MJ2642NjfnZz36WVVZZJd27d896662X6667rl3M8ccfn1Kp1O41YMCA+U0fAAAAAAAAWk2Zkqy/fvb79rl55vSWXHtJdR7f+KL8YPMfdHZmQBdUNT/BU6dOzWabbZbPfe5zufbaa9OvX78888wzWXrppT/0PWeeeWZGjRqVc845J5/5zGdy991358ADD0yfPn2y8847J0mOPfbYXHTRRTnnnHOyxhpr5Prrr8/uu++eO+64I+uvv375WWuttVZuvPHG8riysnI+Py4AAAAAAAD8129+k0ycmCQZMi0Z0tCc/PDU5Av7dmpaQNc0X8X1k08+OYMHD87o0aPLcyuttNJHvucvf/lLDj744Oy1115JkpVXXjl33nlnTj755HJx/S9/+Ut+9KMf5Ytf/GKS5NBDD83111+fU089NRdddFFbslVV89WtPnPmzMycObM8bmhomOf3AgAAAAAAsJh75ZWkVGobt7Qkkyd3Xj5AlzZfx8JfddVV2XDDDfPlL385/fr1y/rrr59zzjnnI98zc+bMdO/evd1cjx49cvfdd6exsfEjY26//fZ2c0899VQGDhyYoUOH5qtf/WqeffbZj/zeJ554Yurq6sqvwYMHz+tHBQAAAAAAYHG3xRZJU1PbuLIy2WqrzssH6NLmq7j+7LPP5swzz8xqq62W66+/PoccckiOOOKI2e5Qf7/tt98+5557bu67774URZF77703559/fhobG/PGG2+UY0477bQ89dRTaWlpydixY3PllVdm8vt+M2jjjTfOhRdemOuvvz7nnHNOXnnllYwYMSJTpkz50O89atSo1NfXl18vvvji/HxcAAAAAAAAFmf77Zf84AdJxX9LZquumuy2W2sHO8AHlIqiKOY1uFu3btlwww1zxx13lOeOOOKI3HPPPRk3btwc3/Puu+/msMMOy1/+8pcURZH+/fvna1/7Wn71q1/l1VdfTb9+/fL666/nwAMPzNVXX51SqZRVVlkl2267bUaPHp133nlnjs99++23s8oqq+R73/tejj766HnKv6GhIXV1damvr09tbe28fmwAAAAAAAAWZ2edlRx6aNt4jz2S//u/tqI7sNianxryfO0Iyy+/fIYPH95ubs0118wLL7zwoe/p0aNHzj///LzzzjuZOHFiXnjhhay00krp3bt3ll122STJcsstlyuuuCJvv/12nn/++Tz++ONZaqmlMnTo0A99bq9evbLOOuvkqaeemp+PAAAAAAAAAG1mzEi+8532c5ddllx7befkA3RZ81Vc32yzzfLEE0+0m3vyySczZMiQub63uro6gwYNSmVlZcaMGZOddtopFR/4bZ/u3btnhRVWSFNTUy699NLsuuuuH/q8mTNn5rHHHsvyyy8/Px8BAAAAAAAA2kydmsyaNfv8pEkdnwvQpVXNT/BRRx2VESNG5Je//GW+8pWv5O67787ZZ5+ds88+uxwzatSoTJo0qXwP+5NPPpm77747G2+8caZOnZrTTjstDz/8cC644ILye+66665MmjQpn/rUpzJp0qQcf/zxaWlpyfe+971yzDHHHJOdd945K664Yl577bWccMIJaWhoyP777/9J/wwAAAAAAABYUvXvnwwenLz8ctLc3DpXKiUbbdS5eQFdznx1rn/mM5/J5Zdfnosvvjhrr712fv7zn+f000/PvvvuW46ZPHlyu2Pim5ubc+qpp2a99dbL5z//+cyYMSN33HFHVlpppXLMjBkzcuyxx2b48OHZfffds8IKK+T222/P0ksvXY556aWXsvfee2f11VfPHnvskW7duuXOO++cp655AAAAAAAAmKOKiuSaa5IVVmgdd+uWnHde8sYbyRZbJOuskxx/fNLU1KlpAp2vVBRF0dlJdJT5uYweAAAAAACAJUhLS/Laa0mfPsmECckmmyRF0TpfKiXf/W7y6193dpbAAjY/NeT56lwHAAAAAACAxVJFRTJgQFJTk1xySWtBvaWlda0oktGjOzc/oNPN153rAAAAAAAAsNirmkMJ7X1zb896O3dNuivdKrtl4xU2TnVldQcmB3QWnesAAAAAAADwfgcc0Hr3emVlawd7khxzTJJk4rSJGX7G8Gxz4TbZYvQW2WL0Fnlr1ludmCzQURTXAQAAAAAA4P1WWy25++5kv/2SPfZILrig9c71JEdce0Renv5y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3Pb6yy67LNtvv32WXXbZlEqlPPjgg7M9w14P0HV91D7f2NiY73//+1lnnXXSq1evDBw4MPvtt19efvnlds+wzwMsmhTXFzG33HJLDjvssNx5550ZO3Zsmpqast122+Xtt98uxxx11FG57rrrctFFF+Wxxx7LUUcdlW9/+9u58soryzFHHnlkLr/88owZMya333573nrrrey0005pbm4ux+yzzz558MEHc9111+W6667Lgw8+mJEjR5bXm5ubs+OOO+btt9/O7bffnjFjxuTSSy/Nd7/73XJMQ0NDPv/5z2fgwIG555578vvf/z6nnHJKTjvttIX8JwWwaJrbPl8URXbbbbc8++yzufLKK/PAAw9kyJAh2Xbbbdv9u8A+D9B1DRo0KCeddFLuvffe3Hvvvdl6662z6667lgvov/rVr3LaaaflD3/4Q+65554MGDAgn//85zN9+vTyM+zzAF3b3Pb6t99+O5tttllOOumkD32GvR6g6/qoff6dd97J/fffn+OOOy73339/Lrvssjz55JPZZZdd2j3DPg+wiCpYpL322mtFkuKWW24pz6211lrFz372s3Zxn/70p4tjjz22KIqimDZtWlFdXV2MGTOmvD5p0qSioqKiuO6664qiKIpHH320SFLceeed5Zhx48YVSYrHH3+8KIqi+Oc//1lUVFQUkyZNKsdcfPHFRU1NTVFfX18URVGcccYZRV1dXTFjxoxyzIknnlgMHDiwaGlpWVB/DACLrQ/u80888USRpHj44YfLMU1NTcUyyyxTnHPOOUVR2OcBFkV9+vQpzj333KKlpaUYMGBAcdJJJ5XXZsyYUdTV1RVnnXVWURT2eYBF1Xt7/fs999xzRZLigQceaDdvrwdY9Mxpn3/P3XffXSQpnn/++aIo7PMAizKd64u4+vr6JMkyyyxTntt8881z1VVXZdKkSSmKIjfddFOefPLJbL/99kmS++67L42Njdluu+3K7xk4cGDWXnvt3HHHHUmScePGpa6uLhtvvHE5ZpNNNkldXV27mLXXXjsDBw4sx2y//faZOXNm7rvvvnLMVlttlZqamnYxL7/8ciZOnLiA/zQAFj8f3OdnzpyZJOnevXs5prKyMt26dcvtt9+exD4PsChpbm7OmDFj8vbbb2fTTTfNc889l1deeaXdHl5TU5OtttqqvD/b5wEWLR/c6+eFvR5g0TEv+3x9fX1KpVKWXnrpJPZ5gEWZ4voirCiKHH300dl8882z9tprl+d/97vfZfjw4Rk0aFC6deuWL3zhCznjjDOy+eabJ0leeeWVdOvWLX369Gn3vP79++eVV14px/Tr12+279mvX792Mf3792+33qdPn3Tr1u0jY94bvxcDwJzNaZ9fY401MmTIkIwaNSpTp07NrFmzctJJJ+WVV17J5MmTk9jnARYFEyZMyFJLLZWampoccsghufzyyzN8+PDy3jmnvfX9e699HqDr+7C9fl7Y6wG6vnnd52fMmJEf/OAH2WeffVJbW5vEPg+wKKvq7AT4+A4//PA89NBD5U7F9/zud7/LnXfemauuuipDhgzJrbfemm9961tZfvnls+22237o84qiSKlUKo/f//WCjCmK4kPfC0CbOe3z1dXVufTSS/PNb34zyyyzTCorK7Pttttmhx12mOvz7PMAXcfqq6+eBx98MNOmTcull16a/fffP7fcckt5fU5769z2Vfs8QNfyYXv9vBbY58ReD9B1zMs+39jYmK9+9atpaWnJGWecMddn2ucBuj6d64uob3/727nqqqty0003ZdCgQeX5d999Nz/84Q9z2mmnZeedd866666bww8/PHvttVdOOeWUJMmAAQMya9asTJ06td0zX3vttfJvrA0YMCCvvvrqbN/39ddfbxfzwd9smzp1ahobGz8y5rXXXksyezcOAG0+bJ9Pkg022KD8H2+TJ0/OddddlylTpmTo0KFJ7PMAi4Ju3bpl1VVXzYYbbpgTTzwx6623Xn77299mwIABSWbvIPngHm6fB+j6Pmyvnxf2eoCub277fGNjY77yla/kueeey9ixY8td64l9HmBRpri+iCmKIocffnguu+yy/Pvf/y4XUt7T2NiYxsbGVFS0/5+2srIyLS0tSVqLMtXV1Rk7dmx5ffLkyXn44YczYsSIJMmmm26a+vr63H333eWYu+66K/X19e1iHn744fIxxElyww03pKamJhtssEE55tZbb82sWbPaxQwcODArrbTSAvgTAVi8zG2ff7+6urost9xyeeqpp3Lvvfdm1113TWKfB1gUFUWRmTNnZujQoRkwYEC7PXzWrFm55ZZbyvuzfR5g0fTeXj8v7PUAi5737/PvFdafeuqp3Hjjjenbt2+7WPs8wCKsYJFy6KGHFnV1dcXNN99cTJ48ufx65513yjFbbbVVsdZaaxU33XRT8eyzzxajR48uunfvXpxxxhnlmEMOOaQYNGhQceONNxb3339/sfXWWxfrrbde0dTUVI75whe+UKy77rrFuHHjinHjxhXrrLNOsdNOO5XXm5qairXXXrvYZpttivvvv7+48cYbi0GDBhWHH354OWbatGlF//79i7333ruYMGFCcdlllxW1tbXFKaecspD/pAAWTfOyz//tb38rbrrppuKZZ54prrjiimLIkCHFHnvs0e459nmArmvUqFHFrbfeWjz33HPFQw89VPzwhz8sKioqihtuuKEoiqI46aSTirq6uuKyyy4rJkyYUOy9997F8ssvXzQ0NJSfYZ8H6NrmttdPmTKleOCBB4prrrmmSFKMGTOmeOCBB4rJkyeXn2GvB+i6Pmqfb2xsLHbZZZdi0KBBxYMPPtju5zszZ84sP8M+D7BoUlxfxCSZ42v06NHlmMmTJxdf//rXi4EDBxbdu3cvVl999eLUU08tWlpayjHvvvtucfjhhxfLLLNM0aNHj2KnnXYqXnjhhXbfa8qUKcW+++5b9O7du+jdu3ex7777FlOnTm0X8/zzzxc77rhj0aNHj2KZZZYpDj/88GLGjBntYh566KFiiy22KGpqaooBAwYUxx9/fLtcAGgzL/v8b3/722LQoEFFdXV1seKKKxbHHntsu/84Kwr7PEBXdsABBxRDhgwpunXrViy33HLFNttsUy62FEVRtLS0FD/5yU+KAQMGFDU1NcWWW25ZTJgwod0z7PMAXdvc9vrRo0fP8e/9P/nJT8ox9nqAruuj9vnnnnvuQ3++c9NNN5WfYZ8HWDSViqIoOq5PHgAAAAAAAAAWPe5cBwAAAAAAAIC5UFwHAAAAAAAAgLlQXAcAAAAAAACAuVBcBwAAAAAAAIC5UFwHAAAAAAAAgLlQXAcAAAAAAACAuVBcBwAAAAAAAIC5UFwHAAAAAAAAgLlQXAcAAAAAAACAuVBcBwAAAAAAAIC5UFwHAAAAAAAAgLn4/4+scO2iJpNsAAAAAElFTkSuQmCC",
317
+ "text/plain": [
318
+ "<Figure size 2500x1000 with 1 Axes>"
319
+ ]
320
+ },
321
+ "metadata": {},
322
+ "output_type": "display_data"
323
+ }
324
+ ],
325
+ "source": [
326
+ "# import seaborn as sns\n",
327
+ "\n",
328
+ "# dist_data = np.clip(dists, 0, 50)\n",
329
+ "\n",
330
+ "# plt.figure(figsize=(38, 6))\n",
331
+ "# plt.scatter(np.arange(len(dist_data)), dist_data)\n",
332
+ "# plt.ylim((0,60))\n",
333
+ "\n",
334
+ "# fig, ax = plt.subplots(1,1) #figsize=(19, 6)\n",
335
+ "# # sns.histplot(np.clip(dists[::6], 0, 50), kde=True, bins=30)\n",
336
+ "# sns.kdeplot(dist_data, fill=True)\n",
337
+ "# # plt.plot(np.clip(dists[::6], 0, 50))\n",
338
+ "# # plt.ylim((0,60))\n",
339
+ "# plt.xlim(0, 50)\n",
340
+ "# plt.title('VPR Match Distance Distribution')\n",
341
+ "# plt.xticks([0, 10, 20, 30, 40, 50], labels=['0m', '10m', '20m', '30m', '40m', '>50m'])\n",
342
+ "# # print(np.sum(np.array(dists)>5000))\n",
343
+ "# plt.figure(figsize=(38, 3))\n",
344
+ "# plt.plot(valid_qry)\n",
345
+ "# warra = [278500, 7017500]\n",
346
+ "# macalister = [375000, 7007000]\n",
347
+ "# brigalow = [265360, 7046313]\n",
348
+ "dalby_utm = [326893.216, 6992729.692]\n",
349
+ "macalister_utm = [309287.466, 7007270.149]\n",
350
+ "warra_utm = [293685.547, 7019462.582]\n",
351
+ "brigalow_utm = [280382.449, 7028979.359]\n",
352
+ "\n",
353
+ "plt.figure(figsize=(25,10))\n",
354
+ "plt.scatter(np.array(plot_utms)[::100,0], np.array(plot_utms)[::100,1], marker='.', c=colors[::100])\n",
355
+ "plt.scatter(warra_utm[0], warra_utm[1], marker='o', s=120, c='b')\n",
356
+ "plt.scatter(macalister_utm[0], macalister_utm[1], marker='o', s=120, c='b')\n",
357
+ "plt.scatter(brigalow_utm[0], brigalow_utm[1], marker='o', s=120, c='b')\n",
358
+ "plt.scatter(dalby_utm[0], dalby_utm[1], marker='o', s=120, c='b')"
359
+ ]
360
+ }
361
+ ],
362
+ "metadata": {
363
+ "kernelspec": {
364
+ "display_name": "CARRSQ",
365
+ "language": "python",
366
+ "name": "python3"
367
+ },
368
+ "language_info": {
369
+ "codemirror_mode": {
370
+ "name": "ipython",
371
+ "version": 3
372
+ },
373
+ "file_extension": ".py",
374
+ "mimetype": "text/x-python",
375
+ "name": "python",
376
+ "nbconvert_exporter": "python",
377
+ "pygments_lexer": "ipython3",
378
+ "version": "3.10.15"
379
+ }
380
+ },
381
+ "nbformat": 4,
382
+ "nbformat_minor": 2
383
+ }
localisation/VPR_eval.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import matplotlib.pyplot as plt
3
+ import matplotlib.image as mpimg
4
+ import sys
5
+ sys.path.append(os.path.abspath(".")) # one level up
6
+ import numpy as np
7
+ import cv2
8
+ import open3d as o3d
9
+ from scipy.spatial.transform import Rotation
10
+ from utils.lidar import PointCloud
11
+ from utils.camera import ImageData
12
+ import utils.utils as utils
13
+ from natsort import natsorted, index_natsorted
14
+ import torch
15
+ from tqdm.notebook import tqdm
16
+
17
+ ################## set device based on cuda availability #################
18
+ device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
19
+
20
+ print('CUDA availability: ' + str(torch.cuda.is_available()))
21
+
22
+ ####################### Functions for matching using numpy on CPU or Pytorch on GPU ###################
23
+ def getMatchIndsCPU(ft_ref,ft_qry,topK=20,metric='cosine'):
24
+ """
25
+ metric: 'euclidean' or 'cosine'
26
+ """
27
+ # dMat = cdist(ft_ref,ft_qry,metric)
28
+
29
+ ft_qry_norm = ft_qry / np.linalg.norm(ft_qry, axis=1, keepdims=True) # Shape (M, N)
30
+ ft_ref_norm = ft_ref / np.linalg.norm(ft_ref, axis=1, keepdims=True) # Shape (C, N)
31
+
32
+ # Step 2: Compute cosine similarity
33
+ dMat = 1 - (ft_ref_norm @ ft_qry_norm.T)
34
+ mInds = np.argsort(dMat,axis=0)[:topK].squeeze() # shape: K x ft_qry.shape[0]
35
+ return mInds, dMat
36
+
37
+
38
+ def getMatchIndsGPU(ft_ref, ft_qry,topK=20, metric='cosine'):
39
+ # metric: 'euclidean' or 'cosine'
40
+ ft_qry_tensor = torch.Tensor(ft_qry).to(device)
41
+ ft_ref_tensor = torch.Tensor(ft_ref).to(device)
42
+
43
+ if metric == 'euclidean':
44
+ # Use torch's cdist for Euclidean distance
45
+ dMat = torch.cdist(ft_ref, ft_qry)
46
+
47
+ elif metric == 'cosine':
48
+ # # Normalize both the query and reference tensors
49
+ ft_qry_norm = ft_qry_tensor / ft_qry_tensor.norm(dim=1, keepdim=True)
50
+ ft_ref_norm = ft_ref_tensor / ft_ref_tensor.norm(dim=1, keepdim=True)
51
+ # Compute cosine similarity (1 - cosine similarity for distance)
52
+ dMat = 1 - ft_ref_norm @ ft_qry_norm.t()
53
+
54
+ # Get the indices of the top 5 closest matches
55
+ mInds = torch.argsort(dMat, dim=0)[:topK].squeeze()
56
+
57
+ return mInds, dMat
58
+
59
+ # User parameters
60
+ vpr_desc = 'salad'
61
+ # location = 'Holmview'
62
+ # location = 'Cambogan'
63
+ location = 'DairyCreek'
64
+
65
+ ################ Query filenames and directories #################################
66
+ # qry_sequence = '20250820_130327'
67
+ # qry_sequence = '20250812_120856'
68
+ ###
69
+ # qry_sequence = '20250811_113017'
70
+ # qry_sequence = '20250812_122621'
71
+ qry_condition = 'flooded'
72
+ # qry_sequence = '20250812_122339'
73
+ ###
74
+ qry_sequence = '20250811_103318'
75
+ # qry_condition = 'dry'
76
+ qry_camera_pos = 'front'
77
+ qry_root_directory = f"../Datasets/FRED/{qry_condition}/KITTI-style"
78
+ qry_vpr_root = f"../Datasets/FRED/vpr_ftrs/{qry_condition}/KITTI-style"
79
+
80
+ qry_image_dir = f"{qry_root_directory}/{location}_{qry_sequence}/{qry_camera_pos}-imgs/"
81
+ qry_utm_dir = f"{qry_root_directory}/{location}_{qry_sequence}/utm/"
82
+ qry_timestamps = [filename.split('.png')[0] for filename in natsorted(os.listdir(qry_image_dir)) if os.path.isfile(qry_image_dir+filename)]
83
+ qry_name_sort_idx = index_natsorted(os.listdir(qry_image_dir))
84
+ qry_ftrs = np.load(f"{qry_vpr_root}/{location}_{qry_sequence}/{vpr_desc}/queries_descriptors.npy")
85
+ qry_ftrs = qry_ftrs[qry_name_sort_idx]
86
+
87
+ ################ Reference filenames and directories #################################
88
+ # ref_sequence = '20250812_120100'
89
+ # 20250812_120856
90
+ # ref_sequence = '20250812_122339'
91
+ # 20250812_122621
92
+ if location == 'DairyCreek': location = 'Dairy-Creek'
93
+ ref_sequence = '20250812_122954'
94
+ ref_condition = 'dry'
95
+ ref_camera_pos = 'front'
96
+ ref_root_directory = f"../Datasets/FRED/{ref_condition}/KITTI-style"
97
+ ref_vpr_root = f"../Datasets/FRED/vpr_ftrs/{ref_condition}/KITTI-style"
98
+
99
+ ref_image_dir = f"{ref_root_directory}/{location}_{ref_sequence}/{ref_camera_pos}-imgs/"
100
+ ref_utm_dir = f"{ref_root_directory}/{location}_{ref_sequence}/utm/"
101
+ ref_timestamps = [filename.split('.png')[0] for filename in natsorted(os.listdir(ref_image_dir)) if os.path.isfile(ref_image_dir+filename)]
102
+ ref_name_sort_idx = index_natsorted(os.listdir(ref_image_dir))
103
+ ref_utms = np.array([np.loadtxt(ref_utm_dir+filename) for filename in natsorted(os.listdir(ref_utm_dir)) if os.path.isfile(ref_utm_dir+filename)])
104
+ print(ref_utms.shape)
105
+ ref_img_filenames = [filename for filename in natsorted(os.listdir(ref_image_dir)) if os.path.isfile(ref_image_dir+filename)]
106
+ ref_utm_filenames = np.array([filename for filename in natsorted(os.listdir(ref_utm_dir)) if os.path.isfile(ref_utm_dir+filename)])
107
+ ref_ftrs = np.load(f"{ref_vpr_root}/{location}_{ref_sequence}/{vpr_desc}/queries_descriptors.npy")
108
+ ref_ftrs = ref_ftrs[ref_name_sort_idx]
109
+
110
+ img_calib_file = f"./camera_calib.txt"
111
+
112
+ dist_tolerance = 10 # metres
113
+ # qry_idx = 4
114
+
115
+ mInds, dMat = getMatchIndsGPU(ref_ftrs,qry_ftrs,topK=1)
116
+ mInds = mInds.cpu().numpy()
117
+ in_tol = []
118
+ dists = []
119
+
120
+
121
+ plt.ion()
122
+
123
+ fig, ax = plt.subplots(1, 2, figsize=(19.4, 6))
124
+
125
+ # Dummy images to initialize artists
126
+ img0 = ax[0].imshow(np.zeros((10, 10, 3), dtype=np.uint8))
127
+ img1 = ax[1].imshow(np.zeros((10, 10, 3), dtype=np.uint8))
128
+
129
+ ax[0].axis("off")
130
+ ax[1].axis("off")
131
+
132
+ pressed_key = None
133
+
134
+ def on_key(event):
135
+ global pressed_key
136
+ pressed_key = event.key
137
+
138
+ cid = fig.canvas.mpl_connect("key_press_event", on_key)
139
+
140
+ for qry_idx in tqdm(range(len(qry_timestamps))):
141
+
142
+ pressed_key = None
143
+
144
+ qry_image_timestamp = qry_timestamps[qry_idx]
145
+ qry_image_filename = f"{qry_image_dir}/{qry_image_timestamp}.png"
146
+ qry_utm_timestamp = utils.get_corr_files(
147
+ qry_image_timestamp, [qry_utm_dir]
148
+ )
149
+ qry_utm = np.loadtxt(qry_utm_timestamp)
150
+
151
+ ref_utm_timestamp = utils.get_corr_files(
152
+ ref_timestamps[int(mInds[qry_idx])], [ref_utm_dir]
153
+ )
154
+ ref_utm = np.loadtxt(ref_utm_timestamp)
155
+
156
+ diff = ref_utm - qry_utm
157
+ dist = np.linalg.norm(diff)
158
+
159
+ dists.append(dist)
160
+ in_tol.append(1 if dist < dist_tolerance else 0)
161
+
162
+ # Load images
163
+ qry_image = ImageData(qry_image_filename, img_calib_file)
164
+ ref_image = ImageData(
165
+ f"{ref_image_dir}/{ref_timestamps[int(mInds[qry_idx])]}.png",
166
+ img_calib_file,
167
+ )
168
+
169
+ # Update image data (NO re-plotting)
170
+ img0.set_data(qry_image.image[:, :, ::-1])
171
+ img1.set_data(ref_image.image[:, :, ::-1])
172
+
173
+ ax[0].set_title(f"{qry_image_timestamp}.png")
174
+ ax[1].set_title(
175
+ f"{ref_timestamps[int(mInds[qry_idx])]}\nDist = {dist:.2f} m"
176
+ )
177
+
178
+ fig.canvas.draw_idle()
179
+ fig.canvas.flush_events()
180
+
181
+ # print("Press any key for next (mouse click also works)")
182
+ # key = plt.waitforbuttonpress()
183
+
184
+ # print("Press any key for next, 'q' to quit")
185
+
186
+ print("Any key = next | q = quit")
187
+
188
+ while pressed_key is None:
189
+ plt.pause(0.05)
190
+
191
+ if pressed_key == "q":
192
+ break
193
+
194
+
195
+ # print(f"Recall: {np.sum(np.array(in_tol))/len(qry_timestamps):.02%}")
196
+ # plt.figure()
197
+ # plt.plot(np.clip(dists, 0, 30))
198
+ # plt.ylim((0,35))
199
+
localisation/__init__.py ADDED
File without changes
localisation/create_VPR_eval_dataset.ipynb ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "code",
5
+ "execution_count": 30,
6
+ "metadata": {},
7
+ "outputs": [],
8
+ "source": [
9
+ "import os\n",
10
+ "import shutil\n",
11
+ "import matplotlib.pyplot as plt\n",
12
+ "import sys\n",
13
+ "from tqdm import tqdm_notebook as tqdm\n",
14
+ "sys.path.append(os.path.abspath(\"..\")) # one level up\n",
15
+ "import numpy as np\n",
16
+ "import utils.utils as utils\n",
17
+ "from natsort import natsorted\n",
18
+ "\n",
19
+ "cmap = plt.get_cmap(\"jet\")"
20
+ ]
21
+ },
22
+ {
23
+ "cell_type": "code",
24
+ "execution_count": 31,
25
+ "metadata": {},
26
+ "outputs": [],
27
+ "source": [
28
+ "# User parameters\n",
29
+ "# location = 'Holmview'\n",
30
+ "location = 'Cambogan'\n",
31
+ "\n",
32
+ "################ Query filenames and directories #################################\n",
33
+ "# qry_sequence = '20250820_130327'\n",
34
+ "qry_sequence = '20250811_113017' # '20250812_122339' '20250811_113017'\n",
35
+ "qry_condition = 'flooded' # 'dry' 'flooded'\n",
36
+ "qry_camera_pos = 'front'\n",
37
+ "qry_root_directory = f\"../../Datasets/FRED/{qry_condition}/KITTI-style\"\n",
38
+ "\n",
39
+ "qry_image_dir = f\"{qry_root_directory}/{location}_{qry_sequence}/{qry_camera_pos}-imgs/\"\n",
40
+ "qry_utm_dir = f\"{qry_root_directory}/{location}_{qry_sequence}/utm/\"\n",
41
+ "qry_timestamps = [filename.split('.png')[0] for filename in natsorted(os.listdir(qry_image_dir)) if os.path.isfile(qry_image_dir+filename)]\n",
42
+ "\n",
43
+ "# ################ Reference filenames and directories #################################\n",
44
+ "# # ref_sequence = '20250812_120100'\n",
45
+ "# ref_sequence = '20250812_122339'\n",
46
+ "# ref_condition = 'dry'\n",
47
+ "# ref_camera_pos = 'front'\n",
48
+ "# ref_root_directory = f\"../Datasets/FRED/{ref_condition}/KITTI-style\"\n",
49
+ "\n",
50
+ "# ref_image_dir = f\"{ref_root_directory}/{location}_{ref_sequence}/{ref_camera_pos}-imgs/\"\n",
51
+ "# ref_utm_dir = f\"{ref_root_directory}/{location}_{ref_sequence}/utm/\"\n",
52
+ "# ref_utms = np.array([np.loadtxt(ref_utm_dir+filename) for filename in natsorted(os.listdir(ref_utm_dir)) if os.path.isfile(ref_utm_dir+filename)])\n",
53
+ "# ref_img_filenames = [filename for filename in natsorted(os.listdir(ref_image_dir)) if os.path.isfile(ref_image_dir+filename)]\n",
54
+ "# ref_utm_filenames = np.array([filename for filename in natsorted(os.listdir(ref_utm_dir)) if os.path.isfile(ref_utm_dir+filename)])\n",
55
+ "\n",
56
+ "img_calib_file = f\"./camera_calib.txt\"\n",
57
+ "\n",
58
+ "# dist_tolerance = 10 # metres\n",
59
+ "\n",
60
+ "save_folder = f\"../../Datasets/FRED/VPR-eval/{qry_condition}_{location}_{qry_sequence}/\""
61
+ ]
62
+ },
63
+ {
64
+ "cell_type": "code",
65
+ "execution_count": 32,
66
+ "metadata": {},
67
+ "outputs": [
68
+ {
69
+ "name": "stderr",
70
+ "output_type": "stream",
71
+ "text": [
72
+ "C:\\Users\\malonecj\\AppData\\Local\\Temp\\ipykernel_30324\\3268383255.py:2: TqdmDeprecationWarning: This function will be removed in tqdm==5.0.0\n",
73
+ "Please use `tqdm.notebook.tqdm` instead of `tqdm.tqdm_notebook`\n",
74
+ " for i in tqdm(range(len(qry_timestamps))):\n"
75
+ ]
76
+ },
77
+ {
78
+ "data": {
79
+ "application/vnd.jupyter.widget-view+json": {
80
+ "model_id": "93387ddb94d84df5adba819c16412c8f",
81
+ "version_major": 2,
82
+ "version_minor": 0
83
+ },
84
+ "text/plain": [
85
+ " 0%| | 0/243 [00:00<?, ?it/s]"
86
+ ]
87
+ },
88
+ "metadata": {},
89
+ "output_type": "display_data"
90
+ }
91
+ ],
92
+ "source": [
93
+ "os.makedirs(save_folder, exist_ok=True)\n",
94
+ "for i in tqdm(range(len(qry_timestamps))):\n",
95
+ " qry_image_timestamp = qry_timestamps[i]\n",
96
+ " # try:\n",
97
+ " qry_image_filename = f\"{qry_image_dir}/{qry_image_timestamp}.png\"\n",
98
+ " qry_utm_timestamp = utils.get_corr_files(qry_image_timestamp, [qry_utm_dir,])\n",
99
+ " qry_utm = np.loadtxt(qry_utm_timestamp)\n",
100
+ "\n",
101
+ " # qry_image = ImageData(qry_image_filename, img_calib_file)\n",
102
+ "\n",
103
+ " shutil.copy(qry_image_filename, f\"{save_folder}/@{qry_utm[0]}@{qry_utm[1]}@.png\")\n",
104
+ " \n",
105
+ "\n",
106
+ "\n",
107
+ " "
108
+ ]
109
+ }
110
+ ],
111
+ "metadata": {
112
+ "kernelspec": {
113
+ "display_name": "CARRSQ",
114
+ "language": "python",
115
+ "name": "python3"
116
+ },
117
+ "language_info": {
118
+ "codemirror_mode": {
119
+ "name": "ipython",
120
+ "version": 3
121
+ },
122
+ "file_extension": ".py",
123
+ "mimetype": "text/x-python",
124
+ "name": "python",
125
+ "nbconvert_exporter": "python",
126
+ "pygments_lexer": "ipython3",
127
+ "version": "3.10.15"
128
+ }
129
+ },
130
+ "nbformat": 4,
131
+ "nbformat_minor": 2
132
+ }
localisation/groundtruth_utm-single.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
localisation/groundtruth_utm_checker-all.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import matplotlib.pyplot as plt
3
+ import matplotlib.image as mpimg
4
+ import sys
5
+ sys.path.append(os.path.abspath(".")) # one level up
6
+ import numpy as np
7
+ import cv2
8
+ import open3d as o3d
9
+ from scipy.spatial.transform import Rotation
10
+ from utils.lidar import PointCloud
11
+ from utils.camera import ImageData
12
+ import utils.utils as utils
13
+ from natsort import natsorted
14
+
15
+ cmap = plt.get_cmap("jet")
16
+
17
+ # User parameters
18
+ # location = 'Holmview'
19
+ location = 'Cambogan'
20
+
21
+ ################ Query filenames and directories #################################
22
+ # qry_sequence = '20250820_130327'
23
+ qry_sequence = '20250811_113017'
24
+ qry_condition = 'flooded'
25
+ qry_camera_pos = 'front'
26
+ qry_root_directory = f"../Datasets/FRED/{qry_condition}/KITTI-style"
27
+
28
+ qry_image_dir = f"{qry_root_directory}/{location}_{qry_sequence}/{qry_camera_pos}-imgs/"
29
+ qry_utm_dir = f"{qry_root_directory}/{location}_{qry_sequence}/utm/"
30
+ qry_timestamps = [filename.split('.png')[0] for filename in natsorted(os.listdir(qry_image_dir)) if os.path.isfile(qry_image_dir+filename)]
31
+
32
+ ################ Reference filenames and directories #################################
33
+ # ref_sequence = '20250812_120100'
34
+ ref_sequence = '20250812_122339'
35
+ ref_condition = 'dry'
36
+ ref_camera_pos = 'front'
37
+ ref_root_directory = f"../Datasets/FRED/{ref_condition}/KITTI-style"
38
+
39
+ ref_image_dir = f"{ref_root_directory}/{location}_{ref_sequence}/{ref_camera_pos}-imgs/"
40
+ ref_utm_dir = f"{ref_root_directory}/{location}_{ref_sequence}/utm/"
41
+ ref_utms = np.array([np.loadtxt(ref_utm_dir+filename) for filename in natsorted(os.listdir(ref_utm_dir)) if os.path.isfile(ref_utm_dir+filename)])
42
+ ref_img_filenames = [filename for filename in natsorted(os.listdir(ref_image_dir)) if os.path.isfile(ref_image_dir+filename)]
43
+ ref_utm_filenames = np.array([filename for filename in natsorted(os.listdir(ref_utm_dir)) if os.path.isfile(ref_utm_dir+filename)])
44
+
45
+ img_calib_file = f"./camera_calib.txt"
46
+
47
+ dist_tolerance = 10 # metres
48
+
49
+
50
+ fig, ax = plt.subplots(1, 2, figsize=(19.4, 6))
51
+ # idx = [0] # mutable index
52
+ idx = [183]
53
+
54
+ def show_image(i):
55
+ ax[0].clear()
56
+ ax[1].clear()
57
+ if i >= len(qry_timestamps):
58
+ plt.close(fig)
59
+ return
60
+ qry_image_timestamp = qry_timestamps[i]
61
+ # try:
62
+ qry_image_filename = f"{qry_image_dir}/{qry_image_timestamp}.png"
63
+ qry_utm_timestamp = utils.get_corr_files(qry_image_timestamp, [qry_utm_dir,])
64
+ qry_utm = np.loadtxt(qry_utm_timestamp)
65
+
66
+
67
+ diffs = ref_utms - qry_utm # shape (N, 2)
68
+ dists = np.linalg.norm(diffs, axis=1) # shape (N,)
69
+ closest_idx = np.argmin(dists)
70
+ closest_dist = dists[closest_idx]
71
+
72
+ qry_image = ImageData(qry_image_filename, img_calib_file)
73
+
74
+
75
+ ax[0].imshow(qry_image.image[:, :, ::-1])
76
+ ax[0].set_title(f"{qry_image_timestamp}.png")
77
+ ax[0].axis("off")
78
+
79
+ if closest_dist <= dist_tolerance:
80
+ # Show matching reference image
81
+ ref_img_timestamp = utils.get_corr_files(ref_utm_filenames[closest_idx].split('.txt')[0], [ref_image_dir,])
82
+ ref_image = ImageData(ref_img_timestamp, img_calib_file)
83
+ ax[1].imshow(ref_image.image[:, :, ::-1])
84
+ ax[1].set_title(f"{ref_img_timestamp.split('/')[-1]}\nDist={closest_dist:.2f}m")
85
+ else:
86
+ # Show black image with message
87
+ black_img = np.zeros_like(qry_image.image)
88
+ ax[1].imshow(black_img)
89
+ ax[1].text(
90
+ 0.5, 0.5, "No reference image found\nwithin distance tolerance",
91
+ color="white", fontsize=16, ha="center", va="center", transform=ax[1].transAxes
92
+ )
93
+ ax[1].set_title(f"No Match (min dist={closest_dist:.2f}m)")
94
+
95
+ ax[1].axis("off")
96
+ plt.savefig('paper_figures/localization_check.pdf', format="pdf", bbox_inches='tight')
97
+ fig.canvas.draw()
98
+
99
+ def on_key(event):
100
+ if event.key in [' ', 'right']: # space or right arrow
101
+ idx[0] += 1
102
+ show_image(idx[0])
103
+ elif event.key in [' ', 'left']: # space or right arrow
104
+ if idx[0] > 0:
105
+ idx[0] -= 1
106
+ show_image(idx[0])
107
+ elif event.key in ['q', 'escape']: # q or Esc → quit
108
+ plt.close(fig)
109
+
110
+ fig.canvas.mpl_connect('key_press_event', on_key)
111
+ show_image(idx[0])
112
+ plt.show()
localisation/plot_utm_traj.ipynb ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "code",
5
+ "execution_count": 30,
6
+ "metadata": {},
7
+ "outputs": [],
8
+ "source": [
9
+ "import os\n",
10
+ "import matplotlib.pyplot as plt\n",
11
+ "import matplotlib.image as mpimg\n",
12
+ "import sys\n",
13
+ "sys.path.append(os.path.abspath(\"..\")) # one level up\n",
14
+ "import numpy as np\n",
15
+ "import cv2\n",
16
+ "import open3d as o3d\n",
17
+ "from scipy.spatial.transform import Rotation\n",
18
+ "from utils.lidar import PointCloud\n",
19
+ "from utils.camera import ImageData\n",
20
+ "import utils.utils as utils\n",
21
+ "from natsort import natsorted\n",
22
+ "\n",
23
+ "cmap = plt.get_cmap(\"jet\")"
24
+ ]
25
+ },
26
+ {
27
+ "cell_type": "code",
28
+ "execution_count": 31,
29
+ "metadata": {},
30
+ "outputs": [
31
+ {
32
+ "name": "stdout",
33
+ "output_type": "stream",
34
+ "text": [
35
+ "81\n"
36
+ ]
37
+ }
38
+ ],
39
+ "source": [
40
+ "\n",
41
+ "# User parameters\n",
42
+ "location = 'Cambogan'\n",
43
+ "# sequence = '20250811_113017'\n",
44
+ "# location = 'Holmview'\n",
45
+ "\n",
46
+ "################ Query filenames and directories #################################\n",
47
+ "# qry_sequence = '20250820_130327'\n",
48
+ "qry_sequence = '20250811_113017'\n",
49
+ "qry_condition = 'flooded'\n",
50
+ "qry_camera_pos = 'front'\n",
51
+ "qry_root_directory = f\"../../Datasets/FRED/{qry_condition}/KITTI-style\"\n",
52
+ "\n",
53
+ "qry_image_dir = f\"{qry_root_directory}/{location}_{qry_sequence}/{qry_camera_pos}-imgs/\"\n",
54
+ "qry_utm_dir = f\"{qry_root_directory}/{location}_{qry_sequence}/utm/\"\n",
55
+ "qry_utms = np.array([np.loadtxt(qry_utm_dir+filename) for filename in natsorted(os.listdir(qry_utm_dir)) if os.path.isfile(qry_utm_dir+filename)])\n",
56
+ "qry_timestamps = [filename.split('.png')[0] for filename in natsorted(os.listdir(qry_image_dir)) if os.path.isfile(qry_image_dir+filename)]\n",
57
+ "\n",
58
+ "################ Reference filenames and directories #################################\n",
59
+ "# ref_sequence = '20250812_120100'\n",
60
+ "ref_sequence = '20250812_122339'\n",
61
+ "ref_condition = 'dry'\n",
62
+ "ref_camera_pos = 'front'\n",
63
+ "ref_root_directory = f\"../../Datasets/FRED/{ref_condition}/KITTI-style\"\n",
64
+ "\n",
65
+ "ref_image_dir = f\"{ref_root_directory}/{location}_{ref_sequence}/{ref_camera_pos}-imgs/\"\n",
66
+ "ref_utm_dir = f\"{ref_root_directory}/{location}_{ref_sequence}/utm/\"\n",
67
+ "ref_utms = np.array([np.loadtxt(ref_utm_dir+filename) for filename in natsorted(os.listdir(ref_utm_dir)) if os.path.isfile(ref_utm_dir+filename)])\n",
68
+ "ref_img_filenames = [filename for filename in natsorted(os.listdir(ref_image_dir)) if os.path.isfile(ref_image_dir+filename)]\n",
69
+ "ref_timestamps = [filename.split('.png')[0] for filename in natsorted(os.listdir(ref_image_dir)) if os.path.isfile(ref_image_dir+filename)]\n",
70
+ "\n",
71
+ "end_ref = np.where((qry_utms[-1,0] < ref_utms[:,0]) * (qry_utms[-1,1] > ref_utms[:,1]))[0][0]\n",
72
+ "print(end_ref)\n",
73
+ "\n",
74
+ "# img_calib_file = f\"./camera_calib.txt\""
75
+ ]
76
+ },
77
+ {
78
+ "cell_type": "code",
79
+ "execution_count": 33,
80
+ "metadata": {},
81
+ "outputs": [
82
+ {
83
+ "data": {
84
+ "image/png": "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",
85
+ "text/plain": [
86
+ "<Figure size 1200x400 with 1 Axes>"
87
+ ]
88
+ },
89
+ "metadata": {},
90
+ "output_type": "display_data"
91
+ }
92
+ ],
93
+ "source": [
94
+ "plt.figure(figsize=(12,4))\n",
95
+ "plt.plot(qry_utms[:,0]-qry_utms[0,0], qry_utms[:,1]-qry_utms[0,1])\n",
96
+ "plt.plot(ref_utms[:end_ref,0]-qry_utms[0,0], ref_utms[:end_ref,1]-qry_utms[0,1], '--')\n",
97
+ "plt.savefig('../paper_figures/trajectory_plot.pdf', format=\"pdf\", bbox_inches='tight')"
98
+ ]
99
+ }
100
+ ],
101
+ "metadata": {
102
+ "kernelspec": {
103
+ "display_name": "CARRSQ",
104
+ "language": "python",
105
+ "name": "python3"
106
+ },
107
+ "language_info": {
108
+ "codemirror_mode": {
109
+ "name": "ipython",
110
+ "version": 3
111
+ },
112
+ "file_extension": ".py",
113
+ "mimetype": "text/x-python",
114
+ "name": "python",
115
+ "nbconvert_exporter": "python",
116
+ "pygments_lexer": "ipython3",
117
+ "version": "3.10.15"
118
+ }
119
+ },
120
+ "nbformat": 4,
121
+ "nbformat_minor": 2
122
+ }
segmentation/.gitkeep ADDED
File without changes
segmentation/__init__.py ADDED
File without changes
segmentation/evaluate-predictions.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import argparse
3
+ import matplotlib.pyplot as plt
4
+ import matplotlib.image as mpimg
5
+ import sys
6
+ sys.path.append(os.path.abspath(".")) # one level up
7
+ import numpy as np
8
+ import cv2
9
+ import open3d as o3d
10
+ from scipy.spatial.transform import Rotation
11
+ from utils.lidar import PointCloud
12
+ from utils.camera import ImageData
13
+ import utils.utils as utils
14
+ from natsort import natsorted
15
+ import json
16
+ import yaml # pip install pyyaml
17
+ from tqdm import tqdm
18
+
19
+ cmap = plt.get_cmap("jet")
20
+
21
+ # ---------------- Argument Parsing ---------------- #
22
+ parser = argparse.ArgumentParser()
23
+
24
+ parser.add_argument("--config", type=str, help="Path to config file (YAML or JSON). If provided, overrides other args.")
25
+
26
+ parser.add_argument("--location", type=str, default="Cambogan", help="Location name (e.g., Cambogan)")
27
+ parser.add_argument("--sequence", type=str, default="20250811_113017", help="Sequence ID (e.g., 20250811_113017)")
28
+ parser.add_argument("--condition", type=str, default="flooded", help="Condition (e.g., flooded)")
29
+ parser.add_argument("--camera_pos", type=str, default="front", help="Camera position (e.g., front)")
30
+ parser.add_argument("--root", type=str, default="../Datasets/FRED/", help="Root dataset directory (e.g., ../Datasets/FRED/)")
31
+ parser.add_argument("--masks", type=str, required=True, help="Where predicted masks are saved")
32
+ parser.add_argument("--img_calib_file", type=str, default="./camera_calib.txt", help="Path to camera calibration file (e.g., ./camera_calib.txt)")
33
+ parser.add_argument('--vis', action='store_true', help="Store visual comparisons of the predictions and labels")
34
+ parser.add_argument("--output", type=str, default=None, help="Where to save visual comparisons")
35
+
36
+ args = parser.parse_args()
37
+
38
+ # ---------------- Config Loading ---------------- #
39
+ if args.config:
40
+ if args.config.endswith(".yaml") or args.config.endswith(".yml"):
41
+ with open(args.config, "r") as f:
42
+ cfg = yaml.safe_load(f)
43
+ elif args.config.endswith(".json"):
44
+ with open(args.config, "r") as f:
45
+ cfg = json.load(f)
46
+ else:
47
+ raise ValueError("Config file must be .yaml, .yml, or .json")
48
+
49
+ location = cfg["location"]
50
+ sequence = cfg["sequence"]
51
+ condition = cfg["condition"]
52
+ camera_pos = cfg["camera_pos"]
53
+ root = cfg["root"]
54
+ root_directory = f"{root}/{condition}/KITTI-style"
55
+ img_calib_file = cfg["img_calib_file"]
56
+
57
+ else:
58
+ # Fallback: require all CLI args
59
+ required_args = ["location", "sequence", "condition", "camera_pos", "root", "img_calib_file"]
60
+ missing = [arg for arg in required_args if getattr(args, arg) is None]
61
+ if missing:
62
+ parser.error(f"Missing arguments: {', '.join(missing)} (or provide --config)")
63
+
64
+ location = args.location
65
+ sequence = args.sequence
66
+ condition = args.condition
67
+ camera_pos = args.camera_pos
68
+ root_directory = f"{args.root}/{args.condition}/KITTI-style"
69
+ img_calib_file = args.img_calib_file
70
+
71
+ # # User parameters
72
+ # # location = 'Cambogan'
73
+ # # sequence = '20250811_113017'
74
+ # # location = 'Holmview'
75
+ # # sequence = '20250820_130327'
76
+ # # location = 'Pullenvale'
77
+ # # sequence = '20250916_124105'
78
+ # location = 'Mount-Cotton'
79
+ # sequence = '20241217_113410'
80
+ # condition = 'flooded'
81
+ # camera_pos = 'front'
82
+ # root_directory = f"../Datasets/FRED/{condition}/KITTI-style"
83
+ # # 01000000
84
+
85
+ ############ Define filenames and directories ####################################
86
+
87
+ image_dir = f"{root_directory}/{location}_{sequence}/{camera_pos}-imgs/"
88
+ label_dir = f"{root_directory}/{location}_{sequence}/{camera_pos}-labels/"
89
+ img_calib_file = f"./camera_calib.txt"
90
+ timestamps = [filename.split('.png')[0] for filename in natsorted(os.listdir(image_dir)) if os.path.isfile(image_dir+filename)]
91
+ mask_filenames = [filename for filename in natsorted(os.listdir(args.masks)) if os.path.isfile(args.masks+filename)]
92
+
93
+
94
+ fig, ax = plt.subplots(figsize=(12.8, 8))
95
+ idx = 0 # mutable index
96
+ # idx = 183 # mutable index
97
+
98
+ def load_image_data(i):
99
+ image_timestamp = timestamps[i]
100
+ try:
101
+ image_filename = f"{image_dir}/{image_timestamp}.png"
102
+ label_filename = f"{label_dir}/{image_timestamp}.png"
103
+ image = ImageData(image_filename, img_calib_file, label_filename)
104
+ water_label = image.label_img == 1
105
+
106
+ return water_label.astype(int)
107
+
108
+ # label_mask = np.any(image.colour_label != image.semantic_classes['other'], axis=-1)
109
+ except Exception as e:
110
+ print(f"Could not show label for {image_timestamp}.png: {e}")
111
+
112
+
113
+
114
+
115
+ def calculate_iou(label: np.ndarray, prediction: np.ndarray, verbose=False) -> float:
116
+ """
117
+ Calculate Intersection over Union (IOU) for two binary masks.
118
+
119
+ Args:
120
+ label: Ground truth binary mask (values 0 or 1)
121
+ prediction: Predicted binary mask (values 0 or 1)
122
+
123
+ Returns:
124
+ IOU score as a float in [0, 1]. Returns 0.0 if both masks are empty.
125
+ """
126
+ if label.shape != prediction.shape:
127
+ # raise ValueError(f"Shape mismatch: label {label.shape} vs prediction {prediction.shape}")
128
+ if verbose:
129
+ print(f"reshaping predictions from {prediction.shape} to {label.shape} to match labels.")
130
+ prediction = cv2.resize(prediction, (label.shape[1], label.shape[0]), interpolation=cv2.INTER_NEAREST)
131
+
132
+ intersection = np.logical_and(label, prediction).sum()
133
+ union = np.logical_or(label, prediction).sum()
134
+
135
+ if union == 0:
136
+ return 1.0 # or 1.0 if you want to treat two empty masks as a perfect match
137
+
138
+ return float(intersection / union)
139
+
140
+ def save_mask_comparison(label: np.ndarray, prediction: np.ndarray, save_path: str, iou: float | None = None) -> None:
141
+ """
142
+ Save label and prediction masks side by side for visual comparison.
143
+
144
+ Args:
145
+ label: Ground truth binary mask
146
+ prediction: Predicted binary mask
147
+ save_path: Path to save the output image (e.g. 'comparison.png')
148
+ iou: Optional IOU score to display in the title
149
+ """
150
+ fig, axes = plt.subplots(1, 2, figsize=(8, 4))
151
+
152
+ axes[0].imshow(label, cmap='gray', vmin=0, vmax=1)
153
+ axes[0].set_title('Label')
154
+ axes[0].axis('off')
155
+
156
+ axes[1].imshow(prediction, cmap='gray', vmin=0, vmax=1)
157
+ axes[1].set_title('Prediction')
158
+ axes[1].axis('off')
159
+
160
+ title = 'Mask Comparison'
161
+ if iou is not None:
162
+ title += f' | IOU: {iou:.4f}'
163
+ fig.suptitle(title)
164
+
165
+ plt.tight_layout()
166
+ plt.savefig(save_path, bbox_inches='tight', dpi=150)
167
+ plt.close(fig)
168
+
169
+ iou_scores = []
170
+
171
+ for i in tqdm(range(idx, len(timestamps))):
172
+
173
+ img_label = load_image_data(i)
174
+ # print(mask_filenames[i])
175
+ pred_label = (cv2.cvtColor(cv2.imread(args.masks+mask_filenames[i]), cv2.COLOR_BGR2GRAY)/255).astype(int)
176
+
177
+ # cv2.imshow('pred', cv2.resize(cv2.cvtColor(cv2.imread(args.masks+mask_filenames[i]), cv2.COLOR_BGR2GRAY), (640, 480)))
178
+ # cv2.waitKey(0)
179
+
180
+ # print(f"label shape: {img_label.shape}, label values: {np.unique(img_label)}")
181
+ # print(f"pred shape: {pred_label.shape}, pred values: {np.unique(pred_label)}")
182
+ # break
183
+ iou = calculate_iou(img_label, pred_label)
184
+ iou_scores.append(iou)
185
+ if args.vis:
186
+ os.makedirs(args.output, exist_ok=True)
187
+ save_mask_comparison(img_label, pred_label, f"{args.output}/{timestamps[i]}.png", iou)
188
+
189
+ # break
190
+
191
+ print(f"Mean IOU for water predictions: {np.mean(np.array(iou_scores))}")
segmentation/show_labels-all.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import argparse
3
+ import matplotlib.pyplot as plt
4
+ import matplotlib.image as mpimg
5
+ import sys
6
+ sys.path.append(os.path.abspath(".")) # one level up
7
+ import numpy as np
8
+ import cv2
9
+ import open3d as o3d
10
+ from scipy.spatial.transform import Rotation
11
+ from utils.lidar import PointCloud
12
+ from utils.camera import ImageData
13
+ import utils.utils as utils
14
+ from natsort import natsorted
15
+ import json
16
+ import yaml # pip install pyyaml
17
+
18
+ cmap = plt.get_cmap("jet")
19
+
20
+ # ---------------- Argument Parsing ---------------- #
21
+ parser = argparse.ArgumentParser()
22
+
23
+ parser.add_argument("--config", type=str, help="Path to config file (YAML or JSON). If provided, overrides other args.")
24
+
25
+ parser.add_argument("--location", type=str, default="Cambogan", help="Location name (e.g., Cambogan)")
26
+ parser.add_argument("--sequence", type=str, default="20250811_113017", help="Sequence ID (e.g., 20250811_113017)")
27
+ parser.add_argument("--condition", type=str, default="flooded", help="Condition (e.g., flooded)")
28
+ parser.add_argument("--camera_pos", type=str, default="front", help="Camera position (e.g., front)")
29
+ parser.add_argument("--root", type=str, default="../Datasets/FRED/", help="Root dataset directory (e.g., ../Datasets/FRED/)")
30
+ parser.add_argument("--img_calib_file", type=str, default="./camera_calib.txt", help="Path to camera calibration file (e.g., ./camera_calib.txt)")
31
+
32
+ args = parser.parse_args()
33
+
34
+ # ---------------- Config Loading ---------------- #
35
+ if args.config:
36
+ if args.config.endswith(".yaml") or args.config.endswith(".yml"):
37
+ with open(args.config, "r") as f:
38
+ cfg = yaml.safe_load(f)
39
+ elif args.config.endswith(".json"):
40
+ with open(args.config, "r") as f:
41
+ cfg = json.load(f)
42
+ else:
43
+ raise ValueError("Config file must be .yaml, .yml, or .json")
44
+
45
+ location = cfg["location"]
46
+ sequence = cfg["sequence"]
47
+ condition = cfg["condition"]
48
+ camera_pos = cfg["camera_pos"]
49
+ root = cfg["root"]
50
+ root_directory = f"{root}/{condition}/KITTI-style"
51
+ img_calib_file = cfg["img_calib_file"]
52
+
53
+ else:
54
+ # Fallback: require all CLI args
55
+ required_args = ["location", "sequence", "condition", "camera_pos", "root", "img_calib_file"]
56
+ missing = [arg for arg in required_args if getattr(args, arg) is None]
57
+ if missing:
58
+ parser.error(f"Missing arguments: {', '.join(missing)} (or provide --config)")
59
+
60
+ location = args.location
61
+ sequence = args.sequence
62
+ condition = args.condition
63
+ camera_pos = args.camera_pos
64
+ root_directory = f"{args.root}/{args.condition}/KITTI-style"
65
+ img_calib_file = args.img_calib_file
66
+
67
+ # # User parameters
68
+ # # location = 'Cambogan'
69
+ # # sequence = '20250811_113017'
70
+ # # location = 'Holmview'
71
+ # # sequence = '20250820_130327'
72
+ # # location = 'Pullenvale'
73
+ # # sequence = '20250916_124105'
74
+ # location = 'Mount-Cotton'
75
+ # sequence = '20241217_113410'
76
+ # condition = 'flooded'
77
+ # camera_pos = 'front'
78
+ # root_directory = f"../Datasets/FRED/{condition}/KITTI-style"
79
+ # # 01000000
80
+
81
+ ############ Define filenames and directories ####################################
82
+
83
+ image_dir = f"{root_directory}/{location}_{sequence}/{camera_pos}-imgs/"
84
+ label_dir = f"{root_directory}/{location}_{sequence}/{camera_pos}-labels/"
85
+ img_calib_file = f"./camera_calib.txt"
86
+ timestamps = [filename.split('.png')[0] for filename in natsorted(os.listdir(image_dir)) if os.path.isfile(image_dir+filename)]
87
+
88
+ fig, ax = plt.subplots(figsize=(12.8, 8))
89
+ # idx = [0] # mutable index
90
+ idx = [183] # mutable index
91
+
92
+ def show_image(i):
93
+ ax.clear()
94
+ if i >= len(timestamps):
95
+ plt.close(fig)
96
+ return
97
+ image_timestamp = timestamps[i]
98
+ try:
99
+ image_filename = f"{image_dir}/{image_timestamp}.png"
100
+ label_filename = f"{label_dir}/{image_timestamp}.png"
101
+ image = ImageData(image_filename, img_calib_file, label_filename)
102
+
103
+ label_mask = np.any(image.colour_label != image.semantic_classes['other'], axis=-1)
104
+ overlay_img = image.image.copy()
105
+
106
+ # Catch for when there is no label or all pixels are labelled as 'other' (i.e. not labelled) #
107
+ if not np.all(image.colour_label == image.semantic_classes['other']):
108
+ overlay_img[label_mask] = cv2.addWeighted(image.image[label_mask], 0.5, image.colour_label[label_mask], 0.5, 0)
109
+
110
+ ax.imshow(overlay_img[:, :, ::-1])
111
+ ax.set_title(f"{image_timestamp}.png")
112
+ ax.axis("off")
113
+ # plt.savefig('paper_figures/semantic_image_labels.pdf', format="pdf", bbox_inches='tight')
114
+ fig.canvas.draw()
115
+ except Exception as e:
116
+ print(f"Could not show label for {image_timestamp}.png: {e}")
117
+ idx[0] += 1
118
+ show_image(idx[0]) # skip bad one
119
+
120
+ def on_key(event):
121
+ if event.key in [' ', 'right']: # space or right arrow
122
+ idx[0] += 1
123
+ show_image(idx[0])
124
+ elif event.key in [' ', 'left']: # space or right arrow
125
+ if idx[0] > 0:
126
+ idx[0] -= 1
127
+ show_image(idx[0])
128
+ elif event.key in ['q', 'escape']: # q or Esc → quit
129
+ plt.close(fig)
130
+
131
+ fig.canvas.mpl_connect('key_press_event', on_key)
132
+ show_image(idx[0])
133
+ plt.show()
segmentation/show_labels-single.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
utils/.gitkeep ADDED
File without changes
utils/__init__.py ADDED
File without changes
utils/camera.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import numpy as np
3
+ import cv2
4
+ from PIL import Image
5
+ import open3d as o3d
6
+ from utils.utils import read_calib_file #, fill_projected_os1_rings
7
+
8
+ color_key = {
9
+ 0: [0, 0, 128], # road
10
+ 1: [0, 128, 0], # water
11
+ 2: [0, 0, 0] # other
12
+ }
13
+
14
+ class ImageData():
15
+ def __init__(self, file_path, calib_path, label_path=None):
16
+ self.image = cv2.imread(file_path)
17
+
18
+ intrinsics = read_calib_file(calib_path)
19
+ focal_len = intrinsics['focal_len'][0]
20
+ principal_x = intrinsics['principal_x'][0]
21
+ principal_y = intrinsics['principal_y'][0]
22
+ pp_mm_x = intrinsics['pp_mm_x'][0]
23
+ pp_mm_y = intrinsics['pp_mm_y'][0]
24
+
25
+ # print(f"{focal_len}, {principal_x}, {principal_y}, {pp_mm_x}, {pp_mm_y}")
26
+
27
+ self.camera_matrix = self.create_camera_matrix(focal_len, principal_x, principal_y, pp_mm_x, pp_mm_y)
28
+ self.dist_coeffs = np.array([0.0, 0.0, 0.0, 0.0, 0.0])
29
+
30
+ self.semantic_classes = None
31
+ self.colour_label = None
32
+ self.semantic_classes = {'road': [0, 0, 128], 'water': [0, 128, 0], 'other': [0, 0, 0]} # In the opencv BGR convention
33
+
34
+ if label_path is not None:
35
+ if os.path.exists(label_path):
36
+ label_img = cv2.imread(label_path)
37
+ else:
38
+ # Catch for no label existing #
39
+ print(f"Could not load label \'{label_path}\'. Using blank labels.")
40
+ label_img = np.zeros(self.image.shape).astype(np.uint8)
41
+ self.colour_label = cv2.resize(label_img, (self.image.shape[1], self.image.shape[0]), interpolation=cv2.INTER_NEAREST)
42
+
43
+ H, W, _ = label_img.shape
44
+ self.label_img = np.full((H, W), 2, dtype=np.uint8) # unknown = 255
45
+
46
+ # Convert to uint8 in case it's float
47
+ img = label_img.astype(np.uint8)
48
+
49
+ for class_idx, color in color_key.items():
50
+ # Create mask where all 3 channels match
51
+ mask = np.all(img == color, axis=2)
52
+ self.label_img[mask] = class_idx
53
+
54
+ self.label_img = cv2.resize(self.label_img, (self.image.shape[1], self.image.shape[0]), interpolation=cv2.INTER_NEAREST)
55
+
56
+ def create_camera_matrix(self, focal_length_mm, principal_point_x_pixels, principal_point_y_pixels,
57
+ pixels_per_mm_x, pixels_per_mm_y):
58
+ """
59
+ Create camera matrix from physical camera parameters.
60
+
61
+ Args:
62
+ focal_length_mm: Focal length in millimeters
63
+ principal_point_x_pixels: Principal point X coordinate in pixels
64
+ principal_point_y_pixels: Principal point Y coordinate in pixels
65
+ pixels_per_mm_x: Pixels per millimeter in X direction
66
+ pixels_per_mm_y: Pixels per millimeter in Y direction
67
+
68
+ Returns:
69
+ camera_matrix: 3x3 camera intrinsic matrix
70
+ """
71
+
72
+ # Check for None or invalid values
73
+ if any(x is None for x in [focal_length_mm, principal_point_x_pixels, principal_point_y_pixels,
74
+ pixels_per_mm_x, pixels_per_mm_y]):
75
+ print("ERROR: One or more input parameters is None!")
76
+ return None
77
+
78
+ if pixels_per_mm_x == 0 or pixels_per_mm_y == 0:
79
+ print("ERROR: pixels_per_mm cannot be zero!")
80
+ return None
81
+
82
+ try:
83
+ # Convert focal length from mm to pixels
84
+ fx = focal_length_mm * pixels_per_mm_x
85
+ fy = focal_length_mm * pixels_per_mm_y
86
+
87
+ # Principal point is already in pixels
88
+ cx = principal_point_x_pixels
89
+ cy = principal_point_y_pixels
90
+
91
+ camera_matrix = np.array([
92
+ [fx, 0, cx],
93
+ [0, fy, cy],
94
+ [0, 0, 1]
95
+ ], dtype=np.float64)
96
+
97
+ return camera_matrix
98
+
99
+ except Exception as e:
100
+ print(f"ERROR in create_camera_matrix: {e}")
101
+ return None
102
+
103
+ def project_points(self, points, colours, cmap, valid_cam, colour_norm=None):
104
+ # , beam_id, azimuth
105
+ # Project to image coordinates
106
+ rvec = np.zeros(3) # No additional rotation
107
+ tvec = np.zeros(3) # No additional translation
108
+
109
+ image_points, _ = cv2.projectPoints(points, rvec, tvec, self.camera_matrix, self.dist_coeffs)
110
+
111
+ image_points = image_points.reshape(-1, 2)
112
+
113
+ # Filter points within image bounds
114
+ h, w = self.image.shape[0], self.image.shape[1]
115
+ valid_img_mask = ((image_points[:, 0] >= 0) & (image_points[:, 0] < w) &
116
+ (image_points[:, 1] >= 0) & (image_points[:, 1] < h))
117
+
118
+ valid_mask = valid_img_mask & valid_cam
119
+ points2project = image_points[valid_mask]
120
+ if colour_norm is None:
121
+ colour2project = colours[valid_mask]/colours[valid_img_mask].max()
122
+ else:
123
+ colour2project = colours[valid_mask]/colour_norm
124
+
125
+ img_vis = self.image.copy()
126
+ # Draw points
127
+ for (point, c) in zip(points2project.astype(int), colour2project):
128
+ r, g, b, _ = cmap(c)
129
+ colour = (r*255, g*255, b*255)
130
+ cv2.circle(img_vis, (int(point[0]), int(point[1])), 5, colour, -1) # -1 = filled circle
131
+
132
+ return img_vis, image_points, valid_img_mask
133
+
134
+ def get_image_coords(self, points):
135
+ # Project to image coordinates
136
+ rvec = np.zeros(3) # No additional rotation
137
+ tvec = np.zeros(3) # No additional translation
138
+
139
+ image_points, _ = cv2.projectPoints(points, rvec, tvec, self.camera_matrix, self.dist_coeffs)
140
+
141
+ image_points = image_points.reshape(-1, 2)
142
+
143
+ return image_points
utils/lidar.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import os
3
+ from utils.utils import read_calib_file #, compute_os1_angles, elevation_to_beam_id
4
+
5
+ INLIER_BIT = 1 << 8
6
+ INSTANCE_SHIFT = 16
7
+ beam_altitude_angles_deg = np.array([
8
+ 20.97, 18.51, 15.97, 13.37, 12.04, 10.70, 9.35, 8.70,
9
+ 7.99, 7.34, 6.62, 5.96, 5.23, 4.55, 3.84, 3.51,
10
+ 3.17, 2.82, 2.46, 2.11, 1.76, 1.43, 1.05, 0.70,
11
+ 0.36, 0.03, -0.34, -0.70, -1.04, -1.37, -1.76, -2.10,
12
+ -2.43, -2.77, -3.15, -3.48, -3.84, -4.18, -4.54, -4.88,
13
+ -5.24, -5.55, -5.93, -6.29, -6.63, -6.94, -7.32, -7.67,
14
+ -8.00, -8.33, -9.04, -9.71, -10.42, -11.06, -11.77, -12.41,
15
+ -13.12, -13.74, -15.06, -16.36, -17.64, -18.91, -20.16, -21.38
16
+ ])
17
+
18
+ class PointCloud:
19
+ def __init__(self, file_path, calib_path):
20
+
21
+ self.points = self.load_pointcloud(file_path)
22
+ ground_labels = np.fromfile(f"{file_path.split('/ouster')[0]}/ouster_ground_labels/{file_path.split('/')[-1].split('.bin')[0]}.label", dtype=np.uint32)
23
+ # points = self.load_pointcloud(file_path)
24
+ # self.points = points[self.ground_labels==0,:]
25
+ self.ground_semantic = ground_labels & 0xFF
26
+ self.ground_inlier = (ground_labels & INLIER_BIT) != 0
27
+
28
+ calibration = read_calib_file(calib_path)
29
+ self.P2, self.R0, self.Tr4 = self.get_matrices(calibration)
30
+
31
+ self.pixel_shift_by_row = [12, 12, 12, 12, 12, 12, 12, -4,
32
+ 12, -4, 12, -4, 12, -4, 12, 4,
33
+ -4, -12, 12, 4, -4, -12, 12, 4,
34
+ -4, -12, 12, 4, -4, -12, 12, 4,
35
+ -4, -12, 12, 4, -4, -12, 12, 4,
36
+ -4, -12, 12, 4, -4, -12, 12, 4,
37
+ -4, -12, 4, -12, 4, -12, 4, -12,
38
+ 4, -12, -12, -12, -12, -12, -12, -12]
39
+
40
+
41
+
42
+ def load_pointcloud(self, file_path):
43
+ """
44
+ Load point cloud from various formats including .bin files
45
+ Assumes each point has 4 values (x, y, z, reflectivity)
46
+
47
+ Args:
48
+ file_path (str): Path to point cloud file
49
+
50
+ Returns:
51
+ np.array: Nx4 array of 3D points with
52
+ """
53
+
54
+ file_ext = os.path.splitext(file_path)[1].lower()
55
+
56
+ if not file_ext == '.bin':
57
+ raise ValueError(f"Given file is not binary format: {file_path}")
58
+
59
+ points = np.fromfile(file_path, dtype=np.float32)
60
+
61
+ if len(points) % 4 == 0:
62
+ points_array = points.reshape(-1, 4)
63
+
64
+ return points_array
65
+
66
+
67
+ def get_matrices(self,calib):
68
+ # P2 (3x4)
69
+ P2 = calib['P2'].reshape(3,4)
70
+ # R0_rect (3x3) or identity
71
+ R0 = calib.get('R0_rect', np.array([1,0,0,0,1,0,0,0,1])).reshape(3,3)
72
+ # Tr_ouster_to_cam (3x4)
73
+ Tr = calib['Tr_ouster_to_cam'].reshape(3,4)
74
+ # Make 4x4 homogeneous
75
+ Tr4 = np.vstack((Tr, [0,0,0,1]))
76
+
77
+ return P2, R0, Tr4
78
+
79
+ def points_ouster_to_cam(self,):
80
+ """
81
+ pts_ouster: (N,3) numpy array in LiDAR frame.
82
+ Tr4: 4x4 homogeneous transform from ouster -> camera.
83
+ Returns pts_cam (N,3) in camera coordinates.
84
+ """
85
+ n = self.points.shape[0]
86
+
87
+ pts_xyz = self.points[:, :3]
88
+
89
+ pts_h = np.column_stack((pts_xyz, np.ones((n)))) # (N,4)
90
+ pts_cam_h = (self.Tr4 @ pts_h.T).T[:,:3] # (N,4)
91
+
92
+ valid = pts_cam_h[:,2] > 1e-6
93
+
94
+ distances = np.linalg.norm(pts_cam_h[valid,:], axis=1)
95
+
96
+ return pts_cam_h[valid,:], np.linalg.norm(pts_cam_h, axis=1), self.points[:,3], pts_cam_h, valid
97
+
98
+ def select_points_ouster_to_cam(self, points):
99
+ """
100
+ pts_ouster: (N,3) numpy array in LiDAR frame.
101
+ Tr4: 4x4 homogeneous transform from ouster -> camera.
102
+ Returns pts_cam (N,3) in camera coordinates.
103
+ """
104
+ n = points.shape[0]
105
+ pts_h = np.column_stack((points[:,:3], np.ones((n)))) # (N,4)
106
+ pts_cam_h = (self.Tr4 @ pts_h.T).T[:,:3] # (N,4)
107
+
108
+ valid = pts_cam_h[:,2] > 1e-6
109
+ # valid = np.ones((pts_cam_h.shape[0]))
110
+
111
+ distances = np.linalg.norm(pts_cam_h[valid,:], axis=1)
112
+
113
+ return pts_cam_h[valid,:], distances, points[valid,3]
114
+
115
+ def destagger(self, points, ground_labels, inlier_labels):
116
+
117
+ pixel_shift_by_row = np.array(self.pixel_shift_by_row)
118
+
119
+ H = 64
120
+ W = 1024
121
+
122
+ # pc_img = points.reshape(W, H, 4).transpose(1, 0, 2)
123
+ # # shape: (64, 1024, 4)
124
+
125
+ # rows = np.arange(H)[:, None]
126
+ # cols = np.arange(W)[None, :]
127
+
128
+ # pc_destaggered = pc_img[
129
+ # rows,
130
+ # (cols - pixel_shift_by_row[:, None]) % W,
131
+ # :
132
+ # ]
133
+
134
+ # return pc_destaggered.reshape(-1, 4)
135
+
136
+
137
+ # --- reshape ---
138
+ pc_img = points.reshape(W, H, 4).transpose(1, 0, 2) # (64, 1024, 4)
139
+ lbl_img = ground_labels.reshape(W, H).T # (64, 1024)
140
+ inlier_img = inlier_labels.reshape(W, H).T
141
+
142
+ rows = np.arange(H)[:, None]
143
+ cols = np.arange(W)[None, :]
144
+
145
+ # --- destagger (IDENTICAL indexing) ---
146
+ pc_destaggered = pc_img[
147
+ rows,
148
+ (cols - pixel_shift_by_row[:, None]) % W,
149
+ :
150
+ ]
151
+
152
+ lbl_destaggered = lbl_img[
153
+ rows,
154
+ (cols - pixel_shift_by_row[:, None]) % W
155
+ ]
156
+
157
+ inlier_destaggered = inlier_img[
158
+ rows,
159
+ (cols - pixel_shift_by_row[:, None]) % W
160
+ ]
161
+
162
+ return pc_destaggered.reshape(-1, 4), lbl_destaggered.reshape(-1), inlier_destaggered.reshape(-1)
163
+
utils/utils.py ADDED
@@ -0,0 +1,494 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import os
3
+ import cv2
4
+ import open3d as o3d
5
+ from natsort import natsorted
6
+
7
+ beam_altitudes = np.deg2rad(np.array([
8
+ 20.97, 18.51, 15.97, 13.37, 12.04, 10.70, 9.35, 8.70,
9
+ 7.99, 7.34, 6.62, 5.96, 5.23, 4.55, 3.84, 3.51,
10
+ 3.17, 2.82, 2.46, 2.11, 1.76, 1.43, 1.05, 0.70,
11
+ 0.36, 0.03, -0.34, -0.70, -1.04, -1.37, -1.76, -2.10,
12
+ -2.43, -2.77, -3.15, -3.48, -3.84, -4.18, -4.54, -4.88,
13
+ -5.24, -5.55, -5.93, -6.29, -6.63, -6.94, -7.32, -7.67,
14
+ -8.00, -8.33, -9.04, -9.71, -10.42, -11.06, -11.77, -12.41,
15
+ -13.12, -13.74, -15.06, -16.36, -17.64, -18.91, -20.16, -21.38
16
+ ]))
17
+
18
+ def read_calib_file(path):
19
+ """
20
+ Read KITTI-style calibration file lines like:
21
+ parameter: <value>
22
+ parameter: <value>
23
+ ...
24
+ Returns a dict mapping keys to numpy arrays.
25
+ """
26
+ d = {}
27
+ with open(path, 'r') as f:
28
+ for line in f:
29
+ line = line.strip()
30
+ if not line or ':' not in line or line.startswith('#'):
31
+ continue
32
+ key, vals = line.split(':', 1)
33
+ nums = [float(x) for x in vals.strip().split()]
34
+ d[key.strip()] = np.array(nums)
35
+ return d
36
+
37
+ def get_all_corr_files(image_timestamps, dirs, tol=200000, adjust=None):
38
+
39
+ single = not isinstance(image_timestamps, (list, np.ndarray))
40
+ if single:
41
+ image_timestamps = [image_timestamps]
42
+
43
+ image_timestamps = np.array(image_timestamps, dtype=np.int64)
44
+
45
+ results_per_dir = []
46
+ indices_per_dir = []
47
+ for dir in dirs:
48
+ if adjust is None:
49
+ filenames = np.array([f for f in natsorted(os.listdir(dir)) if os.path.isfile(dir + f)])
50
+ else:
51
+ filenames = np.array([f for f in natsorted(os.listdir(dir)) if os.path.isfile(dir + f)])[:adjust]
52
+
53
+ timestamps = np.array([int(f.split('.')[0]) for f in filenames], dtype=np.int64)
54
+
55
+ diffs = np.abs(image_timestamps[:, None] - timestamps[None, :])
56
+ closest_indices = np.argmin(diffs, axis=1)
57
+ closest_diffs = diffs[np.arange(len(image_timestamps)), closest_indices]
58
+
59
+ violations = closest_diffs > tol
60
+ if violations.any():
61
+ bad_ts = image_timestamps[violations]
62
+ raise Exception(f"No timestamp in {dir} within tolerance for timestamps: {bad_ts}")
63
+
64
+ results_per_dir.append(filenames[closest_indices])
65
+ indices_per_dir.append(closest_indices)
66
+
67
+ if len(dirs) == 1:
68
+ results = [f"{dirs[0]}/{f}" for f in results_per_dir[0]]
69
+ indices = indices_per_dir[0].tolist()
70
+ else:
71
+ results = [
72
+ tuple(f"{dirs[i]}/{results_per_dir[i][j]}" for i in range(len(dirs)))
73
+ for j in range(len(image_timestamps))
74
+ ]
75
+ indices = [
76
+ tuple(int(indices_per_dir[i][j]) for i in range(len(dirs)))
77
+ for j in range(len(image_timestamps))
78
+ ]
79
+
80
+ if single:
81
+ return results[0], indices[0]
82
+ return results, indices
83
+
84
+ def get_corr_files(image_timestamp, dirs, tol=200000):
85
+ output_filenames = []
86
+ for dir in dirs:
87
+ ############ Find closest point cloud and UTM position data ########################
88
+ filenames = np.array([filename for filename in os.listdir(dir) if os.path.isfile(dir+filename)])
89
+ timestamps = np.array([int(filename.split('.')[0]) for filename in os.listdir(dir) if os.path.isfile(dir+filename)])
90
+
91
+ closest_lidar = np.argmin(abs(timestamps-int(image_timestamp)))
92
+ timestamp_diff = abs(int(image_timestamp)-timestamps[closest_lidar])
93
+
94
+ timestamp_tolerance = tol # in microseconds (0.2 seconds)
95
+
96
+ if timestamp_diff > timestamp_tolerance:
97
+ raise Exception(f"No timestamp in {dir} in close enough proximity to {image_timestamp}")
98
+ else:
99
+ output_filenames.append(f"{dir}/{filenames[closest_lidar]}")
100
+
101
+ if len(dirs) == 1:
102
+ return output_filenames[0]
103
+ else:
104
+ return tuple(output_filenames)
105
+
106
+ NUM_COLS = 1024
107
+
108
+ def compute_column_index(points_xyz):
109
+ az = np.arctan2(points_xyz[:, 1], points_xyz[:, 0])
110
+ az = np.mod(az, 2 * np.pi)
111
+ col = np.round(az / (2 * np.pi) * NUM_COLS).astype(int)
112
+ return col % NUM_COLS
113
+
114
+ # beam_altitudes = np.deg2rad(np.array(beam_altitude_angles))
115
+
116
+ def compute_ring_ids(points_xyz):
117
+ xy_norm = np.linalg.norm(points_xyz[:, :2], axis=1)
118
+ elev = np.arctan2(points_xyz[:, 2], xy_norm)
119
+
120
+ return np.argmin(
121
+ np.abs(elev[:, None] - beam_altitudes[None, :]),
122
+ axis=1
123
+ )
124
+
125
+ def compute_ring_col_from_index(num_points, num_cols=1024):
126
+ idx = np.arange(num_points)
127
+ ring_ids = idx // num_cols
128
+ col_ids = idx % num_cols
129
+ return ring_ids, col_ids
130
+
131
+ def is_valid_point(points_xyz):
132
+ return np.linalg.norm(points_xyz, axis=1) > 1e-6
133
+
134
+ def fill_ring_known_cols_with_intensity(points_xyzi, ring_ids, col_ids):
135
+ """
136
+ points_xyzi: (N,4) -> x,y,z,intensity
137
+ """
138
+ filled_points = []
139
+ interp_flags = []
140
+
141
+ unique_ring_ids = np.array([i for i in range(0,64)])
142
+
143
+ for ring in unique_ring_ids:
144
+ ring_mask = ring_ids == ring
145
+ ring_pts = points_xyzi[ring_mask]
146
+ ring_cols = col_ids[ring_mask]
147
+
148
+ # storage per column
149
+ ring_grid = [None] * NUM_COLS
150
+
151
+ # place original points
152
+ for p, c in zip(ring_pts, ring_cols):
153
+ ring_grid[c] = p # keep intensity
154
+
155
+ elev = beam_altitudes[ring]
156
+ cos_e, sin_e = np.cos(elev), np.sin(elev)
157
+
158
+ for c in range(NUM_COLS):
159
+ if ring_grid[c] is not None:
160
+ filled_points.append(ring_grid[c])
161
+ interp_flags.append(False)
162
+ else:
163
+ # find neighbors cyclically
164
+ left = (c - 1) % NUM_COLS
165
+ right = (c + 1) % NUM_COLS
166
+
167
+ while ring_grid[left] is None:
168
+ left = (left - 1) % NUM_COLS
169
+ while ring_grid[right] is None:
170
+ right = (right + 1) % NUM_COLS
171
+
172
+ p0, p1 = ring_grid[left], ring_grid[right]
173
+
174
+ r0 = np.linalg.norm(p0[:3])
175
+ r1 = np.linalg.norm(p1[:3])
176
+
177
+ d = (c - left) % NUM_COLS
178
+ span = (right - left) % NUM_COLS
179
+ t = d / span if span > 0 else 0.0
180
+ r = (1 - t) * r0 + t * r1
181
+
182
+ az = 2 * np.pi * c / NUM_COLS
183
+
184
+ x = r * cos_e * np.cos(az)
185
+ y = r * cos_e * np.sin(az)
186
+ z = r * sin_e
187
+
188
+ filled_points.append([x, y, z, 255])
189
+ interp_flags.append(True)
190
+
191
+ return np.array(filled_points), np.array(interp_flags)
192
+
193
+ def fill_ring_known_cols_with_intensity_and_plane(
194
+ points_xyzi,
195
+ ring_ids,
196
+ col_ids,
197
+ plane,
198
+ max_range=200.0
199
+ ):
200
+ """
201
+ points_xyzi: (N,4) -> x,y,z,intensity
202
+ """
203
+ a, b, c, d = plane
204
+
205
+ filled_points = []
206
+ interp_flags = []
207
+
208
+ unique_ring_ids = np.array([i for i in range(0,64)])
209
+
210
+ for ring in unique_ring_ids:
211
+ # print(f"ring {ring}")
212
+ ring_mask = ring_ids == ring
213
+ ring_pts = points_xyzi[ring_mask]
214
+ ring_cols = col_ids[ring_mask]
215
+
216
+ ring_grid = [None] * NUM_COLS
217
+ # print(f"ring grid shape: {np.array(ring_grid).shape}")
218
+
219
+
220
+ # place original points
221
+ for p, col in zip(ring_pts, ring_cols):
222
+ ring_grid[col] = p
223
+
224
+ elev = beam_altitudes[ring]
225
+ cos_e, sin_e = np.cos(elev), np.sin(elev)
226
+
227
+ for c_idx in range(NUM_COLS):
228
+
229
+ # print(f"c_idx: {c_idx}")
230
+
231
+ if ring_grid[c_idx] is not None:
232
+ filled_points.append(ring_grid[c_idx])
233
+ interp_flags.append(False)
234
+ continue
235
+
236
+ # find neighbors cyclically
237
+ # print(f"Find neighbours")
238
+ # left = (c_idx - 1) % NUM_COLS
239
+ # right = (c_idx + 1) % NUM_COLS
240
+
241
+ # while ring_grid[left] is None:
242
+ # left = (left - 1) % NUM_COLS
243
+ # while ring_grid[right] is None:
244
+ # right = (right + 1) % NUM_COLS
245
+
246
+ # print(f"p0, p1")
247
+
248
+ # p0, p1 = ring_grid[left], ring_grid[right]
249
+
250
+ # r0 = np.linalg.norm(p0[:3])
251
+ # r1 = np.linalg.norm(p1[:3])
252
+
253
+ # print(f"dcol, span")
254
+
255
+ # dcol = (c_idx - left) % NUM_COLS
256
+ # span = (right - left) % NUM_COLS
257
+ # t_interp = dcol / span if span > 0 else 0.0
258
+
259
+ # r_guess = (1 - t_interp) * r0 + t_interp * r1
260
+
261
+ # print(f"az")
262
+
263
+ az = 2 * np.pi * c_idx / NUM_COLS
264
+
265
+ # Ray direction
266
+ dx = cos_e * np.cos(az)
267
+ dy = cos_e * np.sin(az)
268
+ dz = sin_e
269
+
270
+ denom = a * dx + b * dy + c * dz
271
+ if abs(denom) < 1e-6:
272
+ # print(f"point in rang {ring} is parallel")
273
+ continue # ray parallel to plane
274
+
275
+ t_plane = -d / denom
276
+ # if t_plane <= 0 or t_plane > max_range:
277
+ # # print(f"point in rang {ring} is {t_plane}m away")
278
+ # continue
279
+
280
+ # print(f"Fill point")
281
+
282
+ x = t_plane * dx
283
+ y = t_plane * dy
284
+ z = t_plane * dz
285
+
286
+ filled_points.append([x, y, z, 255])
287
+ interp_flags.append(True)
288
+
289
+ return np.asarray(filled_points), np.asarray(interp_flags)
290
+
291
+
292
+ def complete_cloud(points_xyzi, plane):
293
+ # ring_ids = compute_ring_ids(points_xyzi[:, :3])
294
+ # col_ids = compute_column_index(points_xyzi[:, :3])
295
+ ring_ids, col_ids = compute_ring_col_from_index(
296
+ len(points_xyzi), NUM_COLS
297
+ )
298
+ valid_mask = is_valid_point(points_xyzi[:, :3])
299
+
300
+ # return fill_ring_known_cols_with_intensity_and_plane(points_xyzi, ring_ids, col_ids, plane)
301
+ return fill_ring_known_cols_with_intensity_and_heightfield(points_xyzi, ring_ids, col_ids, valid_mask, plane)
302
+
303
+
304
+ def assign_semantic_labels(
305
+ points_xyz,
306
+ uv,
307
+ valid,
308
+ semantic_image,
309
+ interp_flags=None,
310
+ unknown_label=-1
311
+ ):
312
+ """
313
+ semantic_image: (H, W) or (H, W, 1) integer labels
314
+ """
315
+ H, W = semantic_image.shape[:2]
316
+ N = points_xyz.shape[0]
317
+
318
+ labels = np.full(N, unknown_label, dtype=np.uint8)
319
+
320
+ # round to nearest pixel
321
+ u = np.round(uv[:, 0]).astype(int)
322
+ v = np.round(uv[:, 1]).astype(int)
323
+
324
+ inside = (
325
+ valid &
326
+ (u >= 0) & (u < W) &
327
+ (v >= 0) & (v < H)
328
+ )
329
+
330
+ labels[inside] = semantic_image[v[inside], u[inside]]
331
+
332
+ # Optional: mark interpolated points as unknown
333
+ if interp_flags is not None:
334
+ labels[interp_flags] = unknown_label
335
+
336
+ return labels
337
+
338
+ def fit_height_field_linear(ground_points):
339
+ """
340
+ Fit z = ax + by + c to ground points
341
+ """
342
+ X = ground_points[:, 0]
343
+ Y = ground_points[:, 1]
344
+ Z = ground_points[:, 2]
345
+
346
+ A = np.column_stack((X, Y, np.ones_like(X)))
347
+ coef, _, _, _ = np.linalg.lstsq(A, Z, rcond=None)
348
+
349
+ a, b, c = coef
350
+ return a, b, c
351
+
352
+ def fill_ring_known_cols_with_intensity_and_heightfield(
353
+ points_xyzi,
354
+ ring_ids,
355
+ col_ids,
356
+ valid_mask,
357
+ height_field, # (a, b, c)
358
+ max_range=200.0
359
+ ):
360
+ """
361
+ points_xyzi: (N,4) -> x,y,z,intensity
362
+ """
363
+ a, b, c = height_field
364
+
365
+ filled_points = []
366
+ interp_flags = []
367
+
368
+ for ring in range(64):
369
+ ring_mask = ring_ids == ring
370
+
371
+ ring_pts = points_xyzi[ring_mask]
372
+ ring_cols = col_ids[ring_mask]
373
+ ring_valid = valid_mask[ring_mask]
374
+
375
+ ring_grid = [None] * NUM_COLS
376
+
377
+ # Place ONLY valid original points
378
+ for p, col, v in zip(ring_pts, ring_cols, ring_valid):
379
+ if v:
380
+ ring_grid[col] = p
381
+
382
+ elev = beam_altitudes[ring]
383
+ cos_e, sin_e = np.cos(elev), np.sin(elev)
384
+
385
+ for c_idx in range(NUM_COLS):
386
+
387
+ if ring_grid[c_idx] is not None:
388
+ filled_points.append(ring_grid[c_idx])
389
+ interp_flags.append(False)
390
+ continue
391
+
392
+ # Missing return → ray cast
393
+ az = 2 * np.pi * c_idx / NUM_COLS
394
+
395
+ dx = cos_e * np.cos(az)
396
+ dy = cos_e * np.sin(az)
397
+ dz = sin_e
398
+
399
+ denom = dz - a * dx - b * dy
400
+ if abs(denom) < 1e-6:
401
+ continue
402
+
403
+ t = c / denom
404
+ if t <= 0 or t > max_range:
405
+ continue
406
+
407
+ x = t * dx
408
+ y = t * dy
409
+ z = t * dz
410
+
411
+ filled_points.append([x, y, z, 255])
412
+ interp_flags.append(True)
413
+
414
+ return np.asarray(filled_points), np.asarray(interp_flags)
415
+ # a, b, c = height_field
416
+
417
+ # filled_points = []
418
+ # interp_flags = []
419
+
420
+ # unique_ring_ids = np.arange(64)
421
+
422
+ # for ring in unique_ring_ids:
423
+ # ring_mask = ring_ids == ring
424
+ # ring_pts = points_xyzi[ring_mask]
425
+ # ring_cols = col_ids[ring_mask]
426
+
427
+ # ring_grid = [None] * NUM_COLS
428
+
429
+ # # place original points
430
+ # for p, col in zip(ring_pts, ring_cols):
431
+ # ring_grid[col] = p
432
+
433
+ # elev = beam_altitudes[ring]
434
+ # cos_e, sin_e = np.cos(elev), np.sin(elev)
435
+
436
+ # for c_idx in range(NUM_COLS):
437
+
438
+ # if ring_grid[c_idx] is not None:
439
+ # filled_points.append(ring_grid[c_idx])
440
+ # interp_flags.append(False)
441
+ # continue
442
+
443
+ # az = 2 * np.pi * c_idx / NUM_COLS
444
+
445
+ # # Unit ray direction
446
+ # dx = cos_e * np.cos(az)
447
+ # dy = cos_e * np.sin(az)
448
+ # dz = sin_e
449
+
450
+ # denom = dz - a * dx - b * dy
451
+ # if abs(denom) < 1e-6:
452
+ # continue # ray parallel to height field
453
+
454
+ # t = c / denom
455
+ # if t <= 0 or t > max_range:
456
+ # continue
457
+
458
+ # x = t * dx
459
+ # y = t * dy
460
+ # z = t * dz
461
+
462
+ # filled_points.append([x, y, z, 255])
463
+ # interp_flags.append(True)
464
+
465
+ # return np.asarray(filled_points), np.asarray(interp_flags)
466
+
467
+ def create_height_field_mesh(plane, xlim, ylim, resolution=1.0):
468
+ a, b, c = plane
469
+ xs = np.arange(xlim[0], xlim[1], resolution)
470
+ ys = np.arange(ylim[0], ylim[1], resolution)
471
+
472
+ xx, yy = np.meshgrid(xs, ys)
473
+ zz = a * xx + b * yy + c
474
+
475
+ points = np.column_stack((xx.ravel(), yy.ravel(), zz.ravel()))
476
+
477
+ mesh = o3d.geometry.TriangleMesh()
478
+ mesh.vertices = o3d.utility.Vector3dVector(points)
479
+
480
+ triangles = []
481
+ w = len(xs)
482
+ h = len(ys)
483
+
484
+ for y in range(h - 1):
485
+ for x in range(w - 1):
486
+ i = y * w + x
487
+ triangles.append([i, i + 1, i + w])
488
+ triangles.append([i + 1, i + w + 1, i + w])
489
+
490
+ mesh.triangles = o3d.utility.Vector3iVector(triangles)
491
+ mesh.compute_vertex_normals()
492
+ mesh.paint_uniform_color([0.8, 0.8, 0.8])
493
+
494
+ return mesh
visualisation/.gitkeep ADDED
File without changes
visualisation/__init__.py ADDED
File without changes
visualisation/convert_imgs2video.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ images_to_mp4.py — Combine a directory of images into an MP4 video.
4
+
5
+ Usage:
6
+ python images_to_mp4.py <image_dir> [options]
7
+
8
+ Options:
9
+ --fps FPS Frames per second (default: 10)
10
+ --output OUTPUT Output file path (default: output.mp4)
11
+ --pattern PATTERN Glob pattern for images (default: auto-detect)
12
+ --sort {name,time} Sort order for frames (default: name)
13
+ """
14
+
15
+ import argparse
16
+ import sys
17
+ from pathlib import Path
18
+ from natsort import natsorted
19
+ import cv2
20
+
21
+
22
+ SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif", ".webp"}
23
+
24
+
25
+ def collect_images(directory: Path, pattern: str | None, sort: str) -> list[Path]:
26
+ if pattern:
27
+ images = sorted(directory.glob(pattern))
28
+ else:
29
+ images = [
30
+ p for p in directory.iterdir()
31
+ if p.suffix.lower() in SUPPORTED_EXTENSIONS
32
+ ]
33
+ if sort == "time":
34
+ images.sort(key=lambda p: p.stat().st_mtime)
35
+ else:
36
+ images.sort(key=lambda p: p.name)
37
+
38
+ return natsorted(images)
39
+
40
+
41
+ def build_video(images: list[Path], output: Path, fps: int) -> None:
42
+ # Read the first frame to get dimensions
43
+ first = cv2.imread(str(images[0]))
44
+ if first is None:
45
+ sys.exit(f"Error: could not read image '{images[0]}'")
46
+
47
+ height, width = first.shape[:2]
48
+ print(f"Frame size : {width}x{height}")
49
+ print(f"Frame count: {len(images)}")
50
+ print(f"FPS : {fps}")
51
+ print(f"Output : {output}")
52
+
53
+ fourcc = cv2.VideoWriter_fourcc(*"avc1") # H.264
54
+ writer = cv2.VideoWriter(str(output), fourcc, fps, (width, height))
55
+
56
+ if not writer.isOpened():
57
+ # Fallback: try mp4v codec if avc1 unavailable
58
+ print("Warning: avc1 (H.264) unavailable, falling back to mp4v codec.")
59
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
60
+ writer = cv2.VideoWriter(str(output), fourcc, fps, (width, height))
61
+
62
+ if not writer.isOpened():
63
+ sys.exit("Error: could not open VideoWriter. Check OpenCV build & codecs.")
64
+
65
+ for i, path in enumerate(images, 1):
66
+ frame = cv2.imread(str(path))
67
+ if frame is None:
68
+ print(f" Warning: skipping unreadable file '{path.name}'")
69
+ continue
70
+ # Resize if a frame differs from the first frame's dimensions
71
+ if (frame.shape[1], frame.shape[0]) != (width, height):
72
+ frame = cv2.resize(frame, (width, height))
73
+ writer.write(frame)
74
+ if i % 50 == 0 or i == len(images):
75
+ print(f" Encoded {i}/{len(images)} frames...")
76
+
77
+ writer.release()
78
+ print(f"\nDone! Video saved to: {output}")
79
+
80
+
81
+ def main():
82
+ parser = argparse.ArgumentParser(
83
+ description="Combine images in a directory into an MP4 video."
84
+ )
85
+ parser.add_argument("image_dir", help="Path to directory containing images")
86
+ parser.add_argument("--fps", type=int, default=10, help="Frames per second (default: 10)")
87
+ parser.add_argument("--output", default="output.mp4", help="Output video file (default: output.mp4)")
88
+ parser.add_argument("--pattern", default=None, help="Glob pattern, e.g. 'frame_*.png'")
89
+ parser.add_argument("--sort", choices=["name", "time"], default="name",
90
+ help="Sort frames by name (default) or modification time")
91
+ args = parser.parse_args()
92
+
93
+ directory = Path(args.image_dir)
94
+ if not directory.is_dir():
95
+ sys.exit(f"Error: '{directory}' is not a valid directory.")
96
+
97
+ images = collect_images(directory, args.pattern, args.sort)
98
+ if not images:
99
+ sys.exit(f"Error: no supported images found in '{directory}'.")
100
+
101
+ print(f"Found {len(images)} image(s) in '{directory}'")
102
+ build_video(images, Path(args.output), args.fps)
103
+
104
+
105
+ if __name__ == "__main__":
106
+ main()
visualisation/points2image-all.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import matplotlib.pyplot as plt
3
+ import matplotlib.image as mpimg
4
+ import sys
5
+ sys.path.append(os.path.abspath(".")) # one level up
6
+ import numpy as np
7
+ import cv2
8
+ import open3d as o3d
9
+ from scipy.spatial.transform import Rotation
10
+ from utils.lidar import PointCloud
11
+ from utils.camera import ImageData
12
+ import utils.utils as utils
13
+ from natsort import natsorted
14
+
15
+ cmap = plt.get_cmap("jet")
16
+
17
+ # User parameters
18
+ location = 'Cambogan'
19
+ sequence = '20250811_113017'
20
+ # sequence = '20250812_122101'
21
+ # location = 'Holmview'
22
+ # sequence = '20250820_130327'
23
+ # location = 'Mount-Cotton'
24
+ # sequence = '20241217_113410'
25
+ condition = 'flooded'
26
+ # condition = 'dry'
27
+ camera_pos = 'front'
28
+ root_directory = f"../Datasets/FRED/{condition}/KITTI-style"
29
+ # 01000000
30
+
31
+ ############ Define filenames and directories ####################################
32
+
33
+ image_dir = f"{root_directory}/{location}_{sequence}/{camera_pos}-imgs/"
34
+ lidar_dir = f"{root_directory}/{location}_{sequence}/ouster/"
35
+ utm_dir = f"{root_directory}/{location}_{sequence}/utm/"
36
+
37
+ img_calib_file = f"./camera_calib.txt"
38
+ lidar_calib_file = f"./calib.txt"
39
+
40
+ timestamps = [filename.split('.png')[0] for filename in natsorted(os.listdir(image_dir)) if os.path.isfile(image_dir+filename)]
41
+
42
+ # timestamps.sort()
43
+
44
+ fig, ax = plt.subplots(figsize=(12.8, 8))
45
+ # idx = [0] # mutable index
46
+ idx = [183]
47
+
48
+ def show_image(i):
49
+ ax.clear()
50
+ if i >= len(timestamps):
51
+ plt.close(fig)
52
+ return
53
+ image_timestamp = timestamps[i]
54
+ try:
55
+ image_filename = f"{image_dir}/{image_timestamp}.png"
56
+ lidar_filename, utm_filename = utils.get_corr_files(image_timestamp, [lidar_dir, utm_dir])
57
+
58
+ image = ImageData(image_filename, img_calib_file)
59
+ pointcloud = PointCloud(lidar_filename, lidar_calib_file)
60
+
61
+
62
+ point_cam, distances_cam, intensities_cam, all_points_cam, valid_cam = pointcloud.points_ouster_to_cam() #, beam_id, azimuth
63
+ img_vis, _, _ = image.project_points(all_points_cam, distances_cam, cmap, valid_cam, colour_norm=None) #, beam_id, azimuth
64
+
65
+ ax.imshow(img_vis[:, :, ::-1])
66
+ ax.set_title(f"{image_timestamp}.png")
67
+ ax.axis("off")
68
+ # plt.savefig('paper_figures/CADRRAS/projected_pointcloud_distance_flooded.svg', format="svg", bbox_inches='tight')
69
+ fig.canvas.draw()
70
+ except Exception as e:
71
+ print(f"Could not project pointcloud onto {image_timestamp}.png: {e}")
72
+ idx[0] += 1
73
+ show_image(idx[0]) # skip bad one
74
+
75
+ def on_key(event):
76
+ if event.key in [' ', 'right']: # space or right arrow
77
+ idx[0] += 1
78
+ show_image(idx[0])
79
+ elif event.key in [' ', 'left']: # space or right arrow
80
+ if idx[0] > 0:
81
+ idx[0] -= 1
82
+ show_image(idx[0])
83
+ elif event.key in ['q', 'escape']: # q or Esc → quit
84
+ plt.close(fig)
85
+
86
+ fig.canvas.mpl_connect('key_press_event', on_key)
87
+ show_image(idx[0])
88
+ plt.show()