Revert to hand-drawn weather icons, styled after EC's set but exact panel colors
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user