maps4fs 2.8.3__tar.gz → 2.8.5__tar.gz

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.

Potentially problematic release.


This version of maps4fs might be problematic. Click here for more details.

Files changed (36) hide show
  1. {maps4fs-2.8.3 → maps4fs-2.8.5}/PKG-INFO +1 -1
  2. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/component/base/component_mesh.py +3 -1
  3. maps4fs-2.8.5/maps4fs/generator/component/config.py +808 -0
  4. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/game.py +40 -0
  5. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/settings.py +6 -0
  6. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs.egg-info/PKG-INFO +1 -1
  7. {maps4fs-2.8.3 → maps4fs-2.8.5}/pyproject.toml +1 -1
  8. maps4fs-2.8.3/maps4fs/generator/component/config.py +0 -215
  9. {maps4fs-2.8.3 → maps4fs-2.8.5}/LICENSE.md +0 -0
  10. {maps4fs-2.8.3 → maps4fs-2.8.5}/README.md +0 -0
  11. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/__init__.py +0 -0
  12. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/__init__.py +0 -0
  13. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/component/__init__.py +0 -0
  14. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/component/background.py +0 -0
  15. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/component/base/__init__.py +0 -0
  16. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/component/base/component.py +0 -0
  17. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/component/base/component_image.py +0 -0
  18. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/component/base/component_xml.py +0 -0
  19. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/component/dem.py +0 -0
  20. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/component/grle.py +0 -0
  21. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/component/i3d.py +0 -0
  22. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/component/layer.py +0 -0
  23. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/component/satellite.py +0 -0
  24. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/component/texture.py +0 -0
  25. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/config.py +0 -0
  26. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/map.py +0 -0
  27. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/qgis.py +0 -0
  28. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/statistics.py +0 -0
  29. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/generator/utils.py +0 -0
  30. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs/logger.py +0 -0
  31. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs.egg-info/SOURCES.txt +0 -0
  32. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs.egg-info/dependency_links.txt +0 -0
  33. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs.egg-info/requires.txt +0 -0
  34. {maps4fs-2.8.3 → maps4fs-2.8.5}/maps4fs.egg-info/top_level.txt +0 -0
  35. {maps4fs-2.8.3 → maps4fs-2.8.5}/setup.cfg +0 -0
  36. {maps4fs-2.8.3 → maps4fs-2.8.5}/tests/test_generator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maps4fs
3
- Version: 2.8.3
3
+ Version: 2.8.5
4
4
  Summary: Generate map templates for Farming Simulator from real places.
5
5
  Author-email: iwatkot <iwatkot@gmail.com>
6
6
  License: GNU Affero General Public License v3.0
@@ -241,7 +241,9 @@ class MeshComponent(Component):
241
241
  cube_mesh = trimesh.creation.box([remove_size, remove_size, z_size * 4])
242
242
 
243
243
  return trimesh.boolean.difference(
244
- [mesh_copy, cube_mesh], check_volume=False, engine="blender"
244
+ [mesh_copy, cube_mesh],
245
+ check_volume=False,
246
+ engine="blender",
245
247
  )
246
248
 
247
249
  @staticmethod
