641 lines
18 KiB
Python
641 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
|
||
import io
|
||
import json
|
||
import math
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from urllib.parse import urlencode
|
||
from urllib.request import Request, urlopen
|
||
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
|
||
|
||
VERSION = "2026-05-31-infographic-v5-single-location-footer-fix"
|
||
|
||
MAP_WIDTH = 906
|
||
MAP_HEIGHT = 510
|
||
TILE_SIZE = 256
|
||
MIN_ZOOM = 3
|
||
MAX_ZOOM = 13
|
||
CLUSTER_RADIUS_KM = 2.0
|
||
FOOTER_HEIGHT = 118
|
||
FOOTER_MARGIN = 14
|
||
USER_AGENT = "gpsmap-infographic-script/1.0"
|
||
|
||
PHOTO_EXTENSIONS = {
|
||
".jpg", ".jpeg", ".png", ".heic", ".heif", ".webp", ".tif", ".tiff"
|
||
}
|
||
VIDEO_EXTENSIONS = {
|
||
".mp4", ".mov", ".m4v", ".avi", ".mkv", ".3gp"
|
||
}
|
||
|
||
FONT_BOLD = "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf"
|
||
FONT_REGULAR = "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf"
|
||
|
||
|
||
@dataclass
|
||
class MediaItem:
|
||
path: Path
|
||
lat: float
|
||
lon: float
|
||
kind: str
|
||
taken_at: datetime | None = None
|
||
|
||
|
||
@dataclass
|
||
class Cluster:
|
||
items: list[MediaItem] = field(default_factory=list)
|
||
|
||
@property
|
||
def lat(self):
|
||
return sum(i.lat for i in self.items) / len(self.items)
|
||
|
||
@property
|
||
def lon(self):
|
||
return sum(i.lon for i in self.items) / len(self.items)
|
||
|
||
@property
|
||
def photos(self):
|
||
return sum(1 for i in self.items if i.kind == "photo")
|
||
|
||
@property
|
||
def videos(self):
|
||
return sum(1 for i in self.items if i.kind == "video")
|
||
|
||
@property
|
||
def first_sort_key(self):
|
||
return min(sort_key(i) for i in self.items)
|
||
|
||
|
||
def load_font(path, size):
|
||
try:
|
||
return ImageFont.truetype(path, size)
|
||
except Exception:
|
||
return ImageFont.load_default()
|
||
|
||
|
||
def parse_exif_date(value):
|
||
if not value:
|
||
return None
|
||
|
||
# Common exiftool format: 2025:07:19 16:47:12
|
||
for fmt in (
|
||
"%Y:%m:%d %H:%M:%S",
|
||
"%Y:%m:%d %H:%M:%S%z",
|
||
"%Y-%m-%d %H:%M:%S",
|
||
"%Y-%m-%dT%H:%M:%S",
|
||
"%Y-%m-%dT%H:%M:%S%z",
|
||
):
|
||
try:
|
||
return datetime.strptime(str(value).split(".")[0], fmt)
|
||
except Exception:
|
||
pass
|
||
|
||
return None
|
||
|
||
|
||
def media_kind(path):
|
||
ext = path.suffix.lower()
|
||
if ext in PHOTO_EXTENSIONS:
|
||
return "photo"
|
||
if ext in VIDEO_EXTENSIONS:
|
||
return "video"
|
||
return "other"
|
||
|
||
|
||
def get_metadata(file_path):
|
||
try:
|
||
result = subprocess.run(
|
||
[
|
||
"exiftool",
|
||
"-json",
|
||
"-n",
|
||
"-GPSLatitude",
|
||
"-GPSLongitude",
|
||
"-DateTimeOriginal",
|
||
"-CreateDate",
|
||
"-MediaCreateDate",
|
||
"-TrackCreateDate",
|
||
str(file_path),
|
||
],
|
||
capture_output=True,
|
||
text=True,
|
||
check=True,
|
||
)
|
||
|
||
data = json.loads(result.stdout)[0]
|
||
lat = data.get("GPSLatitude")
|
||
lon = data.get("GPSLongitude")
|
||
|
||
if lat is None or lon is None:
|
||
return None
|
||
|
||
taken_at = None
|
||
for key in ("DateTimeOriginal", "CreateDate", "MediaCreateDate", "TrackCreateDate"):
|
||
taken_at = parse_exif_date(data.get(key))
|
||
if taken_at:
|
||
break
|
||
|
||
return float(lat), float(lon), taken_at
|
||
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def sort_key(item):
|
||
# EXIF date first; filename fallback keeps your 001_, 003_, 005_ order stable.
|
||
return (item.taken_at or datetime.max, item.path.name)
|
||
|
||
|
||
def haversine_km(lat1, lon1, lat2, lon2):
|
||
r = 6371.0
|
||
p1 = math.radians(lat1)
|
||
p2 = math.radians(lat2)
|
||
dp = math.radians(lat2 - lat1)
|
||
dl = math.radians(lon2 - lon1)
|
||
|
||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||
return 2 * r * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||
|
||
|
||
def collect_media(media_dir):
|
||
items = []
|
||
|
||
for file_path in media_dir.rglob("*"):
|
||
if not file_path.is_file():
|
||
continue
|
||
|
||
kind = media_kind(file_path)
|
||
if kind == "other":
|
||
continue
|
||
|
||
meta = get_metadata(file_path)
|
||
if not meta:
|
||
continue
|
||
|
||
lat, lon, taken_at = meta
|
||
item = MediaItem(file_path, lat, lon, kind, taken_at)
|
||
items.append(item)
|
||
print(f"GPS: {file_path.name} -> ({lat}, {lon}) [{kind}]")
|
||
|
||
return sorted(items, key=sort_key)
|
||
|
||
|
||
def make_clusters(items):
|
||
clusters = []
|
||
|
||
for item in items:
|
||
best_cluster = None
|
||
best_distance = None
|
||
|
||
for cluster in clusters:
|
||
dist = haversine_km(item.lat, item.lon, cluster.lat, cluster.lon)
|
||
if dist <= CLUSTER_RADIUS_KM and (best_distance is None or dist < best_distance):
|
||
best_cluster = cluster
|
||
best_distance = dist
|
||
|
||
if best_cluster:
|
||
best_cluster.items.append(item)
|
||
else:
|
||
clusters.append(Cluster([item]))
|
||
|
||
return sorted(clusters, key=lambda c: c.first_sort_key)
|
||
|
||
|
||
def country_language(country_code):
|
||
return {
|
||
"dk": "da", "de": "de", "at": "de", "ch": "de", "hr": "hr",
|
||
"se": "sv", "no": "no", "fi": "fi", "pl": "pl", "cz": "cs",
|
||
"sk": "sk", "fr": "fr", "it": "it", "es": "es", "pt": "pt",
|
||
"nl": "nl", "be": "nl", "ba": "bs", "rs": "sr", "si": "sl",
|
||
"hu": "hu", "gb": "en", "uk": "en", "ie": "en",
|
||
}.get(country_code.lower(), "en")
|
||
|
||
|
||
def nominatim_reverse(lat, lon, language):
|
||
params = urlencode({
|
||
"format": "jsonv2",
|
||
"lat": lat,
|
||
"lon": lon,
|
||
"addressdetails": 1,
|
||
"accept-language": language,
|
||
})
|
||
url = f"https://nominatim.openstreetmap.org/reverse?{params}"
|
||
request = Request(url, headers={"User-Agent": USER_AGENT})
|
||
with urlopen(request, timeout=20) as response:
|
||
return json.loads(response.read().decode("utf-8"))
|
||
|
||
|
||
def reverse_geocode_parts(lat, lon):
|
||
try:
|
||
first = nominatim_reverse(lat, lon, "en")
|
||
country_code = first.get("address", {}).get("country_code", "en")
|
||
language = country_language(country_code)
|
||
|
||
time.sleep(1)
|
||
|
||
data = nominatim_reverse(lat, lon, language)
|
||
address = data.get("address", {})
|
||
|
||
city = (
|
||
address.get("city")
|
||
or address.get("town")
|
||
or address.get("village")
|
||
or address.get("island")
|
||
or address.get("suburb")
|
||
or address.get("hamlet")
|
||
or address.get("municipality")
|
||
or ""
|
||
)
|
||
region = (
|
||
address.get("state_district")
|
||
or address.get("region")
|
||
or address.get("state")
|
||
or address.get("county")
|
||
or ""
|
||
)
|
||
country = address.get("country", "")
|
||
|
||
return {"city": city, "region": region, "country": country.upper() if country else ""}
|
||
except Exception:
|
||
return {"city": "", "region": "", "country": ""}
|
||
|
||
|
||
def clean_place_name(value):
|
||
value = (value or "").strip()
|
||
|
||
# Shorter Danish names look much better in the footer.
|
||
replacements = {
|
||
"Region Syddanmark": "Syddanmark",
|
||
"Region Midtjylland": "Midtjylland",
|
||
"Region Nordjylland": "Nordjylland",
|
||
"Region Sjælland": "Sjælland",
|
||
"Region Hovedstaden": "Hovedstaden",
|
||
}
|
||
|
||
return replacements.get(value, value)
|
||
|
||
|
||
def make_map_label(items, clusters=None):
|
||
lats = [i.lat for i in items]
|
||
lons = [i.lon for i in items]
|
||
|
||
# If this is a one-location map, use the center point and keep the label short.
|
||
# Example: Rømø DANMARK instead of Tønder Kommune Region Syddanmark DANMARK.
|
||
if clusters is not None and len(clusters) == 1:
|
||
lat = sum(lats) / len(lats)
|
||
lon = sum(lons) / len(lons)
|
||
info = reverse_geocode_parts(lat, lon)
|
||
city = clean_place_name(info.get("city", ""))
|
||
region = clean_place_name(info.get("region", ""))
|
||
country = clean_place_name(info.get("country", ""))
|
||
|
||
if city and country:
|
||
return f"{city} {country}"
|
||
if region and country:
|
||
return f"{region} {country}"
|
||
return country
|
||
|
||
sample_points = [
|
||
(min(lats), min(lons)),
|
||
(max(lats), max(lons)),
|
||
(sum(lats) / len(lats), sum(lons) / len(lons)),
|
||
]
|
||
|
||
cities, regions, countries = set(), set(), set()
|
||
|
||
for lat, lon in sample_points:
|
||
info = reverse_geocode_parts(lat, lon)
|
||
city = clean_place_name(info.get("city", ""))
|
||
region = clean_place_name(info.get("region", ""))
|
||
country = clean_place_name(info.get("country", ""))
|
||
|
||
if city:
|
||
cities.add(city)
|
||
if region:
|
||
regions.add(region)
|
||
if country:
|
||
countries.add(country)
|
||
|
||
if len(countries) > 1:
|
||
return " • ".join(sorted(countries))
|
||
|
||
country = next(iter(countries), "")
|
||
|
||
if len(regions) > 1:
|
||
return country
|
||
|
||
region = next(iter(regions), "")
|
||
|
||
if len(cities) > 1:
|
||
return f"{region} {country}".strip()
|
||
|
||
city = next(iter(cities), "")
|
||
|
||
if city and region and country:
|
||
return f"{city}, {region} {country}"
|
||
if region and country:
|
||
return f"{region} {country}"
|
||
return country
|
||
|
||
def format_date_range(items):
|
||
dates = [i.taken_at for i in items if i.taken_at]
|
||
if not dates:
|
||
return ""
|
||
|
||
start = min(dates)
|
||
end = max(dates)
|
||
|
||
if start.date() == end.date():
|
||
return start.strftime("%-d %b %Y")
|
||
if start.year == end.year and start.month == end.month:
|
||
return f"{start.strftime('%-d')} – {end.strftime('%-d %b %Y')}"
|
||
if start.year == end.year:
|
||
return f"{start.strftime('%-d %b')} – {end.strftime('%-d %b %Y')}"
|
||
return f"{start.strftime('%-d %b %Y')} – {end.strftime('%-d %b %Y')}"
|
||
|
||
|
||
def latlon_to_pixel(lat, lon, zoom):
|
||
sin_lat = math.sin(math.radians(lat))
|
||
scale = TILE_SIZE * 2**zoom
|
||
x = (lon + 180.0) / 360.0 * scale
|
||
y = (0.5 - math.log((1 + sin_lat) / (1 - sin_lat)) / (4 * math.pi)) * scale
|
||
return x, y
|
||
|
||
|
||
def choose_zoom(points, width, height):
|
||
pad_x = 70
|
||
pad_top = 45
|
||
pad_bottom = FOOTER_HEIGHT + FOOTER_MARGIN
|
||
usable_width = width - 2 * pad_x
|
||
usable_height = height - pad_top - pad_bottom
|
||
|
||
if len(points) <= 1:
|
||
return 11
|
||
|
||
for zoom in range(MAX_ZOOM, MIN_ZOOM - 1, -1):
|
||
pixels = [latlon_to_pixel(lat, lon, zoom) for lat, lon in points]
|
||
xs = [p[0] for p in pixels]
|
||
ys = [p[1] for p in pixels]
|
||
|
||
if max(xs) - min(xs) <= usable_width and max(ys) - min(ys) <= usable_height:
|
||
return zoom
|
||
|
||
return MIN_ZOOM
|
||
|
||
def map_view(points, zoom):
|
||
pixels = [latlon_to_pixel(lat, lon, zoom) for lat, lon in points]
|
||
xs = [p[0] for p in pixels]
|
||
ys = [p[1] for p in pixels]
|
||
|
||
center_x = (min(xs) + max(xs)) / 2
|
||
center_y = (min(ys) + max(ys)) / 2
|
||
|
||
# Keep the route/markers in the upper map area and reserve the lower footer
|
||
# for date, statistics and location. This prevents single-location maps from
|
||
# having the marker collide with the footer.
|
||
usable_center_x = MAP_WIDTH / 2
|
||
usable_center_y = (MAP_HEIGHT - FOOTER_HEIGHT) / 2
|
||
|
||
top_left_x = center_x - usable_center_x
|
||
top_left_y = center_y - usable_center_y
|
||
|
||
return top_left_x, top_left_y
|
||
|
||
def download_tile(x, y, z):
|
||
url = f"https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||
request = Request(url, headers={"User-Agent": USER_AGENT})
|
||
with urlopen(request, timeout=20) as response:
|
||
return Image.open(io.BytesIO(response.read())).convert("RGB")
|
||
|
||
|
||
def render_tiles(top_left_x, top_left_y, zoom):
|
||
first_tile_x = int(top_left_x // TILE_SIZE)
|
||
first_tile_y = int(top_left_y // TILE_SIZE)
|
||
last_tile_x = int((top_left_x + MAP_WIDTH) // TILE_SIZE)
|
||
last_tile_y = int((top_left_y + MAP_HEIGHT) // TILE_SIZE)
|
||
|
||
image = Image.new("RGB", (MAP_WIDTH, MAP_HEIGHT), "white")
|
||
max_tile = 2**zoom
|
||
|
||
for tile_x in range(first_tile_x, last_tile_x + 1):
|
||
for tile_y in range(first_tile_y, last_tile_y + 1):
|
||
wrapped_x = tile_x % max_tile
|
||
if tile_y < 0 or tile_y >= max_tile:
|
||
continue
|
||
|
||
try:
|
||
tile = download_tile(wrapped_x, tile_y, zoom)
|
||
time.sleep(0.1)
|
||
except Exception as e:
|
||
print(f"Failed tile {zoom}/{wrapped_x}/{tile_y}: {e}")
|
||
continue
|
||
|
||
paste_x = int(tile_x * TILE_SIZE - top_left_x)
|
||
paste_y = int(tile_y * TILE_SIZE - top_left_y)
|
||
image.paste(tile, (paste_x, paste_y))
|
||
|
||
return image
|
||
|
||
|
||
def screen_xy(lat, lon, zoom, top_left_x, top_left_y):
|
||
px, py = latlon_to_pixel(lat, lon, zoom)
|
||
return int(px - top_left_x), int(py - top_left_y)
|
||
|
||
|
||
def draw_arrow(draw, x1, y1, x2, y2, fill):
|
||
angle = math.atan2(y2 - y1, x2 - x1)
|
||
size = 13
|
||
cx = x1 + (x2 - x1) * 0.62
|
||
cy = y1 + (y2 - y1) * 0.62
|
||
|
||
p1 = (cx + math.cos(angle) * size, cy + math.sin(angle) * size)
|
||
p2 = (cx + math.cos(angle + 2.55) * size, cy + math.sin(angle + 2.55) * size)
|
||
p3 = (cx + math.cos(angle - 2.55) * size, cy + math.sin(angle - 2.55) * size)
|
||
|
||
draw.polygon([p1, p2, p3], fill=fill, outline="white")
|
||
|
||
|
||
def draw_route(draw, route_points):
|
||
if len(route_points) < 2:
|
||
return
|
||
|
||
# White underlay makes the route visible on busy map tiles.
|
||
draw.line(route_points, fill="white", width=7, joint="curve")
|
||
draw.line(route_points, fill="red", width=4, joint="curve")
|
||
|
||
for (x1, y1), (x2, y2) in zip(route_points, route_points[1:]):
|
||
if math.hypot(x2 - x1, y2 - y1) > 35:
|
||
draw_arrow(draw, x1, y1, x2, y2, "red")
|
||
|
||
|
||
def marker_text(cluster):
|
||
parts = []
|
||
if cluster.photos:
|
||
parts.append(f"{cluster.photos} photo" + ("s" if cluster.photos != 1 else ""))
|
||
if cluster.videos:
|
||
parts.append(f"{cluster.videos} video" + ("s" if cluster.videos != 1 else ""))
|
||
return parts
|
||
|
||
|
||
def draw_cluster_marker(draw, x, y, cluster):
|
||
font = load_font(FONT_BOLD, 22)
|
||
total = cluster.photos + cluster.videos
|
||
|
||
text = str(total)
|
||
bbox = draw.textbbox((0, 0), text, font=font)
|
||
tw = bbox[2] - bbox[0]
|
||
th = bbox[3] - bbox[1]
|
||
|
||
# Smaller marker, with the number truly centered in the circle.
|
||
r = max(14, int(max(tw, th) / 2) + 8)
|
||
|
||
draw.ellipse(
|
||
(x - r, y - r, x + r, y + r),
|
||
fill="white",
|
||
outline="red",
|
||
width=2,
|
||
)
|
||
|
||
text_x = x - (bbox[0] + bbox[2]) / 2
|
||
text_y = y - (bbox[1] + bbox[3]) / 2
|
||
|
||
draw.text(
|
||
(text_x, text_y),
|
||
text,
|
||
font=font,
|
||
fill="black",
|
||
)
|
||
|
||
|
||
def plural(n, singular, plural_word=None):
|
||
if n == 1:
|
||
return f"{n} {singular}"
|
||
return f"{n} {plural_word or singular + 's'}"
|
||
|
||
|
||
def route_distance_km(clusters):
|
||
total = 0.0
|
||
for a, b in zip(clusters, clusters[1:]):
|
||
total += haversine_km(a.lat, a.lon, b.lat, b.lon)
|
||
return total
|
||
|
||
|
||
def draw_centered_text(draw, y, text, font, fill="black"):
|
||
bbox = draw.textbbox((0, 0), text, font=font)
|
||
w = bbox[2] - bbox[0]
|
||
draw.text(((MAP_WIDTH - w) / 2, y), text, font=font, fill=fill)
|
||
|
||
|
||
def fit_font(draw, text, font_path, start_size, max_width, min_size=12):
|
||
for size in range(start_size, min_size - 1, -1):
|
||
font = load_font(font_path, size)
|
||
bbox = draw.textbbox((0, 0), text, font=font)
|
||
if bbox[2] - bbox[0] <= max_width:
|
||
return font
|
||
return load_font(font_path, min_size)
|
||
|
||
|
||
def draw_bottom_title(draw, label, stats, date_text=""):
|
||
max_text_width = MAP_WIDTH - 28
|
||
|
||
title_font = fit_font(draw, label, FONT_BOLD, 26, max_text_width, min_size=20)
|
||
stats_font = fit_font(draw, stats, FONT_REGULAR, 26, max_text_width, min_size=16)
|
||
date_font = fit_font(draw, date_text, FONT_REGULAR, 22, max_text_width, min_size=14) if date_text else None
|
||
|
||
title_bbox = draw.textbbox((0, 0), label, font=title_font)
|
||
stats_bbox = draw.textbbox((0, 0), stats, font=stats_font)
|
||
date_bbox = draw.textbbox((0, 0), date_text, font=date_font) if date_text else (0, 0, 0, 0)
|
||
|
||
title_h = title_bbox[3] - title_bbox[1]
|
||
stats_h = stats_bbox[3] - stats_bbox[1]
|
||
date_h = date_bbox[3] - date_bbox[1] if date_text else 0
|
||
|
||
gap = 3
|
||
bottom_margin = 14
|
||
|
||
y_title = MAP_HEIGHT - title_h - bottom_margin
|
||
y_stats = y_title - stats_h - gap
|
||
y_date = y_stats - date_h - gap if date_text else None
|
||
|
||
if date_text:
|
||
draw_centered_text(draw, y_date, date_text, date_font, fill=(40, 40, 40))
|
||
draw_centered_text(draw, y_stats, stats, stats_font, fill=(30, 30, 30))
|
||
draw_centered_text(draw, y_title, label, title_font, fill="black")
|
||
|
||
def main():
|
||
print(f"gpsmap version: {VERSION}")
|
||
|
||
if len(sys.argv) != 3:
|
||
print(f"Usage: {sys.argv[0]} <media_directory> <output_directory>")
|
||
sys.exit(1)
|
||
|
||
media_dir = Path(sys.argv[1])
|
||
output_arg = Path(sys.argv[2])
|
||
|
||
if not media_dir.is_dir():
|
||
print(f"Media directory not found: {media_dir}")
|
||
sys.exit(1)
|
||
|
||
if output_arg.suffix == ".png":
|
||
output_png = output_arg
|
||
output_png.parent.mkdir(parents=True, exist_ok=True)
|
||
else:
|
||
output_arg.mkdir(parents=True, exist_ok=True)
|
||
output_png = output_arg / f"{media_dir.resolve().name}.png"
|
||
|
||
items = collect_media(media_dir)
|
||
if not items:
|
||
print("No GPS coordinates found.")
|
||
sys.exit(2)
|
||
|
||
clusters = make_clusters(items)
|
||
points = [(c.lat, c.lon) for c in clusters]
|
||
|
||
zoom = choose_zoom(points, MAP_WIDTH, MAP_HEIGHT)
|
||
top_left_x, top_left_y = map_view(points, zoom)
|
||
|
||
label = make_map_label(items, clusters)
|
||
photo_count = sum(1 for i in items if i.kind == "photo")
|
||
video_count = sum(1 for i in items if i.kind == "video")
|
||
distance = round(route_distance_km(clusters))
|
||
stats_parts = [plural(photo_count, "photo")]
|
||
if video_count:
|
||
stats_parts.append(plural(video_count, "video"))
|
||
if len(clusters) > 1 and distance > 0:
|
||
stats_parts.append(f"{distance} km route")
|
||
stats = " • ".join(stats_parts)
|
||
date_text = format_date_range(items)
|
||
print(f"Map label: {label}")
|
||
if date_text:
|
||
print(f"Date: {date_text}")
|
||
print(f"Stats: {stats}")
|
||
|
||
image = render_tiles(top_left_x, top_left_y, zoom)
|
||
draw = ImageDraw.Draw(image)
|
||
|
||
route_points = [screen_xy(c.lat, c.lon, zoom, top_left_x, top_left_y) for c in clusters]
|
||
draw_route(draw, route_points)
|
||
|
||
for cluster, (x, y) in zip(clusters, route_points):
|
||
draw_cluster_marker(draw, x, y, cluster)
|
||
|
||
draw_bottom_title(draw, label, stats, date_text)
|
||
|
||
image.save(output_png)
|
||
|
||
print()
|
||
print(f"Created: {output_png}")
|
||
print(f"Photos: {photo_count}")
|
||
print(f"Videos: {video_count}")
|
||
print(f"Locations: {len(clusters)}")
|
||
print(f"Zoom: {zoom}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|