mirror of
https://github.com/harivansh-afk/nix.git
synced 2026-04-17 08:01:02 +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);
|
++ lib.optionals hostConfig.isDarwin (builtins.attrValues customScripts.darwinPackages);
|
||||||
|
|
||||||
home.activation.initializeThemeState = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
|
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
|
if [[ -f "${customScripts.theme.paths.stateFile}" ]]; then
|
||||||
mode=$(tr -d '[:space:]' < "${customScripts.theme.paths.stateFile}")
|
mode=$(tr -d '[:space:]' < "${customScripts.theme.paths.stateFile}")
|
||||||
|
|
@ -28,17 +33,43 @@ in
|
||||||
fzf_target="${customScripts.theme.paths.fzfDir}/cozybox-light"
|
fzf_target="${customScripts.theme.paths.fzfDir}/cozybox-light"
|
||||||
ghostty_target="${customScripts.theme.paths.ghosttyDir}/cozybox-light"
|
ghostty_target="${customScripts.theme.paths.ghosttyDir}/cozybox-light"
|
||||||
tmux_target="${customScripts.tmuxConfigs.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}"
|
printf '%s\n' "${customScripts.theme.defaultMode}" > "${customScripts.theme.paths.stateFile}"
|
||||||
fzf_target="${customScripts.theme.paths.fzfDir}/cozybox-dark"
|
fzf_target="${customScripts.theme.paths.fzfDir}/cozybox-dark"
|
||||||
ghostty_target="${customScripts.theme.paths.ghosttyDir}/cozybox-dark"
|
ghostty_target="${customScripts.theme.paths.ghosttyDir}/cozybox-dark"
|
||||||
tmux_target="${customScripts.tmuxConfigs.dark}"
|
tmux_target="${customScripts.tmuxConfigs.dark}"
|
||||||
|
lazygit_target="${customScripts.theme.paths.lazygitDir}/config-dark.yml"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
ln -sfn "$fzf_target" "${customScripts.theme.paths.fzfCurrentFile}"
|
ln -sfn "$fzf_target" "${customScripts.theme.paths.fzfCurrentFile}"
|
||||||
ln -sfn "$ghostty_target" "${customScripts.theme.paths.ghosttyCurrentFile}"
|
ln -sfn "$ghostty_target" "${customScripts.theme.paths.ghosttyCurrentFile}"
|
||||||
ln -sfn "$tmux_target" "${customScripts.theme.paths.tmuxCurrentFile}"
|
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";
|
gray = "#928374";
|
||||||
};
|
};
|
||||||
wallpapers = {
|
wallpapers = {
|
||||||
dark = ../assets/wallpapers/topography-dark.jpg;
|
dir = "${config.home.homeDirectory}/Pictures/Screensavers";
|
||||||
light = ../assets/wallpapers/topography-light.jpg;
|
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 = {
|
paths = {
|
||||||
stateDir = "${config.xdg.stateHome}/theme";
|
stateDir = "${config.xdg.stateHome}/theme";
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,15 @@ let
|
||||||
runtimeInputs = with pkgs; [ nix ];
|
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 {
|
theme = mkScript {
|
||||||
name = "theme";
|
name = "theme";
|
||||||
file = ./theme.sh;
|
file = ./theme.sh;
|
||||||
|
|
@ -110,8 +119,12 @@ let
|
||||||
"@LAZYGIT_DARWIN_FILE@" = "${config.home.homeDirectory}/Library/Application Support/lazygit/config.yml";
|
"@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_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";
|
"@LAZYGIT_DARWIN_LIGHT_FILE@" = "${config.home.homeDirectory}/Library/Application Support/lazygit/config-light.yml";
|
||||||
"@WALLPAPER_DARK_FILE@" = "${theme.wallpapers.dark}";
|
"@WALLPAPER_DIR@" = theme.wallpapers.dir;
|
||||||
"@WALLPAPER_LIGHT_FILE@" = "${theme.wallpapers.light}";
|
"@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() {
|
usage() {
|
||||||
echo "usage: theme <dark|light|toggle|current>"
|
echo "usage: theme <dark|light|toggle|gen>"
|
||||||
}
|
}
|
||||||
|
|
||||||
read_mode() {
|
read_mode() {
|
||||||
|
|
@ -14,6 +14,21 @@ read_mode() {
|
||||||
echo "@DEFAULT_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() {
|
link_mode_assets() {
|
||||||
local mode="$1"
|
local mode="$1"
|
||||||
local fzf_target
|
local fzf_target
|
||||||
|
|
@ -44,13 +59,17 @@ link_mode_assets() {
|
||||||
;;
|
;;
|
||||||
esac
|
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@"
|
printf '%s\n' "$mode" > "@STATE_FILE@"
|
||||||
ln -sfn "$fzf_target" "@FZF_CURRENT_FILE@"
|
ln -sfn "$fzf_target" "@FZF_CURRENT_FILE@"
|
||||||
ln -sfn "$ghostty_target" "@GHOSTTY_CURRENT_FILE@"
|
ln -sfn "$ghostty_target" "@GHOSTTY_CURRENT_FILE@"
|
||||||
ln -sfn "$tmux_target" "@TMUX_CURRENT_FILE@"
|
ln -sfn "$tmux_target" "@TMUX_CURRENT_FILE@"
|
||||||
ln -sfn "$lazygit_target" "@LAZYGIT_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
|
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
|
tmux source-file "@TMUX_CONFIG@" >/dev/null 2>&1 || true
|
||||||
fi
|
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 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
|
osascript <<'EOF' >/dev/null 2>&1 || true
|
||||||
tell application "System Events"
|
tell application "System Events"
|
||||||
|
|
@ -91,7 +110,7 @@ EOF
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
mode="${1:-current}"
|
mode="${1:-}"
|
||||||
|
|
||||||
case "$mode" in
|
case "$mode" in
|
||||||
dark|light)
|
dark|light)
|
||||||
|
|
@ -103,8 +122,10 @@ case "$mode" in
|
||||||
mode="dark"
|
mode="dark"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
current)
|
gen)
|
||||||
read_mode
|
wallpaper-gen
|
||||||
|
set_wallpaper
|
||||||
|
printf 'generated new wallpaper\n'
|
||||||
exit 0
|
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