maps4fs 1.8.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
maps4fs/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ # pylint: disable=missing-module-docstring
2
+ from maps4fs.generator.dtm.dtm import DTMProvider
3
+ from maps4fs.generator.dtm.srtm import SRTM30Provider, SRTM30ProviderSettings
4
+ from maps4fs.generator.dtm.usgs import USGSProvider, USGSProviderSettings
5
+ from maps4fs.generator.dtm.nrw import NRWProvider
6
+ from maps4fs.generator.dtm.bavaria import BavariaProvider
7
+ from maps4fs.generator.dtm.niedersachsen import NiedersachsenProvider
8
+ from maps4fs.generator.dtm.hessen import HessenProvider
9
+ from maps4fs.generator.dtm.england import England1MProvider
10
+ from maps4fs.generator.game import Game
11
+ from maps4fs.generator.map import Map
12
+ from maps4fs.generator.settings import (
13
+ BackgroundSettings,
14
+ DEMSettings,
15
+ GRLESettings,
16
+ I3DSettings,
17
+ SatelliteSettings,
18
+ SettingsModel,
19
+ SplineSettings,
20
+ TextureSettings,
21
+ )
22
+ from maps4fs.logger import Logger
@@ -0,0 +1 @@
1
+ # pylint: disable=missing-module-docstring
@@ -0,0 +1,625 @@
1
+ """This module contains the Background component, which generates 3D obj files based on DEM data
2
+ around the map."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import shutil
9
+ from copy import deepcopy
10
+
11
+ import cv2
12
+ import numpy as np
13
+ import trimesh # type: ignore
14
+ from tqdm import tqdm
15
+
16
+ from maps4fs.generator.component import Component
17
+ from maps4fs.generator.dem import DEM
18
+ from maps4fs.generator.texture import Texture
19
+
20
+ DEFAULT_DISTANCE = 2048
21
+ FULL_NAME = "FULL"
22
+ FULL_PREVIEW_NAME = "PREVIEW"
23
+ ELEMENTS = [FULL_NAME, FULL_PREVIEW_NAME]
24
+
25
+
26
+ # pylint: disable=R0902
27
+ class Background(Component):
28
+ """Component for creating 3D obj files based on DEM data around the map.
29
+
30
+ Arguments:
31
+ game (Game): The game instance for which the map is generated.
32
+ coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
33
+ map_size (int): The size of the map in pixels (it's a square).
34
+ rotated_map_size (int): The size of the map in pixels after rotation.
35
+ rotation (int): The rotation angle of the map.
36
+ map_directory (str): The directory where the map files are stored.
37
+ logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
38
+ info, warning. If not provided, default logging will be used.
39
+ """
40
+
41
+ # pylint: disable=R0801
42
+ def preprocess(self) -> None:
43
+ """Registers the DEMs for the background terrain."""
44
+ self.stl_preview_path: str | None = None
45
+ self.water_resources_path: str | None = None
46
+
47
+ if self.rotation:
48
+ self.logger.debug("Rotation is enabled: %s.", self.rotation)
49
+ output_size_multiplier = 1.5
50
+ else:
51
+ output_size_multiplier = 1
52
+
53
+ self.background_size = self.map_size + DEFAULT_DISTANCE * 2
54
+ self.rotated_size = int(self.background_size * output_size_multiplier)
55
+
56
+ self.background_directory = os.path.join(self.map_directory, "background")
57
+ self.water_directory = os.path.join(self.map_directory, "water")
58
+ os.makedirs(self.background_directory, exist_ok=True)
59
+ os.makedirs(self.water_directory, exist_ok=True)
60
+
61
+ self.output_path = os.path.join(self.background_directory, f"{FULL_NAME}.png")
62
+ if self.map.custom_background_path:
63
+ self.check_custom_background(self.map.custom_background_path)
64
+ shutil.copyfile(self.map.custom_background_path, self.output_path)
65
+
66
+ self.not_substracted_path = os.path.join(self.background_directory, "not_substracted.png")
67
+ self.not_resized_path = os.path.join(self.background_directory, "not_resized.png")
68
+
69
+ self.dem = DEM(
70
+ self.game,
71
+ self.map,
72
+ self.coordinates,
73
+ self.background_size,
74
+ self.rotated_size,
75
+ self.rotation,
76
+ self.map_directory,
77
+ self.logger,
78
+ )
79
+ self.dem.preprocess()
80
+ self.dem.set_output_resolution((self.rotated_size, self.rotated_size))
81
+ self.dem.set_dem_path(self.output_path)
82
+
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) # pylint: disable=no-member
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
+ def process(self) -> None:
117
+ """Launches the component processing. Iterates over all tiles and processes them
118
+ as a result the DEM files will be saved, then based on them the obj files will be
119
+ generated."""
120
+ self.create_background_textures()
121
+
122
+ if not self.map.custom_background_path:
123
+ self.dem.process()
124
+
125
+ shutil.copyfile(self.dem.dem_path, self.not_substracted_path)
126
+ self.cutout(self.dem.dem_path, save_path=self.not_resized_path)
127
+
128
+ if self.map.dem_settings.water_depth:
129
+ self.subtraction()
130
+
131
+ cutted_dem_path = self.cutout(self.dem.dem_path)
132
+ if self.game.additional_dem_name is not None:
133
+ self.make_copy(cutted_dem_path, self.game.additional_dem_name)
134
+
135
+ if self.map.background_settings.generate_background:
136
+ self.generate_obj_files()
137
+ if self.map.background_settings.generate_water:
138
+ self.generate_water_resources_obj()
139
+
140
+ def make_copy(self, dem_path: str, dem_name: str) -> None:
141
+ """Copies DEM data to additional DEM file.
142
+
143
+ Arguments:
144
+ dem_path (str): Path to the DEM file.
145
+ dem_name (str): Name of the additional DEM file.
146
+ """
147
+ dem_directory = os.path.dirname(dem_path)
148
+
149
+ additional_dem_path = os.path.join(dem_directory, dem_name)
150
+
151
+ shutil.copyfile(dem_path, additional_dem_path)
152
+ self.logger.debug("Additional DEM data was copied to %s.", additional_dem_path)
153
+
154
+ def info_sequence(self) -> dict[str, str | float | int]:
155
+ """Returns a dictionary with information about the background terrain.
156
+ Adds the EPSG:3857 string to the data for convenient usage in QGIS.
157
+
158
+ Returns:
159
+ dict[str, str, float | int] -- A dictionary with information about the background
160
+ terrain.
161
+ """
162
+ self.qgis_sequence()
163
+
164
+ north, south, east, west = self.dem.bbox
165
+ epsg3857_string = self.dem.get_epsg3857_string()
166
+ epsg3857_string_with_margin = self.dem.get_epsg3857_string(add_margin=True)
167
+
168
+ data = {
169
+ "center_latitude": self.dem.coordinates[0],
170
+ "center_longitude": self.dem.coordinates[1],
171
+ "epsg3857_string": epsg3857_string,
172
+ "epsg3857_string_with_margin": epsg3857_string_with_margin,
173
+ "height": self.dem.map_size,
174
+ "width": self.dem.map_size,
175
+ "north": north,
176
+ "south": south,
177
+ "east": east,
178
+ "west": west,
179
+ }
180
+
181
+ dem_info_sequence = self.dem.info_sequence()
182
+ data["DEM"] = dem_info_sequence
183
+ return data # type: ignore
184
+
185
+ def qgis_sequence(self) -> None:
186
+ """Generates QGIS scripts for creating bounding box layers and rasterizing them."""
187
+ qgis_layer = (f"Background_{FULL_NAME}", *self.dem.get_espg3857_bbox())
188
+ qgis_layer_with_margin = (
189
+ f"Background_{FULL_NAME}_margin",
190
+ *self.dem.get_espg3857_bbox(add_margin=True),
191
+ )
192
+ self.create_qgis_scripts([qgis_layer, qgis_layer_with_margin])
193
+
194
+ def generate_obj_files(self) -> None:
195
+ """Iterates over all dems and generates 3D obj files based on DEM data.
196
+ If at least one DEM file is missing, the generation will be stopped at all.
197
+ """
198
+ if not os.path.isfile(self.dem.dem_path):
199
+ self.logger.warning(
200
+ "DEM file not found, generation will be stopped: %s", self.dem.dem_path
201
+ )
202
+ return
203
+
204
+ self.logger.debug("DEM file for found: %s", self.dem.dem_path)
205
+
206
+ filename = os.path.splitext(os.path.basename(self.dem.dem_path))[0]
207
+ save_path = os.path.join(self.background_directory, f"{filename}.obj")
208
+ self.logger.debug("Generating obj file in path: %s", save_path)
209
+
210
+ dem_data = cv2.imread(self.dem.dem_path, cv2.IMREAD_UNCHANGED) # pylint: disable=no-member
211
+ self.plane_from_np(
212
+ dem_data,
213
+ save_path,
214
+ create_preview=True,
215
+ remove_center=self.map.background_settings.remove_center,
216
+ include_zeros=False,
217
+ ) # type: ignore
218
+
219
+ # pylint: disable=too-many-locals
220
+ def cutout(self, dem_path: str, save_path: str | None = None) -> str:
221
+ """Cuts out the center of the DEM (the actual map) and saves it as a separate file.
222
+
223
+ Arguments:
224
+ dem_path (str): The path to the DEM file.
225
+ save_path (str, optional): The path where the cutout DEM file will be saved.
226
+
227
+ Returns:
228
+ str -- The path to the cutout DEM file.
229
+ """
230
+ dem_data = cv2.imread(dem_path, cv2.IMREAD_UNCHANGED) # pylint: disable=no-member
231
+
232
+ center = (dem_data.shape[0] // 2, dem_data.shape[1] // 2)
233
+ half_size = self.map_size // 2
234
+ x1 = center[0] - half_size
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]
239
+
240
+ if save_path:
241
+ cv2.imwrite(save_path, dem_data) # pylint: disable=no-member
242
+ self.logger.debug("Not resized DEM saved: %s", save_path)
243
+ return save_path
244
+
245
+ output_size = self.map_size + 1
246
+
247
+ main_dem_path = self.game.dem_file_path(self.map_directory)
248
+
249
+ try:
250
+ os.remove(main_dem_path)
251
+ except FileNotFoundError:
252
+ pass
253
+
254
+ # pylint: disable=no-member
255
+ resized_dem_data = cv2.resize(
256
+ dem_data, (output_size, output_size), interpolation=cv2.INTER_LINEAR
257
+ )
258
+
259
+ cv2.imwrite(main_dem_path, resized_dem_data) # pylint: disable=no-member
260
+ self.logger.debug("DEM cutout saved: %s", main_dem_path)
261
+
262
+ return main_dem_path
263
+
264
+ def remove_center(self, dem_data: np.ndarray, resize_factor: float) -> np.ndarray:
265
+ """Removes the center part of the DEM data.
266
+
267
+ Arguments:
268
+ dem_data (np.ndarray) -- The DEM data as a numpy array.
269
+ resize_factor (float) -- The resize factor of the DEM data.
270
+
271
+ Returns:
272
+ np.ndarray -- The DEM data with the center part removed.
273
+ """
274
+ center = (dem_data.shape[0] // 2, dem_data.shape[1] // 2)
275
+ half_size = int(self.map_size // 2 * resize_factor)
276
+ x1 = center[0] - half_size
277
+ x2 = center[0] + half_size
278
+ y1 = center[1] - half_size
279
+ y2 = center[1] + half_size
280
+ dem_data[x1:x2, y1:y2] = 0
281
+ return dem_data
282
+
283
+ # pylint: disable=R0913, R0917, R0915
284
+ def plane_from_np(
285
+ self,
286
+ dem_data: np.ndarray,
287
+ save_path: str,
288
+ include_zeros: bool = True,
289
+ create_preview: bool = False,
290
+ remove_center: bool = False,
291
+ ) -> None:
292
+ """Generates a 3D obj file based on DEM data.
293
+
294
+ Arguments:
295
+ dem_data (np.ndarray) -- The DEM data as a numpy array.
296
+ save_path (str) -- The path where the obj file will be saved.
297
+ include_zeros (bool, optional) -- If True, the mesh will include the zero height values.
298
+ create_preview (bool, optional) -- If True, a simplified mesh will be saved as an STL.
299
+ remove_center (bool, optional) -- If True, the center of the mesh will be removed.
300
+ This setting is used for a Background Terrain, where the center part where the
301
+ playable area is will be cut out.
302
+ """
303
+ resize_factor = 1 / self.map.background_settings.resize_factor
304
+ dem_data = cv2.resize( # pylint: disable=no-member
305
+ dem_data, (0, 0), fx=resize_factor, fy=resize_factor
306
+ )
307
+ if remove_center:
308
+ dem_data = self.remove_center(dem_data, resize_factor)
309
+ self.logger.debug("Center removed from DEM data.")
310
+ self.logger.debug(
311
+ "DEM data resized to shape: %s with factor: %s", dem_data.shape, resize_factor
312
+ )
313
+
314
+ # Invert the height values.
315
+ dem_data = dem_data.max() - dem_data
316
+
317
+ rows, cols = dem_data.shape
318
+ x = np.linspace(0, cols - 1, cols)
319
+ y = np.linspace(0, rows - 1, rows)
320
+ x, y = np.meshgrid(x, y)
321
+ z = dem_data
322
+
323
+ ground = z.max()
324
+ self.logger.debug("Ground level: %s", ground)
325
+
326
+ self.logger.debug(
327
+ "Starting to generate a mesh for with shape: %s x %s. This may take a while.",
328
+ cols,
329
+ rows,
330
+ )
331
+
332
+ vertices = np.column_stack([x.ravel(), y.ravel(), z.ravel()])
333
+ faces = []
334
+
335
+ skipped = 0
336
+
337
+ for i in tqdm(range(rows - 1), desc="Generating mesh", unit="row"):
338
+ for j in range(cols - 1):
339
+ top_left = i * cols + j
340
+ top_right = top_left + 1
341
+ bottom_left = top_left + cols
342
+ bottom_right = bottom_left + 1
343
+
344
+ if (
345
+ ground in [z[i, j], z[i, j + 1], z[i + 1, j], z[i + 1, j + 1]]
346
+ and not include_zeros
347
+ ):
348
+ skipped += 1
349
+ continue
350
+
351
+ faces.append([top_left, bottom_left, bottom_right])
352
+ faces.append([top_left, bottom_right, top_right])
353
+
354
+ self.logger.debug("Skipped faces: %s", skipped)
355
+
356
+ faces = np.array(faces) # type: ignore
357
+ mesh = trimesh.Trimesh(vertices=vertices, faces=faces)
358
+
359
+ # Apply rotation: 180 degrees around Y-axis and Z-axis
360
+ rotation_matrix_y = trimesh.transformations.rotation_matrix(np.pi, [0, 1, 0])
361
+ rotation_matrix_z = trimesh.transformations.rotation_matrix(np.pi, [0, 0, 1])
362
+ mesh.apply_transform(rotation_matrix_y)
363
+ mesh.apply_transform(rotation_matrix_z)
364
+
365
+ # if not include_zeros:
366
+ z_scaling_factor = self.get_z_scaling_factor()
367
+ self.logger.debug("Z scaling factor: %s", z_scaling_factor)
368
+ mesh.apply_scale([1 / resize_factor, 1 / resize_factor, z_scaling_factor])
369
+
370
+ old_faces = len(mesh.faces)
371
+ self.logger.debug("Mesh generated with %s faces.", old_faces)
372
+
373
+ if self.map.background_settings.apply_decimation:
374
+ percent = self.map.background_settings.decimation_percent / 100
375
+ mesh = mesh.simplify_quadric_decimation(
376
+ percent=percent, aggression=self.map.background_settings.decimation_agression
377
+ )
378
+
379
+ new_faces = len(mesh.faces)
380
+ decimation_percent = (old_faces - new_faces) / old_faces * 100
381
+
382
+ self.logger.debug(
383
+ "Mesh simplified to %s faces. Decimation percent: %s", new_faces, decimation_percent
384
+ )
385
+
386
+ mesh.export(save_path)
387
+ self.logger.debug("Obj file saved: %s", save_path)
388
+
389
+ if create_preview:
390
+ # Simplify the preview mesh to reduce the size of the file.
391
+ # mesh = mesh.simplify_quadric_decimation(face_count=len(mesh.faces) // 2**7)
392
+
393
+ # Apply scale to make the preview mesh smaller in the UI.
394
+ mesh.apply_scale([0.5, 0.5, 0.5])
395
+ self.mesh_to_stl(mesh)
396
+
397
+ def mesh_to_stl(self, mesh: trimesh.Trimesh) -> None:
398
+ """Converts the mesh to an STL file and saves it in the previews directory.
399
+ Uses powerful simplification to reduce the size of the file since it will be used
400
+ only for the preview.
401
+
402
+ Arguments:
403
+ mesh (trimesh.Trimesh) -- The mesh to convert to an STL file.
404
+ """
405
+ mesh = mesh.simplify_quadric_decimation(face_count=len(mesh.faces) // 2**6)
406
+ preview_path = os.path.join(self.previews_directory, "background_dem.stl")
407
+ mesh.export(preview_path)
408
+
409
+ self.logger.debug("STL file saved: %s", preview_path)
410
+
411
+ self.stl_preview_path = preview_path # pylint: disable=attribute-defined-outside-init
412
+
413
+ # pylint: disable=no-member
414
+ def previews(self) -> list[str]:
415
+ """Returns the path to the image previews paths and the path to the STL preview file.
416
+
417
+ Returns:
418
+ list[str] -- A list of paths to the previews.
419
+ """
420
+ preview_paths = self.dem_previews(self.game.dem_file_path(self.map_directory))
421
+
422
+ background_dem_preview_path = os.path.join(self.previews_directory, "background_dem.png")
423
+ background_dem_preview_image = cv2.imread(self.dem.dem_path, cv2.IMREAD_UNCHANGED)
424
+
425
+ background_dem_preview_image = cv2.resize(
426
+ background_dem_preview_image, (0, 0), fx=1 / 4, fy=1 / 4
427
+ )
428
+ background_dem_preview_image = cv2.normalize( # type: ignore
429
+ background_dem_preview_image, None, 0, 255, cv2.NORM_MINMAX, cv2.CV_8U
430
+ )
431
+ background_dem_preview_image = cv2.cvtColor(
432
+ background_dem_preview_image, cv2.COLOR_GRAY2BGR
433
+ )
434
+
435
+ cv2.imwrite(background_dem_preview_path, background_dem_preview_image)
436
+ preview_paths.append(background_dem_preview_path)
437
+
438
+ if self.stl_preview_path:
439
+ preview_paths.append(self.stl_preview_path)
440
+
441
+ return preview_paths
442
+
443
+ def dem_previews(self, image_path: str) -> list[str]:
444
+ """Get list of preview images.
445
+
446
+ Arguments:
447
+ image_path (str): Path to the DEM file.
448
+
449
+ Returns:
450
+ list[str]: List of preview images.
451
+ """
452
+ self.logger.debug("Starting DEM previews generation.")
453
+ return [self.grayscale_preview(image_path), self.colored_preview(image_path)]
454
+
455
+ def grayscale_preview(self, image_path: str) -> str:
456
+ """Converts DEM image to grayscale RGB image and saves it to the map directory.
457
+ Returns path to the preview image.
458
+
459
+ Arguments:
460
+ image_path (str): Path to the DEM file.
461
+
462
+ Returns:
463
+ str: Path to the preview image.
464
+ """
465
+ grayscale_dem_path = os.path.join(self.previews_directory, "dem_grayscale.png")
466
+
467
+ self.logger.debug("Creating grayscale preview of DEM data in %s.", grayscale_dem_path)
468
+
469
+ dem_data = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
470
+ dem_data_rgb = cv2.cvtColor(dem_data, cv2.COLOR_GRAY2RGB)
471
+ cv2.imwrite(grayscale_dem_path, dem_data_rgb)
472
+ return grayscale_dem_path
473
+
474
+ def colored_preview(self, image_path: str) -> str:
475
+ """Converts DEM image to colored RGB image and saves it to the map directory.
476
+ Returns path to the preview image.
477
+
478
+ Arguments:
479
+ image_path (str): Path to the DEM file.
480
+
481
+ Returns:
482
+ list[str]: List with a single path to the DEM file
483
+ """
484
+ colored_dem_path = os.path.join(self.previews_directory, "dem_colored.png")
485
+
486
+ self.logger.debug("Creating colored preview of DEM data in %s.", colored_dem_path)
487
+
488
+ dem_data = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
489
+
490
+ self.logger.debug(
491
+ "DEM data before normalization. Shape: %s, dtype: %s. Min: %s, max: %s.",
492
+ dem_data.shape,
493
+ dem_data.dtype,
494
+ dem_data.min(),
495
+ dem_data.max(),
496
+ )
497
+
498
+ # Create an empty array with the same shape and type as dem_data.
499
+ dem_data_normalized = np.empty_like(dem_data)
500
+
501
+ # Normalize the DEM data to the range [0, 255]
502
+ cv2.normalize(dem_data, dem_data_normalized, 0, 255, cv2.NORM_MINMAX)
503
+ self.logger.debug(
504
+ "DEM data after normalization. Shape: %s, dtype: %s. Min: %s, max: %s.",
505
+ dem_data_normalized.shape,
506
+ dem_data_normalized.dtype,
507
+ dem_data_normalized.min(),
508
+ dem_data_normalized.max(),
509
+ )
510
+ dem_data_colored = cv2.applyColorMap(dem_data_normalized, cv2.COLORMAP_JET)
511
+
512
+ cv2.imwrite(colored_dem_path, dem_data_colored)
513
+ return colored_dem_path
514
+
515
+ def create_background_textures(self) -> None:
516
+ """Creates background textures for the map."""
517
+ if not os.path.isfile(self.game.texture_schema):
518
+ self.logger.warning("Texture schema file not found: %s", self.game.texture_schema)
519
+ return
520
+
521
+ with open(self.game.texture_schema, "r", encoding="utf-8") as f:
522
+ layers_schema = json.load(f)
523
+
524
+ background_layers = []
525
+ for layer in layers_schema:
526
+ if layer.get("background") is True:
527
+ layer_copy = deepcopy(layer)
528
+ layer_copy["count"] = 1
529
+ layer_copy["name"] = f"{layer['name']}_background"
530
+ background_layers.append(layer_copy)
531
+
532
+ if not background_layers:
533
+ return
534
+
535
+ self.background_texture = Texture( # pylint: disable=W0201
536
+ self.game,
537
+ self.map,
538
+ self.coordinates,
539
+ self.background_size,
540
+ self.rotated_size,
541
+ rotation=self.rotation,
542
+ map_directory=self.map_directory,
543
+ logger=self.logger,
544
+ texture_custom_schema=background_layers, # type: ignore
545
+ )
546
+
547
+ self.background_texture.preprocess()
548
+ self.background_texture.process()
549
+
550
+ processed_layers = self.background_texture.get_background_layers()
551
+ weights_directory = self.game.weights_dir_path(self.map_directory)
552
+ background_paths = [layer.path(weights_directory) for layer in processed_layers]
553
+ self.logger.debug("Found %s background textures.", len(background_paths))
554
+
555
+ if not background_paths:
556
+ self.logger.warning("No background textures found.")
557
+ return
558
+
559
+ # Merge all images into one.
560
+ background_image = np.zeros((self.background_size, self.background_size), dtype=np.uint8)
561
+ for path in background_paths:
562
+ layer = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
563
+ background_image = cv2.add(background_image, layer) # type: ignore
564
+
565
+ background_save_path = os.path.join(self.water_directory, "water_resources.png")
566
+ cv2.imwrite(background_save_path, background_image)
567
+ self.logger.debug("Background texture saved: %s", background_save_path)
568
+ self.water_resources_path = background_save_path # pylint: disable=W0201
569
+
570
+ def subtraction(self) -> None:
571
+ """Subtracts the water depth from the DEM data where the water resources are located."""
572
+ if not self.water_resources_path:
573
+ self.logger.warning("Water resources texture not found.")
574
+ return
575
+
576
+ # Single channeled 8 bit image, where the water have values of 255, and the rest 0.
577
+ water_resources_image = cv2.imread(self.water_resources_path, cv2.IMREAD_UNCHANGED)
578
+ mask = water_resources_image == 255
579
+
580
+ # Make mask a little bit smaller (1 pixel).
581
+ mask = cv2.erode(mask.astype(np.uint8), np.ones((3, 3), np.uint8), iterations=1).astype(
582
+ bool
583
+ )
584
+
585
+ dem_image = cv2.imread(self.output_path, cv2.IMREAD_UNCHANGED)
586
+
587
+ # Create a mask where water_resources_image is 255 (or not 0)
588
+ # Subtract water_depth from dem_image where mask is True
589
+ dem_image[mask] = dem_image[mask] - self.map.dem_settings.water_depth
590
+
591
+ # Save the modified dem_image back to the output path
592
+ cv2.imwrite(self.output_path, dem_image)
593
+ self.logger.debug("Water depth subtracted from DEM data: %s", self.output_path)
594
+
595
+ def generate_water_resources_obj(self) -> None:
596
+ """Generates 3D obj files based on water resources data."""
597
+ if not self.water_resources_path:
598
+ self.logger.warning("Water resources texture not found.")
599
+ return
600
+
601
+ # Single channeled 8 bit image, where the water have values of 255, and the rest 0.
602
+ plane_water = cv2.imread(self.water_resources_path, cv2.IMREAD_UNCHANGED)
603
+ dilated_plane_water = cv2.dilate(
604
+ plane_water.astype(np.uint8), np.ones((5, 5), np.uint8), iterations=5
605
+ ).astype(np.uint8)
606
+ plane_save_path = os.path.join(self.water_directory, "plane_water.obj")
607
+ self.plane_from_np(dilated_plane_water, plane_save_path, include_zeros=False)
608
+
609
+ # Single channeled 16 bit DEM image of terrain.
610
+ background_dem = cv2.imread(self.not_substracted_path, cv2.IMREAD_UNCHANGED)
611
+
612
+ # Remove all the values from the background dem where the plane_water is 0.
613
+ background_dem[plane_water == 0] = 0
614
+
615
+ # Dilate the background dem to make the water more smooth.
616
+ elevated_water = cv2.dilate(background_dem, np.ones((3, 3), np.uint16), iterations=10)
617
+
618
+ # Use the background dem as a mask to prevent the original values from being overwritten.
619
+ mask = background_dem > 0
620
+
621
+ # Combine the dilated background dem with non-dilated background dem.
622
+ elevated_water = np.where(mask, background_dem, elevated_water)
623
+ elevated_save_path = os.path.join(self.water_directory, "elevated_water.obj")
624
+
625
+ self.plane_from_np(elevated_water, elevated_save_path, include_zeros=False)