First version of Karten App
This commit is contained in:
Binary file not shown.
Executable
+619
@@ -0,0 +1,619 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare DTK25 Blätter into an Übersicht and optional Kacheln.
|
||||
|
||||
Reads each Blatt's GeoTIFF tags with the standard library (no shell, no
|
||||
GDAL). Every Blatt must be 9600 by 9600 pixels at 1.25 m in EPSG:25832,
|
||||
spanning 12 km by 12 km, and must have a matching world file. The Übersicht
|
||||
lists every Blatt. Kacheln are written only when ``--tiles`` is passed, for
|
||||
one Blatt unless ``--all`` is given. ``--tiles public`` writes
|
||||
``public/kacheln``.
|
||||
|
||||
``gdalwarp`` and ``gdal_translate`` are used when both are on PATH. Otherwise
|
||||
Pillow opens the LZW palette TIFF, keeps the native pixels at 1.25 m, and
|
||||
resizes each coarser step.
|
||||
|
||||
Resolutions are metres per pixel, finest first. Each coarser step doubles
|
||||
the ground resolution, from the native sheet pixel up to 160 m. The phone's
|
||||
furthest ground span is app configuration and is not read here; changing
|
||||
that span does not require a new tile set.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
TILE_SIZE_PX = 256
|
||||
NATIVE_RESOLUTION = 1.25
|
||||
EXPECTED_PIXELS = 9600
|
||||
EXPECTED_SPAN_M = 12_000
|
||||
EXPECTED_EPSG = 25832
|
||||
SCALE_TOLERANCE = 1e-6
|
||||
SPAN_TOLERANCE_M = 0.01
|
||||
PIXEL_TOLERANCE = 1e-4
|
||||
|
||||
RESOLUTIONS = [1.25, 2.5, 5, 10, 20, 40, 80, 160]
|
||||
|
||||
TAG_WIDTH = 256
|
||||
TAG_HEIGHT = 257
|
||||
TAG_PIXEL_SCALE = 33550
|
||||
TAG_TIEPOINT = 33922
|
||||
TAG_GEOKEY = 34735
|
||||
GEOKEY_PROJECTED_CS = 3072
|
||||
|
||||
TYPE_SIZE = {
|
||||
1: 1,
|
||||
2: 1,
|
||||
3: 2,
|
||||
4: 4,
|
||||
5: 8,
|
||||
6: 1,
|
||||
7: 1,
|
||||
8: 2,
|
||||
9: 4,
|
||||
10: 8,
|
||||
11: 4,
|
||||
12: 8,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Blatt:
|
||||
path: Path
|
||||
min_e: float
|
||||
min_n: float
|
||||
max_e: float
|
||||
max_n: float
|
||||
|
||||
def as_json(self) -> dict[str, int | float]:
|
||||
return {
|
||||
"minE": json_number(self.min_e),
|
||||
"minN": json_number(self.min_n),
|
||||
"maxE": json_number(self.max_e),
|
||||
"maxN": json_number(self.max_n),
|
||||
}
|
||||
|
||||
|
||||
def json_number(value: float) -> int | float:
|
||||
rounded = round(value)
|
||||
if abs(value - rounded) <= SCALE_TOLERANCE:
|
||||
return int(rounded)
|
||||
return round(value, 6)
|
||||
|
||||
|
||||
def snap(value: float, what: str) -> int:
|
||||
nearest = round(value)
|
||||
if abs(value - nearest) > PIXEL_TOLERANCE:
|
||||
raise SystemExit(f"{what}: {value} is not an integer pixel")
|
||||
return nearest
|
||||
|
||||
|
||||
def floor_index(value: float, size: float) -> int:
|
||||
quotient = value / size
|
||||
nearest = round(quotient)
|
||||
if abs(quotient - nearest) <= SCALE_TOLERANCE:
|
||||
return nearest
|
||||
return math.floor(quotient)
|
||||
|
||||
|
||||
def ceil_index(value: float, size: float) -> int:
|
||||
quotient = value / size
|
||||
nearest = round(quotient)
|
||||
if abs(quotient - nearest) <= SCALE_TOLERANCE:
|
||||
return nearest
|
||||
return math.ceil(quotient)
|
||||
|
||||
|
||||
def tile_indices(min_v: float, max_v: float, ground: float) -> range:
|
||||
"""Columns or rows whose tiles intersect [min_v, max_v)."""
|
||||
start = floor_index(min_v, ground)
|
||||
stop = ceil_index(max_v, ground) - 1
|
||||
return range(start, stop + 1)
|
||||
|
||||
|
||||
def intersects(min_e: float, min_n: float, max_e: float, max_n: float, blatt: Blatt) -> bool:
|
||||
return min_e < blatt.max_e and max_e > blatt.min_e and min_n < blatt.max_n and max_n > blatt.min_n
|
||||
|
||||
|
||||
def read_values(handle, endian: str, typ: int, count: int, raw: bytes) -> tuple:
|
||||
if typ not in TYPE_SIZE:
|
||||
raise ValueError(f"unsupported TIFF type {typ}")
|
||||
size = TYPE_SIZE[typ] * count
|
||||
if size <= 4:
|
||||
blob = raw[:size]
|
||||
else:
|
||||
(offset,) = struct.unpack(endian + "I", raw)
|
||||
handle.seek(offset)
|
||||
blob = handle.read(size)
|
||||
if len(blob) != size:
|
||||
raise ValueError(f"TIFF value at {offset} is short ({len(blob)} of {size} bytes)")
|
||||
if typ == 3:
|
||||
return struct.unpack(endian + f"{count}H", blob)
|
||||
if typ == 4:
|
||||
return struct.unpack(endian + f"{count}I", blob)
|
||||
if typ == 8:
|
||||
return struct.unpack(endian + f"{count}h", blob)
|
||||
if typ == 12:
|
||||
return struct.unpack(endian + f"{count}d", blob)
|
||||
if typ == 11:
|
||||
return struct.unpack(endian + f"{count}f", blob)
|
||||
raise ValueError(f"unsupported TIFF type {typ}")
|
||||
|
||||
|
||||
def read_ifd(path: Path) -> dict[int, tuple]:
|
||||
with path.open("rb") as handle:
|
||||
header = handle.read(8)
|
||||
if len(header) < 8 or header[:2] not in (b"II", b"MM"):
|
||||
raise ValueError(f"{path.name}: not a TIFF (header {header[:4]!r})")
|
||||
endian = "<" if header[:2] == b"II" else ">"
|
||||
magic, ifd_offset = struct.unpack(endian + "HI", header[2:8])
|
||||
if magic != 42:
|
||||
raise ValueError(f"{path.name}: TIFF version {magic} is not 42")
|
||||
handle.seek(ifd_offset)
|
||||
count_bytes = handle.read(2)
|
||||
if len(count_bytes) != 2:
|
||||
raise ValueError(f"{path.name}: IFD at {ifd_offset} is unreadable")
|
||||
(count,) = struct.unpack(endian + "H", count_bytes)
|
||||
entries: list[tuple[int, int, int, bytes]] = []
|
||||
for _ in range(count):
|
||||
entry = handle.read(12)
|
||||
if len(entry) != 12:
|
||||
raise ValueError(f"{path.name}: truncated IFD entry")
|
||||
tag, typ, field_count = struct.unpack(endian + "HHI", entry[:8])
|
||||
entries.append((tag, typ, field_count, entry[8:12]))
|
||||
needed = {TAG_WIDTH, TAG_HEIGHT, TAG_PIXEL_SCALE, TAG_TIEPOINT, TAG_GEOKEY}
|
||||
values: dict[int, tuple] = {}
|
||||
for tag, typ, field_count, raw in entries:
|
||||
if tag not in needed:
|
||||
continue
|
||||
values[tag] = read_values(handle, endian, typ, field_count, raw)
|
||||
return values
|
||||
|
||||
|
||||
def projected_epsg(path: Path, geokeys: tuple) -> int:
|
||||
if len(geokeys) < 4:
|
||||
raise ValueError(f"{path.name}: GeoKeyDirectory is shorter than its header")
|
||||
key_count = geokeys[3]
|
||||
needed = 4 + key_count * 4
|
||||
if len(geokeys) < needed:
|
||||
raise ValueError(
|
||||
f"{path.name}: GeoKeyDirectory has {len(geokeys)} shorts, needs {needed}"
|
||||
)
|
||||
for index in range(key_count):
|
||||
base = 4 + index * 4
|
||||
key_id, location, _count, value = geokeys[base : base + 4]
|
||||
if key_id != GEOKEY_PROJECTED_CS:
|
||||
continue
|
||||
if location != 0:
|
||||
raise ValueError(
|
||||
f"{path.name}: ProjectedCSTypeGeoKey location {location} is not an inline value"
|
||||
)
|
||||
return int(value)
|
||||
raise ValueError(f"{path.name}: missing ProjectedCSTypeGeoKey {GEOKEY_PROJECTED_CS}")
|
||||
|
||||
|
||||
def read_blatt(path: Path) -> tuple[Blatt | None, list[str]]:
|
||||
"""Return the Blatt, or validation errors that name the file and the bad value."""
|
||||
errors: list[str] = []
|
||||
try:
|
||||
tags = read_ifd(path)
|
||||
except (OSError, ValueError, struct.error) as exc:
|
||||
return None, [str(exc) if str(exc).startswith(path.name) else f"{path.name}: {exc}"]
|
||||
|
||||
def require(tag: int, label: str) -> tuple | None:
|
||||
if tag not in tags:
|
||||
errors.append(f"{path.name}: missing GeoTIFF tag {tag} ({label})")
|
||||
return None
|
||||
return tags[tag]
|
||||
|
||||
width_values = require(TAG_WIDTH, "ImageWidth")
|
||||
height_values = require(TAG_HEIGHT, "ImageLength")
|
||||
scale_values = require(TAG_PIXEL_SCALE, "ModelPixelScale")
|
||||
tie_values = require(TAG_TIEPOINT, "ModelTiepoint")
|
||||
key_values = require(TAG_GEOKEY, "GeoKeyDirectory")
|
||||
if errors:
|
||||
return None, errors
|
||||
|
||||
assert width_values and height_values and scale_values and tie_values and key_values
|
||||
width = int(width_values[0])
|
||||
height = int(height_values[0])
|
||||
if width != EXPECTED_PIXELS:
|
||||
errors.append(f"{path.name}: width {width} is not {EXPECTED_PIXELS}")
|
||||
if height != EXPECTED_PIXELS:
|
||||
errors.append(f"{path.name}: height {height} is not {EXPECTED_PIXELS}")
|
||||
|
||||
if len(scale_values) < 2:
|
||||
errors.append(f"{path.name}: pixel scale {scale_values} is not 1.25")
|
||||
scale_x = scale_y = None
|
||||
else:
|
||||
scale_x = float(scale_values[0])
|
||||
scale_y = float(scale_values[1])
|
||||
if abs(scale_x - NATIVE_RESOLUTION) > SCALE_TOLERANCE or abs(scale_y - NATIVE_RESOLUTION) > SCALE_TOLERANCE:
|
||||
errors.append(f"{path.name}: pixel scale ({scale_x}, {scale_y}) is not 1.25")
|
||||
|
||||
if len(tie_values) < 6:
|
||||
errors.append(f"{path.name}: tiepoint {tie_values} does not have six values")
|
||||
return None, errors
|
||||
pixel_i, pixel_j, pixel_k = (float(tie_values[0]), float(tie_values[1]), float(tie_values[2]))
|
||||
if (
|
||||
abs(pixel_i) > SCALE_TOLERANCE
|
||||
or abs(pixel_j) > SCALE_TOLERANCE
|
||||
or abs(pixel_k) > SCALE_TOLERANCE
|
||||
):
|
||||
errors.append(
|
||||
f"{path.name}: tiepoint pixel ({pixel_i}, {pixel_j}, {pixel_k}) is not (0, 0, 0)"
|
||||
)
|
||||
tie_x = float(tie_values[3])
|
||||
tie_y = float(tie_values[4])
|
||||
|
||||
try:
|
||||
epsg = projected_epsg(path, key_values)
|
||||
except ValueError as exc:
|
||||
errors.append(str(exc))
|
||||
epsg = None
|
||||
if epsg is not None and epsg != EXPECTED_EPSG:
|
||||
errors.append(f"{path.name}: EPSG {epsg} is not {EXPECTED_EPSG}")
|
||||
|
||||
if scale_x is None or scale_y is None:
|
||||
return None, errors
|
||||
|
||||
span_e = width * scale_x
|
||||
span_n = height * scale_y
|
||||
if abs(span_e - EXPECTED_SPAN_M) > SPAN_TOLERANCE_M or abs(span_n - EXPECTED_SPAN_M) > SPAN_TOLERANCE_M:
|
||||
errors.append(
|
||||
f"{path.name}: ground span {span_e} m by {span_n} m is not "
|
||||
f"{EXPECTED_SPAN_M} m by {EXPECTED_SPAN_M} m"
|
||||
)
|
||||
|
||||
world = path.with_suffix(".tfw")
|
||||
if not world.is_file():
|
||||
errors.append(f"{path.name}: missing world file {world.name}")
|
||||
|
||||
if errors:
|
||||
return None, errors
|
||||
|
||||
# Tiepoint (0, 0) is the upper-left corner. The world file's origin is
|
||||
# the center of that pixel; the tags are enough once they validate.
|
||||
return Blatt(
|
||||
path=path,
|
||||
min_e=tie_x,
|
||||
max_n=tie_y,
|
||||
max_e=tie_x + span_e,
|
||||
min_n=tie_y - span_n,
|
||||
), []
|
||||
|
||||
|
||||
def load_blaetter(directory: Path) -> tuple[list[Blatt], list[str]]:
|
||||
if not directory.is_dir():
|
||||
return [], [f"{directory} is not a directory"]
|
||||
paths = sorted(directory.glob("DTK25_*_col.tif"))
|
||||
if not paths:
|
||||
return [], [f"no Blätter matching DTK25_*_col.tif in {directory}"]
|
||||
sheets: list[Blatt] = []
|
||||
errors: list[str] = []
|
||||
for path in paths:
|
||||
sheet, sheet_errors = read_blatt(path)
|
||||
errors.extend(sheet_errors)
|
||||
if sheet is not None:
|
||||
sheets.append(sheet)
|
||||
sheets.sort(key=lambda sheet: (sheet.min_n, sheet.min_e, sheet.path.name))
|
||||
return sheets, errors
|
||||
|
||||
|
||||
def write_uebersicht(path: Path, sheets: list[Blatt]) -> None:
|
||||
document = {
|
||||
"tileSizePx": TILE_SIZE_PX,
|
||||
"resolutions": RESOLUTIONS,
|
||||
"blaetter": [sheet.as_json() for sheet in sheets],
|
||||
"gebiete": [{"name": "Hessen", "covers": "all"}],
|
||||
}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def gdal_available() -> bool:
|
||||
return shutil.which("gdalwarp") is not None and shutil.which("gdal_translate") is not None
|
||||
|
||||
|
||||
def write_kachel(incoming, out: Path) -> None:
|
||||
"""Store one Kachel. A shared edge keeps opaque pixels from both Blätter.
|
||||
|
||||
A tile often crosses a Blatt boundary. Writing the second Blatt used to
|
||||
replace the file, so one side of the Kachel stayed transparent and the
|
||||
map showed the background through that strip.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
layer = incoming.convert("RGBA")
|
||||
if layer is incoming:
|
||||
layer = incoming.copy()
|
||||
try:
|
||||
if out.is_file() and out.stat().st_size > 0:
|
||||
with Image.open(out) as existing:
|
||||
base = existing.convert("RGBA")
|
||||
if base is existing:
|
||||
base = existing.copy()
|
||||
base.alpha_composite(layer)
|
||||
base.save(out, format="PNG")
|
||||
base.close()
|
||||
else:
|
||||
layer.save(out, format="PNG")
|
||||
finally:
|
||||
layer.close()
|
||||
|
||||
|
||||
def run_gdal(command: list[str]) -> None:
|
||||
env = os.environ.copy()
|
||||
env["GDAL_PAM_ENABLED"] = "NO"
|
||||
result = subprocess.run(command, capture_output=True, text=True, env=env)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout).strip()
|
||||
raise SystemExit(f"{command[0]} failed ({result.returncode}): {detail}")
|
||||
|
||||
|
||||
def tile_with_gdal(sheets: list[Blatt], dest: Path) -> int:
|
||||
written = 0
|
||||
with tempfile.TemporaryDirectory(prefix="kacheln-") as tmp:
|
||||
for sheet in sheets:
|
||||
for z, resolution in enumerate(RESOLUTIONS):
|
||||
ground = resolution * TILE_SIZE_PX
|
||||
cols = list(tile_indices(sheet.min_e, sheet.max_e, ground))
|
||||
rows = list(tile_indices(sheet.min_n, sheet.max_n, ground))
|
||||
if not cols or not rows:
|
||||
continue
|
||||
te_min_e = cols[0] * ground
|
||||
te_max_e = (cols[-1] + 1) * ground
|
||||
te_min_n = rows[0] * ground
|
||||
te_max_n = (rows[-1] + 1) * ground
|
||||
warped = Path(tmp) / f"{sheet.path.stem}-z{z}.tif"
|
||||
resample = "near" if z == 0 else "lanczos"
|
||||
run_gdal(
|
||||
[
|
||||
"gdalwarp",
|
||||
"-q",
|
||||
"-t_srs",
|
||||
f"EPSG:{EXPECTED_EPSG}",
|
||||
"-te",
|
||||
str(te_min_e),
|
||||
str(te_min_n),
|
||||
str(te_max_e),
|
||||
str(te_max_n),
|
||||
"-tr",
|
||||
str(resolution),
|
||||
str(resolution),
|
||||
"-r",
|
||||
resample,
|
||||
"-dstalpha",
|
||||
"-of",
|
||||
"GTiff",
|
||||
"-co",
|
||||
"TILED=YES",
|
||||
"-co",
|
||||
"COMPRESS=DEFLATE",
|
||||
str(sheet.path),
|
||||
str(warped),
|
||||
]
|
||||
)
|
||||
tags = read_ifd(warped)
|
||||
width = int(tags[TAG_WIDTH][0])
|
||||
height = int(tags[TAG_HEIGHT][0])
|
||||
expect_w = len(cols) * TILE_SIZE_PX
|
||||
expect_h = len(rows) * TILE_SIZE_PX
|
||||
if width != expect_w or height != expect_h:
|
||||
raise SystemExit(
|
||||
f"{sheet.path.name}: gdalwarp size {width} by {height} "
|
||||
f"is not {expect_w} by {expect_h} at {resolution} m"
|
||||
)
|
||||
for col in cols:
|
||||
column_dir = dest / "kacheln" / str(z) / str(col)
|
||||
column_dir.mkdir(parents=True, exist_ok=True)
|
||||
x_off = int(round((col * ground - te_min_e) / resolution))
|
||||
for row in rows:
|
||||
y_off = int(round((te_max_n - (row + 1) * ground) / resolution))
|
||||
out = column_dir / f"{row}.png"
|
||||
fresh = Path(tmp) / f"tile-{z}-{col}-{row}.png"
|
||||
run_gdal(
|
||||
[
|
||||
"gdal_translate",
|
||||
"-q",
|
||||
"-srcwin",
|
||||
str(x_off),
|
||||
str(y_off),
|
||||
str(TILE_SIZE_PX),
|
||||
str(TILE_SIZE_PX),
|
||||
"-of",
|
||||
"PNG",
|
||||
str(warped),
|
||||
str(fresh),
|
||||
]
|
||||
)
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(fresh) as incoming:
|
||||
write_kachel(incoming, out)
|
||||
fresh.unlink(missing_ok=True)
|
||||
written += 1
|
||||
print(
|
||||
f" z={z} {resolution} m {len(cols) * len(rows)} tiles {sheet.path.name}",
|
||||
flush=True,
|
||||
)
|
||||
return written
|
||||
|
||||
|
||||
def paste_tile(level, sheet: Blatt, resolution: float, col: int, row: int):
|
||||
from PIL import Image
|
||||
|
||||
ground = resolution * TILE_SIZE_PX
|
||||
tile_min_e = col * ground
|
||||
tile_max_n = (row + 1) * ground
|
||||
what = f"{sheet.path.name}: tile {resolution} m {col}/{row}"
|
||||
src_x = snap((tile_min_e - sheet.min_e) / resolution, what)
|
||||
src_y = snap((sheet.max_n - tile_max_n) / resolution, what)
|
||||
src_w, src_h = level.size
|
||||
left = max(src_x, 0)
|
||||
upper = max(src_y, 0)
|
||||
right = min(src_x + TILE_SIZE_PX, src_w)
|
||||
lower = min(src_y + TILE_SIZE_PX, src_h)
|
||||
if right <= left or lower <= upper:
|
||||
raise SystemExit(f"{what} intersects the Blatt but has no pixels")
|
||||
tile = Image.new("RGBA", (TILE_SIZE_PX, TILE_SIZE_PX), (0, 0, 0, 0))
|
||||
crop = level.crop((left, upper, right, lower))
|
||||
tile.paste(crop, (left - src_x, upper - src_y))
|
||||
return tile
|
||||
|
||||
|
||||
def tile_with_pillow(sheets: list[Blatt], dest: Path) -> int | None:
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError as exc:
|
||||
print(
|
||||
"Pillow could not be imported, and gdalwarp/gdal_translate are not on PATH. "
|
||||
f"The Übersicht was written. No tiles were written. ({exc})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
# A Blatt is 9600² pixels, just over Pillow's default bomb limit.
|
||||
Image.MAX_IMAGE_PIXELS = EXPECTED_PIXELS * EXPECTED_PIXELS
|
||||
|
||||
written = 0
|
||||
for sheet in sheets:
|
||||
try:
|
||||
with Image.open(sheet.path) as opened:
|
||||
opened.load()
|
||||
native = opened.convert("RGBA")
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"Pillow could not decode {sheet.path.name}: {exc}. "
|
||||
"The Übersicht was written. No tiles were written.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
if native.size != (EXPECTED_PIXELS, EXPECTED_PIXELS):
|
||||
print(
|
||||
f"{sheet.path.name}: Pillow size {native.size[0]} by {native.size[1]} "
|
||||
f"is not {EXPECTED_PIXELS} by {EXPECTED_PIXELS}. "
|
||||
"The Übersicht was written. No tiles were written.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
native.close()
|
||||
return None
|
||||
try:
|
||||
for z, resolution in enumerate(RESOLUTIONS):
|
||||
scale = NATIVE_RESOLUTION / resolution
|
||||
level_size = (
|
||||
snap(native.width * scale, f"{sheet.path.name}: resized width at {resolution} m"),
|
||||
snap(native.height * scale, f"{sheet.path.name}: resized height at {resolution} m"),
|
||||
)
|
||||
level = native if z == 0 else native.resize(level_size, Image.Resampling.LANCZOS)
|
||||
ground = resolution * TILE_SIZE_PX
|
||||
cols = list(tile_indices(sheet.min_e, sheet.max_e, ground))
|
||||
rows = list(tile_indices(sheet.min_n, sheet.max_n, ground))
|
||||
level_count = 0
|
||||
try:
|
||||
for col in cols:
|
||||
column_dir = dest / "kacheln" / str(z) / str(col)
|
||||
column_dir.mkdir(parents=True, exist_ok=True)
|
||||
for row in rows:
|
||||
tile_min_e = col * ground
|
||||
tile_min_n = row * ground
|
||||
if not intersects(
|
||||
tile_min_e,
|
||||
tile_min_n,
|
||||
tile_min_e + ground,
|
||||
tile_min_n + ground,
|
||||
sheet,
|
||||
):
|
||||
continue
|
||||
out = column_dir / f"{row}.png"
|
||||
tile = paste_tile(level, sheet, resolution, col, row)
|
||||
write_kachel(tile, out)
|
||||
tile.close()
|
||||
written += 1
|
||||
level_count += 1
|
||||
finally:
|
||||
if level is not native:
|
||||
level.close()
|
||||
print(
|
||||
f" z={z} {resolution} m {level_count} tiles {sheet.path.name}",
|
||||
flush=True,
|
||||
)
|
||||
finally:
|
||||
native.close()
|
||||
return written
|
||||
|
||||
|
||||
def select_sheets(sheets: list[Blatt], only: str | None, tile_all: bool) -> tuple[list[Blatt], str | None]:
|
||||
if tile_all and only:
|
||||
return [], "pass either --only or --all, not both"
|
||||
if tile_all:
|
||||
return sheets, None
|
||||
if only:
|
||||
name = Path(only).name
|
||||
chosen = [sheet for sheet in sheets if sheet.path.name == name]
|
||||
if not chosen:
|
||||
return [], f"no Blatt named {name}"
|
||||
return chosen, None
|
||||
return [sheets[0]], None
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--karten", type=Path, required=True, help="directory of DTK25 GeoTIFFs")
|
||||
parser.add_argument("--uebersicht", type=Path, required=True, help="Übersicht JSON to write")
|
||||
parser.add_argument(
|
||||
"--tiles",
|
||||
type=Path,
|
||||
help="write {DIR}/kacheln/{z}/{col}/{row}.png; pass public (one Blatt unless --all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--only",
|
||||
help="filename of the single Blatt to tile, e.g. DTK25_464000_5576000_col.tif",
|
||||
)
|
||||
parser.add_argument("--all", action="store_true", help="tile every Blatt")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
sheets, errors = load_blaetter(args.karten)
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(error, file=sys.stderr)
|
||||
return 1
|
||||
write_uebersicht(args.uebersicht, sheets)
|
||||
print(f"{len(sheets)} Blätter")
|
||||
print(f"wrote {args.uebersicht}")
|
||||
if args.tiles is None:
|
||||
return 0
|
||||
|
||||
chosen, error = select_sheets(sheets, args.only, args.all)
|
||||
if error:
|
||||
print(error, file=sys.stderr)
|
||||
return 1
|
||||
if not args.all and args.only is None:
|
||||
print(f"tiling one Blatt ({chosen[0].path.name})")
|
||||
|
||||
if gdal_available():
|
||||
print("tiling with gdalwarp and gdal_translate")
|
||||
written = tile_with_gdal(chosen, args.tiles)
|
||||
else:
|
||||
print("gdalwarp/gdal_translate not on PATH; tiling with Pillow")
|
||||
written = tile_with_pillow(chosen, args.tiles)
|
||||
if written is None:
|
||||
return 1
|
||||
print(f"{written} PNG tiles written to {args.tiles / 'kacheln'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user