maps4fs 1.8.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.
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)