camera-client 0.1.0__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.
- camera_client/__init__.py +3 -0
- camera_client/client.py +305 -0
- camera_client/loading.py +84 -0
- camera_client/utils.py +178 -0
- camera_client-0.1.0.dist-info/METADATA +284 -0
- camera_client-0.1.0.dist-info/RECORD +10 -0
- camera_client-0.1.0.dist-info/WHEEL +5 -0
- camera_client-0.1.0.dist-info/entry_points.txt +2 -0
- camera_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- camera_client-0.1.0.dist-info/top_level.txt +1 -0
camera_client/client.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from camera_client.utils import compile_safe_expression
|
|
3
|
+
from camera_client.loading import read_npz_file
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CameraProjection:
|
|
7
|
+
"""
|
|
8
|
+
Vectorized camera coordinate transformer for batch processing.
|
|
9
|
+
|
|
10
|
+
This class is optimized for processing multiple points at once.
|
|
11
|
+
All methods expect array inputs with shape (N, 2) or (N, 3).
|
|
12
|
+
|
|
13
|
+
Handles transformations between:
|
|
14
|
+
- src: Source (distorted) image coordinates
|
|
15
|
+
- ctd: Corrected (undistorted) image coordinates
|
|
16
|
+
- gnd: Ground (3D world) coordinates
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# Allowed variable names for different transformation types
|
|
20
|
+
VARS_CTD_TO_GND = {"x_im", "y_im", "proj_height", "np"}
|
|
21
|
+
VARS_GND_TO_CTD = {"x_gnd", "y_gnd", "z_gnd", "np"}
|
|
22
|
+
|
|
23
|
+
def __init__(self, cam_archive_data):
|
|
24
|
+
"""
|
|
25
|
+
Initialize the camera transformer with calibration data.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
cam_archive_data: Dictionary containing:
|
|
29
|
+
- src2ctd: Source to corrected distortion map (H x W x 2)
|
|
30
|
+
- ctd2src: Corrected to source distortion map (H x W x 2)
|
|
31
|
+
- x_gnd, y_gnd, z_gnd: String expressions for ctd -> ground
|
|
32
|
+
- x_im, y_im: String expressions for ground -> ctd
|
|
33
|
+
"""
|
|
34
|
+
data = cam_archive_data
|
|
35
|
+
|
|
36
|
+
self.plan_scale = data["plan_scale"]
|
|
37
|
+
self.im_width = data["im_width"]
|
|
38
|
+
self.im_height = data["im_height"]
|
|
39
|
+
self.im_wh_size = (self.im_width, self.im_height)
|
|
40
|
+
|
|
41
|
+
# Store lookup tables
|
|
42
|
+
self.src2ctd_points_map = data["src2ctd"]
|
|
43
|
+
self.ctd2src_points_map = data["ctd2src"]
|
|
44
|
+
|
|
45
|
+
self.im_size = self.src2ctd_points_map.shape[:2]
|
|
46
|
+
|
|
47
|
+
# Compile transformation expressions for ctd -> gnd
|
|
48
|
+
self._x_gnd_func = compile_safe_expression(
|
|
49
|
+
data["x_gnd"],
|
|
50
|
+
param_names=["x_im", "y_im", "proj_height"],
|
|
51
|
+
allowed_vars=self.VARS_CTD_TO_GND,
|
|
52
|
+
)
|
|
53
|
+
self._y_gnd_func = compile_safe_expression(
|
|
54
|
+
data["y_gnd"],
|
|
55
|
+
param_names=["x_im", "y_im", "proj_height"],
|
|
56
|
+
allowed_vars=self.VARS_CTD_TO_GND,
|
|
57
|
+
)
|
|
58
|
+
self._z_gnd_func = compile_safe_expression(
|
|
59
|
+
data["z_gnd"],
|
|
60
|
+
param_names=["x_im", "y_im", "proj_height"],
|
|
61
|
+
allowed_vars=self.VARS_CTD_TO_GND,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Compile transformation expressions for gnd -> ctd
|
|
65
|
+
self._x_im_func = compile_safe_expression(
|
|
66
|
+
data["x_im"],
|
|
67
|
+
param_names=["x_gnd", "y_gnd", "z_gnd"],
|
|
68
|
+
allowed_vars=self.VARS_GND_TO_CTD,
|
|
69
|
+
)
|
|
70
|
+
self._y_im_func = compile_safe_expression(
|
|
71
|
+
data["y_im"],
|
|
72
|
+
param_names=["x_gnd", "y_gnd", "z_gnd"],
|
|
73
|
+
allowed_vars=self.VARS_GND_TO_CTD,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def src_to_ctd(self, points):
|
|
77
|
+
"""
|
|
78
|
+
Transform from source (distorted) to corrected coordinates.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
points: (N, 2) array of source points [[x1, y1], [x2, y2], ...]
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
(N, 2) array of corrected points, with [nan, nan] for out-of-bounds
|
|
85
|
+
"""
|
|
86
|
+
points = np.asarray(points, dtype=float)
|
|
87
|
+
if points.ndim != 2 or points.shape[1] != 2:
|
|
88
|
+
raise ValueError(f"Expected (N, 2) array, got shape {points.shape}")
|
|
89
|
+
|
|
90
|
+
N = len(points)
|
|
91
|
+
result = np.full((N, 2), np.nan, dtype=float)
|
|
92
|
+
|
|
93
|
+
# Check for NaN input points
|
|
94
|
+
valid_input = ~np.isnan(points).any(axis=1)
|
|
95
|
+
|
|
96
|
+
if not valid_input.any():
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
# Round to integer coordinates
|
|
100
|
+
p_int = np.round(points[valid_input]).astype(int)
|
|
101
|
+
|
|
102
|
+
# Vectorized bounds checking
|
|
103
|
+
in_bounds = (
|
|
104
|
+
(p_int[:, 0] >= 0)
|
|
105
|
+
& (p_int[:, 0] < self.src2ctd_points_map.shape[1])
|
|
106
|
+
& (p_int[:, 1] >= 0)
|
|
107
|
+
& (p_int[:, 1] < self.src2ctd_points_map.shape[0])
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Create mask for points that are both valid input and in bounds
|
|
111
|
+
valid_indices = np.where(valid_input)[0]
|
|
112
|
+
final_valid_indices = valid_indices[in_bounds]
|
|
113
|
+
valid_p_int = p_int[in_bounds]
|
|
114
|
+
|
|
115
|
+
# Vectorized lookup using advanced indexing
|
|
116
|
+
# Note: lookup map is [y, x] indexed
|
|
117
|
+
result[final_valid_indices] = self.src2ctd_points_map[
|
|
118
|
+
valid_p_int[:, 1], valid_p_int[:, 0]
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
def ctd_to_src(self, points):
|
|
124
|
+
"""
|
|
125
|
+
Transform from corrected to source (distorted) coordinates.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
points: (N, 2) array of corrected points [[x1, y1], [x2, y2], ...]
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
(N, 2) array of source points, with [nan, nan] for out-of-bounds
|
|
132
|
+
"""
|
|
133
|
+
points = np.asarray(points, dtype=float)
|
|
134
|
+
if points.ndim != 2 or points.shape[1] != 2:
|
|
135
|
+
raise ValueError(f"Expected (N, 2) array, got shape {points.shape}")
|
|
136
|
+
|
|
137
|
+
N = len(points)
|
|
138
|
+
result = np.full((N, 2), np.nan, dtype=float)
|
|
139
|
+
|
|
140
|
+
# Check for NaN input points
|
|
141
|
+
valid_input = ~np.isnan(points).any(axis=1)
|
|
142
|
+
|
|
143
|
+
if not valid_input.any():
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
# Round to integer coordinates
|
|
147
|
+
p_int = np.round(points[valid_input]).astype(int)
|
|
148
|
+
|
|
149
|
+
# Vectorized bounds checking
|
|
150
|
+
in_bounds = (
|
|
151
|
+
(p_int[:, 0] >= 0)
|
|
152
|
+
& (p_int[:, 0] < self.ctd2src_points_map.shape[1])
|
|
153
|
+
& (p_int[:, 1] >= 0)
|
|
154
|
+
& (p_int[:, 1] < self.ctd2src_points_map.shape[0])
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Create mask for points that are both valid input and in bounds
|
|
158
|
+
valid_indices = np.where(valid_input)[0]
|
|
159
|
+
final_valid_indices = valid_indices[in_bounds]
|
|
160
|
+
valid_p_int = p_int[in_bounds]
|
|
161
|
+
|
|
162
|
+
# Vectorized lookup using advanced indexing
|
|
163
|
+
result[final_valid_indices] = self.ctd2src_points_map[
|
|
164
|
+
valid_p_int[:, 1], valid_p_int[:, 0]
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
def ctd_to_gnd(self, points, h):
|
|
170
|
+
"""
|
|
171
|
+
Transform from corrected image coordinates to ground (3D world) coordinates.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
points: (N, 2) array of corrected points [[x1, y1], [x2, y2], ...]
|
|
175
|
+
h: Scalar height or (N,) array of heights for each point
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
(N, 3) array of ground points [[x1, y1, z1], ...], with [nan, nan, nan] for invalid
|
|
179
|
+
"""
|
|
180
|
+
points = np.asarray(points, dtype=float)
|
|
181
|
+
if points.ndim != 2 or points.shape[1] != 2:
|
|
182
|
+
raise ValueError(f"Expected (N, 2) array, got shape {points.shape}")
|
|
183
|
+
|
|
184
|
+
N = len(points)
|
|
185
|
+
result = np.full((N, 3), np.nan, dtype=float)
|
|
186
|
+
|
|
187
|
+
# Handle scalar or array height
|
|
188
|
+
if np.isscalar(h):
|
|
189
|
+
h_array = np.full(N, h, dtype=float)
|
|
190
|
+
else:
|
|
191
|
+
h_array = np.asarray(h, dtype=float)
|
|
192
|
+
if h_array.shape != (N,):
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"Height array must have shape ({N},), got {h_array.shape}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Check for NaN input points
|
|
198
|
+
valid = ~np.isnan(points).any(axis=1)
|
|
199
|
+
|
|
200
|
+
if not valid.any():
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
# Extract valid points
|
|
204
|
+
valid_points = points[valid]
|
|
205
|
+
valid_heights = h_array[valid]
|
|
206
|
+
|
|
207
|
+
# Vectorized expression evaluation
|
|
208
|
+
# NumPy operations in expressions will work element-wise on arrays
|
|
209
|
+
x_im = valid_points[:, 0]
|
|
210
|
+
y_im = valid_points[:, 1]
|
|
211
|
+
proj_height = valid_heights
|
|
212
|
+
|
|
213
|
+
# Evaluate expressions (they should handle arrays automatically via NumPy)
|
|
214
|
+
gnd_x = self._x_gnd_func(x_im, y_im, proj_height)
|
|
215
|
+
gnd_y = self._y_gnd_func(x_im, y_im, proj_height)
|
|
216
|
+
gnd_z = self._z_gnd_func(x_im, y_im, proj_height)
|
|
217
|
+
|
|
218
|
+
# Stack results and assign to valid indices
|
|
219
|
+
result[valid] = np.column_stack([gnd_x, gnd_y, gnd_z])
|
|
220
|
+
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
def src_to_gnd(self, points, h):
|
|
224
|
+
"""
|
|
225
|
+
Transform from source to ground coordinates.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
points: (N, 2) array of source points
|
|
229
|
+
h: Scalar height or (N,) array of heights
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
(N, 3) array of ground points
|
|
233
|
+
"""
|
|
234
|
+
ctd_points = self.src_to_ctd(points)
|
|
235
|
+
return self.ctd_to_gnd(ctd_points, h)
|
|
236
|
+
|
|
237
|
+
def gnd_to_ctd(self, points):
|
|
238
|
+
"""
|
|
239
|
+
Transform from ground (3D world) to corrected image coordinates.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
points: (N, 3) array of ground points [[x1, y1, z1], [x2, y2, z2], ...]
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
(N, 2) array of image points, with [nan, nan] for invalid
|
|
246
|
+
"""
|
|
247
|
+
points = np.asarray(points, dtype=float)
|
|
248
|
+
if points.ndim != 2 or points.shape[1] != 3:
|
|
249
|
+
raise ValueError(f"Expected (N, 3) array, got shape {points.shape}")
|
|
250
|
+
|
|
251
|
+
N = len(points)
|
|
252
|
+
result = np.full((N, 2), np.nan, dtype=float)
|
|
253
|
+
|
|
254
|
+
# Check for NaN input points
|
|
255
|
+
valid = ~np.isnan(points).any(axis=1)
|
|
256
|
+
|
|
257
|
+
if not valid.any():
|
|
258
|
+
return result
|
|
259
|
+
|
|
260
|
+
# Extract valid points
|
|
261
|
+
valid_points = points[valid]
|
|
262
|
+
|
|
263
|
+
# Vectorized expression evaluation
|
|
264
|
+
x_gnd = valid_points[:, 0]
|
|
265
|
+
y_gnd = valid_points[:, 1]
|
|
266
|
+
z_gnd = valid_points[:, 2]
|
|
267
|
+
|
|
268
|
+
# Evaluate expressions
|
|
269
|
+
x_im = self._x_im_func(x_gnd, y_gnd, z_gnd)
|
|
270
|
+
y_im = self._y_im_func(x_gnd, y_gnd, z_gnd)
|
|
271
|
+
|
|
272
|
+
# Stack results and assign to valid indices
|
|
273
|
+
result[valid] = np.column_stack([x_im, y_im])
|
|
274
|
+
|
|
275
|
+
return result
|
|
276
|
+
|
|
277
|
+
def gnd_to_src(self, points):
|
|
278
|
+
"""
|
|
279
|
+
Transform from ground to source coordinates.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
points: (N, 3) array of ground points
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
(N, 2) array of source points
|
|
286
|
+
"""
|
|
287
|
+
ctd_points = self.gnd_to_ctd(points)
|
|
288
|
+
return self.ctd_to_src(ctd_points)
|
|
289
|
+
|
|
290
|
+
@classmethod
|
|
291
|
+
def load(cls, archive_path):
|
|
292
|
+
"""
|
|
293
|
+
Load camera transformer from NPZ archive file.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
archive_path: Path to .npz calibration file
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
CameraProjectionVectorized instance
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
# Convert to regular dict for easier access
|
|
303
|
+
cam_data = read_npz_file(archive_path)
|
|
304
|
+
|
|
305
|
+
return cls(cam_data)
|
camera_client/loading.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# Method 1: Read from a saved file
|
|
5
|
+
def read_npz_file(filename):
|
|
6
|
+
"""Read camera projection data from saved .npz file.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
filename (str): Path to the .npz file containing camera projection data
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
dict: Dictionary containing the following keys:
|
|
13
|
+
- plan_scale (float): Scale factor for ground plane coordinates (pixels per meter)
|
|
14
|
+
- im_width (int): Width of the image in pixels
|
|
15
|
+
- im_height (int): Height of the image in pixels
|
|
16
|
+
- src2ctd (np.ndarray): Map of coordinates for undistorted (corrected) image.
|
|
17
|
+
Shape: (height, width, 2) where channel 0 is X, channel 1 is Y
|
|
18
|
+
- ctd2src (np.ndarray): Map of coordinates for distorted (raw) image based on undistorted.
|
|
19
|
+
Shape: (height, width, 2) where channel 0 is X, channel 1 is Y
|
|
20
|
+
- map_scale_h (np.ndarray): Scalar map of height scale values. Shape: (height, width)
|
|
21
|
+
- map_scale_w (np.ndarray): Scalar map of width scale values. Shape: (height, width)
|
|
22
|
+
- map_scale_vang (np.ndarray): Scalar map of vertical angle values. Shape: (height, width)
|
|
23
|
+
- x_gnd (str): Symbolic expression for gndal X coordinate perspective transformation
|
|
24
|
+
- y_gnd (str): Symbolic expression for gndal Y coordinate perspective transformation
|
|
25
|
+
- z_gnd (str): Symbolic expression for gndal Z coordinate perspective transformation
|
|
26
|
+
- x_im (str): Symbolic expression for image X coordinate transformation
|
|
27
|
+
- y_im (str): Symbolic expression for image Y coordinate transformation
|
|
28
|
+
- x_key (str): Symbolic expression for keypoint X coordinate
|
|
29
|
+
- y_key (str): Symbolic expression for keypoint Y coordinate
|
|
30
|
+
- z_key (str): Symbolic expression for keypoint Z coordinate
|
|
31
|
+
- x_ray (str): Symbolic expression for ray X direction
|
|
32
|
+
- y_ray (str): Symbolic expression for ray Y direction
|
|
33
|
+
- z_ray (str): Symbolic expression for ray Z direction
|
|
34
|
+
"""
|
|
35
|
+
data = np.load(filename)
|
|
36
|
+
|
|
37
|
+
plan_scale = data["plan_scale"]
|
|
38
|
+
im_width = data["im_width"]
|
|
39
|
+
im_height = data["im_height"]
|
|
40
|
+
|
|
41
|
+
# Access the arrays
|
|
42
|
+
src2ctd = data["src2ctd"]
|
|
43
|
+
ctd2src = data["ctd2src"]
|
|
44
|
+
map_scale_h = data["map_scale_h"]
|
|
45
|
+
map_scale_w = data["map_scale_w"]
|
|
46
|
+
map_scale_vang = data["map_scale_vang"]
|
|
47
|
+
|
|
48
|
+
# String values (convert from numpy string to Python string)
|
|
49
|
+
x_gnd = str(data["x_gnd_exp"])
|
|
50
|
+
y_gnd = str(data["y_gnd_exp"])
|
|
51
|
+
z_gnd = str(data["z_gnd_exp"])
|
|
52
|
+
x_im = str(data["x_im_exp"])
|
|
53
|
+
y_im = str(data["y_im_exp"])
|
|
54
|
+
x_key = str(data["x_key_exp"])
|
|
55
|
+
y_key = str(data["y_key_exp"])
|
|
56
|
+
z_key = str(data["z_key_exp"])
|
|
57
|
+
x_ray = str(data["x_ray_exp"])
|
|
58
|
+
y_ray = str(data["y_ray_exp"])
|
|
59
|
+
z_ray = str(data["z_ray_exp"])
|
|
60
|
+
|
|
61
|
+
# Don't forget to close the file
|
|
62
|
+
data.close()
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
"plan_scale": plan_scale,
|
|
66
|
+
"im_width": im_width,
|
|
67
|
+
"im_height": im_height,
|
|
68
|
+
"src2ctd": src2ctd,
|
|
69
|
+
"ctd2src": ctd2src,
|
|
70
|
+
"map_scale_h": map_scale_h,
|
|
71
|
+
"map_scale_w": map_scale_w,
|
|
72
|
+
"map_scale_vang": map_scale_vang,
|
|
73
|
+
"x_gnd": x_gnd,
|
|
74
|
+
"y_gnd": y_gnd,
|
|
75
|
+
"z_gnd": z_gnd,
|
|
76
|
+
"x_im": x_im,
|
|
77
|
+
"y_im": y_im,
|
|
78
|
+
"x_key": x_key,
|
|
79
|
+
"y_key": y_key,
|
|
80
|
+
"z_key": z_key,
|
|
81
|
+
"x_ray": x_ray,
|
|
82
|
+
"y_ray": y_ray,
|
|
83
|
+
"z_ray": z_ray,
|
|
84
|
+
}
|
camera_client/utils.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import ast
|
|
3
|
+
import operator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# ============================================================================
|
|
7
|
+
# Expression Validation and Compilation Functions
|
|
8
|
+
# ============================================================================
|
|
9
|
+
|
|
10
|
+
# Allowed operations for expression validation
|
|
11
|
+
ALLOWED_OPERATORS = {
|
|
12
|
+
ast.Add: operator.add,
|
|
13
|
+
ast.Sub: operator.sub,
|
|
14
|
+
ast.Mult: operator.mul,
|
|
15
|
+
ast.Div: operator.truediv,
|
|
16
|
+
ast.Pow: operator.pow,
|
|
17
|
+
ast.USub: operator.neg,
|
|
18
|
+
ast.UAdd: operator.pos,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Allowed functions
|
|
22
|
+
ALLOWED_FUNCTIONS = {
|
|
23
|
+
"sqrt": np.sqrt,
|
|
24
|
+
"sin": np.sin,
|
|
25
|
+
"cos": np.cos,
|
|
26
|
+
"tan": np.tan,
|
|
27
|
+
"arcsin": np.arcsin,
|
|
28
|
+
"arccos": np.arccos,
|
|
29
|
+
"arctan": np.arctan,
|
|
30
|
+
"arctan2": np.arctan2,
|
|
31
|
+
"abs": np.abs,
|
|
32
|
+
"exp": np.exp,
|
|
33
|
+
"log": np.log,
|
|
34
|
+
"log10": np.log10,
|
|
35
|
+
"pow": np.power,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def normalize_expression(expr):
|
|
40
|
+
"""
|
|
41
|
+
Normalize expression by replacing common math functions with numpy equivalents.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
expr: String expression to normalize
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Normalized expression string
|
|
48
|
+
"""
|
|
49
|
+
expr = expr.replace("sqrt", "np.sqrt")
|
|
50
|
+
expr = expr.replace("sin(", "np.sin(")
|
|
51
|
+
expr = expr.replace("cos(", "np.cos(")
|
|
52
|
+
expr = expr.replace("tan(", "np.tan(")
|
|
53
|
+
# Handle cases where np.np might occur
|
|
54
|
+
expr = expr.replace("np.np.", "np.")
|
|
55
|
+
return expr
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_expression(expr, allowed_vars):
|
|
59
|
+
"""
|
|
60
|
+
Validate that expression only contains safe operations.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
expr: String expression to validate
|
|
64
|
+
allowed_vars: Set of allowed variable names
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ValueError: If expression contains dangerous operations
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
tree = ast.parse(expr, mode="eval")
|
|
71
|
+
except SyntaxError as e:
|
|
72
|
+
raise ValueError(f"Invalid expression syntax: {expr}") from e
|
|
73
|
+
|
|
74
|
+
# Walk through AST and check all nodes
|
|
75
|
+
for node in ast.walk(tree):
|
|
76
|
+
if isinstance(node, ast.Name):
|
|
77
|
+
# Check variable names
|
|
78
|
+
if node.id not in allowed_vars and node.id not in ALLOWED_FUNCTIONS:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"Disallowed variable '{node.id}' in expression: {expr}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
elif isinstance(node, ast.Call):
|
|
84
|
+
# Check function calls
|
|
85
|
+
if isinstance(node.func, ast.Attribute):
|
|
86
|
+
# Handle np.sqrt, np.sin, etc.
|
|
87
|
+
if isinstance(node.func.value, ast.Name):
|
|
88
|
+
if node.func.value.id != "np":
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"Disallowed module '{node.func.value.id}' in expression: {expr}"
|
|
91
|
+
)
|
|
92
|
+
# np.function_name - allow numpy functions
|
|
93
|
+
else:
|
|
94
|
+
raise ValueError(f"Complex attribute access not allowed: {expr}")
|
|
95
|
+
|
|
96
|
+
elif isinstance(node.func, ast.Name):
|
|
97
|
+
if node.func.id not in ALLOWED_FUNCTIONS:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"Disallowed function '{node.func.id}' in expression: {expr}"
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
raise ValueError(f"Complex function call not allowed: {expr}")
|
|
103
|
+
|
|
104
|
+
elif isinstance(node, ast.BinOp):
|
|
105
|
+
# Check binary operations
|
|
106
|
+
if type(node.op) not in ALLOWED_OPERATORS:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"Disallowed operator '{node.op.__class__.__name__}' in expression: {expr}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
elif isinstance(node, ast.UnaryOp):
|
|
112
|
+
# Check unary operations
|
|
113
|
+
if type(node.op) not in ALLOWED_OPERATORS:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f"Disallowed unary operator '{node.op.__class__.__name__}' in expression: {expr}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
elif isinstance(node, (ast.Attribute, ast.Import, ast.ImportFrom)):
|
|
119
|
+
# Block imports and most attribute access
|
|
120
|
+
if isinstance(node, ast.Attribute):
|
|
121
|
+
# Only allow np.function
|
|
122
|
+
if not (isinstance(node.value, ast.Name) and node.value.id == "np"):
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"Disallowed attribute access in expression: {expr}"
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
raise ValueError(f"Import statements not allowed in expression: {expr}")
|
|
128
|
+
|
|
129
|
+
elif isinstance(node, (ast.Lambda, ast.FunctionDef, ast.ClassDef)):
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"Function/class definitions not allowed in expression: {expr}"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
elif isinstance(node, (ast.ListComp, ast.DictComp, ast.GeneratorExp)):
|
|
135
|
+
raise ValueError(f"Comprehensions not allowed in expression: {expr}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def compile_safe_expression(expr, param_names, allowed_vars=None):
|
|
139
|
+
"""
|
|
140
|
+
Validate and compile expression into a callable function.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
expr: String expression to compile
|
|
144
|
+
param_names: List of parameter names expected in expression
|
|
145
|
+
allowed_vars: Set of allowed variable names (defaults to param_names + 'np')
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Compiled function that evaluates the expression
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
ValueError: If expression validation fails
|
|
152
|
+
"""
|
|
153
|
+
# Default allowed vars to param_names plus 'np'
|
|
154
|
+
if allowed_vars is None:
|
|
155
|
+
allowed_vars = set(param_names) | {"np"}
|
|
156
|
+
|
|
157
|
+
# Normalize the expression
|
|
158
|
+
normalized_expr = normalize_expression(expr)
|
|
159
|
+
|
|
160
|
+
# Validate the expression
|
|
161
|
+
validate_expression(normalized_expr, allowed_vars)
|
|
162
|
+
|
|
163
|
+
# Compile the expression
|
|
164
|
+
code = compile(normalized_expr, "<string>", "eval")
|
|
165
|
+
|
|
166
|
+
# Create restricted namespace with only allowed functions
|
|
167
|
+
safe_namespace = {
|
|
168
|
+
"np": np,
|
|
169
|
+
"__builtins__": {}, # Remove all builtins
|
|
170
|
+
}
|
|
171
|
+
safe_namespace.update(ALLOWED_FUNCTIONS)
|
|
172
|
+
|
|
173
|
+
def compiled_func(*args):
|
|
174
|
+
# Create local namespace with parameter values
|
|
175
|
+
local_vars = dict(zip(param_names, args))
|
|
176
|
+
return eval(code, safe_namespace, local_vars)
|
|
177
|
+
|
|
178
|
+
return compiled_func
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: camera-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for camera calibration projection data - transform between distorted, corrected, and world coordinates.
|
|
5
|
+
Author-email: Alexander Abramov <extremal.ru@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/avabr/camera-client
|
|
8
|
+
Project-URL: Repository, https://github.com/avabr/camera-client
|
|
9
|
+
Project-URL: Issues, https://github.com/avabr/camera-client/issues
|
|
10
|
+
Keywords: camera,calibration,projection,3d,mathematics
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
24
|
+
Requires-Python: >=3.7
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: numpy>=1.20.0
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# camera-client
|
|
31
|
+
|
|
32
|
+
Python SDK for camera calibration and projection transformations. Transform coordinates between distorted image space, corrected image space, and real-world 3D coordinates using pre-computed calibration data.
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
- **Vectorized operations** - Process multiple points simultaneously for high performance
|
|
37
|
+
- **Multiple coordinate systems** - Transform between source (distorted), corrected, and ground (3D world) coordinates
|
|
38
|
+
- **Lens distortion handling** - Correct for camera lens distortion using calibration lookup tables
|
|
39
|
+
- **Ground plane projection** - Project image coordinates to 3D world coordinates and vice versa
|
|
40
|
+
- **NumPy-based** - Fast array operations with minimal dependencies
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
Install from PyPI:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install camera-client
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import numpy as np
|
|
54
|
+
from camera_client import CameraProjection
|
|
55
|
+
|
|
56
|
+
# Load camera calibration data from NPZ archive
|
|
57
|
+
camera = CameraProjection.load("camera_calibration.npz")
|
|
58
|
+
|
|
59
|
+
# Transform single or multiple points (vectorized operations)
|
|
60
|
+
source_points = np.array([
|
|
61
|
+
[100, 200],
|
|
62
|
+
[300, 400],
|
|
63
|
+
[500, 600]
|
|
64
|
+
])
|
|
65
|
+
|
|
66
|
+
# Remove lens distortion
|
|
67
|
+
corrected_points = camera.src_to_ctd(source_points)
|
|
68
|
+
|
|
69
|
+
# Project to ground plane (height = 0)
|
|
70
|
+
ground_points = camera.src_to_gnd(source_points, h=0)
|
|
71
|
+
print(ground_points) # Returns (N, 3) array with [x, y, z] coordinates
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Coordinate Systems
|
|
75
|
+
|
|
76
|
+
This library handles transformations between three coordinate systems:
|
|
77
|
+
|
|
78
|
+
- **src** (Source): Distorted image coordinates from the camera
|
|
79
|
+
- **ctd** (Corrected): Undistorted image coordinates after lens correction
|
|
80
|
+
- **gnd** (Ground): Real-world 3D coordinates (x, y, z)
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
Distorted Undistorted World 3D
|
|
84
|
+
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
|
85
|
+
│ │ │ │ │ │
|
|
86
|
+
│ Source (src) │ <──> │ Corrected (ctd) │ <──> │ Ground (gnd) │
|
|
87
|
+
│ │ │ │ │ │
|
|
88
|
+
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
|
89
|
+
Lens distortion Lens correction 3D projection
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Usage Examples
|
|
93
|
+
|
|
94
|
+
### Basic Coordinate Transformations
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from camera_client import CameraProjection
|
|
98
|
+
import numpy as np
|
|
99
|
+
|
|
100
|
+
# Load calibration data
|
|
101
|
+
camera = CameraProjection.load("camera_calibration.npz")
|
|
102
|
+
|
|
103
|
+
# Source (distorted) to Corrected (undistorted)
|
|
104
|
+
src_points = np.array([[640, 480], [1280, 720]])
|
|
105
|
+
ctd_points = camera.src_to_ctd(src_points)
|
|
106
|
+
|
|
107
|
+
# Corrected back to Source
|
|
108
|
+
src_points_back = camera.ctd_to_src(ctd_points)
|
|
109
|
+
|
|
110
|
+
# Check round-trip accuracy
|
|
111
|
+
error = np.linalg.norm(src_points - src_points_back, axis=1)
|
|
112
|
+
print(f"Round-trip error: {error}")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### 3D Ground Projection
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
# Project image points to ground plane
|
|
119
|
+
src_points = np.array([[640, 480], [800, 600]])
|
|
120
|
+
|
|
121
|
+
# Project to ground at height = 0 (ground level)
|
|
122
|
+
ground_points = camera.src_to_gnd(src_points, h=0)
|
|
123
|
+
print(ground_points) # Shape: (2, 3) with [x, y, z] coordinates
|
|
124
|
+
|
|
125
|
+
# Project to elevated plane (e.g., 1.5 meters above ground)
|
|
126
|
+
elevated_points = camera.src_to_gnd(src_points, h=1.5)
|
|
127
|
+
|
|
128
|
+
# Different height for each point
|
|
129
|
+
heights = np.array([0, 1.5])
|
|
130
|
+
mixed_points = camera.src_to_gnd(src_points, h=heights)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Reverse Projection (3D to Image)
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
# Project 3D world coordinates back to image
|
|
137
|
+
world_points = np.array([
|
|
138
|
+
[10.0, 5.0, 0.0], # x, y, z in meters
|
|
139
|
+
[15.0, 8.0, 1.5]
|
|
140
|
+
])
|
|
141
|
+
|
|
142
|
+
# Get corrected image coordinates
|
|
143
|
+
ctd_points = camera.gnd_to_ctd(world_points)
|
|
144
|
+
|
|
145
|
+
# Get source (distorted) image coordinates
|
|
146
|
+
src_points = camera.gnd_to_src(world_points)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Batch Processing
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
# Process large batches of points efficiently
|
|
153
|
+
num_points = 10000
|
|
154
|
+
random_points = np.random.rand(num_points, 2) * [1920, 1080]
|
|
155
|
+
|
|
156
|
+
# Vectorized transformation (fast!)
|
|
157
|
+
corrected = camera.src_to_ctd(random_points)
|
|
158
|
+
ground = camera.src_to_gnd(random_points, h=0)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Accessing Camera Properties
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
# Get camera dimensions
|
|
165
|
+
print(f"Image size: {camera.im_width} x {camera.im_height}")
|
|
166
|
+
print(f"Image WH: {camera.im_wh_size}")
|
|
167
|
+
print(f"Plan scale: {camera.plan_scale}")
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## API Reference
|
|
171
|
+
|
|
172
|
+
### `CameraProjection.load(archive_path)`
|
|
173
|
+
|
|
174
|
+
Load camera calibration from NPZ file.
|
|
175
|
+
|
|
176
|
+
**Parameters:**
|
|
177
|
+
- `archive_path` (str): Path to .npz calibration archive
|
|
178
|
+
|
|
179
|
+
**Returns:**
|
|
180
|
+
- `CameraProjection` instance
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
### `src_to_ctd(points)`
|
|
185
|
+
|
|
186
|
+
Transform from source (distorted) to corrected coordinates.
|
|
187
|
+
|
|
188
|
+
**Parameters:**
|
|
189
|
+
- `points` (np.ndarray): Shape (N, 2) array of [x, y] coordinates
|
|
190
|
+
|
|
191
|
+
**Returns:**
|
|
192
|
+
- `np.ndarray`: Shape (N, 2) corrected coordinates
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
### `ctd_to_src(points)`
|
|
197
|
+
|
|
198
|
+
Transform from corrected to source (distorted) coordinates.
|
|
199
|
+
|
|
200
|
+
**Parameters:**
|
|
201
|
+
- `points` (np.ndarray): Shape (N, 2) array of [x, y] coordinates
|
|
202
|
+
|
|
203
|
+
**Returns:**
|
|
204
|
+
- `np.ndarray`: Shape (N, 2) source coordinates
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
### `src_to_gnd(points, h)`
|
|
209
|
+
|
|
210
|
+
Transform from source coordinates to 3D ground coordinates.
|
|
211
|
+
|
|
212
|
+
**Parameters:**
|
|
213
|
+
- `points` (np.ndarray): Shape (N, 2) array of [x, y] coordinates
|
|
214
|
+
- `h` (float or np.ndarray): Height(s) above ground. Scalar or shape (N,) array
|
|
215
|
+
|
|
216
|
+
**Returns:**
|
|
217
|
+
- `np.ndarray`: Shape (N, 3) ground coordinates [x, y, z]
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
### `gnd_to_src(points)`
|
|
222
|
+
|
|
223
|
+
Transform from 3D ground coordinates to source coordinates.
|
|
224
|
+
|
|
225
|
+
**Parameters:**
|
|
226
|
+
- `points` (np.ndarray): Shape (N, 3) array of [x, y, z] coordinates
|
|
227
|
+
|
|
228
|
+
**Returns:**
|
|
229
|
+
- `np.ndarray`: Shape (N, 2) source coordinates
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
### `ctd_to_gnd(points, h)`
|
|
234
|
+
|
|
235
|
+
Transform from corrected coordinates to 3D ground coordinates.
|
|
236
|
+
|
|
237
|
+
**Parameters:**
|
|
238
|
+
- `points` (np.ndarray): Shape (N, 2) array of [x, y] coordinates
|
|
239
|
+
- `h` (float or np.ndarray): Height(s) above ground
|
|
240
|
+
|
|
241
|
+
**Returns:**
|
|
242
|
+
- `np.ndarray`: Shape (N, 3) ground coordinates
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
### `gnd_to_ctd(points)`
|
|
247
|
+
|
|
248
|
+
Transform from 3D ground coordinates to corrected coordinates.
|
|
249
|
+
|
|
250
|
+
**Parameters:**
|
|
251
|
+
- `points` (np.ndarray): Shape (N, 3) array of [x, y, z] coordinates
|
|
252
|
+
|
|
253
|
+
**Returns:**
|
|
254
|
+
- `np.ndarray`: Shape (N, 2) corrected coordinates
|
|
255
|
+
|
|
256
|
+
## Calibration File Format
|
|
257
|
+
|
|
258
|
+
The calibration file is a NumPy `.npz` archive containing:
|
|
259
|
+
|
|
260
|
+
- `src2ctd`: Lookup table for source to corrected transformation (H x W x 2)
|
|
261
|
+
- `ctd2src`: Lookup table for corrected to source transformation (H x W x 2)
|
|
262
|
+
- `x_gnd`, `y_gnd`, `z_gnd`: Expression strings for corrected to ground transformation
|
|
263
|
+
- `x_im`, `y_im`: Expression strings for ground to corrected transformation
|
|
264
|
+
- `im_width`, `im_height`: Image dimensions
|
|
265
|
+
- `plan_scale`: Scale factor for ground plane
|
|
266
|
+
|
|
267
|
+
## Requirements
|
|
268
|
+
|
|
269
|
+
- Python >= 3.7
|
|
270
|
+
- NumPy >= 1.20.0
|
|
271
|
+
|
|
272
|
+
## Links
|
|
273
|
+
|
|
274
|
+
- **Repository**: [https://github.com/avabr/camera-client](https://github.com/avabr/camera-client)
|
|
275
|
+
- **Issues**: [https://github.com/avabr/camera-client/issues](https://github.com/avabr/camera-client/issues)
|
|
276
|
+
- **PyPI**: [https://pypi.org/project/camera-client/](https://pypi.org/project/camera-client/)
|
|
277
|
+
|
|
278
|
+
## License
|
|
279
|
+
|
|
280
|
+
MIT License - see [LICENSE](LICENSE) file for details.
|
|
281
|
+
|
|
282
|
+
## Author
|
|
283
|
+
|
|
284
|
+
Alexander Abramov ([extremal.ru@gmail.com](mailto:extremal.ru@gmail.com))
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
camera_client/__init__.py,sha256=XAIJyYru2yFfyPVH7bnS17g0PhZ3kOh6KpFHtIAedWc,82
|
|
2
|
+
camera_client/client.py,sha256=_K2nBARp9Y9tLR09ST_t_EBJK77Ys7ne1AaYJBYoOBI,9965
|
|
3
|
+
camera_client/loading.py,sha256=j90sW93vlry2C3jC3yVyvx9bWCKD6ztTwRRZ0_KXkqk,3477
|
|
4
|
+
camera_client/utils.py,sha256=QgZBygEKU5XGFFYn9p68rP08x68yXCXtELsz8iTkwAs,5933
|
|
5
|
+
camera_client-0.1.0.dist-info/licenses/LICENSE,sha256=1i4BjaOpClb6tkjlx0xls3RuF1q2AOeAG0uhG0y_RUU,1065
|
|
6
|
+
camera_client-0.1.0.dist-info/METADATA,sha256=cmV7PczFNE3E0uhQV3Zp6FuSqq2tBWepWTonNjVCQiQ,8511
|
|
7
|
+
camera_client-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
camera_client-0.1.0.dist-info/entry_points.txt,sha256=WR81o3UOxpPbLmVZXnncFFC9-GU1jbxNb0LSG3-2waA,60
|
|
9
|
+
camera_client-0.1.0.dist-info/top_level.txt,sha256=a1RgpRkQgXtlz1R9wYa4FF3b1B_r5Q9ic7TdoCuxlx4,14
|
|
10
|
+
camera_client-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexandr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
camera_client
|