mirror of
https://github.com/harivansh-afk/nix.git
synced 2026-04-15 05:02:10 +00:00
feat: init dynamic wallpaper ($theme gen)
This commit is contained in:
parent
38c096dbc1
commit
af19b1e78b
6 changed files with 489 additions and 11 deletions
|
|
@ -14,7 +14,12 @@ in
|
|||
++ lib.optionals hostConfig.isDarwin (builtins.attrValues customScripts.darwinPackages);
|
||||
|
||||
home.activation.initializeThemeState = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
|
||||
mkdir -p "${customScripts.theme.paths.stateDir}" "${customScripts.theme.paths.fzfDir}" "${customScripts.theme.paths.ghosttyDir}" "${customScripts.theme.paths.tmuxDir}"
|
||||
mkdir -p "${customScripts.theme.paths.stateDir}" \
|
||||
"${customScripts.theme.paths.fzfDir}" \
|
||||
"${customScripts.theme.paths.ghosttyDir}" \
|
||||
"${customScripts.theme.paths.tmuxDir}" \
|
||||
"${customScripts.theme.paths.lazygitDir}" \
|
||||
"${customScripts.theme.wallpapers.dir}"
|
||||
|
||||
if [[ -f "${customScripts.theme.paths.stateFile}" ]]; then
|
||||
mode=$(tr -d '[:space:]' < "${customScripts.theme.paths.stateFile}")
|
||||
|
|
@ -28,17 +33,43 @@ in
|
|||
fzf_target="${customScripts.theme.paths.fzfDir}/cozybox-light"
|
||||
ghostty_target="${customScripts.theme.paths.ghosttyDir}/cozybox-light"
|
||||
tmux_target="${customScripts.tmuxConfigs.light}"
|
||||
lazygit_target="${customScripts.theme.paths.lazygitDir}/config-light.yml"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "${customScripts.theme.defaultMode}" > "${customScripts.theme.paths.stateFile}"
|
||||
fzf_target="${customScripts.theme.paths.fzfDir}/cozybox-dark"
|
||||
ghostty_target="${customScripts.theme.paths.ghosttyDir}/cozybox-dark"
|
||||
tmux_target="${customScripts.tmuxConfigs.dark}"
|
||||
lazygit_target="${customScripts.theme.paths.lazygitDir}/config-dark.yml"
|
||||
;;
|
||||
esac
|
||||
|
||||
ln -sfn "$fzf_target" "${customScripts.theme.paths.fzfCurrentFile}"
|
||||
ln -sfn "$ghostty_target" "${customScripts.theme.paths.ghosttyCurrentFile}"
|
||||
ln -sfn "$tmux_target" "${customScripts.theme.paths.tmuxCurrentFile}"
|
||||
ln -sfn "$lazygit_target" "${customScripts.theme.paths.lazygitCurrentFile}"
|
||||
${lib.optionalString hostConfig.isDarwin ''
|
||||
lg_darwin="${config.home.homeDirectory}/Library/Application Support/lazygit"
|
||||
mkdir -p "$lg_darwin"
|
||||
case "$mode" in
|
||||
light) ln -sfn "$lg_darwin/config-light.yml" "$lg_darwin/config.yml" ;;
|
||||
*) ln -sfn "$lg_darwin/config-dark.yml" "$lg_darwin/config.yml" ;;
|
||||
esac
|
||||
''}
|
||||
|
||||
# seed wallpapers from static assets if no generated ones exist yet
|
||||
if [[ ! -f "${customScripts.theme.wallpapers.dark}" ]]; then
|
||||
cp "${customScripts.theme.wallpapers.staticDark}" "${customScripts.theme.wallpapers.dark}"
|
||||
fi
|
||||
if [[ ! -f "${customScripts.theme.wallpapers.light}" ]]; then
|
||||
cp "${customScripts.theme.wallpapers.staticLight}" "${customScripts.theme.wallpapers.light}"
|
||||
fi
|
||||
|
||||
# ensure wallpaper symlink points to active mode
|
||||
case "$mode" in
|
||||
light) wp_target="${customScripts.theme.wallpapers.light}" ;;
|
||||
*) wp_target="${customScripts.theme.wallpapers.dark}" ;;
|
||||
esac
|
||||
ln -sfn "$wp_target" "${customScripts.theme.wallpapers.current}"
|
||||
'';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,12 @@ let
|
|||
gray = "#928374";
|
||||
};
|
||||
wallpapers = {
|
||||
dark = ../assets/wallpapers/topography-dark.jpg;
|
||||
light = ../assets/wallpapers/topography-light.jpg;
|
||||
dir = "${config.home.homeDirectory}/Pictures/Screensavers";
|
||||
dark = "${config.home.homeDirectory}/Pictures/Screensavers/wallpaper-dark.jpg";
|
||||
light = "${config.home.homeDirectory}/Pictures/Screensavers/wallpaper-light.jpg";
|
||||
current = "${config.home.homeDirectory}/Pictures/Screensavers/wallpaper.jpg";
|
||||
staticDark = ../assets/wallpapers/topography-dark.jpg;
|
||||
staticLight = ../assets/wallpapers/topography-light.jpg;
|
||||
};
|
||||
paths = {
|
||||
stateDir = "${config.xdg.stateHome}/theme";
|
||||
|
|
|
|||
|
|
@ -76,6 +76,15 @@ let
|
|||
runtimeInputs = with pkgs; [ nix ];
|
||||
};
|
||||
|
||||
wallpaper-gen = mkScript {
|
||||
name = "wallpaper-gen";
|
||||
file = ./wallpaper-gen.sh;
|
||||
runtimeInputs = with pkgs; [ uv ];
|
||||
replacements = {
|
||||
"@WALLPAPER_GEN_PY@" = "${./wallpaper-gen.py}";
|
||||
};
|
||||
};
|
||||
|
||||
theme = mkScript {
|
||||
name = "theme";
|
||||
file = ./theme.sh;
|
||||
|
|
@ -110,8 +119,12 @@ let
|
|||
"@LAZYGIT_DARWIN_FILE@" = "${config.home.homeDirectory}/Library/Application Support/lazygit/config.yml";
|
||||
"@LAZYGIT_DARWIN_DARK_FILE@" = "${config.home.homeDirectory}/Library/Application Support/lazygit/config-dark.yml";
|
||||
"@LAZYGIT_DARWIN_LIGHT_FILE@" = "${config.home.homeDirectory}/Library/Application Support/lazygit/config-light.yml";
|
||||
"@WALLPAPER_DARK_FILE@" = "${theme.wallpapers.dark}";
|
||||
"@WALLPAPER_LIGHT_FILE@" = "${theme.wallpapers.light}";
|
||||
"@WALLPAPER_DIR@" = theme.wallpapers.dir;
|
||||
"@WALLPAPER_DARK_FILE@" = theme.wallpapers.dark;
|
||||
"@WALLPAPER_LIGHT_FILE@" = theme.wallpapers.light;
|
||||
"@WALLPAPER_CURRENT_FILE@" = theme.wallpapers.current;
|
||||
"@WALLPAPER_STATIC_DARK@" = "${theme.wallpapers.staticDark}";
|
||||
"@WALLPAPER_STATIC_LIGHT@" = "${theme.wallpapers.staticLight}";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
usage() {
|
||||
echo "usage: theme <dark|light|toggle|current>"
|
||||
echo "usage: theme <dark|light|toggle|gen>"
|
||||
}
|
||||
|
||||
read_mode() {
|
||||
|
|
@ -14,6 +14,21 @@ read_mode() {
|
|||
echo "@DEFAULT_MODE@"
|
||||
}
|
||||
|
||||
set_wallpaper() {
|
||||
if [[ "$(uname -s)" == "Darwin" ]] && command -v osascript >/dev/null 2>&1; then
|
||||
if [[ -f "@WALLPAPER_CURRENT_FILE@" ]]; then
|
||||
wp_resolved=$(readlink -f "@WALLPAPER_CURRENT_FILE@" 2>/dev/null || echo "@WALLPAPER_CURRENT_FILE@")
|
||||
# macOS caches wallpaper data by file path - copy to a unique temp path
|
||||
# so macOS is forced to read the new image data
|
||||
wp_dir=$(dirname "$wp_resolved")
|
||||
wp_tmp="${wp_dir}/.wallpaper-active-$$.jpg"
|
||||
rm -f "${wp_dir}"/.wallpaper-active-*.jpg 2>/dev/null || true
|
||||
cp "$wp_resolved" "$wp_tmp"
|
||||
osascript -e "tell application \"System Events\" to tell every desktop to set picture to \"${wp_tmp}\"" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
link_mode_assets() {
|
||||
local mode="$1"
|
||||
local fzf_target
|
||||
|
|
@ -44,13 +59,17 @@ link_mode_assets() {
|
|||
;;
|
||||
esac
|
||||
|
||||
mkdir -p "@STATE_DIR@" "@FZF_DIR@" "@GHOSTTY_DIR@" "@TMUX_DIR@" "@LAZYGIT_DIR@"
|
||||
mkdir -p "@STATE_DIR@" "@FZF_DIR@" "@GHOSTTY_DIR@" "@TMUX_DIR@" "@LAZYGIT_DIR@" "@WALLPAPER_DIR@"
|
||||
printf '%s\n' "$mode" > "@STATE_FILE@"
|
||||
ln -sfn "$fzf_target" "@FZF_CURRENT_FILE@"
|
||||
ln -sfn "$ghostty_target" "@GHOSTTY_CURRENT_FILE@"
|
||||
ln -sfn "$tmux_target" "@TMUX_CURRENT_FILE@"
|
||||
ln -sfn "$lazygit_target" "@LAZYGIT_CURRENT_FILE@"
|
||||
|
||||
if [[ -f "$wallpaper" ]]; then
|
||||
ln -sfn "$wallpaper" "@WALLPAPER_CURRENT_FILE@"
|
||||
fi
|
||||
|
||||
if command -v tmux >/dev/null 2>&1 && tmux start-server >/dev/null 2>&1; then
|
||||
tmux source-file "@TMUX_CONFIG@" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
|
@ -65,7 +84,7 @@ link_mode_assets() {
|
|||
|
||||
osascript -e "tell application \"System Events\" to tell appearance preferences to set dark mode to ${apple_dark_mode}" >/dev/null 2>&1 || true
|
||||
|
||||
osascript -e "tell application \"System Events\" to tell every desktop to set picture to \"${wallpaper}\"" >/dev/null 2>&1 || true
|
||||
set_wallpaper
|
||||
|
||||
osascript <<'EOF' >/dev/null 2>&1 || true
|
||||
tell application "System Events"
|
||||
|
|
@ -91,7 +110,7 @@ EOF
|
|||
)
|
||||
}
|
||||
|
||||
mode="${1:-current}"
|
||||
mode="${1:-}"
|
||||
|
||||
case "$mode" in
|
||||
dark|light)
|
||||
|
|
@ -103,8 +122,10 @@ case "$mode" in
|
|||
mode="dark"
|
||||
fi
|
||||
;;
|
||||
current)
|
||||
read_mode
|
||||
gen)
|
||||
wallpaper-gen
|
||||
set_wallpaper
|
||||
printf 'generated new wallpaper\n'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
|
|
|
|||
408
scripts/wallpaper-gen.py
Normal file
408
scripts/wallpaper-gen.py
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
# /// script
|
||||
# dependencies = ["pillow"]
|
||||
# ///
|
||||
"""Generate topographic contour wallpapers from real-world elevation data."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from typing import Iterator
|
||||
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
Coord = tuple[float, float]
|
||||
Candidate = tuple[float, float, str]
|
||||
Rgb = tuple[int, int, int]
|
||||
|
||||
HOME: str = os.environ["HOME"]
|
||||
DIR: str = os.path.join(HOME, "Pictures", "Screensavers")
|
||||
STATE: str = os.path.join(
|
||||
os.environ.get("XDG_STATE_HOME", os.path.join(HOME, ".local", "state")),
|
||||
"theme",
|
||||
"current",
|
||||
)
|
||||
LOG_DIR: str = os.path.join(
|
||||
os.environ.get("XDG_STATE_HOME", os.path.join(HOME, ".local", "state")),
|
||||
"wallpaper",
|
||||
)
|
||||
LOG_FILE: str = os.path.join(LOG_DIR, "gen.log")
|
||||
HISTORY_FILE: str = os.path.join(LOG_DIR, "history.json")
|
||||
CACHE: str = os.path.join(
|
||||
os.environ.get("XDG_CACHE_HOME", os.path.join(HOME, ".cache")),
|
||||
"wallpaper",
|
||||
)
|
||||
|
||||
os.makedirs(DIR, exist_ok=True)
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
os.makedirs(CACHE, exist_ok=True)
|
||||
|
||||
TILE_URL: str = "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
|
||||
TILE_SIZE: int = 256
|
||||
ZOOM: int = 11
|
||||
CONTOUR_LEVELS: int = 20
|
||||
|
||||
# cozybox palette - matches lib/theme.nix
|
||||
THEMES: dict[str, dict[str, Rgb]] = {
|
||||
"dark": {
|
||||
"bg": (0x18, 0x18, 0x18),
|
||||
"line": (0x2d, 0x2d, 0x2d),
|
||||
"label": (0xFF, 0xFF, 0xFF),
|
||||
},
|
||||
"light": {
|
||||
"bg": (0xE7, 0xE7, 0xE7),
|
||||
"line": (0xCE, 0xCE, 0xCE),
|
||||
"label": (0x00, 0x00, 0x00),
|
||||
},
|
||||
}
|
||||
|
||||
# curated mountain/terrain locations with interesting topography
|
||||
LOCATIONS: list[Coord] = [
|
||||
(46.56, 7.69), # Swiss Alps - Bernese Oberland
|
||||
(36.10, -112.09), # Grand Canyon
|
||||
(27.99, 86.93), # Everest region
|
||||
(61.22, 6.78), # Norwegian fjords
|
||||
(-50.94, -73.15), # Patagonia
|
||||
(36.25, 137.60), # Japanese Alps
|
||||
(39.11, -106.45), # Colorado Rockies
|
||||
(46.42, 11.84), # Dolomites
|
||||
(44.07, 6.87), # French Alps
|
||||
(-43.59, 170.14), # New Zealand Alps
|
||||
(64.07, -16.20), # Iceland highlands
|
||||
(28.60, 83.82), # Annapurna
|
||||
(42.50, 44.50), # Caucasus
|
||||
(47.07, 12.69), # Austrian Alps
|
||||
(45.83, 6.86), # Mont Blanc
|
||||
]
|
||||
|
||||
MIN_RELIEF: int = 400
|
||||
MAX_RETRIES: int = 20
|
||||
SEA_LEVEL: int = 32768
|
||||
MIN_LAND_FRACTION: float = 0.1
|
||||
PREVIEW_W: int = 384
|
||||
PREVIEW_H: int = 240
|
||||
GRID_COLS: int = 6
|
||||
GRID_ROWS: int = 4
|
||||
MIN_CONTOUR_COVERAGE: float = 0.15
|
||||
MIN_OCCUPIED_CELLS: int = 12
|
||||
MAX_CELL_SHARE: float = 0.15
|
||||
MAX_CACHED_CANDIDATES: int = 24
|
||||
HISTORY_SIZE: int = 10 # remember this many recent locations to avoid repeats
|
||||
RUN_ID: str = f"{datetime.now().strftime('%Y%m%dT%H%M%S')}-pid{os.getpid()}"
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
line = f"{datetime.now().astimezone().isoformat(timespec='seconds')} [{RUN_ID}] {message}"
|
||||
print(line, file=sys.stderr, flush=True)
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
print(line, file=f)
|
||||
|
||||
|
||||
def load_history() -> list[Coord]:
|
||||
try:
|
||||
with open(HISTORY_FILE, encoding="utf-8") as f:
|
||||
entries = json.load(f)
|
||||
return [(e[0], e[1]) for e in entries if isinstance(e, list) and len(e) == 2]
|
||||
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
||||
return []
|
||||
|
||||
|
||||
def save_history(lat: float, lon: float) -> None:
|
||||
history = load_history()
|
||||
history.append((round(lat, 2), round(lon, 2)))
|
||||
history = history[-HISTORY_SIZE:]
|
||||
with open(HISTORY_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(history, f)
|
||||
|
||||
|
||||
def random_location() -> Coord:
|
||||
return random.uniform(-60, 70), random.uniform(-180, 180)
|
||||
|
||||
|
||||
def cached_locations(tiles_x: int, tiles_y: int) -> list[Coord]:
|
||||
suffix = f"_z{ZOOM}_{tiles_x}x{tiles_y}.png"
|
||||
locations: list[Coord] = []
|
||||
for name in os.listdir(CACHE):
|
||||
if not name.startswith("terrain_") or not name.endswith(suffix):
|
||||
continue
|
||||
coords = name[len("terrain_") : -len(suffix)]
|
||||
try:
|
||||
lat, lon = coords.split("_", 1)
|
||||
locations.append((float(lat), float(lon)))
|
||||
except ValueError:
|
||||
continue
|
||||
random.shuffle(locations)
|
||||
return locations[:MAX_CACHED_CANDIDATES]
|
||||
|
||||
|
||||
def candidate_locations(tiles_x: int, tiles_y: int) -> Iterator[Candidate]:
|
||||
seen: set[Coord] = set()
|
||||
recent = {(round(lat, 2), round(lon, 2)) for lat, lon in load_history()}
|
||||
cached: list[Coord] = cached_locations(tiles_x, tiles_y)
|
||||
fallback: list[Coord] = list(LOCATIONS)
|
||||
random.shuffle(fallback)
|
||||
log(f"candidate_pools cached={len(cached)} curated={len(fallback)} random={MAX_RETRIES} recent_skip={len(recent)}")
|
||||
for lat, lon in cached:
|
||||
key = (round(lat, 2), round(lon, 2))
|
||||
if key in seen or key in recent:
|
||||
continue
|
||||
seen.add(key)
|
||||
yield lat, lon, "cached"
|
||||
for lat, lon in fallback:
|
||||
key = (round(lat, 2), round(lon, 2))
|
||||
if key in seen or key in recent:
|
||||
continue
|
||||
seen.add(key)
|
||||
yield lat, lon, "curated"
|
||||
for _ in range(MAX_RETRIES):
|
||||
lat, lon = random_location()
|
||||
key = (round(lat, 2), round(lon, 2))
|
||||
if key in seen or key in recent:
|
||||
continue
|
||||
seen.add(key)
|
||||
yield lat, lon, "random"
|
||||
|
||||
|
||||
def reverse_geocode(lat: float, lon: float) -> str | None:
|
||||
try:
|
||||
url = f"https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lon}&format=json&zoom=6&accept-language=en"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "wallpaper-gen/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read())
|
||||
addr = data.get("address", {})
|
||||
name = (
|
||||
addr.get("region")
|
||||
or addr.get("state_district")
|
||||
or addr.get("state")
|
||||
or addr.get("county")
|
||||
or addr.get("country")
|
||||
)
|
||||
return name
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_resolution() -> tuple[int, int]:
|
||||
"""Get primary display resolution on macOS."""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["system_profiler", "SPDisplaysDataType", "-json"],
|
||||
timeout=5,
|
||||
).decode()
|
||||
displays = json.loads(out)
|
||||
for gpu in displays.get("SPDisplaysDataType", []):
|
||||
for disp in gpu.get("spdisplays_ndrvs", []):
|
||||
res = disp.get("_spdisplays_resolution", "")
|
||||
if " x " in res:
|
||||
parts = res.split(" x ")
|
||||
w = int(parts[0].strip())
|
||||
h = int(parts[1].split()[0].strip())
|
||||
return w, h
|
||||
except Exception:
|
||||
pass
|
||||
return 3024, 1964 # MacBook Pro 14" default
|
||||
|
||||
|
||||
def lat_lon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]:
|
||||
n = 2**zoom
|
||||
x = int(n * ((lon + 180) / 360))
|
||||
lat_rad = math.radians(lat)
|
||||
y = int(n * (1 - (math.log(math.tan(lat_rad) + 1 / math.cos(lat_rad)) / math.pi)) / 2)
|
||||
return x, y
|
||||
|
||||
|
||||
def fetch_tile(z: int, x: int, y: int) -> Image.Image:
|
||||
url = TILE_URL.format(z=z, x=x, y=y)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "wallpaper-gen/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return Image.open(BytesIO(resp.read()))
|
||||
|
||||
|
||||
def fetch_terrain(
|
||||
lat: float, lon: float, tiles_x: int, tiles_y: int, zoom: int
|
||||
) -> tuple[Image.Image, bool]:
|
||||
tag = f"{lat:.2f}_{lon:.2f}_z{zoom}_{tiles_x}x{tiles_y}"
|
||||
cache_file = os.path.join(CACHE, f"terrain_{tag}.png")
|
||||
if os.path.exists(cache_file):
|
||||
return Image.open(cache_file), True
|
||||
cx, cy = lat_lon_to_tile(lat, lon, zoom)
|
||||
sx, sy = cx - tiles_x // 2, cy - tiles_y // 2
|
||||
full = Image.new("RGB", (tiles_x * TILE_SIZE, tiles_y * TILE_SIZE))
|
||||
for ty in range(tiles_y):
|
||||
for tx in range(tiles_x):
|
||||
tile = fetch_tile(zoom, sx + tx, sy + ty)
|
||||
full.paste(tile, (tx * TILE_SIZE, ty * TILE_SIZE))
|
||||
full.save(cache_file)
|
||||
return full, False
|
||||
|
||||
|
||||
def decode_terrarium(img: Image.Image) -> tuple[Image.Image, int, float]:
|
||||
raw_bytes = img.tobytes()
|
||||
px_count = len(raw_bytes) // 3
|
||||
raw: list[int] = [0] * px_count
|
||||
land: int = 0
|
||||
for i in range(px_count):
|
||||
off = i * 3
|
||||
raw[i] = (raw_bytes[off] << 8) | raw_bytes[off + 1]
|
||||
if raw[i] > SEA_LEVEL:
|
||||
land += 1
|
||||
lo, hi = min(raw), max(raw)
|
||||
rng = hi - lo or 1
|
||||
norm = bytearray((e - lo) * 255 // rng for e in raw)
|
||||
return Image.frombytes("L", img.size, bytes(norm)), hi - lo, land / px_count
|
||||
|
||||
|
||||
def build_contour_mask(elevation: Image.Image, W: int, H: int) -> Image.Image:
|
||||
S: int = 2
|
||||
iW, iH = W * S, H * S
|
||||
terrain = elevation.resize((iW, iH), Image.BICUBIC)
|
||||
terrain = terrain.filter(ImageFilter.GaussianBlur(radius=15 * S))
|
||||
step = max(1, 256 // CONTOUR_LEVELS)
|
||||
terrain = terrain.point(lambda p: (p // step) * step)
|
||||
eroded = terrain.filter(ImageFilter.MinFilter(3))
|
||||
edges = ImageChops.subtract(terrain, eroded)
|
||||
edges = edges.point(lambda p: 255 if p > 0 else 0)
|
||||
edges = edges.filter(ImageFilter.MaxFilter(3))
|
||||
edges = edges.filter(ImageFilter.GaussianBlur(radius=1.5 * S))
|
||||
return edges.point(lambda p: 255 if p > 80 else 0)
|
||||
|
||||
|
||||
def contour_stats(elevation: Image.Image) -> tuple[float, int, float]:
|
||||
mask = build_contour_mask(elevation, PREVIEW_W, PREVIEW_H).resize(
|
||||
(PREVIEW_W, PREVIEW_H), Image.NEAREST
|
||||
)
|
||||
mask_bytes = mask.tobytes()
|
||||
total: int = len(mask_bytes)
|
||||
filled: int = 0
|
||||
cell_counts: list[int] = [0] * (GRID_COLS * GRID_ROWS)
|
||||
for y in range(PREVIEW_H):
|
||||
row_off = y * PREVIEW_W
|
||||
cy = y * GRID_ROWS // PREVIEW_H
|
||||
for x in range(PREVIEW_W):
|
||||
if mask_bytes[row_off + x]:
|
||||
filled += 1
|
||||
cx = x * GRID_COLS // PREVIEW_W
|
||||
cell_counts[cy * GRID_COLS + cx] += 1
|
||||
coverage = filled / total
|
||||
occupied = sum(1 for count in cell_counts if count)
|
||||
largest_share = max(cell_counts) / filled if filled else 1
|
||||
return coverage, occupied, largest_share
|
||||
|
||||
|
||||
def candidate_summary(
|
||||
elevation: Image.Image, relief: int, land_fraction: float
|
||||
) -> tuple[bool, str]:
|
||||
if relief < MIN_RELIEF or land_fraction < MIN_LAND_FRACTION:
|
||||
return False, (
|
||||
f"relief={relief} land_fraction={land_fraction:.3f} "
|
||||
f"thresholds=({MIN_RELIEF},{MIN_LAND_FRACTION:.3f})"
|
||||
)
|
||||
coverage, occupied, largest_share = contour_stats(elevation)
|
||||
ok = (
|
||||
coverage >= MIN_CONTOUR_COVERAGE
|
||||
and occupied >= MIN_OCCUPIED_CELLS
|
||||
and largest_share <= MAX_CELL_SHARE
|
||||
)
|
||||
return ok, (
|
||||
f"relief={relief} land_fraction={land_fraction:.3f} "
|
||||
f"coverage={coverage:.3f} occupied={occupied} largest_cell_share={largest_share:.3f} "
|
||||
f"thresholds=({MIN_CONTOUR_COVERAGE:.3f},{MIN_OCCUPIED_CELLS},{MAX_CELL_SHARE:.3f})"
|
||||
)
|
||||
|
||||
|
||||
def render_contours(
|
||||
elevation: Image.Image, W: int, H: int, bg: Rgb, line_color: Rgb
|
||||
) -> Image.Image:
|
||||
S: int = 2
|
||||
iW, iH = W * S, H * S
|
||||
edges = build_contour_mask(elevation, W, H)
|
||||
bg_img = Image.new("RGB", (iW, iH), bg)
|
||||
fg_img = Image.new("RGB", (iW, iH), line_color)
|
||||
img = Image.composite(fg_img, bg_img, edges)
|
||||
return img.resize((W, H), Image.LANCZOS)
|
||||
|
||||
|
||||
def find_font() -> str | None:
|
||||
candidates = [
|
||||
os.path.join(HOME, "Library/Fonts/BerkeleyMono-Regular.otf"),
|
||||
os.path.join(HOME, "Library/Fonts/BerkeleyMono-Regular.ttf"),
|
||||
os.path.join(HOME, ".local/share/fonts/berkeley-mono/BerkeleyMono-Regular.ttf"),
|
||||
"/Library/Fonts/BerkeleyMono-Regular.otf",
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def gen() -> None:
|
||||
W, H = get_resolution()
|
||||
tiles_x = math.ceil(W / TILE_SIZE)
|
||||
tiles_y = math.ceil(H / TILE_SIZE)
|
||||
|
||||
theme = "dark"
|
||||
try:
|
||||
with open(STATE) as f:
|
||||
t = f.read().strip()
|
||||
if t in THEMES:
|
||||
theme = t
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
log(f"start theme={theme} resolution={W}x{H} tiles={tiles_x}x{tiles_y}")
|
||||
|
||||
for index, (lat, lon, source) in enumerate(candidate_locations(tiles_x, tiles_y), start=1):
|
||||
log(f"candidate[{index}] source={source} lat={lat:.2f} lon={lon:.2f} begin")
|
||||
try:
|
||||
terrain_img, cache_hit = fetch_terrain(lat, lon, tiles_x, tiles_y, ZOOM)
|
||||
except Exception as err:
|
||||
log(f"candidate[{index}] source={source} lat={lat:.2f} lon={lon:.2f} fetch_error={err!r}")
|
||||
continue
|
||||
elevation, relief, land_fraction = decode_terrarium(terrain_img)
|
||||
ok, summary = candidate_summary(elevation, relief, land_fraction)
|
||||
status = "accept" if ok else "reject"
|
||||
cache = "hit" if cache_hit else "miss"
|
||||
log(f"candidate[{index}] source={source} lat={lat:.2f} lon={lon:.2f} cache={cache} {status} {summary}")
|
||||
if ok:
|
||||
break
|
||||
else:
|
||||
log("finish status=no_candidate")
|
||||
return
|
||||
|
||||
elevation = elevation.crop((0, 0, min(elevation.width, W), min(elevation.height, H)))
|
||||
save_history(lat, lon)
|
||||
place = reverse_geocode(lat, lon)
|
||||
log(f"selected lat={lat:.2f} lon={lon:.2f} place={place or '<none>'}")
|
||||
|
||||
coords = f"{lat:.2f}, {lon:.2f}"
|
||||
label = f"{place} ({coords})" if place else coords
|
||||
font_path = find_font()
|
||||
|
||||
for theme_name, colors in THEMES.items():
|
||||
img = render_contours(elevation, W, H, colors["bg"], colors["line"])
|
||||
draw = ImageDraw.Draw(img)
|
||||
if font_path:
|
||||
font = ImageFont.truetype(font_path, 14)
|
||||
else:
|
||||
font = ImageFont.load_default()
|
||||
draw.text((24, H - 30), label, fill=colors["label"], font=font)
|
||||
out_path = os.path.join(DIR, f"wallpaper-{theme_name}.jpg")
|
||||
img.save(out_path, quality=95)
|
||||
log(f"wrote theme={theme_name} path={out_path}")
|
||||
|
||||
link = os.path.join(DIR, "wallpaper.jpg")
|
||||
if os.path.lexists(link):
|
||||
os.unlink(link)
|
||||
target = os.path.join(DIR, f"wallpaper-{theme}.jpg")
|
||||
os.symlink(target, link)
|
||||
log(f"finish status=ok target={target} link={link}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gen()
|
||||
1
scripts/wallpaper-gen.sh
Normal file
1
scripts/wallpaper-gen.sh
Normal file
|
|
@ -0,0 +1 @@
|
|||
uv run "@WALLPAPER_GEN_PY@"
|
||||
Loading…
Add table
Add a link
Reference in a new issue