q3dviewer 1.0.8__py3-none-any.whl → 1.0.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- q3dviewer/base_glwidget.py +53 -8
- q3dviewer/base_item.py +15 -0
- q3dviewer/custom_items/axis_item.py +31 -94
- q3dviewer/custom_items/cloud_item.py +36 -30
- q3dviewer/custom_items/frame_item.py +56 -36
- q3dviewer/custom_items/gaussian_item.py +3 -3
- q3dviewer/custom_items/grid_item.py +88 -37
- q3dviewer/custom_items/image_item.py +1 -2
- q3dviewer/custom_items/line_item.py +4 -5
- q3dviewer/gau_io.py +0 -168
- q3dviewer/glwidget.py +22 -16
- q3dviewer/test/test_interpolation.py +58 -0
- q3dviewer/test/test_rendering.py +72 -0
- q3dviewer/tools/cinematographer.py +367 -0
- q3dviewer/tools/film_maker.py +395 -0
- q3dviewer/tools/lidar_calib.py +11 -22
- q3dviewer/tools/lidar_cam_calib.py +9 -20
- q3dviewer/utils/maths.py +155 -5
- q3dviewer/viewer.py +30 -7
- {q3dviewer-1.0.8.dist-info → q3dviewer-1.0.9.dist-info}/METADATA +8 -15
- q3dviewer-1.0.9.dist-info/RECORD +45 -0
- {q3dviewer-1.0.8.dist-info → q3dviewer-1.0.9.dist-info}/entry_points.txt +1 -1
- q3dviewer/basic_window.py +0 -228
- q3dviewer/cloud_viewer.py +0 -74
- q3dviewer/custom_items/camera_frame_item.py +0 -173
- q3dviewer/custom_items/trajectory_item.py +0 -79
- q3dviewer/utils.py +0 -71
- q3dviewer-1.0.8.dist-info/RECORD +0 -46
- {q3dviewer-1.0.8.dist-info → q3dviewer-1.0.9.dist-info}/LICENSE +0 -0
- {q3dviewer-1.0.8.dist-info → q3dviewer-1.0.9.dist-info}/WHEEL +0 -0
- {q3dviewer-1.0.8.dist-info → q3dviewer-1.0.9.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Copyright 2024 Panasonic Advanced Technology Development Co.,Ltd. (Liu Yang)
|
|
5
|
+
Distributed under MIT license. See LICENSE for more information.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import q3dviewer as q3d
|
|
10
|
+
from PySide6.QtWidgets import QVBoxLayout, QListWidget, QListWidgetItem, QPushButton, QDoubleSpinBox, QCheckBox, QLineEdit, QMessageBox, QLabel, QHBoxLayout, QDockWidget, QWidget, QComboBox
|
|
11
|
+
from PySide6.QtCore import QTimer
|
|
12
|
+
from q3dviewer.tools.cloud_viewer import ProgressDialog, FileLoaderThread
|
|
13
|
+
from PySide6 import QtCore
|
|
14
|
+
from PySide6.QtGui import QKeyEvent
|
|
15
|
+
from q3dviewer import GLWidget
|
|
16
|
+
import imageio.v2 as imageio
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class KeyFrame:
|
|
21
|
+
def __init__(self, Twc, lin_vel=10, ang_vel=np.pi/3, stop_time=0):
|
|
22
|
+
self.Twc = Twc
|
|
23
|
+
self.lin_vel = lin_vel
|
|
24
|
+
self.ang_vel = ang_vel # rad/s
|
|
25
|
+
self.stop_time = stop_time
|
|
26
|
+
self.item = q3d.FrameItem(Twc, width=3, color='#0000FF')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CustomGLWidget(GLWidget):
|
|
30
|
+
def __init__(self, viewer):
|
|
31
|
+
super().__init__()
|
|
32
|
+
self.viewer = viewer # Add a viewer handle
|
|
33
|
+
|
|
34
|
+
def keyPressEvent(self, ev: QKeyEvent):
|
|
35
|
+
if ev.key() == QtCore.Qt.Key_Space:
|
|
36
|
+
self.viewer.add_key_frame()
|
|
37
|
+
elif ev.key() == QtCore.Qt.Key_Delete:
|
|
38
|
+
self.viewer.del_key_frame()
|
|
39
|
+
elif ev.key() == QtCore.Qt.Key_C:
|
|
40
|
+
self.viewer.dock.show()
|
|
41
|
+
super().keyPressEvent(ev)
|
|
42
|
+
|
|
43
|
+
class CMMViewer(q3d.Viewer):
|
|
44
|
+
"""
|
|
45
|
+
This class is a subclass of Viewer, which is used to create a cloud movie maker.
|
|
46
|
+
"""
|
|
47
|
+
def __init__(self, **kwargs):
|
|
48
|
+
self.key_frames = []
|
|
49
|
+
self.video_path = os.path.join(os.path.expanduser("~"), "output.mp4")
|
|
50
|
+
super().__init__(**kwargs, gl_widget_class=lambda: CustomGLWidget(self))
|
|
51
|
+
# for drop cloud file
|
|
52
|
+
self.setAcceptDrops(True)
|
|
53
|
+
|
|
54
|
+
def add_control_panel(self, main_layout):
|
|
55
|
+
"""
|
|
56
|
+
Add a control panel to the viewer.
|
|
57
|
+
"""
|
|
58
|
+
# Create a vertical layout for the settings
|
|
59
|
+
setting_layout = QVBoxLayout()
|
|
60
|
+
|
|
61
|
+
# Buttons to add and delete key frames
|
|
62
|
+
add_button = QPushButton("Add Key Frame (Key Space)")
|
|
63
|
+
add_button.clicked.connect(self.add_key_frame)
|
|
64
|
+
setting_layout.addWidget(add_button)
|
|
65
|
+
del_button = QPushButton("Delete Key Frame (Key Delete)")
|
|
66
|
+
del_button.clicked.connect(self.del_key_frame)
|
|
67
|
+
setting_layout.addWidget(del_button)
|
|
68
|
+
|
|
69
|
+
# Add play/stop button
|
|
70
|
+
self.play_button = QPushButton("Play")
|
|
71
|
+
self.play_button.clicked.connect(self.toggle_playback)
|
|
72
|
+
setting_layout.addWidget(self.play_button)
|
|
73
|
+
|
|
74
|
+
# add a timer to play the frames
|
|
75
|
+
self.timer = QTimer()
|
|
76
|
+
self.timer.timeout.connect(self.play_frames)
|
|
77
|
+
self.current_frame_index = 0
|
|
78
|
+
self.is_playing = False
|
|
79
|
+
self.is_recording = False
|
|
80
|
+
|
|
81
|
+
# Add record checkbox
|
|
82
|
+
self.record_checkbox = QCheckBox("Record")
|
|
83
|
+
self.record_checkbox.stateChanged.connect(self.toggle_recording)
|
|
84
|
+
setting_layout.addWidget(self.record_checkbox)
|
|
85
|
+
|
|
86
|
+
# Add video path setting
|
|
87
|
+
video_path_layout = QHBoxLayout()
|
|
88
|
+
label_video_path = QLabel("Video Path:")
|
|
89
|
+
video_path_layout.addWidget(label_video_path)
|
|
90
|
+
self.video_path_edit = QLineEdit()
|
|
91
|
+
self.video_path_edit.setText(self.video_path)
|
|
92
|
+
self.video_path_edit.textChanged.connect(self.update_video_path)
|
|
93
|
+
video_path_layout.addWidget(self.video_path_edit)
|
|
94
|
+
setting_layout.addLayout(video_path_layout)
|
|
95
|
+
|
|
96
|
+
# Add codec setting
|
|
97
|
+
codec_layout = QHBoxLayout()
|
|
98
|
+
label_codec = QLabel("Codec:")
|
|
99
|
+
codec_layout.addWidget(label_codec)
|
|
100
|
+
self.codec_combo = QComboBox()
|
|
101
|
+
self.codec_combo.addItems(["mjpeg", "mpeg4", "libx264", "libx265"])
|
|
102
|
+
codec_layout.addWidget(self.codec_combo)
|
|
103
|
+
setting_layout.addLayout(codec_layout)
|
|
104
|
+
|
|
105
|
+
# Add a list of key frames
|
|
106
|
+
self.frame_list = QListWidget()
|
|
107
|
+
setting_layout.addWidget(self.frame_list)
|
|
108
|
+
self.frame_list.itemSelectionChanged.connect(self.on_select_frame)
|
|
109
|
+
self.installEventFilter(self)
|
|
110
|
+
|
|
111
|
+
# Add spin boxes for linear / angular velocity and stop time
|
|
112
|
+
self.lin_vel_spinbox = QDoubleSpinBox()
|
|
113
|
+
self.lin_vel_spinbox.setPrefix("Linear Velocity (m/s): ")
|
|
114
|
+
self.lin_vel_spinbox.setRange(0, 1000)
|
|
115
|
+
self.lin_vel_spinbox.valueChanged.connect(self.set_frame_lin_vel)
|
|
116
|
+
setting_layout.addWidget(self.lin_vel_spinbox)
|
|
117
|
+
|
|
118
|
+
self.lin_ang_spinbox = QDoubleSpinBox()
|
|
119
|
+
self.lin_ang_spinbox.setPrefix("Angular Velocity (deg/s): ")
|
|
120
|
+
self.lin_ang_spinbox.setRange(0, 360)
|
|
121
|
+
self.lin_ang_spinbox.valueChanged.connect(self.set_frame_ang_vel)
|
|
122
|
+
setting_layout.addWidget(self.lin_ang_spinbox)
|
|
123
|
+
|
|
124
|
+
self.stop_time_spinbox = QDoubleSpinBox()
|
|
125
|
+
self.stop_time_spinbox.setPrefix("Stop Time: ")
|
|
126
|
+
self.stop_time_spinbox.setRange(0, 100)
|
|
127
|
+
self.stop_time_spinbox.valueChanged.connect(self.set_frame_stop_time)
|
|
128
|
+
setting_layout.addWidget(self.stop_time_spinbox)
|
|
129
|
+
|
|
130
|
+
setting_layout.setAlignment(QtCore.Qt.AlignTop)
|
|
131
|
+
|
|
132
|
+
# Create a dock widget for the settings
|
|
133
|
+
dock_widget = QDockWidget("Settings", self)
|
|
134
|
+
dock_widget.setWidget(QWidget())
|
|
135
|
+
dock_widget.widget().setLayout(setting_layout)
|
|
136
|
+
self.addDockWidget(QtCore.Qt.RightDockWidgetArea, dock_widget)
|
|
137
|
+
|
|
138
|
+
# Add the dock widget to the main layout
|
|
139
|
+
main_layout.addWidget(dock_widget)
|
|
140
|
+
self.dock = dock_widget
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def update_video_path(self, path):
|
|
144
|
+
self.video_path = path
|
|
145
|
+
|
|
146
|
+
def add_key_frame(self):
|
|
147
|
+
view_matrix = self.glwidget.view_matrix
|
|
148
|
+
# Get camera pose in world frame
|
|
149
|
+
Twc = np.linalg.inv(view_matrix)
|
|
150
|
+
# Add the key frame to the end of the list
|
|
151
|
+
if self.key_frames:
|
|
152
|
+
prev = self.key_frames[-1]
|
|
153
|
+
key_frame = KeyFrame(Twc,
|
|
154
|
+
lin_vel=prev.lin_vel,
|
|
155
|
+
ang_vel=prev.ang_vel)
|
|
156
|
+
else:
|
|
157
|
+
key_frame = KeyFrame(Twc)
|
|
158
|
+
self.key_frames.append(key_frame)
|
|
159
|
+
# visualize this key frame using FrameItem
|
|
160
|
+
self.glwidget.add_item(key_frame.item)
|
|
161
|
+
# move the camera back to 0.5 meter, let the user see the frame
|
|
162
|
+
self.glwidget.update_dist(0.5)
|
|
163
|
+
# Add the key frame to the Qt ListWidget
|
|
164
|
+
item = QListWidgetItem(f"Frame {len(self.key_frames)}")
|
|
165
|
+
self.frame_list.addItem(item)
|
|
166
|
+
self.frame_list.setCurrentRow(len(self.key_frames) - 1)
|
|
167
|
+
|
|
168
|
+
def del_key_frame(self):
|
|
169
|
+
current_index = self.frame_list.currentRow()
|
|
170
|
+
if current_index >= 0:
|
|
171
|
+
self.glwidget.remove_item(self.key_frames[current_index].item)
|
|
172
|
+
self.key_frames.pop(current_index)
|
|
173
|
+
self.frame_list.itemSelectionChanged.disconnect(self.on_select_frame)
|
|
174
|
+
self.frame_list.takeItem(current_index)
|
|
175
|
+
self.frame_list.itemSelectionChanged.connect(self.on_select_frame)
|
|
176
|
+
self.on_select_frame()
|
|
177
|
+
# Update frame labels
|
|
178
|
+
for i in range(len(self.key_frames)):
|
|
179
|
+
self.frame_list.item(i).setText(f"Frame {i + 1}")
|
|
180
|
+
|
|
181
|
+
def on_select_frame(self):
|
|
182
|
+
current = self.frame_list.currentRow()
|
|
183
|
+
for i, frame in enumerate(self.key_frames):
|
|
184
|
+
if i == current:
|
|
185
|
+
# Highlight the selected frame
|
|
186
|
+
frame.item.set_color('#FF0000')
|
|
187
|
+
frame.item.set_line_width(5)
|
|
188
|
+
# show current frame's parameters in the spinboxes
|
|
189
|
+
self.lin_vel_spinbox.setValue(frame.lin_vel)
|
|
190
|
+
self.lin_ang_spinbox.setValue(np.rad2deg(frame.ang_vel))
|
|
191
|
+
self.stop_time_spinbox.setValue(frame.stop_time)
|
|
192
|
+
else:
|
|
193
|
+
frame.item.set_color('#0000FF')
|
|
194
|
+
frame.item.set_line_width(3)
|
|
195
|
+
|
|
196
|
+
def set_frame_lin_vel(self, value):
|
|
197
|
+
current_index = self.frame_list.currentRow()
|
|
198
|
+
if current_index >= 0:
|
|
199
|
+
self.key_frames[current_index].lin_vel = value
|
|
200
|
+
|
|
201
|
+
def set_frame_ang_vel(self, value):
|
|
202
|
+
current_index = self.frame_list.currentRow()
|
|
203
|
+
if current_index >= 0:
|
|
204
|
+
self.key_frames[current_index].ang_vel = np.deg2rad(value)
|
|
205
|
+
|
|
206
|
+
def set_frame_stop_time(self, value):
|
|
207
|
+
current_index = self.frame_list.currentRow()
|
|
208
|
+
if current_index >= 0:
|
|
209
|
+
self.key_frames[current_index].stop_time = value
|
|
210
|
+
|
|
211
|
+
def create_frames(self):
|
|
212
|
+
"""
|
|
213
|
+
Create the frames for playback by interpolating between key frames.
|
|
214
|
+
"""
|
|
215
|
+
self.frames = []
|
|
216
|
+
dt = 1 / float(self.update_interval)
|
|
217
|
+
for i in range(len(self.key_frames) - 1):
|
|
218
|
+
current_frame = self.key_frames[i]
|
|
219
|
+
if current_frame.stop_time > 0:
|
|
220
|
+
num_steps = int(current_frame.stop_time / dt)
|
|
221
|
+
for j in range(num_steps):
|
|
222
|
+
self.frames.append([i, current_frame.Twc])
|
|
223
|
+
next_frame = self.key_frames[i + 1]
|
|
224
|
+
Ts = q3d.interpolate_pose(current_frame.Twc, next_frame.Twc,
|
|
225
|
+
current_frame.lin_vel,
|
|
226
|
+
current_frame.ang_vel,
|
|
227
|
+
dt)
|
|
228
|
+
for T in Ts:
|
|
229
|
+
self.frames.append([i, T])
|
|
230
|
+
|
|
231
|
+
print(f"Total frames: {len(self.frames)}")
|
|
232
|
+
print(f"Total time: {len(self.frames) * dt:.2f} seconds")
|
|
233
|
+
|
|
234
|
+
def toggle_playback(self):
|
|
235
|
+
if self.is_playing:
|
|
236
|
+
self.stop_playback()
|
|
237
|
+
else:
|
|
238
|
+
self.start_playback()
|
|
239
|
+
|
|
240
|
+
def start_playback(self):
|
|
241
|
+
if self.key_frames:
|
|
242
|
+
self.create_frames()
|
|
243
|
+
self.current_frame_index = 0
|
|
244
|
+
self.timer.start(self.update_interval) # Adjust the interval as needed
|
|
245
|
+
self.is_playing = True
|
|
246
|
+
self.play_button.setStyleSheet("")
|
|
247
|
+
self.play_button.setText("Stop")
|
|
248
|
+
self.record_checkbox.setEnabled(False)
|
|
249
|
+
if self.is_recording is True:
|
|
250
|
+
self.start_recording()
|
|
251
|
+
|
|
252
|
+
def stop_playback(self):
|
|
253
|
+
self.timer.stop()
|
|
254
|
+
self.is_playing = False
|
|
255
|
+
self.play_button.setStyleSheet("")
|
|
256
|
+
self.play_button.setText("Play")
|
|
257
|
+
self.record_checkbox.setEnabled(True)
|
|
258
|
+
self.frame_list.setCurrentRow(len(self.key_frames) - 1)
|
|
259
|
+
if self.is_recording:
|
|
260
|
+
self.stop_recording()
|
|
261
|
+
|
|
262
|
+
def play_frames(self):
|
|
263
|
+
"""
|
|
264
|
+
callback function for the timer to play the frames
|
|
265
|
+
"""
|
|
266
|
+
# play the frames
|
|
267
|
+
if self.current_frame_index < len(self.frames):
|
|
268
|
+
key_id, Tcw = self.frames[self.current_frame_index]
|
|
269
|
+
self.glwidget.set_view_matrix(np.linalg.inv(Tcw))
|
|
270
|
+
self.frame_list.setCurrentRow(key_id)
|
|
271
|
+
self.current_frame_index += 1
|
|
272
|
+
if self.is_recording:
|
|
273
|
+
self.record_frame()
|
|
274
|
+
else:
|
|
275
|
+
self.stop_playback()
|
|
276
|
+
|
|
277
|
+
def toggle_recording(self, state):
|
|
278
|
+
if state == 2:
|
|
279
|
+
self.is_recording = True
|
|
280
|
+
else:
|
|
281
|
+
self.is_recording = False
|
|
282
|
+
|
|
283
|
+
def start_recording(self):
|
|
284
|
+
self.is_recording = True
|
|
285
|
+
self.frames_to_record = []
|
|
286
|
+
video_path = self.video_path_edit.text()
|
|
287
|
+
codec = self.codec_combo.currentText()
|
|
288
|
+
self.play_button.setStyleSheet("background-color: red")
|
|
289
|
+
self.play_button.setText("Recording")
|
|
290
|
+
self.writer = imageio.get_writer(video_path,
|
|
291
|
+
fps=self.update_interval,
|
|
292
|
+
codec=codec) # quality=10
|
|
293
|
+
# disable the all the frame_item while recording
|
|
294
|
+
for frame in self.key_frames:
|
|
295
|
+
frame.item.hide()
|
|
296
|
+
self.dock.hide() # Hide the dock while recording
|
|
297
|
+
|
|
298
|
+
def stop_recording(self, save_movie=True):
|
|
299
|
+
self.is_recording = False
|
|
300
|
+
self.record_checkbox.setChecked(False)
|
|
301
|
+
# enable the all the frame_item after recording
|
|
302
|
+
for frame in self.key_frames:
|
|
303
|
+
frame.item.show()
|
|
304
|
+
if hasattr(self, 'writer') and save_movie:
|
|
305
|
+
self.writer.close()
|
|
306
|
+
self.show_save_message()
|
|
307
|
+
self.dock.show() # Show the dock when recording stops
|
|
308
|
+
|
|
309
|
+
def show_save_message(self):
|
|
310
|
+
msg_box = QMessageBox()
|
|
311
|
+
msg_box.setIcon(QMessageBox.Information)
|
|
312
|
+
msg_box.setWindowTitle("Video Saved")
|
|
313
|
+
msg_box.setText(f"Video saved to {self.video_path_edit.text()}")
|
|
314
|
+
msg_box.setStandardButtons(QMessageBox.Ok)
|
|
315
|
+
msg_box.exec()
|
|
316
|
+
|
|
317
|
+
def record_frame(self):
|
|
318
|
+
frame = self.glwidget.capture_frame()
|
|
319
|
+
# make sure the frame size is multiple of 16
|
|
320
|
+
height, width, _ = frame.shape
|
|
321
|
+
if height % 16 != 0 or width % 16 != 0:
|
|
322
|
+
frame = frame[:-(height % 16), :-(width % 16), :]
|
|
323
|
+
frame = np.ascontiguousarray(frame)
|
|
324
|
+
self.frames_to_record.append(frame)
|
|
325
|
+
try:
|
|
326
|
+
self.writer.append_data(frame)
|
|
327
|
+
except Exception as e:
|
|
328
|
+
print("Don't change the window size during recording.")
|
|
329
|
+
self.stop_recording(False) # Stop recording without saving
|
|
330
|
+
self.stop_playback()
|
|
331
|
+
|
|
332
|
+
def eventFilter(self, obj, event):
|
|
333
|
+
if event.type() == QtCore.QEvent.KeyPress:
|
|
334
|
+
if event.key() == QtCore.Qt.Key_Delete:
|
|
335
|
+
self.del_key_frame()
|
|
336
|
+
return True
|
|
337
|
+
return super().eventFilter(obj, event)
|
|
338
|
+
|
|
339
|
+
def dragEnterEvent(self, event):
|
|
340
|
+
if event.mimeData().hasUrls():
|
|
341
|
+
event.accept()
|
|
342
|
+
else:
|
|
343
|
+
event.ignore()
|
|
344
|
+
|
|
345
|
+
def dropEvent(self, event):
|
|
346
|
+
"""
|
|
347
|
+
Overwrite the drop event to open the cloud file.
|
|
348
|
+
"""
|
|
349
|
+
self.progress_dialog = ProgressDialog(self)
|
|
350
|
+
self.progress_dialog.show()
|
|
351
|
+
files = event.mimeData().urls()
|
|
352
|
+
self.progress_thread = FileLoaderThread(self, files)
|
|
353
|
+
self['cloud'].load(files[0].toLocalFile(), append=False)
|
|
354
|
+
self.progress_thread.progress.connect(self.file_loading_progress)
|
|
355
|
+
self.progress_thread.finished.connect(self.file_loading_finished)
|
|
356
|
+
self.progress_thread.start()
|
|
357
|
+
|
|
358
|
+
def file_loading_progress(self, value):
|
|
359
|
+
self.progress_dialog.set_value(value)
|
|
360
|
+
|
|
361
|
+
def file_loading_finished(self):
|
|
362
|
+
self.progress_dialog.close()
|
|
363
|
+
|
|
364
|
+
def open_cloud_file(self, file, append=False):
|
|
365
|
+
cloud_item = self['cloud']
|
|
366
|
+
if cloud_item is None:
|
|
367
|
+
print("Can't find clouditem.")
|
|
368
|
+
return
|
|
369
|
+
cloud = cloud_item.load(file, append=append)
|
|
370
|
+
center = np.nanmean(cloud['xyz'].astype(np.float64), axis=0)
|
|
371
|
+
self.glwidget.set_cam_position(pos=center)
|
|
372
|
+
|
|
373
|
+
def main():
|
|
374
|
+
import argparse
|
|
375
|
+
parser = argparse.ArgumentParser()
|
|
376
|
+
parser.add_argument("--path", help="the cloud file path")
|
|
377
|
+
args = parser.parse_args()
|
|
378
|
+
app = q3d.QApplication(['Film Maker'])
|
|
379
|
+
viewer = CMMViewer(name='Film Maker', update_interval=30)
|
|
380
|
+
cloud_item = q3d.CloudIOItem(size=0.1, point_type='SPHERE', alpha=0.5, depth_test=True)
|
|
381
|
+
grid_item = q3d.GridItem(size=1000, spacing=20)
|
|
382
|
+
|
|
383
|
+
viewer.add_items(
|
|
384
|
+
{'cloud': cloud_item, 'grid': grid_item})
|
|
385
|
+
|
|
386
|
+
if args.path:
|
|
387
|
+
pcd_fn = args.path
|
|
388
|
+
viewer.open_cloud_file(pcd_fn)
|
|
389
|
+
|
|
390
|
+
viewer.show()
|
|
391
|
+
app.exec()
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
if __name__ == '__main__':
|
|
395
|
+
main()
|
q3dviewer/tools/lidar_calib.py
CHANGED
|
@@ -43,7 +43,7 @@ class CustomDoubleSpinBox(QDoubleSpinBox):
|
|
|
43
43
|
return float(text)
|
|
44
44
|
|
|
45
45
|
|
|
46
|
-
class
|
|
46
|
+
class LiDARCalibViewer(q3d.Viewer):
|
|
47
47
|
def __init__(self, **kwargs):
|
|
48
48
|
self.t01 = np.array([0, 0, 0])
|
|
49
49
|
self.R01 = np.eye(3)
|
|
@@ -51,15 +51,15 @@ class ViewerWithPanel(q3d.Viewer):
|
|
|
51
51
|
self.radius = 0.2
|
|
52
52
|
super().__init__(**kwargs)
|
|
53
53
|
|
|
54
|
-
def
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
def default_gl_setting(self, glwidget):
|
|
55
|
+
# Set camera position and background color
|
|
56
|
+
glwidget.set_bg_color('#ffffff')
|
|
57
|
+
glwidget.set_cam_position(distance=5)
|
|
58
|
+
|
|
59
|
+
def add_control_panel(self, main_layout):
|
|
60
60
|
# Create a vertical layout for the settings
|
|
61
61
|
setting_layout = QVBoxLayout()
|
|
62
|
-
|
|
62
|
+
setting_layout.setAlignment(QtCore.Qt.AlignTop)
|
|
63
63
|
# Add XYZ spin boxes
|
|
64
64
|
label_xyz = QLabel("Set XYZ:")
|
|
65
65
|
setting_layout.addWidget(label_xyz)
|
|
@@ -135,19 +135,8 @@ class ViewerWithPanel(q3d.Viewer):
|
|
|
135
135
|
self.box_pitch.valueChanged.connect(self.update_rpy)
|
|
136
136
|
self.box_yaw.valueChanged.connect(self.update_rpy)
|
|
137
137
|
|
|
138
|
-
# Add a stretch to push the widgets to the top
|
|
139
|
-
setting_layout.addStretch(1)
|
|
140
|
-
|
|
141
|
-
self.glwidget = q3d.GLWidget()
|
|
142
138
|
main_layout.addLayout(setting_layout)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
timer = QtCore.QTimer(self)
|
|
146
|
-
timer.setInterval(20) # period, in milliseconds
|
|
147
|
-
timer.timeout.connect(self.update)
|
|
148
|
-
self.glwidget.set_cam_position(distance=5)
|
|
149
|
-
self.glwidget.set_bg_color('#ffffff')
|
|
150
|
-
timer.start()
|
|
139
|
+
|
|
151
140
|
|
|
152
141
|
def update_radius(self):
|
|
153
142
|
self.radius = self.box_radius.value()
|
|
@@ -271,8 +260,8 @@ def main():
|
|
|
271
260
|
args = parser.parse_args()
|
|
272
261
|
|
|
273
262
|
app = q3d.QApplication(["LiDAR Calib"])
|
|
274
|
-
viewer =
|
|
275
|
-
grid_item = q3d.GridItem(size=10, spacing=1, color=
|
|
263
|
+
viewer = LiDARCalibViewer(name='LiDAR Calib')
|
|
264
|
+
grid_item = q3d.GridItem(size=10, spacing=1, color='#00000040')
|
|
276
265
|
scan0_item = q3d.CloudItem(
|
|
277
266
|
size=2, alpha=1, color_mode='FLAT', color='#ff0000')
|
|
278
267
|
scan1_item = q3d.CloudItem(
|
|
@@ -36,7 +36,7 @@ class CustomDoubleSpinBox(QDoubleSpinBox):
|
|
|
36
36
|
return float(text)
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
class
|
|
39
|
+
class LidarCamViewer(q3d.Viewer):
|
|
40
40
|
def __init__(self, **kwargs):
|
|
41
41
|
# b: camera body frame
|
|
42
42
|
# c: camera image frame
|
|
@@ -54,15 +54,15 @@ class ViewerWithPanel(q3d.Viewer):
|
|
|
54
54
|
self.en_rgb = False
|
|
55
55
|
super().__init__(**kwargs)
|
|
56
56
|
|
|
57
|
-
def
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
center_widget.setLayout(main_layout)
|
|
57
|
+
def default_gl_setting(self, glwidget):
|
|
58
|
+
# Set camera position and background color
|
|
59
|
+
glwidget.set_bg_color('#ffffff')
|
|
60
|
+
glwidget.set_cam_position(distance=5)
|
|
62
61
|
|
|
62
|
+
def add_control_panel(self, main_layout):
|
|
63
63
|
# Create a vertical layout for the settings
|
|
64
64
|
setting_layout = QVBoxLayout()
|
|
65
|
-
|
|
65
|
+
setting_layout.setAlignment(QtCore.Qt.AlignTop)
|
|
66
66
|
# Add a checkbox for RGB
|
|
67
67
|
self.checkbox_rgb = QCheckBox("Enable RGB Cloud")
|
|
68
68
|
self.checkbox_rgb.setChecked(False)
|
|
@@ -148,19 +148,8 @@ class ViewerWithPanel(q3d.Viewer):
|
|
|
148
148
|
self.box_pitch.valueChanged.connect(self.update_rpy)
|
|
149
149
|
self.box_yaw.valueChanged.connect(self.update_rpy)
|
|
150
150
|
|
|
151
|
-
# Add a stretch to push the widgets to the top
|
|
152
|
-
setting_layout.addStretch(1)
|
|
153
|
-
|
|
154
|
-
self.glwidget = q3d.GLWidget()
|
|
155
151
|
main_layout.addLayout(setting_layout)
|
|
156
|
-
main_layout.addWidget(self.glwidget, 1)
|
|
157
152
|
|
|
158
|
-
timer = QtCore.QTimer(self)
|
|
159
|
-
timer.setInterval(20) # period, in milliseconds
|
|
160
|
-
timer.timeout.connect(self.update)
|
|
161
|
-
self.glwidget.set_cam_position(distance=5)
|
|
162
|
-
self.glwidget.set_bg_color('#ffffff')
|
|
163
|
-
timer.start()
|
|
164
153
|
|
|
165
154
|
def update_point_size(self):
|
|
166
155
|
self.psize = self.box_psize.value()
|
|
@@ -292,8 +281,8 @@ def main():
|
|
|
292
281
|
args = parser.parse_args()
|
|
293
282
|
|
|
294
283
|
app = q3d.QApplication(['LiDAR Cam Calib'])
|
|
295
|
-
viewer =
|
|
296
|
-
grid_item = q3d.GridItem(size=10, spacing=1, color=
|
|
284
|
+
viewer = LidarCamViewer(name='LiDAR Cam Calib')
|
|
285
|
+
grid_item = q3d.GridItem(size=10, spacing=1, color='#00000040')
|
|
297
286
|
scan_item = q3d.CloudItem(size=2, alpha=1, color_mode='I')
|
|
298
287
|
img_item = q3d.ImageItem(pos=np.array([0, 0]), size=np.array([800, 600]))
|
|
299
288
|
viewer.add_items({'scan': scan_item, 'grid': grid_item, 'img': img_item})
|
q3dviewer/utils/maths.py
CHANGED
|
@@ -3,9 +3,145 @@ Copyright 2024 Panasonic Advanced Technology Development Co.,Ltd. (Liu Yang)
|
|
|
3
3
|
Distributed under MIT license. See LICENSE for more information.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
"""
|
|
7
|
+
Math proofs and implementations:
|
|
8
|
+
https://github.com/scomup/MathematicalRobotics.git
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
|
|
6
12
|
import numpy as np
|
|
7
13
|
|
|
8
14
|
|
|
15
|
+
_epsilon_ = 1e-5
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def skew(vector):
|
|
19
|
+
return np.array([[0, -vector[2], vector[1]],
|
|
20
|
+
[vector[2], 0, -vector[0]],
|
|
21
|
+
[-vector[1], vector[0], 0]])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def expSO3(omega):
|
|
25
|
+
"""
|
|
26
|
+
Exponential map of SO3
|
|
27
|
+
The proof is shown in 3d_rotation_group.md (10)
|
|
28
|
+
"""
|
|
29
|
+
theta2 = omega.dot(omega)
|
|
30
|
+
theta = np.sqrt(theta2)
|
|
31
|
+
nearZero = theta2 <= _epsilon_
|
|
32
|
+
W = skew(omega)
|
|
33
|
+
if (nearZero):
|
|
34
|
+
return np.eye(3) + W
|
|
35
|
+
else:
|
|
36
|
+
K = W/theta
|
|
37
|
+
KK = K.dot(K)
|
|
38
|
+
sin_theta = np.sin(theta)
|
|
39
|
+
one_minus_cos = 1 - np.cos(theta)
|
|
40
|
+
R = np.eye(3) + sin_theta * K + one_minus_cos * KK # rotation.md (10)
|
|
41
|
+
return R
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def logSO3(R):
|
|
45
|
+
"""
|
|
46
|
+
Logarithm map of SO3
|
|
47
|
+
The proof is shown in rotation.md (14)
|
|
48
|
+
"""
|
|
49
|
+
R11, R12, R13 = R[0, :]
|
|
50
|
+
R21, R22, R23 = R[1, :]
|
|
51
|
+
R31, R32, R33 = R[2, :]
|
|
52
|
+
tr = np.trace(R)
|
|
53
|
+
omega = np.zeros(3)
|
|
54
|
+
v = np.array([R32 - R23, R13 - R31, R21 - R12])
|
|
55
|
+
# when trace == -1, i.e., the theta is approx to +-pi, +-3pi, +-5pi, etc.
|
|
56
|
+
# we do something special
|
|
57
|
+
if (tr + 1.0 < 1e-3):
|
|
58
|
+
if (R33 > R22 and R33 > R11):
|
|
59
|
+
# R33 is largest
|
|
60
|
+
# sin(theta) approx to sgn_w*pi-theta, cos(theta) approx to -1
|
|
61
|
+
W = R21 - R12 # 2*r3*sin(theta) = 2*r3*(sgn_w*pi-theta)
|
|
62
|
+
Q1 = R31 + R13 # 4 * r1*r3
|
|
63
|
+
Q2 = R23 + R32 # 4 * r2*r3
|
|
64
|
+
Q3 = 2.0 + 2.0 * R33 # 4 * r3*r3
|
|
65
|
+
r = np.sqrt(Q3) # 2 * r3
|
|
66
|
+
one_over_r = 1 / r # 1 / (2*r3)
|
|
67
|
+
norm = np.sqrt(Q1*Q1 + Q2*Q2 + Q3*Q3 + W*W) # 4*r3
|
|
68
|
+
sgn_w = np.sign(W) # get the sgn of theta
|
|
69
|
+
mag = np.pi - (2 * sgn_w * W) / norm # theta*sgn_w
|
|
70
|
+
scale = 0.5 * mag * one_over_r # theta * sgn_w / (4*r3)
|
|
71
|
+
# omega = theta * [4*r1*r3, 4*r2*r3, 4*r3*r3]/ (4*r3)
|
|
72
|
+
omega = sgn_w * scale * np.array([Q1, Q2, Q3])
|
|
73
|
+
elif (R22 > R11):
|
|
74
|
+
# R22 is the largest
|
|
75
|
+
W = R13 - R31 # 2*r2*sin(theta) = 2*r2*(sgn_w*pi-theta)
|
|
76
|
+
Q1 = R12 + R21 # 4 * r2*r1
|
|
77
|
+
Q2 = 2.0 + 2.0 * R22 # 4 * r2*r2
|
|
78
|
+
Q3 = R23 + R32 # 4 * r2*r3
|
|
79
|
+
r = np.sqrt(Q2)
|
|
80
|
+
one_over_r = 1 / r
|
|
81
|
+
norm = np.sqrt(Q1*Q1 + Q2*Q2 + Q3*Q3 + W*W)
|
|
82
|
+
sgn_w = np.sign(W)
|
|
83
|
+
mag = np.pi - (2 * sgn_w * W) / norm
|
|
84
|
+
scale = 0.5 * one_over_r * mag
|
|
85
|
+
omega = sgn_w * scale * np.array([Q1, Q2, Q3])
|
|
86
|
+
else:
|
|
87
|
+
# R11 is the largest
|
|
88
|
+
W = R32 - R23 # 2*r1*sin(theta) = 2*r1*(sgn_w*pi-theta)
|
|
89
|
+
Q1 = 2.0 + 2.0 * R11 # 4 * r1*r1
|
|
90
|
+
Q2 = R12 + R21 # 4 * r1*r2
|
|
91
|
+
Q3 = R31 + R13 # 4 * r1*r3
|
|
92
|
+
r = np.sqrt(Q1)
|
|
93
|
+
one_over_r = 1 / r
|
|
94
|
+
norm = np.sqrt(Q1*Q1 + Q2*Q2 + Q3*Q3 + W*W)
|
|
95
|
+
sgn_w = np.sign(W)
|
|
96
|
+
mag = np.pi - (2 * sgn_w * W) / norm
|
|
97
|
+
scale = 0.5 * one_over_r * mag
|
|
98
|
+
omega = sgn_w * scale * np.array([Q1, Q2, Q3])
|
|
99
|
+
else:
|
|
100
|
+
magnitude = 0
|
|
101
|
+
tr_3 = tr - 3.0
|
|
102
|
+
if (tr_3 < -1e-6):
|
|
103
|
+
# this is the normal case -1 < trace < 3
|
|
104
|
+
theta = np.arccos((tr - 1.0) / 2.0)
|
|
105
|
+
magnitude = theta / (2.0 * np.sin(theta))
|
|
106
|
+
else:
|
|
107
|
+
# when theta near 0, +-2pi, +-4pi, etc. (trace near 3.0)
|
|
108
|
+
# use Taylor expansion: theta \approx 1/2-(t-3)/12 + O((t-3)^2)
|
|
109
|
+
# see https://github.com/borglab/gtsam/issues/746 for details
|
|
110
|
+
magnitude = 0.5 - tr_3 / 12.0 + tr_3*tr_3/60.0
|
|
111
|
+
omega = magnitude * np.array([R32 - R23, R13 - R31, R21 - R12])
|
|
112
|
+
return omega
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def interpolate_pose(T1, T2, v_max, omega_max, dt=0.02):
|
|
116
|
+
R1, t1 = makeRt(T1)
|
|
117
|
+
R2, t2 = makeRt(T2)
|
|
118
|
+
|
|
119
|
+
# Get transfrom time based on linear velocity
|
|
120
|
+
d = np.linalg.norm(t2 - t1)
|
|
121
|
+
t_lin = d / v_max
|
|
122
|
+
|
|
123
|
+
# Get transform time based on angular velocity
|
|
124
|
+
omega = logSO3(R2 @ R1.T)
|
|
125
|
+
theta = np.linalg.norm(omega)
|
|
126
|
+
t_ang = theta / omega_max
|
|
127
|
+
|
|
128
|
+
# Get total time based on the linear and angular time.
|
|
129
|
+
t_total = max(t_lin, t_ang)
|
|
130
|
+
num_steps = int(np.ceil(t_total / dt))
|
|
131
|
+
|
|
132
|
+
# Generate interpolated transforms
|
|
133
|
+
interpolated_Ts = []
|
|
134
|
+
for i in range(num_steps):
|
|
135
|
+
s = i / num_steps
|
|
136
|
+
t_interp = (1 - s) * t1 + s * t2
|
|
137
|
+
# Interpolate rotation using SO3.
|
|
138
|
+
R_interp = expSO3(s * omega) @ R1
|
|
139
|
+
T_interp = makeT(R_interp, t_interp)
|
|
140
|
+
interpolated_Ts.append(T_interp)
|
|
141
|
+
|
|
142
|
+
return interpolated_Ts
|
|
143
|
+
|
|
144
|
+
|
|
9
145
|
def frustum(left, right, bottom, top, near, far):
|
|
10
146
|
# see https://www.khronos.org/registry/OpenGL-Refpages/gl2.1/xhtml/glFrustum.xml
|
|
11
147
|
if near <= 0 or far <= 0 or near >= far or left == right or bottom == top:
|
|
@@ -157,11 +293,25 @@ def makeRt(T):
|
|
|
157
293
|
return R, t
|
|
158
294
|
|
|
159
295
|
def hex_to_rgba(hex_color):
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
296
|
+
if not hex_color.startswith("#"):
|
|
297
|
+
print("Invalid hex color string.")
|
|
298
|
+
return (1.0, 1.0, 1.0, 1.0)
|
|
299
|
+
if len(hex_color) == 7:
|
|
300
|
+
color_flat = int(hex_color[1:], 16)
|
|
301
|
+
red = (color_flat >> 16) & 0xFF
|
|
302
|
+
green = (color_flat >> 8) & 0xFF
|
|
303
|
+
blue = color_flat & 0xFF
|
|
304
|
+
return (red / 255.0, green / 255.0, blue / 255.0, 1.0)
|
|
305
|
+
elif len(hex_color) == 9:
|
|
306
|
+
color_flat = int(hex_color[1:], 16)
|
|
307
|
+
red = (color_flat >> 24) & 0xFF
|
|
308
|
+
green = (color_flat >> 16) & 0xFF
|
|
309
|
+
blue = (color_flat >> 8) & 0xFF
|
|
310
|
+
alpha = color_flat & 0xFF
|
|
311
|
+
return (red / 255.0, green / 255.0, blue / 255.0, alpha / 255.0)
|
|
312
|
+
else:
|
|
313
|
+
print("Invalid hex color string.")
|
|
314
|
+
return (1.0, 1.0, 1.0, 1.0)
|
|
165
315
|
|
|
166
316
|
# euler = np.array([1, 0.1, 0.1])
|
|
167
317
|
# euler_angles = matrix_to_euler(euler_to_matrix(euler))
|