Files
YMhut-box-C-/scripts/prepare-solar-textures.py
T
QWQLwToo f59190251d
build-winui / winui (push) Has been cancelled
Add project metadata and docs
2026-06-26 13:26:40 +08:00

333 lines
12 KiB
Python

#!/usr/bin/env python3
"""Download and spatially compress local solar-system texture assets.
The runtime uses 4096x2048 optimized images under the WinUI Assets folder.
Original 8K sources are archived outside the app assets so they are not copied
into the build output.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from shutil import copyfile
from PIL import Image, ImageEnhance, ImageFile, ImageFilter, ImageOps
ImageFile.MAXBLOCK = 128 * 1024 * 1024
ROOT = Path(__file__).resolve().parents[1]
SOURCE_DIR = ROOT / "assets" / "source-textures" / "solar"
OUTPUT_DIR = ROOT / "src" / "box-winUI" / "Assets" / "home-globe" / "textures" / "solar"
MANIFEST_PATH = OUTPUT_DIR / "solar-texture-manifest.json"
TARGET_SIZE = (4096, 2048)
USER_AGENT = "YMhutBoxTexturePipeline/1.0 (+local asset preparation)"
@dataclass(frozen=True)
class TextureJob:
body_id: str
source_name: str
output_name: str
url: str | None
credit: str
mode: str = "rgb"
quality: int = 94
contrast: float = 1.02
sharpness: float = 1.08
JOBS = [
TextureJob(
"mercury",
"8k_mercury.jpg",
"mercury.jpg",
"https://www.solarsystemscope.com/textures/download/8k_mercury.jpg",
"Solar System Scope 8K Mercury texture, NASA/JPL-derived public imagery.",
contrast=1.06,
sharpness=1.14,
),
TextureJob(
"venus",
"8k_venus_surface.jpg",
"venus.jpg",
"https://www.solarsystemscope.com/textures/download/8k_venus_surface.jpg",
"Solar System Scope 8K Venus surface texture, NASA/JPL-derived public imagery.",
contrast=1.04,
sharpness=1.1,
),
TextureJob(
"earth",
"8k_earth_daymap.jpg",
"earth.jpg",
"https://www.solarsystemscope.com/textures/download/8k_earth_daymap.jpg",
"Solar System Scope 8K Earth day map based on NASA Blue Marble imagery.",
contrast=1.03,
sharpness=1.1,
),
TextureJob(
"earth-clouds",
"8k_earth_clouds.jpg",
"earth-clouds.png",
"https://www.solarsystemscope.com/textures/download/8k_earth_clouds.jpg",
"Solar System Scope 8K Earth cloud map based on NASA imagery.",
mode="cloud-alpha",
quality=100,
contrast=1.18,
sharpness=1.08,
),
TextureJob(
"mars",
"8k_mars.jpg",
"mars.jpg",
"https://www.solarsystemscope.com/textures/download/8k_mars.jpg",
"Solar System Scope 8K Mars texture, NASA/JPL-derived public imagery.",
contrast=1.06,
sharpness=1.15,
),
TextureJob(
"jupiter",
"8k_jupiter.jpg",
"jupiter.jpg",
"https://www.solarsystemscope.com/textures/download/8k_jupiter.jpg",
"Solar System Scope high-resolution Jupiter texture, NASA/JPL-derived public imagery.",
contrast=1.04,
sharpness=1.12,
),
TextureJob(
"saturn",
"8k_saturn.jpg",
"saturn.jpg",
"https://www.solarsystemscope.com/textures/download/8k_saturn.jpg",
"Solar System Scope high-resolution Saturn texture, NASA/JPL-derived public imagery.",
contrast=1.04,
sharpness=1.12,
),
TextureJob(
"uranus",
"local_uranus_4k.png",
"uranus.jpg",
"local-png:src/box-winUI/Assets/home-globe/textures/solar/uranus.jpg",
"Local lossless 4K Uranus fallback source, retained because the public 8K direct link is unavailable.",
contrast=1.08,
sharpness=1.1,
),
TextureJob(
"neptune",
"local_neptune_4k.png",
"neptune.jpg",
"local-png:src/box-winUI/Assets/home-globe/textures/solar/neptune.jpg",
"Local lossless 4K Neptune fallback source, retained because the public 8K direct link is unavailable.",
contrast=1.08,
sharpness=1.1,
),
]
def download(url: str, destination: Path, force: bool) -> None:
if destination.exists() and destination.stat().st_size > 0 and not force:
return
destination.parent.mkdir(parents=True, exist_ok=True)
if url.startswith("local:"):
seed_path = ROOT / url.removeprefix("local:")
if not seed_path.exists():
raise FileNotFoundError(seed_path)
copyfile(seed_path, destination)
mb = destination.stat().st_size / 1024 / 1024
print(f"Archived local fallback {destination.name}: {mb:.2f} MB")
return
if url.startswith("local-png:"):
seed_path = ROOT / url.removeprefix("local-png:")
if not seed_path.exists():
raise FileNotFoundError(seed_path)
with Image.open(seed_path) as image:
image.convert("RGB").save(destination, "PNG", optimize=True, compress_level=9)
mb = destination.stat().st_size / 1024 / 1024
print(f"Archived lossless local fallback {destination.name}: {mb:.2f} MB")
return
tmp = destination.with_suffix(destination.suffix + ".part")
last_error: Exception | None = None
for attempt in range(1, 5):
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
with urllib.request.urlopen(request, timeout=180) as response:
total = int(response.headers.get("Content-Length") or 0)
loaded = 0
started = time.monotonic()
with tmp.open("wb") as out:
while True:
chunk = response.read(1024 * 512)
if not chunk:
break
out.write(chunk)
loaded += len(chunk)
if total:
percent = loaded / total * 100
sys.stdout.write(f"\rDownloading {destination.name}: {percent:5.1f}%")
sys.stdout.flush()
elapsed = max(time.monotonic() - started, 0.001)
if tmp.stat().st_size == 0:
raise RuntimeError("downloaded file is empty")
tmp.replace(destination)
mb = destination.stat().st_size / 1024 / 1024
print(f"\rDownloaded {destination.name}: {mb:.2f} MB in {elapsed:.1f}s")
return
except Exception as exc:
last_error = exc
wait_seconds = min(18, attempt * 4)
print(f"\nRetrying {destination.name} after {type(exc).__name__}: attempt {attempt}/4")
time.sleep(wait_seconds)
raise RuntimeError(f"download failed for {destination.name}") from last_error
def resize_source(image: Image.Image, target_size: tuple[int, int]) -> Image.Image:
image = ImageOps.exif_transpose(image)
if image.size != target_size:
image = image.resize(target_size, Image.Resampling.LANCZOS)
return image
def enhance_rgb(image: Image.Image, job: TextureJob) -> Image.Image:
image = image.convert("RGB")
if job.contrast != 1:
image = ImageEnhance.Contrast(image).enhance(job.contrast)
if job.sharpness != 1:
image = ImageEnhance.Sharpness(image).enhance(job.sharpness)
return image.filter(ImageFilter.UnsharpMask(radius=0.9, percent=80, threshold=2))
def build_cloud_alpha(image: Image.Image, job: TextureJob) -> Image.Image:
image = enhance_rgb(image, job)
luminance = image.convert("L")
alpha = ImageEnhance.Contrast(luminance).enhance(1.35)
white = Image.new("RGB", image.size, (255, 255, 255))
rgba = Image.merge("RGBA", (*white.split(), alpha))
return rgba
def source_path_for(job: TextureJob) -> Path:
return SOURCE_DIR / job.source_name
def output_path_for(job: TextureJob) -> Path:
return OUTPUT_DIR / job.output_name
def optimize(job: TextureJob) -> dict[str, object]:
source_path = SOURCE_DIR / job.source_name
output_path = OUTPUT_DIR / job.output_name
print(f"Optimizing {job.body_id}: {source_path.name} -> {output_path.name}")
with Image.open(source_path) as original:
source_size = original.size
source_mode = original.mode
source_raw_bytes = source_size[0] * source_size[1] * max(len(original.getbands()), 3)
image = resize_source(original, TARGET_SIZE)
if job.mode == "cloud-alpha":
image = build_cloud_alpha(image, job)
output_path.parent.mkdir(parents=True, exist_ok=True)
image.save(output_path, "PNG", optimize=True, compress_level=9)
else:
image = enhance_rgb(image, job)
output_path.parent.mkdir(parents=True, exist_ok=True)
image.save(
output_path,
"JPEG",
quality=job.quality,
optimize=True,
progressive=True,
subsampling=0,
)
return {
"id": job.body_id,
"sourceUrl": job.url,
"sourceCredit": job.credit,
"sourceArchivePath": str(source_path.relative_to(ROOT)).replace("\\", "/"),
"sourceBytes": source_path.stat().st_size,
"sourceImageSize": {"width": source_size[0], "height": source_size[1], "mode": source_mode},
"sourceRawBytes": source_raw_bytes,
"optimizedPath": str(output_path.relative_to(ROOT)).replace("\\", "/"),
"optimizedBytes": output_path.stat().st_size,
"optimizedImageSize": {"width": TARGET_SIZE[0], "height": TARGET_SIZE[1]},
"compression": "spatial Lanczos resize to 4096x2048 plus high-quality JPEG/PNG optimization",
}
def write_manifest(entries: list[dict[str, object]]) -> None:
total_source_bytes = sum(int(entry["sourceBytes"]) for entry in entries)
total_source_raw_bytes = sum(int(entry["sourceRawBytes"]) for entry in entries)
total_optimized_bytes = sum(int(entry["optimizedBytes"]) for entry in entries)
manifest = {
"version": 1,
"generatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"policy": {
"sourceArchive": "Original high-resolution sources are local but outside WinUI copied assets.",
"runtimeAssets": "Runtime textures are local 4096x2048 spatially compressed images.",
"minimumSourceRawBytes": 60 * 1024 * 1024,
},
"totals": {
"sourceBytes": total_source_bytes,
"sourceRawBytes": total_source_raw_bytes,
"optimizedBytes": total_optimized_bytes,
"sourceBytesMB": round(total_source_bytes / 1024 / 1024, 2),
"sourceRawBytesMB": round(total_source_raw_bytes / 1024 / 1024, 2),
"optimizedBytesMB": round(total_optimized_bytes / 1024 / 1024, 2),
},
"sources": [
"https://www.solarsystemscope.com/textures/",
"https://visibleearth.nasa.gov/collection/1484/blue-marble",
"https://maps.jpl.nasa.gov/tmaps/",
],
"textures": entries,
}
MANIFEST_PATH.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
raw_mb = manifest["totals"]["sourceRawBytesMB"]
source_mb = manifest["totals"]["sourceBytesMB"]
optimized_mb = manifest["totals"]["optimizedBytesMB"]
print(f"Source archive: {source_mb:.2f} MB downloaded")
print(f"Source raw pixels: {raw_mb:.2f} MB before spatial compression")
print(f"Optimized runtime: {optimized_mb:.2f} MB")
if total_source_raw_bytes < manifest["policy"]["minimumSourceRawBytes"]:
raise RuntimeError("Source raw texture budget is below 60 MB")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--force", action="store_true", help="redownload and rebuild all textures")
parser.add_argument("--download-workers", type=int, default=4, help="parallel download workers")
args = parser.parse_args()
workers = max(1, min(args.download_workers, len(JOBS)))
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {
executor.submit(download, job.url or "", source_path_for(job), args.force): job
for job in JOBS
}
for future in as_completed(futures):
job = futures[future]
try:
future.result()
except Exception as exc:
raise RuntimeError(f"Failed to download {job.source_name}") from exc
entries = [optimize(job) for job in JOBS]
write_manifest(entries)
if __name__ == "__main__":
main()