maps4fs 1.8.13__py3-none-any.whl → 1.8.15__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.
- maps4fs/generator/{background.py → component/background.py} +50 -214
- maps4fs/generator/component/base/component_image.py +90 -0
- maps4fs/generator/component/base/component_mesh.py +125 -0
- maps4fs/generator/component/base/component_xml.py +13 -0
- maps4fs/generator/{grle.py → component/grle.py} +160 -178
- maps4fs/generator/dtm/dtm.py +0 -4
- maps4fs/generator/game.py +54 -2
- maps4fs/generator/map.py +1 -1
- maps4fs/generator/satellite.py +2 -2
- maps4fs/generator/settings.py +10 -0
- maps4fs/generator/texture.py +6 -1
- {maps4fs-1.8.13.dist-info → maps4fs-1.8.15.dist-info}/METADATA +7 -1
- {maps4fs-1.8.13.dist-info → maps4fs-1.8.15.dist-info}/RECORD +16 -14
- {maps4fs-1.8.13.dist-info → maps4fs-1.8.15.dist-info}/LICENSE.md +0 -0
- {maps4fs-1.8.13.dist-info → maps4fs-1.8.15.dist-info}/WHEEL +0 -0
- {maps4fs-1.8.13.dist-info → maps4fs-1.8.15.dist-info}/top_level.txt +0 -0
@@ -10,21 +10,15 @@ from copy import deepcopy
|
|
10
10
|
|
11
11
|
import cv2
|
12
12
|
import numpy as np
|
13
|
-
import trimesh # type: ignore
|
14
|
-
from tqdm import tqdm
|
15
13
|
|
16
|
-
from maps4fs.generator.component.base.
|
14
|
+
from maps4fs.generator.component.base.component_image import ImageComponent
|
15
|
+
from maps4fs.generator.component.base.component_mesh import MeshComponent
|
17
16
|
from maps4fs.generator.dem import DEM
|
17
|
+
from maps4fs.generator.settings import Parameters
|
18
18
|
from maps4fs.generator.texture import Texture
|
19
19
|
|
20
|
-
DEFAULT_DISTANCE = 2048
|
21
|
-
FULL_NAME = "FULL"
|
22
|
-
FULL_PREVIEW_NAME = "PREVIEW"
|
23
|
-
ELEMENTS = [FULL_NAME, FULL_PREVIEW_NAME]
|
24
20
|
|
25
|
-
|
26
|
-
# pylint: disable=R0902
|
27
|
-
class Background(Component):
|
21
|
+
class Background(MeshComponent, ImageComponent):
|
28
22
|
"""Component for creating 3D obj files based on DEM data around the map.
|
29
23
|
|
30
24
|
Arguments:
|
@@ -38,11 +32,9 @@ class Background(Component):
|
|
38
32
|
info, warning. If not provided, default logging will be used.
|
39
33
|
"""
|
40
34
|
|
41
|
-
# pylint: disable=R0801
|
42
35
|
def preprocess(self) -> None:
|
43
36
|
"""Registers the DEMs for the background terrain."""
|
44
|
-
self.stl_preview_path
|
45
|
-
self.water_resources_path: str | None = None
|
37
|
+
self.stl_preview_path = os.path.join(self.previews_directory, "background_dem.stl")
|
46
38
|
|
47
39
|
if self.rotation:
|
48
40
|
self.logger.debug("Rotation is enabled: %s.", self.rotation)
|
@@ -50,7 +42,7 @@ class Background(Component):
|
|
50
42
|
else:
|
51
43
|
output_size_multiplier = 1
|
52
44
|
|
53
|
-
self.background_size = self.map_size +
|
45
|
+
self.background_size = self.map_size + Parameters.BACKGROUND_DISTANCE * 2
|
54
46
|
self.rotated_size = int(self.background_size * output_size_multiplier)
|
55
47
|
|
56
48
|
self.background_directory = os.path.join(self.map_directory, "background")
|
@@ -58,13 +50,17 @@ class Background(Component):
|
|
58
50
|
os.makedirs(self.background_directory, exist_ok=True)
|
59
51
|
os.makedirs(self.water_directory, exist_ok=True)
|
60
52
|
|
61
|
-
self.
|
53
|
+
self.water_resources_path = os.path.join(self.water_directory, "water_resources.png")
|
54
|
+
|
55
|
+
self.output_path = os.path.join(self.background_directory, f"{Parameters.FULL}.png")
|
62
56
|
if self.map.custom_background_path:
|
63
|
-
self.
|
57
|
+
self.validate_np_for_mesh(self.map.custom_background_path, self.map_size)
|
64
58
|
shutil.copyfile(self.map.custom_background_path, self.output_path)
|
65
59
|
|
66
|
-
self.not_substracted_path = os.path.join(
|
67
|
-
|
60
|
+
self.not_substracted_path: str = os.path.join(
|
61
|
+
self.background_directory, "not_substracted.png"
|
62
|
+
)
|
63
|
+
self.not_resized_path: str = os.path.join(self.background_directory, "not_resized.png")
|
68
64
|
|
69
65
|
self.dem = DEM(
|
70
66
|
self.game,
|
@@ -80,39 +76,6 @@ class Background(Component):
|
|
80
76
|
self.dem.set_output_resolution((self.rotated_size, self.rotated_size))
|
81
77
|
self.dem.set_dem_path(self.output_path)
|
82
78
|
|
83
|
-
def check_custom_background(self, image_path: str) -> None:
|
84
|
-
"""Checks if the custom background image meets the requirements.
|
85
|
-
|
86
|
-
Arguments:
|
87
|
-
image_path (str): The path to the custom background image.
|
88
|
-
|
89
|
-
Raises:
|
90
|
-
ValueError: If the custom background image does not meet the requirements.
|
91
|
-
"""
|
92
|
-
image = cv2.imread(image_path, cv2.IMREAD_UNCHANGED)
|
93
|
-
if image.shape[0] != image.shape[1]:
|
94
|
-
raise ValueError("The custom background image must be a square.")
|
95
|
-
|
96
|
-
if image.shape[0] != self.map_size + DEFAULT_DISTANCE * 2:
|
97
|
-
raise ValueError("The custom background image must have the size of the map + 4096.")
|
98
|
-
|
99
|
-
if len(image.shape) != 2:
|
100
|
-
raise ValueError("The custom background image must be a grayscale image.")
|
101
|
-
|
102
|
-
if image.dtype != np.uint16:
|
103
|
-
raise ValueError("The custom background image must be a 16-bit grayscale image.")
|
104
|
-
|
105
|
-
def is_preview(self, name: str) -> bool:
|
106
|
-
"""Checks if the DEM is a preview.
|
107
|
-
|
108
|
-
Arguments:
|
109
|
-
name (str): The name of the DEM.
|
110
|
-
|
111
|
-
Returns:
|
112
|
-
bool: True if the DEM is a preview, False otherwise.
|
113
|
-
"""
|
114
|
-
return name == FULL_PREVIEW_NAME
|
115
|
-
|
116
79
|
def process(self) -> None:
|
117
80
|
"""Launches the component processing. Iterates over all tiles and processes them
|
118
81
|
as a result the DEM files will be saved, then based on them the obj files will be
|
@@ -123,12 +86,12 @@ class Background(Component):
|
|
123
86
|
self.dem.process()
|
124
87
|
|
125
88
|
shutil.copyfile(self.dem.dem_path, self.not_substracted_path)
|
126
|
-
self.
|
89
|
+
self.save_map_dem(self.dem.dem_path, save_path=self.not_resized_path)
|
127
90
|
|
128
91
|
if self.map.dem_settings.water_depth:
|
129
92
|
self.subtraction()
|
130
93
|
|
131
|
-
cutted_dem_path = self.
|
94
|
+
cutted_dem_path = self.save_map_dem(self.dem.dem_path)
|
132
95
|
if self.game.additional_dem_name is not None:
|
133
96
|
self.make_copy(cutted_dem_path, self.game.additional_dem_name)
|
134
97
|
|
@@ -184,9 +147,9 @@ class Background(Component):
|
|
184
147
|
|
185
148
|
def qgis_sequence(self) -> None:
|
186
149
|
"""Generates QGIS scripts for creating bounding box layers and rasterizing them."""
|
187
|
-
qgis_layer = (f"Background_{
|
150
|
+
qgis_layer = (f"Background_{Parameters.FULL}", *self.dem.get_espg3857_bbox())
|
188
151
|
qgis_layer_with_margin = (
|
189
|
-
f"Background_{
|
152
|
+
f"Background_{Parameters.FULL}_margin",
|
190
153
|
*self.dem.get_espg3857_bbox(add_margin=True),
|
191
154
|
)
|
192
155
|
self.create_qgis_scripts([qgis_layer, qgis_layer_with_margin])
|
@@ -214,10 +177,9 @@ class Background(Component):
|
|
214
177
|
create_preview=True,
|
215
178
|
remove_center=self.map.background_settings.remove_center,
|
216
179
|
include_zeros=False,
|
217
|
-
)
|
180
|
+
)
|
218
181
|
|
219
|
-
|
220
|
-
def cutout(self, dem_path: str, save_path: str | None = None) -> str:
|
182
|
+
def save_map_dem(self, dem_path: str, save_path: str | None = None) -> str:
|
221
183
|
"""Cuts out the center of the DEM (the actual map) and saves it as a separate file.
|
222
184
|
|
223
185
|
Arguments:
|
@@ -228,14 +190,8 @@ class Background(Component):
|
|
228
190
|
str -- The path to the cutout DEM file.
|
229
191
|
"""
|
230
192
|
dem_data = cv2.imread(dem_path, cv2.IMREAD_UNCHANGED)
|
231
|
-
|
232
|
-
center = (dem_data.shape[0] // 2, dem_data.shape[1] // 2)
|
233
193
|
half_size = self.map_size // 2
|
234
|
-
|
235
|
-
x2 = center[0] + half_size
|
236
|
-
y1 = center[1] - half_size
|
237
|
-
y2 = center[1] + half_size
|
238
|
-
dem_data = dem_data[x1:x2, y1:y2]
|
194
|
+
dem_data = self.cut_out_np(dem_data, half_size, return_cutout=True)
|
239
195
|
|
240
196
|
if save_path:
|
241
197
|
cv2.imwrite(save_path, dem_data)
|
@@ -260,26 +216,6 @@ class Background(Component):
|
|
260
216
|
|
261
217
|
return main_dem_path
|
262
218
|
|
263
|
-
def remove_center(self, dem_data: np.ndarray, resize_factor: float) -> np.ndarray:
|
264
|
-
"""Removes the center part of the DEM data.
|
265
|
-
|
266
|
-
Arguments:
|
267
|
-
dem_data (np.ndarray) -- The DEM data as a numpy array.
|
268
|
-
resize_factor (float) -- The resize factor of the DEM data.
|
269
|
-
|
270
|
-
Returns:
|
271
|
-
np.ndarray -- The DEM data with the center part removed.
|
272
|
-
"""
|
273
|
-
center = (dem_data.shape[0] // 2, dem_data.shape[1] // 2)
|
274
|
-
half_size = int(self.map_size // 2 * resize_factor)
|
275
|
-
x1 = center[0] - half_size
|
276
|
-
x2 = center[0] + half_size
|
277
|
-
y1 = center[1] - half_size
|
278
|
-
y2 = center[1] + half_size
|
279
|
-
dem_data[x1:x2, y1:y2] = 0
|
280
|
-
return dem_data
|
281
|
-
|
282
|
-
# pylint: disable=R0913, R0917, R0915
|
283
219
|
def plane_from_np(
|
284
220
|
self,
|
285
221
|
dem_data: np.ndarray,
|
@@ -302,110 +238,29 @@ class Background(Component):
|
|
302
238
|
resize_factor = 1 / self.map.background_settings.resize_factor
|
303
239
|
dem_data = cv2.resize(dem_data, (0, 0), fx=resize_factor, fy=resize_factor)
|
304
240
|
if remove_center:
|
305
|
-
|
241
|
+
half_size = int(self.map_size // 2 * resize_factor)
|
242
|
+
dem_data = self.cut_out_np(dem_data, half_size, set_zeros=True)
|
306
243
|
self.logger.debug("Center removed from DEM data.")
|
307
244
|
self.logger.debug(
|
308
245
|
"DEM data resized to shape: %s with factor: %s", dem_data.shape, resize_factor
|
309
246
|
)
|
310
247
|
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
ground = z.max()
|
321
|
-
self.logger.debug("Ground level: %s", ground)
|
322
|
-
|
323
|
-
self.logger.debug(
|
324
|
-
"Starting to generate a mesh for with shape: %s x %s. This may take a while.",
|
325
|
-
cols,
|
326
|
-
rows,
|
248
|
+
mesh = self.mesh_from_np(
|
249
|
+
dem_data,
|
250
|
+
include_zeros=include_zeros,
|
251
|
+
z_scaling_factor=self.get_z_scaling_factor(),
|
252
|
+
resize_factor=resize_factor,
|
253
|
+
apply_decimation=self.map.background_settings.apply_decimation,
|
254
|
+
decimation_percent=self.map.background_settings.decimation_percent,
|
255
|
+
decimation_agression=self.map.background_settings.decimation_agression,
|
327
256
|
)
|
328
257
|
|
329
|
-
vertices = np.column_stack([x.ravel(), y.ravel(), z.ravel()])
|
330
|
-
faces = []
|
331
|
-
|
332
|
-
skipped = 0
|
333
|
-
|
334
|
-
for i in tqdm(range(rows - 1), desc="Generating mesh", unit="row"):
|
335
|
-
for j in range(cols - 1):
|
336
|
-
top_left = i * cols + j
|
337
|
-
top_right = top_left + 1
|
338
|
-
bottom_left = top_left + cols
|
339
|
-
bottom_right = bottom_left + 1
|
340
|
-
|
341
|
-
if (
|
342
|
-
ground in [z[i, j], z[i, j + 1], z[i + 1, j], z[i + 1, j + 1]]
|
343
|
-
and not include_zeros
|
344
|
-
):
|
345
|
-
skipped += 1
|
346
|
-
continue
|
347
|
-
|
348
|
-
faces.append([top_left, bottom_left, bottom_right])
|
349
|
-
faces.append([top_left, bottom_right, top_right])
|
350
|
-
|
351
|
-
self.logger.debug("Skipped faces: %s", skipped)
|
352
|
-
|
353
|
-
faces = np.array(faces) # type: ignore
|
354
|
-
mesh = trimesh.Trimesh(vertices=vertices, faces=faces)
|
355
|
-
|
356
|
-
# Apply rotation: 180 degrees around Y-axis and Z-axis
|
357
|
-
rotation_matrix_y = trimesh.transformations.rotation_matrix(np.pi, [0, 1, 0])
|
358
|
-
rotation_matrix_z = trimesh.transformations.rotation_matrix(np.pi, [0, 0, 1])
|
359
|
-
mesh.apply_transform(rotation_matrix_y)
|
360
|
-
mesh.apply_transform(rotation_matrix_z)
|
361
|
-
|
362
|
-
# if not include_zeros:
|
363
|
-
z_scaling_factor = self.get_z_scaling_factor()
|
364
|
-
self.logger.debug("Z scaling factor: %s", z_scaling_factor)
|
365
|
-
mesh.apply_scale([1 / resize_factor, 1 / resize_factor, z_scaling_factor])
|
366
|
-
|
367
|
-
old_faces = len(mesh.faces)
|
368
|
-
self.logger.debug("Mesh generated with %s faces.", old_faces)
|
369
|
-
|
370
|
-
if self.map.background_settings.apply_decimation:
|
371
|
-
percent = self.map.background_settings.decimation_percent / 100
|
372
|
-
mesh = mesh.simplify_quadric_decimation(
|
373
|
-
percent=percent, aggression=self.map.background_settings.decimation_agression
|
374
|
-
)
|
375
|
-
|
376
|
-
new_faces = len(mesh.faces)
|
377
|
-
decimation_percent = (old_faces - new_faces) / old_faces * 100
|
378
|
-
|
379
|
-
self.logger.debug(
|
380
|
-
"Mesh simplified to %s faces. Decimation percent: %s", new_faces, decimation_percent
|
381
|
-
)
|
382
|
-
|
383
258
|
mesh.export(save_path)
|
384
259
|
self.logger.debug("Obj file saved: %s", save_path)
|
385
260
|
|
386
261
|
if create_preview:
|
387
|
-
# Simplify the preview mesh to reduce the size of the file.
|
388
|
-
# mesh = mesh.simplify_quadric_decimation(face_count=len(mesh.faces) // 2**7)
|
389
|
-
|
390
|
-
# Apply scale to make the preview mesh smaller in the UI.
|
391
262
|
mesh.apply_scale([0.5, 0.5, 0.5])
|
392
|
-
self.mesh_to_stl(mesh)
|
393
|
-
|
394
|
-
def mesh_to_stl(self, mesh: trimesh.Trimesh) -> None:
|
395
|
-
"""Converts the mesh to an STL file and saves it in the previews directory.
|
396
|
-
Uses powerful simplification to reduce the size of the file since it will be used
|
397
|
-
only for the preview.
|
398
|
-
|
399
|
-
Arguments:
|
400
|
-
mesh (trimesh.Trimesh) -- The mesh to convert to an STL file.
|
401
|
-
"""
|
402
|
-
mesh = mesh.simplify_quadric_decimation(face_count=len(mesh.faces) // 2**6)
|
403
|
-
preview_path = os.path.join(self.previews_directory, "background_dem.stl")
|
404
|
-
mesh.export(preview_path)
|
405
|
-
|
406
|
-
self.logger.debug("STL file saved: %s", preview_path)
|
407
|
-
|
408
|
-
self.stl_preview_path = preview_path # pylint: disable=attribute-defined-outside-init
|
263
|
+
self.mesh_to_stl(mesh, save_path=self.stl_preview_path)
|
409
264
|
|
410
265
|
def previews(self) -> list[str]:
|
411
266
|
"""Returns the path to the image previews paths and the path to the STL preview file.
|
@@ -421,8 +276,13 @@ class Background(Component):
|
|
421
276
|
background_dem_preview_image = cv2.resize(
|
422
277
|
background_dem_preview_image, (0, 0), fx=1 / 4, fy=1 / 4
|
423
278
|
)
|
424
|
-
background_dem_preview_image = cv2.normalize(
|
425
|
-
background_dem_preview_image,
|
279
|
+
background_dem_preview_image = cv2.normalize(
|
280
|
+
background_dem_preview_image,
|
281
|
+
dst=np.empty_like(background_dem_preview_image),
|
282
|
+
alpha=0,
|
283
|
+
beta=255,
|
284
|
+
norm_type=cv2.NORM_MINMAX,
|
285
|
+
dtype=cv2.CV_8U,
|
426
286
|
)
|
427
287
|
background_dem_preview_image = cv2.cvtColor(
|
428
288
|
background_dem_preview_image, cv2.COLOR_GRAY2BGR
|
@@ -431,7 +291,7 @@ class Background(Component):
|
|
431
291
|
cv2.imwrite(background_dem_preview_path, background_dem_preview_image)
|
432
292
|
preview_paths.append(background_dem_preview_path)
|
433
293
|
|
434
|
-
if self.stl_preview_path:
|
294
|
+
if os.path.isfile(self.stl_preview_path):
|
435
295
|
preview_paths.append(self.stl_preview_path)
|
436
296
|
|
437
297
|
return preview_paths
|
@@ -483,26 +343,11 @@ class Background(Component):
|
|
483
343
|
|
484
344
|
dem_data = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
|
485
345
|
|
486
|
-
self.logger.debug(
|
487
|
-
"DEM data before normalization. Shape: %s, dtype: %s. Min: %s, max: %s.",
|
488
|
-
dem_data.shape,
|
489
|
-
dem_data.dtype,
|
490
|
-
dem_data.min(),
|
491
|
-
dem_data.max(),
|
492
|
-
)
|
493
|
-
|
494
346
|
# Create an empty array with the same shape and type as dem_data.
|
495
347
|
dem_data_normalized = np.empty_like(dem_data)
|
496
348
|
|
497
349
|
# Normalize the DEM data to the range [0, 255]
|
498
350
|
cv2.normalize(dem_data, dem_data_normalized, 0, 255, cv2.NORM_MINMAX)
|
499
|
-
self.logger.debug(
|
500
|
-
"DEM data after normalization. Shape: %s, dtype: %s. Min: %s, max: %s.",
|
501
|
-
dem_data_normalized.shape,
|
502
|
-
dem_data_normalized.dtype,
|
503
|
-
dem_data_normalized.min(),
|
504
|
-
dem_data_normalized.max(),
|
505
|
-
)
|
506
351
|
dem_data_colored = cv2.applyColorMap(dem_data_normalized, cv2.COLORMAP_JET)
|
507
352
|
|
508
353
|
cv2.imwrite(colored_dem_path, dem_data_colored)
|
@@ -528,7 +373,7 @@ class Background(Component):
|
|
528
373
|
if not background_layers:
|
529
374
|
return
|
530
375
|
|
531
|
-
self.background_texture = Texture(
|
376
|
+
self.background_texture = Texture(
|
532
377
|
self.game,
|
533
378
|
self.map,
|
534
379
|
self.coordinates,
|
@@ -555,13 +400,10 @@ class Background(Component):
|
|
555
400
|
# Merge all images into one.
|
556
401
|
background_image = np.zeros((self.background_size, self.background_size), dtype=np.uint8)
|
557
402
|
for path in background_paths:
|
558
|
-
|
559
|
-
background_image = cv2.add(background_image,
|
403
|
+
background_layer = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
|
404
|
+
background_image = cv2.add(background_image, background_layer) # type: ignore
|
560
405
|
|
561
|
-
|
562
|
-
cv2.imwrite(background_save_path, background_image)
|
563
|
-
self.logger.debug("Background texture saved: %s", background_save_path)
|
564
|
-
self.water_resources_path = background_save_path # pylint: disable=W0201
|
406
|
+
cv2.imwrite(self.water_resources_path, background_image)
|
565
407
|
|
566
408
|
def subtraction(self) -> None:
|
567
409
|
"""Subtracts the water depth from the DEM data where the water resources are located."""
|
@@ -569,20 +411,14 @@ class Background(Component):
|
|
569
411
|
self.logger.warning("Water resources texture not found.")
|
570
412
|
return
|
571
413
|
|
572
|
-
# Single channeled 8 bit image, where the water have values of 255, and the rest 0.
|
573
414
|
water_resources_image = cv2.imread(self.water_resources_path, cv2.IMREAD_UNCHANGED)
|
574
|
-
mask = water_resources_image == 255
|
575
|
-
|
576
|
-
# Make mask a little bit smaller (1 pixel).
|
577
|
-
mask = cv2.erode(mask.astype(np.uint8), np.ones((3, 3), np.uint8), iterations=1).astype(
|
578
|
-
bool
|
579
|
-
)
|
580
|
-
|
581
415
|
dem_image = cv2.imread(self.output_path, cv2.IMREAD_UNCHANGED)
|
582
416
|
|
583
|
-
|
584
|
-
|
585
|
-
|
417
|
+
dem_image = self.subtract_by_mask(
|
418
|
+
dem_image,
|
419
|
+
water_resources_image,
|
420
|
+
self.map.dem_settings.water_depth,
|
421
|
+
)
|
586
422
|
|
587
423
|
# Save the modified dem_image back to the output path
|
588
424
|
cv2.imwrite(self.output_path, dem_image)
|
@@ -590,7 +426,7 @@ class Background(Component):
|
|
590
426
|
|
591
427
|
def generate_water_resources_obj(self) -> None:
|
592
428
|
"""Generates 3D obj files based on water resources data."""
|
593
|
-
if not self.water_resources_path:
|
429
|
+
if not os.path.isfile(self.water_resources_path):
|
594
430
|
self.logger.warning("Water resources texture not found.")
|
595
431
|
return
|
596
432
|
|
@@ -0,0 +1,90 @@
|
|
1
|
+
"""Base class for all components that primarily used to work with images."""
|
2
|
+
|
3
|
+
import cv2
|
4
|
+
import numpy as np
|
5
|
+
|
6
|
+
from maps4fs.generator.component.base.component import Component
|
7
|
+
|
8
|
+
|
9
|
+
class ImageComponent(Component):
|
10
|
+
"""Base class for all components that primarily used to work with images."""
|
11
|
+
|
12
|
+
@staticmethod
|
13
|
+
def polygon_points_to_np(
|
14
|
+
polygon_points: list[tuple[int, int]], divide: int | None = None
|
15
|
+
) -> np.ndarray:
|
16
|
+
"""Converts the polygon points to a NumPy array.
|
17
|
+
|
18
|
+
Arguments:
|
19
|
+
polygon_points (list[tuple[int, int]]): The polygon points.
|
20
|
+
divide (int, optional): The number to divide the points by. Defaults to None.
|
21
|
+
|
22
|
+
Returns:
|
23
|
+
np.array: The NumPy array of the polygon points.
|
24
|
+
"""
|
25
|
+
array = np.array(polygon_points, dtype=np.int32).reshape((-1, 1, 2))
|
26
|
+
if divide:
|
27
|
+
return array // divide
|
28
|
+
return array
|
29
|
+
|
30
|
+
@staticmethod
|
31
|
+
def cut_out_np(
|
32
|
+
image: np.ndarray, half_size: int, set_zeros: bool = False, return_cutout: bool = False
|
33
|
+
) -> np.ndarray:
|
34
|
+
"""Cuts out a square from the center of the image.
|
35
|
+
|
36
|
+
Arguments:
|
37
|
+
image (np.ndarray): The image.
|
38
|
+
half_size (int): The half size of the square.
|
39
|
+
set_zeros (bool, optional): Whether to set the cutout to zeros. Defaults to False.
|
40
|
+
return_cutout (bool, optional): Whether to return the cutout. Defaults to False.
|
41
|
+
|
42
|
+
Returns:
|
43
|
+
np.ndarray: The image with the cutout or the cutout itself.
|
44
|
+
"""
|
45
|
+
center = (image.shape[0] // 2, image.shape[1] // 2)
|
46
|
+
x1 = center[0] - half_size
|
47
|
+
x2 = center[0] + half_size
|
48
|
+
y1 = center[1] - half_size
|
49
|
+
y2 = center[1] + half_size
|
50
|
+
|
51
|
+
if return_cutout:
|
52
|
+
return image[x1:x2, y1:y2]
|
53
|
+
|
54
|
+
if set_zeros:
|
55
|
+
image[x1:x2, y1:y2] = 0
|
56
|
+
|
57
|
+
return image
|
58
|
+
|
59
|
+
@staticmethod
|
60
|
+
def subtract_by_mask(
|
61
|
+
image: np.ndarray,
|
62
|
+
image_mask: np.ndarray,
|
63
|
+
subtract_by: int,
|
64
|
+
mask_by: int = 255,
|
65
|
+
erode_kernel: int | None = 3,
|
66
|
+
erode_iter: int | None = 1,
|
67
|
+
) -> np.ndarray:
|
68
|
+
"""Subtracts a value from the image where the mask is equal to the mask by value.
|
69
|
+
|
70
|
+
Arguments:
|
71
|
+
image (np.ndarray): The image.
|
72
|
+
image_mask (np.ndarray): The mask.
|
73
|
+
subtract_by (int): The value to subtract by.
|
74
|
+
mask_by (int, optional): The value to mask by. Defaults to 255.
|
75
|
+
erode_kernel (int, optional): The kernel size for the erosion. Defaults to 3.
|
76
|
+
erode_iter (int, optional): The number of iterations for the erosion. Defaults to 1.
|
77
|
+
|
78
|
+
Returns:
|
79
|
+
np.ndarray: The image with the subtracted value.
|
80
|
+
"""
|
81
|
+
mask = image_mask == mask_by
|
82
|
+
if erode_kernel and erode_iter:
|
83
|
+
mask = cv2.erode(
|
84
|
+
mask.astype(np.uint8),
|
85
|
+
np.ones((erode_kernel, erode_kernel), np.uint8),
|
86
|
+
iterations=erode_iter,
|
87
|
+
).astype(bool)
|
88
|
+
|
89
|
+
image[mask] = image[mask] - subtract_by
|
90
|
+
return image
|
@@ -0,0 +1,125 @@
|
|
1
|
+
"""Base class for all components that primarily used to work with meshes."""
|
2
|
+
|
3
|
+
import cv2
|
4
|
+
import numpy as np
|
5
|
+
import trimesh
|
6
|
+
from tqdm import tqdm
|
7
|
+
|
8
|
+
from maps4fs.generator.component.base.component import Component
|
9
|
+
from maps4fs.generator.settings import Parameters
|
10
|
+
|
11
|
+
|
12
|
+
class MeshComponent(Component):
|
13
|
+
"""Base class for all components that primarily used to work with meshes."""
|
14
|
+
|
15
|
+
@staticmethod
|
16
|
+
def validate_np_for_mesh(image_path: str, map_size: int) -> None:
|
17
|
+
"""Checks if the given image is a valid for mesh generation.
|
18
|
+
|
19
|
+
Arguments:
|
20
|
+
image_path (str): The path to the custom background image.
|
21
|
+
map_size (int): The size of the map.
|
22
|
+
|
23
|
+
Raises:
|
24
|
+
ValueError: If the custom background image does not meet the requirements.
|
25
|
+
"""
|
26
|
+
image = cv2.imread(image_path, cv2.IMREAD_UNCHANGED)
|
27
|
+
if image.shape[0] != image.shape[1]:
|
28
|
+
raise ValueError("The custom background image must be a square.")
|
29
|
+
|
30
|
+
if image.shape[0] != map_size + Parameters.BACKGROUND_DISTANCE * 2:
|
31
|
+
raise ValueError("The custom background image must have the size of the map + 4096.")
|
32
|
+
|
33
|
+
if len(image.shape) != 2:
|
34
|
+
raise ValueError("The custom background image must be a grayscale image.")
|
35
|
+
|
36
|
+
if image.dtype != np.uint16:
|
37
|
+
raise ValueError("The custom background image must be a 16-bit grayscale image.")
|
38
|
+
|
39
|
+
@staticmethod
|
40
|
+
def mesh_to_stl(mesh: trimesh.Trimesh, save_path: str) -> None:
|
41
|
+
"""Converts the mesh to an STL file and saves it in the previews directory.
|
42
|
+
Uses powerful simplification to reduce the size of the file since it will be used
|
43
|
+
only for the preview.
|
44
|
+
|
45
|
+
Arguments:
|
46
|
+
mesh (trimesh.Trimesh) -- The mesh to convert to an STL file.
|
47
|
+
"""
|
48
|
+
mesh = mesh.simplify_quadric_decimation(face_count=len(mesh.faces) // 2**6)
|
49
|
+
mesh.export(save_path)
|
50
|
+
|
51
|
+
@staticmethod
|
52
|
+
def mesh_from_np(
|
53
|
+
image: np.ndarray,
|
54
|
+
include_zeros: bool,
|
55
|
+
z_scaling_factor: float,
|
56
|
+
resize_factor: float,
|
57
|
+
apply_decimation: bool,
|
58
|
+
decimation_percent: int,
|
59
|
+
decimation_agression: int,
|
60
|
+
) -> trimesh.Trimesh:
|
61
|
+
"""Generates a mesh from the given numpy array.
|
62
|
+
|
63
|
+
Arguments:
|
64
|
+
image (np.ndarray): The numpy array to generate the mesh from.
|
65
|
+
include_zeros (bool): Whether to include zero values in the mesh.
|
66
|
+
z_scaling_factor (float): The scaling factor for the Z-axis.
|
67
|
+
resize_factor (float): The resizing factor.
|
68
|
+
apply_decimation (bool): Whether to apply decimation to the mesh.
|
69
|
+
decimation_percent (int): The percent of the decimation.
|
70
|
+
decimation_agression (int): The agression of the decimation.
|
71
|
+
|
72
|
+
Returns:
|
73
|
+
trimesh.Trimesh: The generated mesh.
|
74
|
+
"""
|
75
|
+
image = image.max() - image
|
76
|
+
|
77
|
+
rows, cols = image.shape
|
78
|
+
x = np.linspace(0, cols - 1, cols)
|
79
|
+
y = np.linspace(0, rows - 1, rows)
|
80
|
+
x, y = np.meshgrid(x, y)
|
81
|
+
z = image
|
82
|
+
|
83
|
+
ground = z.max()
|
84
|
+
|
85
|
+
vertices = np.column_stack([x.ravel(), y.ravel(), z.ravel()])
|
86
|
+
faces = []
|
87
|
+
|
88
|
+
skipped = 0
|
89
|
+
|
90
|
+
for i in tqdm(range(rows - 1), desc="Generating mesh", unit="row"):
|
91
|
+
for j in range(cols - 1):
|
92
|
+
top_left = i * cols + j
|
93
|
+
top_right = top_left + 1
|
94
|
+
bottom_left = top_left + cols
|
95
|
+
bottom_right = bottom_left + 1
|
96
|
+
|
97
|
+
if (
|
98
|
+
ground in [z[i, j], z[i, j + 1], z[i + 1, j], z[i + 1, j + 1]]
|
99
|
+
and not include_zeros
|
100
|
+
):
|
101
|
+
skipped += 1
|
102
|
+
continue
|
103
|
+
|
104
|
+
faces.append([top_left, bottom_left, bottom_right])
|
105
|
+
faces.append([top_left, bottom_right, top_right])
|
106
|
+
|
107
|
+
faces_np = np.array(faces)
|
108
|
+
mesh = trimesh.Trimesh(vertices=vertices, faces=faces_np)
|
109
|
+
|
110
|
+
# Apply rotation: 180 degrees around Y-axis and Z-axis
|
111
|
+
rotation_matrix_y = trimesh.transformations.rotation_matrix(np.pi, [0, 1, 0])
|
112
|
+
rotation_matrix_z = trimesh.transformations.rotation_matrix(np.pi, [0, 0, 1])
|
113
|
+
mesh.apply_transform(rotation_matrix_y)
|
114
|
+
mesh.apply_transform(rotation_matrix_z)
|
115
|
+
|
116
|
+
# if not include_zeros:
|
117
|
+
mesh.apply_scale([1 / resize_factor, 1 / resize_factor, z_scaling_factor])
|
118
|
+
|
119
|
+
if apply_decimation:
|
120
|
+
percent = decimation_percent / 100
|
121
|
+
mesh = mesh.simplify_quadric_decimation(
|
122
|
+
percent=percent, aggression=decimation_agression
|
123
|
+
)
|
124
|
+
|
125
|
+
return mesh
|
@@ -93,3 +93,16 @@ class XMLComponent(Component):
|
|
93
93
|
for key, value in data.items():
|
94
94
|
element.set(key, value)
|
95
95
|
return element
|
96
|
+
|
97
|
+
def create_subelement(
|
98
|
+
self, parent: ET.Element, element_name: str, data: dict[str, str]
|
99
|
+
) -> None:
|
100
|
+
"""Creates a subelement under the parent element with the provided data.
|
101
|
+
|
102
|
+
Arguments:
|
103
|
+
parent (ET.Element): The parent element.
|
104
|
+
element_name (str): The name of the subelement.
|
105
|
+
data (dict[str, str]): The data to set the subelement attributes to.
|
106
|
+
"""
|
107
|
+
element = ET.SubElement(parent, element_name)
|
108
|
+
self.update_element(element, data)
|