Photo-Composition-Designer 0.0.7__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.
- Photo_Composition_Designer/__init__.py +0 -0
- Photo_Composition_Designer/__main__.py +8 -0
- Photo_Composition_Designer/_version.py +34 -0
- Photo_Composition_Designer/cli/__init__.py +0 -0
- Photo_Composition_Designer/cli/__main__.py +8 -0
- Photo_Composition_Designer/cli/cli.py +106 -0
- Photo_Composition_Designer/common/Anniversaries.py +93 -0
- Photo_Composition_Designer/common/Locations.py +87 -0
- Photo_Composition_Designer/common/MoonPhase.py +85 -0
- Photo_Composition_Designer/common/Photo.py +113 -0
- Photo_Composition_Designer/common/__init__.py +0 -0
- Photo_Composition_Designer/common/logging.py +216 -0
- Photo_Composition_Designer/config/__init__.py +0 -0
- Photo_Composition_Designer/config/config.py +321 -0
- Photo_Composition_Designer/core/__init__.py +0 -0
- Photo_Composition_Designer/core/base.py +383 -0
- Photo_Composition_Designer/gui/GuiLogWriter.py +79 -0
- Photo_Composition_Designer/gui/__init__.py +0 -0
- Photo_Composition_Designer/gui/__main__.py +8 -0
- Photo_Composition_Designer/gui/gui.py +565 -0
- Photo_Composition_Designer/image/CalendarRenderer.py +319 -0
- Photo_Composition_Designer/image/CollageRenderer.py +433 -0
- Photo_Composition_Designer/image/DescriptionRenderer.py +74 -0
- Photo_Composition_Designer/image/MapRenderer.py +101 -0
- Photo_Composition_Designer/image/__init__.py +0 -0
- Photo_Composition_Designer/tools/DescriptionsFileGenerator.py +44 -0
- Photo_Composition_Designer/tools/GeoPlotter.py +211 -0
- Photo_Composition_Designer/tools/Helpers.py +18 -0
- Photo_Composition_Designer/tools/ImageDistributor.py +153 -0
- Photo_Composition_Designer/tools/__init__.py +0 -0
- __init__.py +0 -0
- firewall_handler.py +198 -0
- main.py +146 -0
- path_handler.py +10 -0
- photo_composition_designer-0.0.7.dist-info/METADATA +205 -0
- photo_composition_designer-0.0.7.dist-info/RECORD +40 -0
- photo_composition_designer-0.0.7.dist-info/WHEEL +5 -0
- photo_composition_designer-0.0.7.dist-info/entry_points.txt +3 -0
- photo_composition_designer-0.0.7.dist-info/licenses/LICENSE +24 -0
- photo_composition_designer-0.0.7.dist-info/top_level.txt +5 -0
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.0.7'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 7)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""CLI interface for Photo-Composition-Designer using the generic config framework.
|
|
2
|
+
|
|
3
|
+
This file uses the CliGenerator from the generic config framework.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os.path
|
|
7
|
+
|
|
8
|
+
from config_cli_gui.cli import CliGenerator
|
|
9
|
+
|
|
10
|
+
from Photo_Composition_Designer.common.logging import initialize_logging
|
|
11
|
+
from Photo_Composition_Designer.config.config import ConfigParameterManager
|
|
12
|
+
from Photo_Composition_Designer.core.base import CompositionDesigner
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_config(config_manager: ConfigParameterManager) -> bool:
|
|
16
|
+
"""Validate the configuration parameters.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
config_manager: Configuration manager instance
|
|
20
|
+
logger: Logger instance for error reporting
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
True if configuration is valid, False otherwise
|
|
24
|
+
"""
|
|
25
|
+
# Initialize logging system
|
|
26
|
+
logger_manager = initialize_logging(config_manager)
|
|
27
|
+
logger = logger_manager.get_logger("Photo_Composition_Designer.cli")
|
|
28
|
+
|
|
29
|
+
# Get CLI category and check required parameters
|
|
30
|
+
cli_parameters = config_manager.get_cli_parameters()
|
|
31
|
+
if not cli_parameters:
|
|
32
|
+
logger.error("No CLI configuration found")
|
|
33
|
+
|
|
34
|
+
for param in cli_parameters:
|
|
35
|
+
if param.name == "photoDirectory":
|
|
36
|
+
photo_dir = param.value
|
|
37
|
+
if os.path.exists(photo_dir):
|
|
38
|
+
logger.debug(f"Input file validation passed: {photo_dir}")
|
|
39
|
+
return True
|
|
40
|
+
else:
|
|
41
|
+
logger.debug(f"Input file not found: {photo_dir}")
|
|
42
|
+
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_main_processing(config_manager: ConfigParameterManager) -> int:
|
|
47
|
+
"""Main processing function that gets called by the CLI generator.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
config_manager: Configuration manager with all settings
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Exit code (0 for success, non-zero for error)
|
|
54
|
+
"""
|
|
55
|
+
# Initialize logging system
|
|
56
|
+
logger_manager = initialize_logging(config_manager)
|
|
57
|
+
logger = logger_manager.get_logger("Photo_Composition_Designer.cli")
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
# Log startup information
|
|
61
|
+
logger.info("Starting Photo_Composition_Designer CLI")
|
|
62
|
+
logger_manager.log_config_summary()
|
|
63
|
+
|
|
64
|
+
# Validate configuration
|
|
65
|
+
if not validate_config(config_manager):
|
|
66
|
+
logger.error("Configuration validation failed")
|
|
67
|
+
return 1
|
|
68
|
+
|
|
69
|
+
# Create and run Composition Designer
|
|
70
|
+
logger.info("Starting conversion process")
|
|
71
|
+
composition_designer = CompositionDesigner(config_manager)
|
|
72
|
+
composition_designer.generate_compositions_from_folders()
|
|
73
|
+
logger.info("Conversion process completed")
|
|
74
|
+
|
|
75
|
+
logger.info("CLI processing completed successfully")
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.error(f"Processing failed: {e}")
|
|
80
|
+
logger.debug("Full traceback:", exc_info=True)
|
|
81
|
+
return 1
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main():
|
|
85
|
+
"""Main entry point for the CLI application."""
|
|
86
|
+
# Create the base configuration manager
|
|
87
|
+
config_manager = ConfigParameterManager()
|
|
88
|
+
|
|
89
|
+
# Create CLI generator
|
|
90
|
+
cli_generator = CliGenerator(
|
|
91
|
+
config_manager=config_manager, app_name="Photo_Composition_Designer"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Run the CLI with our main processing function
|
|
95
|
+
return cli_generator.run_cli(
|
|
96
|
+
main_function=run_main_processing,
|
|
97
|
+
description="Process GPX files with various operations like compression, "
|
|
98
|
+
"merging, and POI extraction",
|
|
99
|
+
validator=validate_config,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
import sys
|
|
105
|
+
|
|
106
|
+
sys.exit(main())
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
|
|
4
|
+
from path_handler import get_base_path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Anniversaries:
|
|
8
|
+
def __init__(self, anniversaries_file=None):
|
|
9
|
+
if not anniversaries_file:
|
|
10
|
+
base_path = get_base_path()
|
|
11
|
+
anniversaries_file = base_path / "anniversaries.ini"
|
|
12
|
+
|
|
13
|
+
self.anniversary_dict = defaultdict(str) # Dictionary fΓΌr die Anniversaries
|
|
14
|
+
|
|
15
|
+
if not os.path.exists(anniversaries_file):
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
# Preprocess the config file to remove comments and parse lines
|
|
19
|
+
with open(anniversaries_file, "r", encoding="utf-8") as file:
|
|
20
|
+
category = None
|
|
21
|
+
for line in file:
|
|
22
|
+
# Remove comments and strip whitespace
|
|
23
|
+
line = line.split(";", 1)[0].strip()
|
|
24
|
+
if not line: # Skip empty lines
|
|
25
|
+
continue
|
|
26
|
+
|
|
27
|
+
# Detect category headers
|
|
28
|
+
if line.startswith("[") and line.endswith("]"):
|
|
29
|
+
category = line[1:-1] # Extract category name
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
# Skip invalid lines without a current category
|
|
33
|
+
if not category:
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
# Process valid data lines within the category
|
|
37
|
+
self._process_line(line, category)
|
|
38
|
+
|
|
39
|
+
def _process_line(self, line, category):
|
|
40
|
+
"""
|
|
41
|
+
Processes a single line of data and adds it to the anniversary dictionary.
|
|
42
|
+
"""
|
|
43
|
+
if "=" not in line:
|
|
44
|
+
return # Skip malformed lines
|
|
45
|
+
name, date = map(str.strip, line.split("=", 1))
|
|
46
|
+
|
|
47
|
+
day, month, *year = date.split(".")
|
|
48
|
+
year = int(year[0]) if year and year[0] else None
|
|
49
|
+
|
|
50
|
+
# Define label formatters for each category
|
|
51
|
+
label_formatter = {
|
|
52
|
+
"Birthdays": lambda _name, _year: f"{_name} {str(_year)[-2:]}" if _year else _name,
|
|
53
|
+
"Dates of death": lambda _name, _year: f"{_name} β {str(_year)[-2:]}"
|
|
54
|
+
if _year
|
|
55
|
+
else f"{_name} β",
|
|
56
|
+
"Weddings": lambda _name, _year: f"{_name} β {str(_year)[-2:]}"
|
|
57
|
+
if _year
|
|
58
|
+
else f"{_name} β",
|
|
59
|
+
}.get(category, lambda _name, _year: _name) # Default formatter if category is unknown
|
|
60
|
+
|
|
61
|
+
label = label_formatter(name, year)
|
|
62
|
+
self._add_to_dict(int(day), int(month), label)
|
|
63
|
+
|
|
64
|
+
def _add_to_dict(self, day, month, label):
|
|
65
|
+
"""
|
|
66
|
+
Adds an entry to the dictionary; merges labels in case of conflicts.
|
|
67
|
+
"""
|
|
68
|
+
key = (day, month)
|
|
69
|
+
if key in self.anniversary_dict:
|
|
70
|
+
if label not in self.anniversary_dict[key]:
|
|
71
|
+
self.anniversary_dict[key] += f", {label}"
|
|
72
|
+
else:
|
|
73
|
+
self.anniversary_dict[key] = label
|
|
74
|
+
|
|
75
|
+
def __getitem__(self, key):
|
|
76
|
+
return self.anniversary_dict.get(key)
|
|
77
|
+
|
|
78
|
+
def __setitem__(self, key, value):
|
|
79
|
+
self.anniversary_dict[key] = value
|
|
80
|
+
|
|
81
|
+
def __contains__(self, key):
|
|
82
|
+
return key in self.anniversary_dict
|
|
83
|
+
|
|
84
|
+
def items(self):
|
|
85
|
+
return self.anniversary_dict.items()
|
|
86
|
+
|
|
87
|
+
def __repr__(self):
|
|
88
|
+
return f"Anniversaries({dict(self.anniversary_dict)})"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
annis = Anniversaries()
|
|
93
|
+
print(annis.items())
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
|
|
4
|
+
from path_handler import get_base_path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Locations:
|
|
8
|
+
"""
|
|
9
|
+
This class is used to provide position information if it is not saved in the image.
|
|
10
|
+
This class searches a locations.ini, which has the following structure below.
|
|
11
|
+
|
|
12
|
+
It provides a dict that contains locations as tuple:
|
|
13
|
+
|
|
14
|
+
[EUROPE]
|
|
15
|
+
London = 51.5074, -0.1278
|
|
16
|
+
Bavaria = 48.7904, 11.4979
|
|
17
|
+
|
|
18
|
+
[AMERICA]
|
|
19
|
+
United States = 37.0902, -95.7129
|
|
20
|
+
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, locations_file=None):
|
|
24
|
+
if not locations_file:
|
|
25
|
+
base_path = get_base_path()
|
|
26
|
+
locations_file = base_path / "locations_en.ini"
|
|
27
|
+
|
|
28
|
+
self.locations_dict = defaultdict(tuple) # Dictionary for the locations
|
|
29
|
+
|
|
30
|
+
if not os.path.exists(locations_file):
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
# Preprocess the config file to remove comments and parse lines
|
|
34
|
+
with open(locations_file, "r", encoding="utf-8") as file:
|
|
35
|
+
current_category = None
|
|
36
|
+
for line in file:
|
|
37
|
+
# Remove comments and strip whitespace
|
|
38
|
+
line = line.split(";", 1)[0].strip()
|
|
39
|
+
if not line: # Skip empty lines
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
# Detect category headers
|
|
43
|
+
if line.startswith("[") and line.endswith("]"):
|
|
44
|
+
current_category = line[1:-1]
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
# Process valid data lines within the category
|
|
48
|
+
self._process_line(line, current_category)
|
|
49
|
+
|
|
50
|
+
def _process_line(self, line, category):
|
|
51
|
+
"""
|
|
52
|
+
Processes a single line of data and adds it to the locations dictionary.
|
|
53
|
+
"""
|
|
54
|
+
if "=" not in line:
|
|
55
|
+
return # Skip malformed lines
|
|
56
|
+
city, coordinates = map(str.strip, line.split("=", 1))
|
|
57
|
+
try:
|
|
58
|
+
lat, lon = map(float, coordinates.split(","))
|
|
59
|
+
self._add_to_dict(city, (lat, lon), category)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
print(f'Error processing line "{line}": {e}')
|
|
62
|
+
|
|
63
|
+
def _add_to_dict(self, city: str, coordinates, _):
|
|
64
|
+
"""
|
|
65
|
+
Adds an entry to the dictionary under the given category.
|
|
66
|
+
"""
|
|
67
|
+
self.locations_dict[city.lower()] = coordinates
|
|
68
|
+
|
|
69
|
+
def __getitem__(self, key):
|
|
70
|
+
return self.locations_dict.get(key)
|
|
71
|
+
|
|
72
|
+
def __setitem__(self, key, value):
|
|
73
|
+
self.locations_dict[key] = value
|
|
74
|
+
|
|
75
|
+
def __contains__(self, key):
|
|
76
|
+
return key in self.locations_dict
|
|
77
|
+
|
|
78
|
+
def items(self):
|
|
79
|
+
return self.locations_dict.items()
|
|
80
|
+
|
|
81
|
+
def __repr__(self):
|
|
82
|
+
return f"Locations({dict(self.locations_dict)})"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
locations = Locations()
|
|
87
|
+
print(locations.items())
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from astral import moon
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MoonPhase:
|
|
9
|
+
"""Utility class for computing Unicode moon phase symbols."""
|
|
10
|
+
|
|
11
|
+
DETAILED = False
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def get_moon_phase_symbol_light(d: datetime) -> str:
|
|
15
|
+
"""
|
|
16
|
+
Return the Unicode symbol representing the Moon's illuminated
|
|
17
|
+
phase for the given date.
|
|
18
|
+
|
|
19
|
+
This method uses the actual lunar phase from Astral and maps it
|
|
20
|
+
to a Unicode icon using `get_moon_symbol()`.
|
|
21
|
+
"""
|
|
22
|
+
return MoonPhase.get_moon_symbol(moon.phase(d))
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def get_moon_phase_symbol_dark(d: datetime) -> str:
|
|
26
|
+
"""
|
|
27
|
+
Return the Unicode symbol for the Moon as it would appear in
|
|
28
|
+
an inverted-illumination (dark-mode) scheme. This is helpful
|
|
29
|
+
for bright background.
|
|
30
|
+
|
|
31
|
+
This is computed by shifting the phase forward by 14 (half a
|
|
32
|
+
lunar cycle), effectively flipping the illuminated side.
|
|
33
|
+
"""
|
|
34
|
+
return MoonPhase.get_moon_symbol((moon.phase(d) + 14) % 28)
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def get_moon_symbol(phase: float) -> str:
|
|
38
|
+
"""
|
|
39
|
+
Map an Astral moon-phase value to a Unicode symbol.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
phase : float
|
|
44
|
+
The moon phase value from Astral (0β29.53 approx).
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
str
|
|
49
|
+
A Unicode moon-phase emoji corresponding to the integer
|
|
50
|
+
phase bucket. Only major phases are returned unless
|
|
51
|
+
`DETAILED` is enabled.
|
|
52
|
+
|
|
53
|
+
Notes
|
|
54
|
+
-----
|
|
55
|
+
Recognized phases:
|
|
56
|
+
|
|
57
|
+
0 β π new moon
|
|
58
|
+
4 β π waxing crescent (detailed mode only)
|
|
59
|
+
7 β π first quarter
|
|
60
|
+
10 β π waxing gibbous (detailed mode only)
|
|
61
|
+
14 β π full moon
|
|
62
|
+
18 β π waning gibbous (detailed mode only)
|
|
63
|
+
21 β π last quarter
|
|
64
|
+
25 β π waning crescent (detailed mode only)
|
|
65
|
+
|
|
66
|
+
All other values return the empty string.
|
|
67
|
+
"""
|
|
68
|
+
p = int(phase)
|
|
69
|
+
if p == 0:
|
|
70
|
+
return "π"
|
|
71
|
+
if p == 4 and MoonPhase.DETAILED:
|
|
72
|
+
return "π"
|
|
73
|
+
if p == 7:
|
|
74
|
+
return "π"
|
|
75
|
+
if p == 10 and MoonPhase.DETAILED:
|
|
76
|
+
return "π"
|
|
77
|
+
if p == 14:
|
|
78
|
+
return "π"
|
|
79
|
+
if p == 18 and MoonPhase.DETAILED:
|
|
80
|
+
return "π"
|
|
81
|
+
if p == 21:
|
|
82
|
+
return "π"
|
|
83
|
+
if p == 25 and MoonPhase.DETAILED:
|
|
84
|
+
return "π"
|
|
85
|
+
return ""
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import exifread
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Photo:
|
|
11
|
+
DATE_PATTERN_FULL = re.compile(
|
|
12
|
+
r"(?:(\d{4})[-_]?(\d{2})[-_]?(\d{2})[-_]?(\d{2})[-_]?(\d{2})[-_]?(\d{2}))"
|
|
13
|
+
)
|
|
14
|
+
DATE_PATTERN_NO_TIME = re.compile(r"(?:(\d{4})[-_]?(\d{2})[-_]?(\d{2}))")
|
|
15
|
+
|
|
16
|
+
def __init__(self, file_path: Path, locations=None):
|
|
17
|
+
self.file_path: Path = Path(file_path)
|
|
18
|
+
self._locations: dict[str, tuple[float, float]] = locations
|
|
19
|
+
if not self.file_path.exists():
|
|
20
|
+
raise FileNotFoundError(f"File not found: {self.file_path}")
|
|
21
|
+
|
|
22
|
+
def get_location(self) -> tuple[float, float] | None:
|
|
23
|
+
return self.get_location_from_exif() or self.get_location_from_name()
|
|
24
|
+
|
|
25
|
+
def get_location_from_exif(self) -> tuple[float, float] | None:
|
|
26
|
+
"""Returns the GPS coordinates from EXIF data if available."""
|
|
27
|
+
with open(self.file_path, "rb") as img_file:
|
|
28
|
+
tags = exifread.process_file(img_file, details=False)
|
|
29
|
+
if "GPS GPSLatitude" in tags and "GPS GPSLongitude" in tags:
|
|
30
|
+
lat = self._convert_to_decimal(tags["GPS GPSLatitude"].values)
|
|
31
|
+
lon = self._convert_to_decimal(tags["GPS GPSLongitude"].values)
|
|
32
|
+
if tags.get("GPS GPSLatitudeRef") and tags["GPS GPSLatitudeRef"].values[0] == "S":
|
|
33
|
+
lat = -lat
|
|
34
|
+
if tags.get("GPS GPSLongitudeRef") and tags["GPS GPSLongitudeRef"].values[0] == "W":
|
|
35
|
+
lon = -lon
|
|
36
|
+
return lat, lon
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
def get_location_from_name(self) -> tuple[float, float] | None:
|
|
40
|
+
location = self._locations
|
|
41
|
+
file_name = self.file_path.name.lower()
|
|
42
|
+
|
|
43
|
+
for place in location:
|
|
44
|
+
if re.search(rf"\b{re.escape(place.lower())}\b", file_name):
|
|
45
|
+
return location[place]
|
|
46
|
+
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def get_date(self) -> datetime | None:
|
|
50
|
+
"""Returns the date from EXIF data or filename if available."""
|
|
51
|
+
date = self._extract_date_from_exif()
|
|
52
|
+
if date:
|
|
53
|
+
return date
|
|
54
|
+
return self._extract_date_from_filename()
|
|
55
|
+
|
|
56
|
+
def get_image(self) -> Image.Image | None:
|
|
57
|
+
"""Returns an Image object if the file can be opened."""
|
|
58
|
+
try:
|
|
59
|
+
return Image.open(self.file_path)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
print(f"Error opening image: {e}")
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _convert_to_decimal(dms) -> float:
|
|
66
|
+
"""Converts degrees, minutes, and seconds to decimal degrees."""
|
|
67
|
+
return float(dms[0]) + float(dms[1]) / 60 + float(dms[2]) / 3600
|
|
68
|
+
|
|
69
|
+
def _extract_date_from_exif(self) -> datetime | None:
|
|
70
|
+
"""Reads EXIF date, if available."""
|
|
71
|
+
with open(self.file_path, "rb") as img_file:
|
|
72
|
+
tags = exifread.process_file(img_file, details=False)
|
|
73
|
+
if "EXIF DateTimeOriginal" in tags:
|
|
74
|
+
try:
|
|
75
|
+
date_str = str(tags["EXIF DateTimeOriginal"])
|
|
76
|
+
return datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
|
|
77
|
+
except ValueError:
|
|
78
|
+
pass
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def _extract_date_from_filename(self) -> datetime | None:
|
|
82
|
+
"""Attempts to extract date from file name."""
|
|
83
|
+
match = self.DATE_PATTERN_FULL.search(self.file_path.name)
|
|
84
|
+
if match:
|
|
85
|
+
return datetime(*map(int, match.groups()))
|
|
86
|
+
match = self.DATE_PATTERN_NO_TIME.search(self.file_path.name)
|
|
87
|
+
if match:
|
|
88
|
+
return datetime(*map(int, match.groups()), 12, 0, 0)
|
|
89
|
+
return datetime.max
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_photos_from_dir(
|
|
93
|
+
image_folder: Path, locations: dict[str, tuple[float, float]] = None
|
|
94
|
+
) -> list["Photo"] | None:
|
|
95
|
+
"""Liest alle Bilddateien aus einem Ordner ein und gibt eine Liste von Photo-Objekten zurΓΌck."""
|
|
96
|
+
|
|
97
|
+
folder_path = Path(image_folder)
|
|
98
|
+
|
|
99
|
+
if not folder_path.is_dir():
|
|
100
|
+
raise ValueError(f"Folder '{image_folder}' does not exist.")
|
|
101
|
+
|
|
102
|
+
# Alle Bilddateien sammeln
|
|
103
|
+
image_files = [
|
|
104
|
+
os.path.join(image_folder, file)
|
|
105
|
+
for file in sorted(os.listdir(image_folder))
|
|
106
|
+
if file.lower().endswith((".png", ".jpg", ".jpeg"))
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
if not image_files:
|
|
110
|
+
print(f"No images found in '{image_folder}'.")
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
return [Photo(Path(file), locations) for file in image_files] # Photo-Objekte erstellen
|
|
File without changes
|