@@ -0,0 +1,808 @@
1
+ """This module contains the Config class for map settings and configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import cv2
8
+ import numpy as np
9
+
10
+ import maps4fs.generator.utils as mfsutils
11
+ from maps4fs.generator.component.base.component_image import ImageComponent
12
+ from maps4fs.generator.component.base.component_xml import XMLComponent
13
+ from maps4fs.generator.settings import Parameters
14
+
15
+ # Defines coordinates for country block on the license plate texture.
16
+ COUNTRY_CODE_TOP = 169
17
+ COUNTRY_CODE_BOTTOM = 252
18
+ COUNTRY_CODE_LEFT = 74
19
+ COUNTRY_CODE_RIGHT = 140
20
+
21
+ LICENSE_PLATES_XML_FILENAME = "licensePlatesPL.xml"
22
+ LICENSE_PLATES_I3D_FILENAME = "licensePlatesPL.i3d"
23
+
24
+
25
+ # pylint: disable=R0903
26
+ class Config(XMLComponent, ImageComponent):
27
+ """Component for map settings and configuration.
28
+
29
+ Arguments:
30
+ game (Game): The game instance for which the map is generated.
31
+ coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
32
+ map_size (int): The size of the map in pixels (it's a square).
33
+ map_rotated_size (int): The size of the map in pixels after rotation.
34
+ rotation (int): The rotation angle of the map.
35
+ map_directory (str): The directory where the map files are stored.
36
+ logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
37
+ info, warning. If not provided, default logging will be used.
38
+ """
39
+
40
+ def preprocess(self) -> None:
41
+ """Gets the path to the map XML file and saves it to the instance variable."""
42
+ self.xml_path = self.game.map_xml_path(self.map_directory)
43
+ self.fog_parameters: dict[str, int] = {}
44
+
45
+ def process(self) -> None:
46
+ """Sets the map size in the map.xml file."""
47
+ self._set_map_size()
48
+
49
+ if self.game.fog_processing:
50
+ self._adjust_fog()
51
+
52
+ self._set_overview()
53
+
54
+ self.update_license_plates()
55
+
56
+ def _set_map_size(self) -> None:
57
+ """Edits map.xml file to set correct map size."""
58
+ tree = self.get_tree()
59
+ if not tree:
60
+ raise FileNotFoundError(f"Map XML file not found: {self.xml_path}")
61
+
62
+ root = tree.getroot()
63
+ data = {
64
+ "width": str(self.scaled_size),
65
+ "height": str(self.scaled_size),
66
+ }
67
+
68
+ for element in root.iter("map"): # type: ignore
69
+ self.update_element(element, data)
70
+ break
71
+ self.save_tree(tree)
72
+
73
+ def info_sequence(self) -> dict[str, dict[str, str | float | int]]:
74
+ """Returns information about the component.
75
+ Overview section is needed to create the overview file (in-game map).
76
+
77
+ Returns:
78
+ dict[str, dict[str, str | float | int]]: Information about the component.
79
+ """
80
+ # The overview file is exactly 2X bigger than the map size, does not matter
81
+ # if the map is 2048x2048 or 4096x4096, the overview will be 4096x4096
82
+ # and the map will be in the center of the overview.
83
+ # That's why the distance is set to the map height not as a half of it.
84
+ bbox = self.get_bbox(distance=self.map_size)
85
+ south, west, north, east = bbox
86
+ epsg3857_string = self.get_epsg3857_string(bbox=bbox)
87
+ epsg3857_string_with_margin = self.get_epsg3857_string(bbox=bbox, add_margin=True)
88
+
89
+ self.qgis_sequence()
90
+
91
+ overview_data = {
92
+ "epsg3857_string": epsg3857_string,
93
+ "epsg3857_string_with_margin": epsg3857_string_with_margin,
94
+ "south": south,
95
+ "west": west,
96
+ "north": north,
97
+ "east": east,
98
+ "height": self.map_size * 2,
99
+ "width": self.map_size * 2,
100
+ }
101
+
102
+ data = {
103
+ "Overview": overview_data,
104
+ }
105
+ if self.fog_parameters:
106
+ data["Fog"] = self.fog_parameters # type: ignore
107
+
108
+ return data # type: ignore
109
+
110
+ def qgis_sequence(self) -> None:
111
+ """Generates QGIS scripts for creating bounding box layers and rasterizing them."""
112
+ bbox = self.get_bbox(distance=self.map_size)
113
+ espg3857_bbox = self.get_espg3857_bbox(bbox=bbox)
114
+ espg3857_bbox_with_margin = self.get_espg3857_bbox(bbox=bbox, add_margin=True)
115
+
116
+ qgis_layers = [("Overview_bbox", *espg3857_bbox)]
117
+ qgis_layers_with_margin = [("Overview_bbox_with_margin", *espg3857_bbox_with_margin)]
118
+
119
+ layers = qgis_layers + qgis_layers_with_margin
120
+
121
+ self.create_qgis_scripts(layers)
122
+
123
+ def _adjust_fog(self) -> None:
124
+ """Adjusts the fog settings in the environment XML file based on the DEM and height scale."""
125
+ self.logger.debug("Adjusting fog settings based on DEM and height scale...")
126
+ try:
127
+ environment_xml_path = self.game.get_environment_xml_path(self.map_directory)
128
+ except NotImplementedError:
129
+ self.logger.warning(
130
+ "Game does not support environment XML file, fog adjustment will not be applied."
131
+ )
132
+ return
133
+
134
+ if not environment_xml_path or not os.path.isfile(environment_xml_path):
135
+ self.logger.warning(
136
+ "Environment XML file not found, fog adjustment will not be applied."
137
+ )
138
+ return
139
+
140
+ self.logger.debug("Will work with environment XML file: %s", environment_xml_path)
141
+
142
+ dem_params = self._get_dem_meter_params()
143
+ if not dem_params:
144
+ return
145
+ maximum_height, minimum_height = dem_params
146
+
147
+ tree = self.get_tree(xml_path=environment_xml_path)
148
+ root = tree.getroot()
149
+
150
+ # Find the <latitude>40.6</latitude> element in the XML file.
151
+ latitude_element = root.find("./latitude") # type: ignore
152
+ if latitude_element is not None:
153
+ map_latitude = round(self.map.coordinates[0], 1)
154
+ latitude_element.text = str(map_latitude)
155
+ self.logger.debug(
156
+ "Found latitude element and set it to: %s",
157
+ latitude_element.text,
158
+ )
159
+
160
+ # The XML file contains 4 <fog> entries in different sections of <weather> representing
161
+ # different seasons, such as <season name="spring">, <season name="summer">, etc.
162
+ # We need to find them all and adjust the parameters accordingly.
163
+ for season in root.findall(".//weather/season"): # type: ignore
164
+ # Example of the <heightFog> element:
165
+ # <heightFog>
166
+ # <groundLevelDensity min="0.05" max="0.2" />
167
+ # <maxHeight min="420" max="600" />
168
+ # </heightFog>
169
+ # We need to adjust the maxheight min and max attributes.
170
+ max_height_element = season.find("./fog/heightFog/maxHeight")
171
+ data = {
172
+ "min": str(minimum_height),
173
+ "max": str(maximum_height),
174
+ }
175
+ self.update_element(max_height_element, data) # type: ignore
176
+ self.logger.debug(
177
+ "Adjusted fog settings for season '%s': min=%s, max=%s",
178
+ season.get("name", "unknown"),
179
+ minimum_height,
180
+ maximum_height,
181
+ )
182
+
183
+ self.logger.debug("Fog adjusted and file will be saved to %s", environment_xml_path)
184
+ self.save_tree(tree, xml_path=environment_xml_path)
185
+
186
+ self.fog_parameters = {
187
+ "minimum_height": minimum_height,
188
+ "maximum_height": maximum_height,
189
+ }
190
+
191
+ def _get_dem_meter_params(self) -> tuple[int, int] | None:
192
+ """Reads the DEM file and returns the maximum and minimum height in meters.
193
+
194
+ Returns:
195
+ tuple[int, int] | None: Maximum and minimum height in meters or None if the DEM file
196
+ is not found or cannot be read.
197
+ """
198
+ self.logger.debug("Reading DEM meter parameters...")
199
+ dem_path = self.game.dem_file_path(self.map_directory)
200
+ if not dem_path or not os.path.isfile(dem_path):
201
+ self.logger.warning("DEM file not found, fog adjustment will not be applied.")
202
+ return None
203
+
204
+ dem_image = cv2.imread(dem_path, cv2.IMREAD_UNCHANGED)
205
+ if dem_image is None:
206
+ self.logger.warning("Failed to read DEM image, fog adjustment will not be applied.")
207
+ return None
208
+ dem_maximum_pixel = dem_image.max()
209
+ dem_minimum_pixel = dem_image.min()
210
+
211
+ self.logger.debug(
212
+ "DEM read successfully. Max pixel: %d, Min pixel: %d",
213
+ dem_maximum_pixel,
214
+ dem_minimum_pixel,
215
+ )
216
+
217
+ try:
218
+ height_scale = self.get_height_scale()
219
+ except ValueError as e:
220
+ self.logger.warning("Error getting height scale from I3D file: %s", e)
221
+ return None
222
+ self.logger.debug("Height scale from I3D file: %d", height_scale)
223
+
224
+ dem_maximum_meter = int(dem_maximum_pixel / height_scale)
225
+ dem_minimum_meter = int(dem_minimum_pixel / height_scale)
226
+ self.logger.debug(
227
+ "DEM maximum height in meters: %d, minimum height in meters: %d",
228
+ dem_maximum_meter,
229
+ dem_minimum_meter,
230
+ )
231
+
232
+ return dem_maximum_meter, dem_minimum_meter
233
+
234
+ def _set_overview(self) -> None:
235
+ """Generates and sets the overview image for the map."""
236
+ try:
237
+ overview_image_path = self.game.overview_file_path(self.map_directory)
238
+ except NotImplementedError:
239
+ self.logger.warning(
240
+ "Game does not support overview image file, overview generation will be skipped."
241
+ )
242
+ return
243
+
244
+ satellite_component = self.map.get_satellite_component()
245
+ if not satellite_component:
246
+ self.logger.warning(
247
+ "Satellite component not found, overview generation will be skipped."
248
+ )
249
+ return
250
+
251
+ if not satellite_component.assets.overview or not os.path.isfile(
252
+ satellite_component.assets.overview
253
+ ):
254
+ self.logger.warning(
255
+ "Satellite overview image not found, overview generation will be skipped."
256
+ )
257
+ return
258
+
259
+ satellite_images_directory = os.path.dirname(satellite_component.assets.overview)
260
+ overview_image = cv2.imread(satellite_component.assets.overview, cv2.IMREAD_UNCHANGED)
261
+
262
+ if overview_image is None:
263
+ self.logger.warning(
264
+ "Failed to read satellite overview image, overview generation will be skipped."
265
+ )
266
+ return
267
+
268
+ resized_overview_image = cv2.resize(
269
+ overview_image,
270
+ (Parameters.OVERVIEW_IMAGE_SIZE, Parameters.OVERVIEW_IMAGE_SIZE),
271
+ interpolation=cv2.INTER_LINEAR,
272
+ )
273
+
274
+ resized_overview_path = os.path.join(
275
+ satellite_images_directory,
276
+ f"{Parameters.OVERVIEW_IMAGE_FILENAME}.png",
277
+ )
278
+
279
+ cv2.imwrite(resized_overview_path, resized_overview_image)
280
+ self.logger.info("Overview image saved to: %s", resized_overview_path)
281
+
282
+ if os.path.isfile(overview_image_path):
283
+ try:
284
+ os.remove(overview_image_path)
285
+ self.logger.debug("Old overview image removed: %s", overview_image_path)
286
+ except Exception as e:
287
+ self.logger.warning("Failed to remove old overview image: %s", e)
288
+ return
289
+
290
+ self.convert_png_to_dds(resized_overview_path, overview_image_path)
291
+ self.logger.info("Overview image converted and saved to: %s", overview_image_path)
292
+
293
+ @property
294
+ def supported_countries(self) -> dict[str, str]:
295
+ """Returns a dictionary of supported countries and their corresponding country codes.
296
+
297
+ Returns:
298
+ dict[str, str]: Supported countries and their country codes.
299
+ """
300
+ return {
301
+ "germany": "DE",
302
+ "austria": "A",
303
+ "switzerland": "CH",
304
+ "france": "F",
305
+ "italy": "I",
306
+ "spain": "E",
307
+ "portugal": "P",
308
+ "netherlands": "NL",
309
+ "belgium": "B",
310
+ "luxembourg": "L",
311
+ "poland": "PL",
312
+ "czech republic": "CZ",
313
+ "slovakia": "SK",
314
+ "hungary": "H",
315
+ "slovenia": "SLO",
316
+ "croatia": "HR",
317
+ "bosnia and herzegovina": "BIH",
318
+ "serbia": "SRB",
319
+ "north macedonia": "MKD",
320
+ "greece": "GR",
321
+ "turkey": "TR",
322
+ }
323
+
324
+ @property
325
+ def eu_countries(self) -> set[str]:
326
+ """Returns a set of country codes that are in the European Union.
327
+
328
+ Returns:
329
+ set[str]: Set of country codes in the EU.
330
+ """
331
+ return {
332
+ "DE",
333
+ "A",
334
+ "CH",
335
+ "F",
336
+ "I",
337
+ "E",
338
+ "P",
339
+ "NL",
340
+ "B",
341
+ "L",
342
+ "PL",
343
+ "CZ",
344
+ "SK",
345
+ "H",
346
+ "SLO",
347
+ "HR",
348
+ }
349
+
350
+ def update_license_plates(self):
351
+ """Updates license plates for the specified country."""
352
+ try:
353
+ license_plates_directory = self.game.license_plates_dir_path(self.map_directory)
354
+ except NotImplementedError:
355
+ self.logger.warning("Game does not support license plates processing.")
356
+ return
357
+
358
+ country_name = mfsutils.get_country_by_coordinates(self.map.coordinates).lower()
359
+ if country_name not in self.supported_countries:
360
+ self.logger.warning(
361
+ "License plates processing is not supported for country: %s.", country_name
362
+ )
363
+ return
364
+
365
+ # Get license plate country code and EU format.
366
+ country_code = self.supported_countries[country_name]
367
+ eu_format = country_code in self.eu_countries
368
+
369
+ self.logger.info(
370
+ "Updating license plates for country: %s, EU format: %s",
371
+ )
372
+
373
+ license_plates_prefix = self.map.i3d_settings.license_plate_prefix
374
+ if len(license_plates_prefix) < 1 or len(license_plates_prefix) > 3:
375
+ self.logger.error(
376
+ "Invalid license plate prefix: %s. It must be 1 to 3 characters long.",
377
+ license_plates_prefix,
378
+ )
379
+ return
380
+
381
+ try:
382
+ # 1. Update licensePlatesPL.xml with license plate prefix.
383
+ self._update_license_plates_xml(license_plates_directory, license_plates_prefix)
384
+
385
+ # 2. Update licensePlatesPL.i3d texture reference depending on EU format.
386
+ self._update_license_plates_i3d(license_plates_directory, eu_format)
387
+
388
+ # 3. Generate texture with country code.
389
+ self._generate_license_plate_texture(
390
+ license_plates_directory,
391
+ country_code,
392
+ eu_format,
393
+ COUNTRY_CODE_LEFT,
394
+ COUNTRY_CODE_TOP,
395
+ COUNTRY_CODE_RIGHT,
396
+ COUNTRY_CODE_BOTTOM,
397
+ )
398
+
399
+ self.logger.info("License plates updated successfully")
400
+ except Exception as e:
401
+ self.logger.error("Failed to update license plates: %s", e)
402
+ return
403
+
404
+ # Edit the map.xml only if all previous steps succeeded.
405
+ self._update_map_xml_license_plates()
406
+
407
+ def _update_map_xml_license_plates(self) -> None:
408
+ """Update map.xml to reference PL license plates.
409
+
410
+ Raises:
411
+ FileNotFoundError: If the map XML file is not found.
412
+ ValueError: If the map XML root element is None.
413
+ """
414
+ tree = self.get_tree()
415
+ if not tree:
416
+ raise FileNotFoundError(f"Map XML file not found: {self.xml_path}")
417
+
418
+ root = tree.getroot()
419
+
420
+ if root is None:
421
+ raise ValueError("Map XML root element is None.")
422
+
423
+ # Find or create licensePlates element
424
+ license_plates_element = root.find(".//licensePlates")
425
+ if license_plates_element is not None:
426
+ license_plates_element.set("filename", "map/licensePlates/licensePlatesPL.xml")
427
+ else:
428
+ # Create new licensePlates element if it doesn't exist
429
+ license_plates_element = root.makeelement(
430
+ "licensePlates", {"filename": "map/licensePlates/licensePlatesPL.xml"}
431
+ )
432
+ root.append(license_plates_element)
433
+
434
+ self.save_tree(tree)
435
+ self.logger.debug("Updated map.xml to use PL license plates")
436
+
437
+ def _update_license_plates_xml(
438
+ self, license_plates_directory: str, license_plate_prefix: str
439
+ ) -> None:
440
+ """Update licensePlatesPL.xml with license plate prefix.
441
+
442
+ Arguments:
443
+ license_plates_directory (str): Directory where license plates XML is located.
444
+ license_plate_prefix (str): The prefix to set on the license plates.
445
+
446
+ Raises:
447
+ FileNotFoundError: If the license plates XML file is not found.
448
+ ValueError: If required XML elements are not found.
449
+ """
450
+ xml_path = os.path.join(license_plates_directory, LICENSE_PLATES_XML_FILENAME)
451
+ if not os.path.isfile(xml_path):
452
+ raise FileNotFoundError(f"License plates XML file not found: {xml_path}.")
453
+
454
+ tree = self.get_tree(xml_path=xml_path)
455
+ root = tree.getroot()
456
+ if root is None:
457
+ raise ValueError("License plates XML root element is None.")
458
+
459
+ # Find licensePlate with node="0"
460
+ license_plate = None
461
+ for plate in root.findall(".//licensePlate"):
462
+ if plate.get("node") == "0":
463
+ license_plate = plate
464
+ break
465
+
466
+ if license_plate is None:
467
+ raise ValueError("Could not find licensePlate element with node='0'")
468
+
469
+ # Find first variations/variation element
470
+ variations = license_plate.find("variations")
471
+ if variations is None:
472
+ raise ValueError("Could not find variations element")
473
+
474
+ variation = variations.find("variation")
475
+ if variation is None:
476
+ raise ValueError("Could not find first variation element")
477
+
478
+ # 1. Update license plate prefix to ensure max 3 letters, uppercase.
479
+ license_plate_prefix = license_plate_prefix.upper()[:3]
480
+
481
+ # 2. Position X values for the letters.
482
+ pos_x_values = ["-0.1712", "-0.1172", "-0.0632"] # ? DO WE REALLY NEED THEM?
483
+
484
+ # 3. Update only the first 3 values (prefix letters), leave others intact.
485
+ # Find and update nodes 0|0, 0|1, 0|2 specifically.
486
+ for i, letter in enumerate(license_plate_prefix):
487
+ target_node = f"0|{i}"
488
+ # Find existing value with this node ID.
489
+ existing_value = None
490
+ for value in variation.findall("value"):
491
+ if value.get("node") == target_node:
492
+ existing_value = value
493
+ break
494
+
495
+ if existing_value is not None:
496
+ # Update existing value.
497
+ existing_value.set("character", letter)
498
+ existing_value.set("posX", pos_x_values[i])
499
+ existing_value.set("numerical", "false")
500
+ existing_value.set("alphabetical", "true")
501
+ else:
502
+ # Create new value if it doesn't exist.
503
+ value_elem = root.makeelement(
504
+ "value",
505
+ {
506
+ "node": target_node,
507
+ "character": letter,
508
+ "posX": pos_x_values[i],
509
+ "numerical": "false",
510
+ "alphabetical": "true",
511
+ },
512
+ )
513
+ # Insert at the beginning to maintain order.
514
+ variation.insert(i, value_elem)
515
+
516
+ # 5. Save the updated XML.
517
+ self.save_tree(tree, xml_path=xml_path)
518
+ self.logger.debug(
519
+ "Updated licensePlatesPL.xml with license plate prefix: %s", license_plate_prefix
520
+ )
521
+
522
+ def _update_license_plates_i3d(self, license_plates_directory: str, eu_format: bool) -> None:
523
+ """Update licensePlatesPL.i3d texture reference.
524
+
525
+ Arguments:
526
+ license_plates_directory (str): Directory where license plates i3d is located.
527
+ eu_format (bool): Whether to use EU format texture.
528
+
529
+ Raises:
530
+ FileNotFoundError: If the license plates i3d file is not found.
531
+ ValueError: If required XML elements are not found.
532
+ """
533
+ i3d_path = os.path.join(license_plates_directory, LICENSE_PLATES_I3D_FILENAME)
534
+ if not os.path.isfile(i3d_path):
535
+ raise FileNotFoundError(f"License plates i3d file not found: {i3d_path}")
536
+
537
+ # 1. Load the i3d XML.
538
+ tree = self.get_tree(xml_path=i3d_path)
539
+ root = tree.getroot()
540
+
541
+ if root is None:
542
+ raise ValueError("License plates i3d XML root element is None.")
543
+
544
+ # 2. Find File element with fileId="12"
545
+ file_element = None
546
+ for file_elem in root.findall(".//File"):
547
+ if file_elem.get("fileId") == "12":
548
+ file_element = file_elem
549
+ break
550
+
551
+ if file_element is None:
552
+ raise ValueError("Could not find File element with fileId='12'")
553
+
554
+ # 3. Update filename to point to local map directory (relative path).
555
+ if eu_format:
556
+ filename = "licensePlates_diffuseEU.png"
557
+ else:
558
+ filename = "licensePlates_diffuse.png"
559
+
560
+ file_element.set("filename", filename)
561
+
562
+ # 4. Save the updated i3d XML.
563
+ self.save_tree(tree, xml_path=i3d_path)
564
+ self.logger.debug("Updated licensePlatesPL.i3d texture reference to: %s", filename)
565
+
566
+ def _generate_license_plate_texture(
567
+ self,
568
+ license_plates_directory: str,
569
+ country_code: str,
570
+ eu_format: bool,
571
+ left: int,
572
+ top: int,
573
+ right: int,
574
+ bottom: int,
575
+ ) -> None:
576
+ """Generate license plate texture with country code.
577
+
578
+ Arguments:
579
+ license_plates_directory (str): Directory where license plates textures are located.
580
+ country_code (str): The country code to render on the license plate.
581
+ eu_format (bool): Whether to use EU format texture.
582
+ left (int): Left coordinate of the country code box.
583
+ top (int): Top coordinate of the country code box.
584
+ right (int): Right coordinate of the country code box.
585
+ bottom (int): Bottom coordinate of the country code box.
586
+
587
+ Raises:
588
+ FileNotFoundError: If the base texture file is not found.
589
+ ValueError: If there is an error generating the texture.
590
+ """
591
+ # 1. Define the path to the base texture depending on EU format.
592
+ if eu_format:
593
+ texture_filename = "licensePlates_diffuseEU.png"
594
+ else:
595
+ texture_filename = "licensePlates_diffuse.png"
596
+
597
+ # 2. Check if the base texture file exists.
598
+ texture_path = os.path.join(license_plates_directory, texture_filename)
599
+ if not os.path.isfile(texture_path):
600
+ self.logger.warning("Base texture file not found: %s.", texture_path)
601
+ raise FileNotFoundError(f"Base texture file not found: {texture_path}")
602
+
603
+ # 3. Load the base texture.
604
+ texture = cv2.imread(texture_path, cv2.IMREAD_UNCHANGED)
605
+ if texture is None:
606
+ raise ValueError(f"Could not load base texture: {texture_path}")
607
+
608
+ # 4. Calculate text box dimensions.
609
+ box_width = right - left
610
+ box_height = bottom - top
611
+
612
+ # 5. Define font and fit text in rotated box.
613
+ font = cv2.FONT_HERSHEY_SIMPLEX
614
+ thickness = 3
615
+ font_scale, large_canvas_size = self.fit_text_in_rotated_box(
616
+ country_code,
617
+ box_width,
618
+ box_height,
619
+ font,
620
+ thickness,
621
+ )
622
+
623
+ # 6. Create the actual text image (black background for white text).
624
+ text_img = np.zeros((large_canvas_size, large_canvas_size, 3), dtype=np.uint8)
625
+ text_size = cv2.getTextSize(country_code, font, font_scale, thickness)[0]
626
+
627
+ # 7. Center text on canvas.
628
+ text_x = (large_canvas_size - text_size[0]) // 2
629
+ text_y = (large_canvas_size + text_size[1]) // 2
630
+
631
+ # 8. Use white text on black background with anti-aliasing.
632
+ cv2.putText(
633
+ text_img,
634
+ country_code,
635
+ (text_x, text_y),
636
+ font,
637
+ font_scale,
638
+ (255, 255, 255),
639
+ thickness,
640
+ lineType=cv2.LINE_AA,
641
+ )
642
+
643
+ # 9. Rotate the text image 90 degrees clockwise.
644
+ rotated_text_rgb = cv2.rotate(text_img, cv2.ROTATE_90_CLOCKWISE)
645
+
646
+ # 10. Find bounding box and crop to content (looking for white pixels).
647
+ gray = cv2.cvtColor(rotated_text_rgb, cv2.COLOR_BGR2GRAY)
648
+ coords = np.column_stack(np.where(gray > 0))
649
+
650
+ # 11. Crop the rotated text image to the bounding box of the text.
651
+ if len(coords) > 0:
652
+ y_min, x_min = coords.min(axis=0)
653
+ y_max, x_max = coords.max(axis=0)
654
+
655
+ # Crop to content with small padding
656
+ padding = 2
657
+ y_min = max(0, y_min - padding)
658
+ x_min = max(0, x_min - padding)
659
+ y_max = min(rotated_text_rgb.shape[0], y_max + padding)
660
+ x_max = min(rotated_text_rgb.shape[1], x_max + padding)
661
+
662
+ cropped_text_rgb = rotated_text_rgb[y_min:y_max, x_min:x_max]
663
+
664
+ # Convert to RGBA with proper alpha
665
+ cropped_height, cropped_width = cropped_text_rgb.shape[:2]
666
+ rotated_text = np.zeros((cropped_height, cropped_width, 4), dtype=np.uint8)
667
+ rotated_text[:, :, :3] = cropped_text_rgb
668
+
669
+ # Set alpha: opaque for text (white pixels), transparent for background
670
+ text_mask = np.any(cropped_text_rgb > 0, axis=2)
671
+ rotated_text[text_mask, 3] = 255 # Opaque text
672
+ rotated_text[~text_mask, 3] = 0 # Transparent background
673
+ else:
674
+ raise ValueError("No text found in the generated image.")
675
+
676
+ # 12. Ensure the texture is RGBA.
677
+ if texture.shape[2] == 3:
678
+ # Convert RGB to RGBA
679
+ texture_rgba = np.zeros((texture.shape[0], texture.shape[1], 4), dtype=np.uint8)
680
+ texture_rgba[:, :, :3] = texture
681
+ texture_rgba[:, :, 3] = 255 # Fully opaque
682
+ texture = texture_rgba
683
+
684
+ # 13. Place the rotated text in the texture.
685
+ h, w = rotated_text.shape[:2]
686
+
687
+ # 14. Center the text within the target box.
688
+ center_x = left + (box_width - w) // 2
689
+ center_y = top + (box_height - h) // 2
690
+
691
+ # 15. Ensure the centered text fits within texture bounds.
692
+ if (
693
+ center_y >= 0
694
+ and center_x >= 0
695
+ and center_y + h <= texture.shape[0]
696
+ and center_x + w <= texture.shape[1]
697
+ ):
698
+ # Extract the region where text will be placed
699
+ texture_region = texture[center_y : center_y + h, center_x : center_x + w]
700
+
701
+ # Alpha blend the text onto the texture
702
+ alpha = rotated_text[:, :, 3:4] / 255.0
703
+
704
+ # Blend only the RGB channels
705
+ texture[center_y : center_y + h, center_x : center_x + w, :3] = (
706
+ rotated_text[:, :, :3] * alpha + texture_region[:, :, :3] * (1 - alpha)
707
+ ).astype(np.uint8)
708
+
709
+ # Update alpha channel where text is present
710
+ texture[center_y : center_y + h, center_x : center_x + w, 3] = np.maximum(
711
+ texture_region[:, :, 3], rotated_text[:, :, 3]
712
+ )
713
+
714
+ self.logger.debug(
715
+ "Text placed at centered position: (%s,%s) size: %sx%s",
716
+ center_x,
717
+ center_y,
718
+ w,
719
+ h,
720
+ )
721
+ else:
722
+ self.logger.warning(
723
+ "Centered text position (%s,%s) with size %sx%s would exceed texture bounds",
724
+ center_x,
725
+ center_y,
726
+ w,
727
+ h,
728
+ )
729
+
730
+ # 16. Save the modified texture.
731
+ cv2.imwrite(texture_path, texture)
732
+ self.logger.debug(
733
+ "Generated license plate texture with country code %s at: %s",
734
+ country_code,
735
+ texture_path,
736
+ )
737
+
738
+ def fit_text_in_rotated_box(
739
+ self,
740
+ text: str,
741
+ box_width: int,
742
+ box_height: int,
743
+ font: int,
744
+ thickness: int,
745
+ ) -> tuple[float, int]:
746
+ """Fits text into a rotated box by adjusting font size.
747
+
748
+ Arguments:
749
+ text (str): The text to fit.
750
+ box_width (int): The width of the box.
751
+ box_height (int): The height of the box.
752
+ font (int): The OpenCV font to use.
753
+ thickness (int): The thickness of the text.
754
+
755
+ Returns:
756
+ tuple[float, int]: The calculated font scale and the size of the large canvas used.
757
+ """
758
+ font_scale = min(box_height / (len(text) * 10), box_width / 22)
759
+
760
+ # Create a large canvas to render text without clipping
761
+ large_canvas_size = max(box_width, box_height) * 2
762
+
763
+ # Iteratively reduce font size until rotated text fits
764
+ for _ in range(15): # More iterations for better fitting
765
+ # Test on a large canvas first (black background for white text)
766
+ test_img = np.zeros((large_canvas_size, large_canvas_size, 3), dtype=np.uint8)
767
+ text_size = cv2.getTextSize(text, font, font_scale, thickness)[0]
768
+
769
+ # Center text on large canvas
770
+ text_x = (large_canvas_size - text_size[0]) // 2
771
+ text_y = (large_canvas_size + text_size[1]) // 2
772
+
773
+ # Use white text for testing with anti-aliasing
774
+ cv2.putText(
775
+ test_img,
776
+ text,
777
+ (text_x, text_y),
778
+ font,
779
+ font_scale,
780
+ (255, 255, 255),
781
+ thickness,
782
+ lineType=cv2.LINE_AA, # Anti-aliasing for smooth edges
783
+ )
784
+
785
+ # Rotate the test image
786
+ rotated_test = cv2.rotate(test_img, cv2.ROTATE_90_CLOCKWISE)
787
+
788
+ # Find the bounding box of non-black pixels
789
+ gray = cv2.cvtColor(rotated_test, cv2.COLOR_BGR2GRAY)
790
+ coords = np.column_stack(np.where(gray > 0))
791
+
792
+ if len(coords) > 0:
793
+ y_min, x_min = coords.min(axis=0)
794
+ y_max, x_max = coords.max(axis=0)
795
+ rotated_width = x_max - x_min + 1
796
+ rotated_height = y_max - y_min + 1
797
+
798
+ # Check if the rotated text fits in our target box with good margin
799
+ if rotated_width <= box_width * 0.90 and rotated_height <= box_height * 0.90:
800
+ break
801
+
802
+ font_scale *= 0.93 # Reduce by 7% each iteration
803
+
804
+ self.logger.debug("Final font scale: %s, Original text size: %s", font_scale, text_size)
805
+ if len(coords) > 0:
806
+ self.logger.debug("Rotated text dimensions: %sx%s", rotated_width, rotated_height)
807
+
808
+ return font_scale, large_canvas_size
@@ -180,6 +180,16 @@ class Game:
180
180
  str: The path to the weights directory."""
181
181
  raise NotImplementedError
182
182
 
183
+ def license_plates_dir_path(self, map_directory: str) -> str:
184
+ """Returns the path to the license plates directory.
185
+
186
+ Arguments:
187
+ map_directory (str): The path to the map directory.
188
+
189
+ Returns:
190
+ str: The path to the license plates directory."""
191
+ raise NotImplementedError
192
+
183
193
  def get_density_map_fruits_path(self, map_directory: str) -> str:
184
194
  """Returns the path to the density map fruits file.
185
195
 
@@ -254,6 +264,16 @@ class Game:
254
264
  str: The path to the i3d file."""
255
265
  raise NotImplementedError
256
266
 
267
+ def overview_file_path(self, map_directory: str) -> str:
268
+ """Returns the path to the overview image file.
269
+
270
+ Arguments:
271
+ map_directory (str): The path to the map directory.
272
+
273
+ Returns:
274
+ str: The path to the overview image file."""
275
+ raise NotImplementedError
276
+
257
277
  @property
258
278
  def i3d_processing(self) -> bool:
259
279
  """Returns whether the i3d file should be processed.
@@ -408,6 +428,16 @@ class FS25(Game):
408
428
  str: The path to the weights directory."""
409
429
  return os.path.join(map_directory, "map", "data")
410
430
 
431
+ def license_plates_dir_path(self, map_directory: str) -> str:
432
+ """Returns the path to the license plates directory.
433
+
434
+ Arguments:
435
+ map_directory (str): The path to the map directory.
436
+
437
+ Returns:
438
+ str: The path to the license plates directory."""
439
+ return os.path.join(map_directory, "map", "licensePlates")
440
+
411
441
  def i3d_file_path(self, map_directory: str) -> str:
412
442
  """Returns the path to the i3d file.
413
443
 
@@ -437,3 +467,13 @@ class FS25(Game):
437
467
  Returns:
438
468
  str: The path to the environment xml file."""
439
469
  return os.path.join(map_directory, "map", "config", "environment.xml")
470
+
471
+ def overview_file_path(self, map_directory: str) -> str:
472
+ """Returns the path to the overview image file.
473
+
474
+ Arguments:
475
+ map_directory (str): The path to the map directory.
476
+
477
+ Returns:
478
+ str: The path to the overview image file."""
479
+ return os.path.join(map_directory, "map", "overview.dds")
@@ -55,6 +55,9 @@ class Parameters:
55
55
 
56
56
  HEIGHT_SCALE = "heightScale"
57
57
 
58
+ OVERVIEW_IMAGE_SIZE = 4096
59
+ OVERVIEW_IMAGE_FILENAME = "overview"
60
+
58
61
 
59
62
  class SharedSettings(BaseModel):
60
63
  """Represents the shared settings for all components."""
@@ -233,6 +236,7 @@ class I3DSettings(SettingsModel):
233
236
  existing points.
234
237
  add_reversed_splines (bool): if True, reversed splines will be added to the map.
235
238
  field_splines (bool): if True, splines will be added to the fields.
239
+ license_plate_prefix (str): prefix for the license plates.
236
240
  """
237
241
 
238
242
  add_trees: bool = True
@@ -244,6 +248,8 @@ class I3DSettings(SettingsModel):
244
248
  add_reversed_splines: bool = False
245
249
  field_splines: bool = False
246
250
 
251
+ license_plate_prefix: str = "M4S"
252
+
247
253
 
248
254
  class TextureSettings(SettingsModel):
249
255
  """Represents the advanced settings for texture component.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maps4fs
3
- Version: 2.8.3
3
+ Version: 2.8.5
4
4
  Summary: Generate map templates for Farming Simulator from real places.
5
5
  Author-email: iwatkot <iwatkot@gmail.com>
6
6
  License: GNU Affero General Public License v3.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "maps4fs"
7
- version = "2.8.3"
7
+ version = "2.8.5"
8
8
  description = "Generate map templates for Farming Simulator from real places."
9
9
  authors = [{name = "iwatkot", email = "iwatkot@gmail.com"}]
10
10
  license = {text = "GNU Affero General Public License v3.0"}
@@ -1,215 +0,0 @@
1
- """This module contains the Config class for map settings and configuration."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
-
7
- import cv2
8
-
9
- from maps4fs.generator.component.base.component_xml import XMLComponent
10
-
11
-
12
- # pylint: disable=R0903
13
- class Config(XMLComponent):
14
- """Component for map settings and configuration.
15
-
16
- Arguments:
17
- game (Game): The game instance for which the map is generated.
18
- coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
19
- map_size (int): The size of the map in pixels (it's a square).
20
- map_rotated_size (int): The size of the map in pixels after rotation.
21
- rotation (int): The rotation angle of the map.
22
- map_directory (str): The directory where the map files are stored.
23
- logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
24
- info, warning. If not provided, default logging will be used.
25
- """
26
-
27
- def preprocess(self) -> None:
28
- """Gets the path to the map XML file and saves it to the instance variable."""
29
- self.xml_path = self.game.map_xml_path(self.map_directory)
30
- self.fog_parameters: dict[str, int] = {}
31
-
32
- def process(self) -> None:
33
- """Sets the map size in the map.xml file."""
34
- self._set_map_size()
35
-
36
- if self.game.fog_processing:
37
- self._adjust_fog()
38
-
39
- def _set_map_size(self) -> None:
40
- """Edits map.xml file to set correct map size."""
41
- tree = self.get_tree()
42
- if not tree:
43
- raise FileNotFoundError(f"Map XML file not found: {self.xml_path}")
44
-
45
- root = tree.getroot()
46
- data = {
47
- "width": str(self.scaled_size),
48
- "height": str(self.scaled_size),
49
- }
50
-
51
- for element in root.iter("map"): # type: ignore
52
- self.update_element(element, data)
53
- break
54
- self.save_tree(tree)
55
-
56
- def info_sequence(self) -> dict[str, dict[str, str | float | int]]:
57
- """Returns information about the component.
58
- Overview section is needed to create the overview file (in-game map).
59
-
60
- Returns:
61
- dict[str, dict[str, str | float | int]]: Information about the component.
62
- """
63
- # The overview file is exactly 2X bigger than the map size, does not matter
64
- # if the map is 2048x2048 or 4096x4096, the overview will be 4096x4096
65
- # and the map will be in the center of the overview.
66
- # That's why the distance is set to the map height not as a half of it.
67
- bbox = self.get_bbox(distance=self.map_size)
68
- south, west, north, east = bbox
69
- epsg3857_string = self.get_epsg3857_string(bbox=bbox)
70
- epsg3857_string_with_margin = self.get_epsg3857_string(bbox=bbox, add_margin=True)
71
-
72
- self.qgis_sequence()
73
-
74
- overview_data = {
75
- "epsg3857_string": epsg3857_string,
76
- "epsg3857_string_with_margin": epsg3857_string_with_margin,
77
- "south": south,
78
- "west": west,
79
- "north": north,
80
- "east": east,
81
- "height": self.map_size * 2,
82
- "width": self.map_size * 2,
83
- }
84
-
85
- data = {
86
- "Overview": overview_data,
87
- }
88
- if self.fog_parameters:
89
- data["Fog"] = self.fog_parameters # type: ignore
90
-
91
- return data # type: ignore
92
-
93
- def qgis_sequence(self) -> None:
94
- """Generates QGIS scripts for creating bounding box layers and rasterizing them."""
95
- bbox = self.get_bbox(distance=self.map_size)
96
- espg3857_bbox = self.get_espg3857_bbox(bbox=bbox)
97
- espg3857_bbox_with_margin = self.get_espg3857_bbox(bbox=bbox, add_margin=True)
98
-
99
- qgis_layers = [("Overview_bbox", *espg3857_bbox)]
100
- qgis_layers_with_margin = [("Overview_bbox_with_margin", *espg3857_bbox_with_margin)]
101
-
102
- layers = qgis_layers + qgis_layers_with_margin
103
-
104
- self.create_qgis_scripts(layers)
105
-
106
- def _adjust_fog(self) -> None:
107
- """Adjusts the fog settings in the environment XML file based on the DEM and height scale."""
108
- self.logger.debug("Adjusting fog settings based on DEM and height scale...")
109
- try:
110
- environment_xml_path = self.game.get_environment_xml_path(self.map_directory)
111
- except NotImplementedError:
112
- self.logger.warning(
113
- "Game does not support environment XML file, fog adjustment will not be applied."
114
- )
115
- return
116
-
117
- if not environment_xml_path or not os.path.isfile(environment_xml_path):
118
- self.logger.warning(
119
- "Environment XML file not found, fog adjustment will not be applied."
120
- )
121
- return
122
-
123
- self.logger.debug("Will work with environment XML file: %s", environment_xml_path)
124
-
125
- dem_params = self._get_dem_meter_params()
126
- if not dem_params:
127
- return
128
- maximum_height, minimum_height = dem_params
129
-
130
- tree = self.get_tree(xml_path=environment_xml_path)
131
- root = tree.getroot()
132
-
133
- # Find the <latitude>40.6</latitude> element in the XML file.
134
- latitude_element = root.find("./latitude") # type: ignore
135
- if latitude_element is not None:
136
- map_latitude = round(self.map.coordinates[0], 1)
137
- latitude_element.text = str(map_latitude)
138
- self.logger.debug(
139
- "Found latitude element and set it to: %s",
140
- latitude_element.text,
141
- )
142
-
143
- # The XML file contains 4 <fog> entries in different sections of <weather> representing
144
- # different seasons, such as <season name="spring">, <season name="summer">, etc.
145
- # We need to find them all and adjust the parameters accordingly.
146
- for season in root.findall(".//weather/season"): # type: ignore
147
- # Example of the <heightFog> element:
148
- # <heightFog>
149
- # <groundLevelDensity min="0.05" max="0.2" />
150
- # <maxHeight min="420" max="600" />
151
- # </heightFog>
152
- # We need to adjust the maxheight min and max attributes.
153
- max_height_element = season.find("./fog/heightFog/maxHeight")
154
- data = {
155
- "min": str(minimum_height),
156
- "max": str(maximum_height),
157
- }
158
- self.update_element(max_height_element, data) # type: ignore
159
- self.logger.debug(
160
- "Adjusted fog settings for season '%s': min=%s, max=%s",
161
- season.get("name", "unknown"),
162
- minimum_height,
163
- maximum_height,
164
- )
165
-
166
- self.logger.debug("Fog adjusted and file will be saved to %s", environment_xml_path)
167
- self.save_tree(tree, xml_path=environment_xml_path)
168
-
169
- self.fog_parameters = {
170
- "minimum_height": minimum_height,
171
- "maximum_height": maximum_height,
172
- }
173
-
174
- def _get_dem_meter_params(self) -> tuple[int, int] | None:
175
- """Reads the DEM file and returns the maximum and minimum height in meters.
176
-
177
- Returns:
178
- tuple[int, int] | None: Maximum and minimum height in meters or None if the DEM file
179
- is not found or cannot be read.
180
- """
181
- self.logger.debug("Reading DEM meter parameters...")
182
- dem_path = self.game.dem_file_path(self.map_directory)
183
- if not dem_path or not os.path.isfile(dem_path):
184
- self.logger.warning("DEM file not found, fog adjustment will not be applied.")
185
- return None
186
-
187
- dem_image = cv2.imread(dem_path, cv2.IMREAD_UNCHANGED)
188
- if dem_image is None:
189
- self.logger.warning("Failed to read DEM image, fog adjustment will not be applied.")
190
- return None
191
- dem_maximum_pixel = dem_image.max()
192
- dem_minimum_pixel = dem_image.min()
193
-
194
- self.logger.debug(
195
- "DEM read successfully. Max pixel: %d, Min pixel: %d",
196
- dem_maximum_pixel,
197
- dem_minimum_pixel,
198
- )
199
-
200
- try:
201
- height_scale = self.get_height_scale()
202
- except ValueError as e:
203
- self.logger.warning("Error getting height scale from I3D file: %s", e)
204
- return None
205
- self.logger.debug("Height scale from I3D file: %d", height_scale)
206
-
207
- dem_maximum_meter = int(dem_maximum_pixel / height_scale)
208
- dem_minimum_meter = int(dem_minimum_pixel / height_scale)
209
- self.logger.debug(
210
- "DEM maximum height in meters: %d, minimum height in meters: %d",
211
- dem_maximum_meter,
212
- dem_minimum_meter,
213
- )
214
-
215
- return dem_maximum_meter, dem_minimum_meter
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes