Revert to hand-drawn weather icons, styled after EC's set but exact panel colors
Build and push server image / test (push) Successful in 30s
Build and push server image / build-and-push (push) Successful in 2m4s
Build and push server image / deploy (push) Successful in 49s

The vendored EC bitmaps looked good but dither into a visible speckle
once quantized to the panel's 6-color palette (their colors are
anti-aliased/arbitrary RGB, essentially never an exact palette match).
Hand-drawn icons filled with the frame's actual ink colors quantize with
zero dithering error to diffuse -- confirmed by running both through the
real quantize pass: the bitmap version speckles, the hand-drawn one is
pixel-identical before and after.

Redrawn to look more like EC's style this time around: pointed
triangular sun rays (the earlier attempt's thin-line rays read as a
crosshair, not a sun) and dendrite snowflakes (tick marks near each tip,
not a bare asterisk), plus the same cloud/raindrop/lightning-bolt shapes
as before. Removed the vendored server/app/weather_icons/ directory
entirely -- no longer used, and removes the icon-image licensing
question along with it.
This commit is contained in:
2026-07-27 17:32:51 +00:00
parent efb0f2e22d
commit 90a014d161
12 changed files with 217 additions and 119 deletions
+64 -40
View File
@@ -3,15 +3,18 @@ Mirrors calendar_render.py's own testing posture (this module has no
dedicated test file there either, since it's exercised indirectly via
test_widgets_calendar.py) but covers the non-obvious behavior worth
pinning down directly: build_multi_city's label shortening, and that
every shared category actually has a distinct vendored icon (a missing
or misnamed asset file would otherwise only surface as a silent
fallback to the "cloudy" glyph, not an error)."""
weather icons are filled with the panel's *exact* ink colors (not an
arbitrary bitmap's anti-aliased ones) -- the whole reason icons are
hand-drawn rather than a fetched/vendored image: an exact palette match
quantizes with zero dithering error to diffuse, where anything else
dithers into a visible speckle at these small on-panel sizes."""
from __future__ import annotations
from PIL import Image
from PIL import Image, ImageDraw
from app import weather_render
from app.image_pipeline import DEFAULT_PALETTE_RGB, _quantize
def test_build_multi_city_shortens_full_geocoder_labels_for_display(monkeypatch):
@@ -25,9 +28,10 @@ def test_build_multi_city_shortens_full_geocoder_labels_for_display(monkeypatch)
seen_labels = []
real_draw_weather_row = weather_render.draw_weather_row
def spy(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels=True):
def spy(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels=True, palette_rgb=None):
seen_labels.extend(e["label"] for e in entries)
return real_draw_weather_row(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels)
return real_draw_weather_row(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels,
palette_rgb)
monkeypatch.setattr(weather_render, "draw_weather_row", spy)
@@ -45,40 +49,60 @@ def test_build_multi_city_empty_list_returns_blank_canvas():
assert img.size == (400, 150)
def test_icon_asset_exists_and_loads_for_every_shared_category():
"""The shared category set (see app/weather/__init__.py's module
docstring) must each resolve to a real vendored file, not silently
fall back to "cloudy" -- a typo'd filename would otherwise only show
up as every OTHER category quietly looking like a plain cloud."""
for category in ("clear", "partly_cloudy", "cloudy", "fog", "rain", "snow", "thunderstorm"):
path = weather_render._ICON_DIR / f"{category}.gif"
assert path.exists(), f"missing vendored icon for {category!r}"
icon = weather_render._icon_asset(category)
assert icon.mode == "RGBA"
assert icon.width > 0 and icon.height > 0
def _colors_present(img: Image.Image) -> set[tuple[int, int, int]]:
return {c for _, c in img.getcolors(maxcolors=100_000)}
def test_icon_asset_is_cached():
assert weather_render._icon_asset("clear") is weather_render._icon_asset("clear")
def test_draw_weather_icon_pastes_a_visibly_different_glyph_per_category():
"""Not a pixel-exact check (the actual art is a vendored asset, not
something this test should hardcode) -- just confirms distinct
categories actually produce visibly different canvases, i.e. the
right file is being loaded per category rather than one glyph
silently reused for all of them."""
rendered = {}
for category in ("clear", "rain", "snow", "thunderstorm"):
img = Image.new("RGB", (100, 100), (255, 255, 255))
weather_render.draw_weather_icon(img, 50, 50, 30, category)
rendered[category] = img.tobytes()
assert len(set(rendered.values())) == len(rendered)
def test_draw_weather_icon_unrecognized_category_falls_back_to_cloudy():
def test_draw_weather_icon_clear_uses_exact_panel_yellow():
img = Image.new("RGB", (100, 100), (255, 255, 255))
weather_render.draw_weather_icon(img, 50, 50, 30, "not_a_real_category")
fallback = Image.new("RGB", (100, 100), (255, 255, 255))
weather_render.draw_weather_icon(fallback, 50, 50, 30, "cloudy")
assert img.tobytes() == fallback.tobytes()
draw = ImageDraw.Draw(img)
weather_render.draw_weather_icon(draw, 50, 50, 30, "clear")
assert tuple(DEFAULT_PALETTE_RGB[2]) in _colors_present(img) # yellow sun
assert (0, 0, 0) not in _colors_present(img) # not a flat-black glyph
def test_draw_weather_icon_rain_uses_exact_panel_blue():
img = Image.new("RGB", (100, 100), (255, 255, 255))
draw = ImageDraw.Draw(img)
weather_render.draw_weather_icon(draw, 50, 50, 30, "rain")
assert tuple(DEFAULT_PALETTE_RGB[4]) in _colors_present(img) # blue raindrops
def test_draw_weather_icon_thunderstorm_uses_exact_panel_yellow():
img = Image.new("RGB", (100, 100), (255, 255, 255))
draw = ImageDraw.Draw(img)
weather_render.draw_weather_icon(draw, 50, 50, 30, "thunderstorm")
assert tuple(DEFAULT_PALETTE_RGB[2]) in _colors_present(img) # yellow bolt
def test_draw_weather_icon_respects_a_custom_frame_palette():
"""A frame with an Advanced-configuration palette override (see
Frame.palette_rgb) should still get ITS actual yellow, not the
hardcoded default -- same resolution rule as calendar_render.py's
_event_colors."""
custom_palette = [(0, 0, 0), (255, 255, 255), (10, 20, 30), (0, 0, 0), (0, 0, 0), (0, 0, 0)]
img = Image.new("RGB", (100, 100), (255, 255, 255))
draw = ImageDraw.Draw(img)
weather_render.draw_weather_icon(draw, 50, 50, 30, "clear", palette_rgb=custom_palette)
assert (10, 20, 30) in _colors_present(img)
def test_icon_fill_quantizes_uniformly_with_no_dithering_speckle():
"""The actual point of hand-drawing icons in exact panel colors: a
small crop taken from deep inside the sun disc (nowhere near its own
edge) is a single flat fill -- if that fill weren't an exact palette
match, _quantize's Floyd-Steinberg dithering would diffuse rounding
error across it, breaking the crop into a speckle of 2+ colors. An
exact match has zero error to diffuse, so the crop stays perfectly
uniform after quantizing, same as before."""
img = Image.new("RGB", (200, 200), (255, 255, 255))
draw = ImageDraw.Draw(img)
weather_render.draw_weather_icon(draw, 100, 100, 60, "clear")
quantized = _quantize(img, DEFAULT_PALETTE_RGB, dither_strength=1.0).convert("RGB")
# A 20x20 crop centered on the disc -- draw_sun's disc radius is
# 0.55*60=33px, comfortably clear of both the outer edge and the ray
# triangles' base line.
crop = quantized.crop((90, 90, 110, 110))
colors_in_crop = _colors_present(crop)
assert colors_in_crop == {tuple(DEFAULT_PALETTE_RGB[2])} # solid yellow, no speckle