diff --git a/appdata.py b/appdata.py index a03a7e1..f9b8d05 100644 --- a/appdata.py +++ b/appdata.py @@ -7,6 +7,7 @@ import os from web import WebInterface from tracker import Tracker from navtex import NAVTEX +from mapservice import MapService class AppData(): @@ -22,6 +23,7 @@ class AppData(): else: self.web = None self.track = Tracker(logger, cfg) + self.mapsrv = MapService(logger, cfg) self.frontend = None self.bv_lat = None self.bv_lon = None diff --git a/mapservice.py b/mapservice.py new file mode 100644 index 0000000..7ab3095 --- /dev/null +++ b/mapservice.py @@ -0,0 +1,180 @@ +""" +Map access inspired by Norbert Walter + +""" + +import os +import math +import http.client +import ssl +from io import BytesIO +from PIL import Image, ImageDraw +import cairo + +class MapService(): + + def __init__(self, logger, cfg): + self.cachepath = os.path.join(cfg['histpath'], "tilecache") + self.lat = 53.56938345759218 + self.lon = 9.679658234303275 + self.dither_type = Image.FLOYDSTEINBERG + self.width = 260 + self.height = 260 + self.zoom_level = 15 + self.debug = False + + def set_output_size(self, width, height): + self.width = width + self.height = height + + def web_get_tile(self, domain, path): + ssl_context = ssl.create_default_context() + conn = http.client.HTTPSConnection(domain, 443, context=ssl_context) + headers = { + #"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:92.0) Gecko/20100101 Firefox/92.0" + "User-Agent": "OBP/1.0 (X11; Linux x86_64; rv:0.1) OBP60v/0.1" + } + data = None + try: + conn.request("GET", path, headers=headers) + response = conn.getresponse() + if response.status == 200: + data = response.read() + else: + print(f"Error: {response.status}") + except http.client.HTTPException as e: + print(f"HTTP error occurred: {e}") + except ssl.SSLError as ssl_error: + print(f"SSL error occurred: {ssl_error}") + finally: + conn.close() + return data + + def latlon_to_xyz(self, lat, lon, zoom): + """ + Converts geographic coordinates (Lat, Lon) to X, Y coordinates for the tiling system + and returns the pixel offset of the exact position in the tile. + """ + # Calculate the X tile coordinate + x_tile = (lon + 180.0) / 360.0 * (2 ** zoom) + + # Calculate the Y tile coordinate + lat_rad = math.radians(lat) + y_tile = (1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * (2 ** zoom) + + # Integer part is the tile coordinates + x = int(x_tile) + y = int(y_tile) + + # Decimal part determines the offset within the tile + x_offset = int((x_tile - x) * 256) # Each tile is 256x256 pixels + y_offset = int((y_tile - y) * 256) + + return x, y, x_offset, y_offset + + def fetch_osm_tile(self, x, y, zoom): + + tile_key = f"{zoom}/{x}/{y}.png" + cache_dir = os.path.join(self.cachepath, str(zoom), str(x)) + + os.makedirs(cache_dir, exist_ok=True) # Create the directory if it doesn't exist + tile_path = os.path.join(cache_dir, f"{y}.png") + + # Check if the tile exists in the disk cache + if os.path.exists(tile_path): + #print(f"Tile {x}, {y} loaded from disk cache.") + with open(tile_path, 'rb') as f: + tile_data = f.read() + #ram_cache.set(cache_key, tile_data) # Load into RAM cache + return Image.open(tile_path) + + data = self.web_get_tile("freenauticalchart.net", f"/qmap-de/{zoom}/{x}/{y}.png") + if data: + tile = Image.open(BytesIO(data)) + tile.save(tile_path) + return tile + else: + return Image.new('RGB', (256, 256), (200, 200, 200)) # Fallback image + + def draw_cross(self, draw, x_offset, y_offset): + line_length = 10 # Length of cross lines + line_color = (255, 0, 0) # Red + draw.line((x_offset - line_length, y_offset, x_offset + line_length, y_offset), fill=line_color, width=2) + draw.line((x_offset, y_offset - line_length, x_offset, y_offset + line_length), fill=line_color, width=2) + + def draw_tile_borders(self, draw, tile_x, tile_y): + """ + Draws a black line around each tile with a pixel width. + """ + top_left_x = tile_x * 256 + top_left_y = tile_y * 256 + bottom_right_x = top_left_x + 256 + bottom_right_y = top_left_y + 256 + draw.rectangle((top_left_x, top_left_y, bottom_right_x - 1, bottom_right_y - 1), outline="black", width=1) + + def stitch_tiles(self, lat, lon, zoom, debug=False): + """ + Loads the required tiles and stitches them into one image, + and then crop that image so that lat/lon ist centered + """ + # Convert geo-coordinates to tile coordinates and offset + x_tile, y_tile, x_offset, y_offset = self.latlon_to_xyz(lat, lon, zoom) + + # No rotation, north up: calculation of needed tiles + num_tiles_x = int(self.width // 256) + 2 + num_tiles_y = int(self.height // 256) + 2 + central_tile_x = num_tiles_x // 2 + central_tile_y = num_tiles_y // 2 + center_x = central_tile_x * 256 + x_offset + center_y = central_tile_y * 256 + y_offset + + # Create an empty image for the final mosaic + combined_image = Image.new('RGB', (num_tiles_x * 256, num_tiles_y * 256)) + draw = ImageDraw.Draw(combined_image) + + # Download and stitch the tiles + x_tile -= num_tiles_x // 2 + y_tile -= num_tiles_y // 2 + for i in range(num_tiles_x): + for j in range(num_tiles_y): + tile = self.fetch_osm_tile(x_tile + i, y_tile + j, zoom) + combined_image.paste(tile, (i * 256, j * 256)) + if debug == True: + # Draw the black line around each tile + self.draw_tile_borders(draw, i, j) + if debug: + # Draw a cross on the central tile at the offset position + self.draw_cross(draw, center_x, center_y) + + # Determine the crop area + new_left = int(center_x - self.width // 2) + new_top = int(center_y - self.height // 2) + new_right = new_left + self.width + new_bottom = new_top + self.height + + return combined_image.crop((new_left, new_top, new_right, new_bottom)) + + def get_round_bwmap(self, lat, lon, zoom, debug=False): + # Rundes s/w Bild erzeugen z.B. für Ankerkreis + image = self.stitch_tiles(lat, lon, zoom, debug) + mask = Image.new('1', (self.width, self.height), 0) + draw = ImageDraw.Draw(mask) + radius = min(self.width, self.height) // 2 + center_x = self.width // 2 + center_y = self.height // 2 + bounding_box = (center_x - radius, center_y - radius, center_x + radius, center_y + radius) + draw.ellipse(bounding_box, fill=255) + image.putalpha(mask) + roundimage = Image.new('RGB', (self.width, self.height), (255, 255, 255)) + roundimage.paste(image, mask=image.split()[3]) + return roundimage.convert('1', dither=self.dither_type) + + def get_round_bwmap_cairo(self, lat, lon, zoom, bgcolor=(255,255,255), debug=False): + image = self.get_round_bwmap(lat, lon, zoom, debug).convert("RGBA") + r, g, b, a = image.split() + r = r.point(lambda p: bgcolor[0] if p == 255 else p) + g = g.point(lambda p: bgcolor[1] if p == 255 else p) + b = b.point(lambda p: bgcolor[2] if p == 255 else p) + image = Image.merge("RGBA", (r, g, b, a)) + data = bytearray(image.tobytes()) + return cairo.ImageSurface.create_for_data(data, cairo.FORMAT_ARGB32, self.width, self.height) diff --git a/pages/anchor.py b/pages/anchor.py index 1c53076..4ba01de 100644 --- a/pages/anchor.py +++ b/pages/anchor.py @@ -33,6 +33,7 @@ import math import time from cfgmenu import Menu from .page import Page +from PIL import Image class Anchor(Page): @@ -44,7 +45,7 @@ class Anchor(Page): self.buttonlabel[5] = '' # ALARM erst möglich wenn der Anker unten ist self.mode = 'N' # (N)ormal, (C)onfiguration - self.scale = 50 # Radius of display circle in meter + self.scale = 425 # Radius of display circle in meter self._bd = boatdata @@ -68,6 +69,10 @@ class Anchor(Page): self.alarm = False # Alarm ist ausgelöst und aktiv self.wind_angle = -1 + # Seekarte, mit Hintergrund wie das Display + self.app.mapsrv.set_output_size(260, 260) + self.bgimage = self.app.mapsrv.get_round_bwmap_cairo(53.56938345759218, 9.679658234303275, 15, (220, 220, 220)) + # Menüsteuerung für Konfiguration self._menu = Menu("Options", 20, 80) self._menu.setItemDimension(120, 20) @@ -179,6 +184,12 @@ class Anchor(Page): # self.anchor_lat = + # Seekarte als Hintergrundbild + ctx.save() + ctx.set_source_surface(self.bgimage, (400 - 260) // 2, 20) + ctx.paint() + ctx.restore() + # Name ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) ctx.set_font_size(20)