maps4fs 2.8.3__py3-none-any.whl → 2.8.5__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.
Potentially problematic release.
This version of maps4fs might be problematic. Click here for more details.
- maps4fs/generator/component/base/component_mesh.py +3 -1
- maps4fs/generator/component/config.py +594 -1
- maps4fs/generator/game.py +40 -0
- maps4fs/generator/settings.py +6 -0
- {maps4fs-2.8.3.dist-info → maps4fs-2.8.5.dist-info}/METADATA +1 -1
- {maps4fs-2.8.3.dist-info → maps4fs-2.8.5.dist-info}/RECORD +9 -9
- {maps4fs-2.8.3.dist-info → maps4fs-2.8.5.dist-info}/WHEEL +0 -0
- {maps4fs-2.8.3.dist-info → maps4fs-2.8.5.dist-info}/licenses/LICENSE.md +0 -0
- {maps4fs-2.8.3.dist-info → maps4fs-2.8.5.dist-info}/top_level.txt +0 -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],
|
|
244
|
+
[mesh_copy, cube_mesh],
|
|
245
|
+
check_volume=False,
|
|
246
|
+
engine="blender",
|
|
245
247
|
)
|
|
246
248
|
|
|
247
249
|
@staticmethod
|
|
@@ -5,12 +5,25 @@ from __future__ import annotations
|
|
|
5
5
|
import os
|
|
6
6
|
|
|
7
7
|
import cv2
|
|
8
|
+
import numpy as np
|
|
8
9
|
|
|
10
|
+
import maps4fs.generator.utils as mfsutils
|
|
11
|
+
from maps4fs.generator.component.base.component_image import ImageComponent
|
|
9
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"
|
|
10
23
|
|
|
11
24
|
|
|
12
25
|
# pylint: disable=R0903
|
|
13
|
-
class Config(XMLComponent):
|
|
26
|
+
class Config(XMLComponent, ImageComponent):
|
|
14
27
|
"""Component for map settings and configuration.
|
|
15
28
|
|
|
16
29
|
Arguments:
|
|
@@ -36,6 +49,10 @@ class Config(XMLComponent):
|
|
|
36
49
|
if self.game.fog_processing:
|
|
37
50
|
self._adjust_fog()
|
|
38
51
|
|
|
52
|
+
self._set_overview()
|
|
53
|
+
|
|
54
|
+
self.update_license_plates()
|
|
55
|
+
|
|
39
56
|
def _set_map_size(self) -> None:
|
|
40
57
|
"""Edits map.xml file to set correct map size."""
|
|
41
58
|
tree = self.get_tree()
|
|
@@ -213,3 +230,579 @@ class Config(XMLComponent):
|
|
|
213
230
|
)
|
|
214
231
|
|
|
215
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
|
maps4fs/generator/game.py
CHANGED
|
@@ -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")
|
maps4fs/generator/settings.py
CHANGED
|
@@ -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.
|
|
@@ -2,15 +2,15 @@ maps4fs/__init__.py,sha256=5ixsCA5vgcIV0OrF9EJBm91Mmc_KfMiDRM-QyifMAvo,386
|
|
|
2
2
|
maps4fs/logger.py,sha256=6sem0aFKQqtVjQ_yNu9iGcc-hqzLQUhfxco05K6nqow,763
|
|
3
3
|
maps4fs/generator/__init__.py,sha256=zZMLEkGzb4z0xql650gOtGSvcgX58DnJ2yN3vC2daRk,43
|
|
4
4
|
maps4fs/generator/config.py,sha256=KwYYWM5wrMB_tKn3Fls6WpEurZJxmrBH5AgAXPGHTJE,7122
|
|
5
|
-
maps4fs/generator/game.py,sha256=
|
|
5
|
+
maps4fs/generator/game.py,sha256=_LNiH__7FeSGsPKsuvAGiktt5GcJQVqcQYtsFZNWGyM,16106
|
|
6
6
|
maps4fs/generator/map.py,sha256=ZZRU8x0feGbgeJgxc0D3N-mfiasyFXxj6gbGyl-WRzE,14528
|
|
7
7
|
maps4fs/generator/qgis.py,sha256=Es8hLuqN_KH8lDfnJE6He2rWYbAKJ3RGPn-o87S6CPI,6116
|
|
8
|
-
maps4fs/generator/settings.py,sha256=
|
|
8
|
+
maps4fs/generator/settings.py,sha256=_QJL4ikQYLFOIB1zWqXjYvyLfoh3cr2RYb2IzsunMJg,13405
|
|
9
9
|
maps4fs/generator/statistics.py,sha256=Dp1-NS-DWv0l0UdmhOoXeQs_N-Hs7svYUnmziSW5Z9I,2098
|
|
10
10
|
maps4fs/generator/utils.py,sha256=ugdQ8C22NeiZLIlldLoEKCc7ioOefz4W-8qF2eOy9qU,4834
|
|
11
11
|
maps4fs/generator/component/__init__.py,sha256=s01yVVVi8R2xxNvflu2D6wTd9I_g73AMM2x7vAC7GX4,490
|
|
12
12
|
maps4fs/generator/component/background.py,sha256=tFWdYASUUZsJ8QiQrxxba-6aMECD3hvpd0pMm_UdrH8,46151
|
|
13
|
-
maps4fs/generator/component/config.py,sha256=
|
|
13
|
+
maps4fs/generator/component/config.py,sha256=oDA0UWrf2ZPxjJgbZHFJuBTp2DZJq2IROj7lzT9ePwE,31078
|
|
14
14
|
maps4fs/generator/component/dem.py,sha256=FPqcXmFQg5MPaGuy4g5kxzvY1wbhozeCf-aNMCj5eaU,11687
|
|
15
15
|
maps4fs/generator/component/grle.py,sha256=0PC1K829wjD4y4d9qfIbnU29ebjflIPBbwIZx8FXwc8,27242
|
|
16
16
|
maps4fs/generator/component/i3d.py,sha256=RvpiW9skkZ6McyahC-AeIdPuSQjpXiFs1l0xOioJAu4,26638
|
|
@@ -20,10 +20,10 @@ maps4fs/generator/component/texture.py,sha256=gXZgr73ehT3_qjuIku0j7N7exloywtmmEQ
|
|
|
20
20
|
maps4fs/generator/component/base/__init__.py,sha256=zZMLEkGzb4z0xql650gOtGSvcgX58DnJ2yN3vC2daRk,43
|
|
21
21
|
maps4fs/generator/component/base/component.py,sha256=-7H3donrH19f0_rivNyI3fgLsiZkntXfGywEx4tOnM4,23924
|
|
22
22
|
maps4fs/generator/component/base/component_image.py,sha256=GXFkEFARNRkWkDiGSjvU4WX6f_8s6R1t2ZYqZflv1jk,9626
|
|
23
|
-
maps4fs/generator/component/base/component_mesh.py,sha256=
|
|
23
|
+
maps4fs/generator/component/base/component_mesh.py,sha256=S5M_SU-FZz17-LgzTIM935ms1Vc4O06UQNTEN4e0INU,24729
|
|
24
24
|
maps4fs/generator/component/base/component_xml.py,sha256=MT-VhU2dEckLFxAgmxg6V3gnv11di_94Qq6atfpOLdc,5342
|
|
25
|
-
maps4fs-2.8.
|
|
26
|
-
maps4fs-2.8.
|
|
27
|
-
maps4fs-2.8.
|
|
28
|
-
maps4fs-2.8.
|
|
29
|
-
maps4fs-2.8.
|
|
25
|
+
maps4fs-2.8.5.dist-info/licenses/LICENSE.md,sha256=Ptw8AkqJ60c4tRts6yuqGP_8B0dxwOGmJsp6YJ8dKqM,34328
|
|
26
|
+
maps4fs-2.8.5.dist-info/METADATA,sha256=spNUf5QNjPOszSmK34GWb6ek7Vt0jlwc6MUUe0p1k3g,10042
|
|
27
|
+
maps4fs-2.8.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
28
|
+
maps4fs-2.8.5.dist-info/top_level.txt,sha256=Ue9DSRlejRQRCaJueB0uLcKrWwsEq9zezfv5dI5mV1M,8
|
|
29
|
+
maps4fs-2.8.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|