diff --git a/docs/widgets.md b/docs/widgets.md index fd6f6cd..3edfa15 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -253,11 +253,17 @@ site, however far" matched a Miami, FL query to a site in Ontario, embedded strip, unchanged) plus this widget's own `build_current`/ `build_hourly`/`build_daily`/`build_multi_city`, dispatched by `build()` -- the weather analogue of `calendar_render.py`'s own `_build_tasks`/ -`render_tasks_preview_png` relationship. Icons are vendored bitmaps (`app/ -weather_icons/`, one per shared category, sourced from Environment -Canada's own weather icon set -- see that directory's `SOURCE.md` for -attribution/licensing), not hand-drawn, and used for every provider's -rendering, not just when EC is selected as the provider. +`render_tasks_preview_png` relationship. Icons are hand-drawn (no custom +font/icon asset), styled after Environment Canada's own icon set +(pointed sun rays, a puffy cloud, teardrop rain, dendrite snowflakes, a +zigzag bolt) but filled with the panel's *exact* ink RGB values rather +than an arbitrary bitmap's anti-aliased colors -- a flat fill that's +already a palette color quantizes with zero dithering error to diffuse, +where a fetched/vendored icon's colors (almost never an exact match) +dither into a visible speckle at these small on-panel sizes (confirmed +by actually running one through the real quantize pass during +development). Used for every provider's rendering, not just when EC is +selected as the provider. ## Known gaps (Phase 6, not yet done) diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py index b7479f2..dccb7f7 100644 --- a/server/app/calendar_render.py +++ b/server/app/calendar_render.py @@ -414,7 +414,8 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve weather_entries = _weather_for_day(weather_cities, day) if weather_entries: y += draw_weather_row(img, draw, text_x0, y, text_w, weather_entries, - icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units) + icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units, + palette_rgb=palette_rgb) day_events = _events_on_day(events, day, tz) row_h = body_font.size + 14 @@ -636,7 +637,8 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h: weather_entries = _weather_for_day(weather_cities, day) if weather_entries: y += draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries, - icon_r=8, font=weather_font, units=weather_units, show_labels=False) + icon_r=8, font=weather_font, units=weather_units, show_labels=False, + palette_rgb=palette_rgb) row_h = chip_font.size + 10 max_rows = max(0, (target_h - MARGIN - y) // row_h) day_events = _events_on_day(events, day, tz) diff --git a/server/app/weather_icons/SOURCE.md b/server/app/weather_icons/SOURCE.md deleted file mode 100644 index d4eaa16..0000000 --- a/server/app/weather_icons/SOURCE.md +++ /dev/null @@ -1,34 +0,0 @@ -# Weather icons - -Source: Environment and Climate Change Canada's public weather icon set, -served from `https://weather.gc.ca/weathericons/`. - -© His Majesty the King in Right of Canada, as represented by the Minister -of Environment and Climate Change Canada. Vendored (not fetched live at -render time) under Canada.ca's non-commercial-reproduction terms -(https://www.canada.ca/en/transparency/terms.html) -- this is a personal, -non-commercial project. If this project's use ever becomes commercial, -these specific image files need their own clearance (the ECCC Data -Servers End-use Licence that covers the weather *data* app/weather/ec.py -consumes is more permissive, but that licence is scoped to ECCC's data -servers -- these icons are served from the public website, not that -domain, so it doesn't clearly cover them). - -One icon per shared weather category (see app/weather/__init__.py's -module docstring for the category set), picked as a representative -"day" variant from EC's full icon set -(https://weather.gc.ca/mainmenu/icon_e.html has the legend): - -| file | EC icon code | original URL | -|---------------------|:------------:|--------------------------------------------------| -| clear.gif | 00 | https://weather.gc.ca/weathericons/00.gif | -| partly_cloudy.gif | 02 | https://weather.gc.ca/weathericons/02.gif | -| cloudy.gif | 10 | https://weather.gc.ca/weathericons/10.gif | -| fog.gif | 24 | https://weather.gc.ca/weathericons/24.gif | -| rain.gif | 12 | https://weather.gc.ca/weathericons/12.gif | -| snow.gif | 17 | https://weather.gc.ca/weathericons/17.gif | -| thunderstorm.gif | 19 | https://weather.gc.ca/weathericons/19.gif | - -Used for every weather provider's rendering (Open-Meteo, NWS, and EC -itself), not just when EC is the selected provider -- see -app/weather_render.py's draw_weather_icon. diff --git a/server/app/weather_icons/clear.gif b/server/app/weather_icons/clear.gif deleted file mode 100644 index 15f51e5..0000000 Binary files a/server/app/weather_icons/clear.gif and /dev/null differ diff --git a/server/app/weather_icons/cloudy.gif b/server/app/weather_icons/cloudy.gif deleted file mode 100644 index 464868b..0000000 Binary files a/server/app/weather_icons/cloudy.gif and /dev/null differ diff --git a/server/app/weather_icons/fog.gif b/server/app/weather_icons/fog.gif deleted file mode 100644 index 9df3002..0000000 Binary files a/server/app/weather_icons/fog.gif and /dev/null differ diff --git a/server/app/weather_icons/partly_cloudy.gif b/server/app/weather_icons/partly_cloudy.gif deleted file mode 100644 index 8de16ce..0000000 Binary files a/server/app/weather_icons/partly_cloudy.gif and /dev/null differ diff --git a/server/app/weather_icons/rain.gif b/server/app/weather_icons/rain.gif deleted file mode 100644 index 818a10c..0000000 Binary files a/server/app/weather_icons/rain.gif and /dev/null differ diff --git a/server/app/weather_icons/snow.gif b/server/app/weather_icons/snow.gif deleted file mode 100644 index 3313f75..0000000 Binary files a/server/app/weather_icons/snow.gif and /dev/null differ diff --git a/server/app/weather_icons/thunderstorm.gif b/server/app/weather_icons/thunderstorm.gif deleted file mode 100644 index 6644ace..0000000 Binary files a/server/app/weather_icons/thunderstorm.gif and /dev/null differ diff --git a/server/app/weather_render.py b/server/app/weather_render.py index b903dab..84fdbc3 100644 --- a/server/app/weather_render.py +++ b/server/app/weather_render.py @@ -12,25 +12,28 @@ cloudy/fog/rain/snow/thunderstorm set, never a raw provider code) and returns an RGB Image exactly target_w x target_h, same contract every other widget renderer in this project follows. -Icons are Environment Canada's own weather icon set (vendored at -app/weather_icons/, one per shared category -- see that directory's -SOURCE.md for attribution/licensing), not hand-drawn: these are small, -flat-shaded bitmaps that dither cleanly onto the panel's 6-color -palette, and read as recognizable weather icons at a glance in a way an -earlier hand-drawn attempt (a plain circle-with-ticks "sun") didn't. -Used for every provider's rendering, not just when EC is selected. +Icons are hand-drawn (no custom font/icon asset, same primitives-only +approach calendar_render.py uses elsewhere for e.g. month view's density +dots), styled after Environment Canada's own icon set (pointed sun rays, +a smooth puffy cloud, teardrop rain, dendrite snowflakes, a zigzag bolt) +but filled with this frame's *exact* panel ink RGB values rather than an +arbitrary bitmap's anti-aliased colors -- a flat fill that's already one +of the palette's 6 colors quantizes with zero dithering error to diffuse, +where a fetched/vendored bitmap's colors (almost never an exact palette +match) dither into a visible speckle. An early plain circle-with-4-ticks +"sun" also just didn't read as a sun at a glance -- pointed triangular +rays fixed that without giving up the clean-quantization property. """ from __future__ import annotations import io +import math from datetime import date, datetime -from functools import lru_cache -from pathlib import Path from PIL import Image, ImageDraw, ImageFont -from .image_pipeline import _apply_manage_overlay, _quantize, draw_text, logical_render_size +from .image_pipeline import DEFAULT_PALETTE_RGB, _apply_manage_overlay, _quantize, draw_text, logical_render_size MARGIN = 20 BG = (255, 255, 255) @@ -38,37 +41,133 @@ FG = (0, 0, 0) MUTED = (110, 110, 110) RULE = (0, 0, 0) -_ICON_DIR = Path(__file__).parent / "weather_icons" -_ICON_FALLBACK = "cloudy" # unrecognized category (shouldn't happen with a valid provider) -- see draw_weather_icon + +def _ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]: + """One of this frame's actual panel colors by DEFAULT_PALETTE_RGB + index (2=yellow, 3=red, 4=blue, 5=green -- 0/1 are black/white, + already this module's BG/FG) -- same resolution idiom as + calendar_render.py's _event_colors, so a custom palette override + (Frame.palette_rgb) still gets its own actual yellow/blue, and every + fill stays an exact, ditherless palette match either way.""" + return tuple((palette_rgb or DEFAULT_PALETTE_RGB)[index]) -@lru_cache(maxsize=None) -def _icon_asset(category: str) -> Image.Image: - """This category's vendored icon, loaded once (a handful of small - static files, never fetched over the network at render time) and - cached at its native resolution -- draw_weather_icon resizes a copy - per call, since the requested size varies with icon_r.""" - path = _ICON_DIR / f"{category}.gif" - if not path.exists(): - path = _ICON_DIR / f"{_ICON_FALLBACK}.gif" # unrecognized category -- shouldn't happen with a valid provider - return Image.open(path).convert("RGBA") +def draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill=BG, outline=FG) -> None: + """A simple puffy-cloud silhouette (three overlapping lobes + a base) + with a clean outline -- drawn as one outline-color pass slightly + larger than the shapes, then the same shapes again in `fill` on top. + Overlapping ellipses each drawn with their own `outline=` would leave + visible seams where they cross; this double-draw trick sidesteps that + entirely regardless of how the lobes overlap.""" + stroke = 2 + lobes = [ + (cx - r, cy - r * 0.3, cx - r * 0.1, cy + r * 0.6), + (cx - r * 0.45, cy - r * 0.8, cx + r * 0.35, cy + r * 0.25), + (cx, cy - r * 0.35, cx + r, cy + r * 0.6), + ] + base = (cx - r * 0.8, cy, cx + r * 0.8, cy + r * 0.5) + for x0, y0, x1, y1 in lobes: + draw.ellipse([x0 - stroke, y0 - stroke, x1 + stroke, y1 + stroke], fill=outline) + draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=outline) + for x0, y0, x1, y1 in lobes: + draw.ellipse([x0, y0, x1, y1], fill=fill) + draw.rectangle(base, fill=fill) -def draw_weather_icon(img: Image.Image, cx: float, cy: float, r: float, category: str) -> None: - """Pastes this category's icon (see module docstring), scaled to - roughly 2r wide, centered on (cx, cy). Alpha-composited via the - icon's own transparency (confirmed present in the source GIFs), so - only the glyph itself shows against whatever's already on `img`.""" - icon = _icon_asset(category) - size = max(1, round(r * 2)) - aspect_h = max(1, round(size * icon.height / icon.width)) - resized = icon.resize((size, aspect_h), Image.LANCZOS) - img.paste(resized, (round(cx - size / 2), round(cy - aspect_h / 2)), resized) +def draw_sun(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, color) -> None: + """A filled disc + 8 pointed triangular rays -- styled after + Environment Canada's own sun glyph. Rays are solid triangles (base on + the disc's edge, tip pointing outward), not thin lines: at small icon + sizes thin lines read as a crosshair/asterisk, not sun rays, which is + exactly what an earlier attempt here looked like.""" + draw.ellipse([cx - r * 0.55, cy - r * 0.55, cx + r * 0.55, cy + r * 0.55], fill=color) + base_r, tip_r, half_w = r * 0.6, r * 1.2, r * 0.16 + for i in range(8): + angle = i * (math.pi / 4) + perp = angle + math.pi / 2 + bx, by = cx + math.cos(angle) * base_r, cy + math.sin(angle) * base_r + tx, ty = cx + math.cos(angle) * tip_r, cy + math.sin(angle) * tip_r + p1 = (bx + math.cos(perp) * half_w, by + math.sin(perp) * half_w) + p2 = (bx - math.cos(perp) * half_w, by - math.sin(perp) * half_w) + draw.polygon([p1, p2, (tx, ty)], fill=color) + + +def draw_raindrop(draw: ImageDraw.ImageDraw, x: float, y: float, size: float, color) -> None: + """A rounded teardrop (point up, bulb down) -- the standard rain + glyph, not a bare diagonal tick.""" + draw.polygon([(x, y), (x - size * 0.38, y + size * 0.55), (x + size * 0.38, y + size * 0.55)], fill=color) + draw.ellipse([x - size * 0.4, y + size * 0.25, x + size * 0.4, y + size * 1.05], fill=color) + + +def draw_snowflake(draw: ImageDraw.ImageDraw, x: float, y: float, r: float, color) -> None: + """A 6-pointed dendrite -- three crossing lines plus a short + perpendicular tick near each of the 6 tips, closer to a real + snowflake glyph than a bare asterisk.""" + for i in range(3): + angle = i * (math.pi / 3) + dx, dy = math.cos(angle) * r, math.sin(angle) * r + draw.line([(x - dx, y - dy), (x + dx, y + dy)], fill=color, width=max(2, round(r * 0.28))) + perp = angle + math.pi / 2 + tick = r * 0.35 + for sign in (1, -1): + tx, ty = x + dx * sign, y + dy * sign + ex, ey = tx * 0.75 + x * 0.25, ty * 0.75 + y * 0.25 + draw.line([(ex - math.cos(perp) * tick, ey - math.sin(perp) * tick), + (ex + math.cos(perp) * tick, ey + math.sin(perp) * tick)], fill=color, width=2) + + +def draw_lightning_bolt(draw: ImageDraw.ImageDraw, cx: float, cy: float, size: float, color) -> None: + """A zigzag bolt polygon -- the standard lightning glyph, not a bare + 3-segment line.""" + points = [ + (cx + size * 0.15, cy - size * 0.7), + (cx - size * 0.35, cy + size * 0.05), + (cx - size * 0.05, cy + size * 0.05), + (cx - size * 0.2, cy + size * 0.7), + (cx + size * 0.4, cy - size * 0.1), + (cx + size * 0.05, cy - size * 0.1), + ] + draw.polygon(points, fill=color) + + +def draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str, + palette_rgb: list | None = None) -> None: + """A small glyph for one weather category, styled after Environment + Canada's own icon set but hand-drawn in this frame's exact panel ink + colors (yellow sun/bolt, blue rain/snow) -- see module docstring for + why that's better for this display than reusing an actual bitmap.""" + yellow = _ink(palette_rgb, 2) + blue = _ink(palette_rgb, 4) + + if category == "clear": + draw_sun(draw, cx, cy, r, yellow) + return + + if category == "partly_cloudy": + draw_sun(draw, cx - r * 0.45, cy - r * 0.45, r * 0.75, yellow) + draw_cloud(draw, cx + r * 0.1, cy + r * 0.2, r * 0.9) + return + + cloud_cy = cy if category in ("cloudy", "fog") else cy - r * 0.25 + draw_cloud(draw, cx, cloud_cy, r) + + if category == "fog": + for i in range(3): + y = cy + r * 0.55 + i * (r * 0.4) + draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2) + elif category == "rain": + for dx in (-0.55, 0, 0.55): + draw_raindrop(draw, cx + dx * r, cloud_cy + r * 0.55, r * 0.55, blue) + elif category == "snow": + for dx in (-0.55, 0, 0.55): + draw_snowflake(draw, cx + dx * r, cloud_cy + r * 0.85, r * 0.3, blue) + elif category == "thunderstorm": + draw_lightning_bolt(draw, cx, cloud_cy + r * 0.7, r * 0.7, yellow) def draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int, entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str, - show_labels: bool = True) -> int: + show_labels: bool = True, palette_rgb: list | None = None) -> int: """Draws one or more cities' weather side by side starting at (x0, y0), stopping once another entry wouldn't fit within max_w (narrow views like week columns just end up showing fewer cities -- @@ -88,7 +187,7 @@ def draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: i if drew_any and x + entry_w > x0 + max_w: break cx, cy = x + icon_r, y0 + icon_r - draw_weather_icon(img, cx, cy, icon_r, entry["category"]) + draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb) draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font) x += entry_w drew_any = True @@ -140,7 +239,7 @@ def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: icon_r = max(20, min(target_w, target_h) // 4) cx, cy = target_w // 2, target_h // 2 - icon_r // 2 - draw_weather_icon(img, cx, cy, icon_r, entry["category"]) + draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb) unit_suffix = "F" if units == "fahrenheit" else "C" temp_size = max(24, min(target_w, target_h) // 3) @@ -204,7 +303,7 @@ def build_hourly(entries: list[dict], target_w: int, target_h: int, palette_rgb: tbbox = draw.textbbox((0, 0), time_label, font=label_font) draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, label_font, MUTED) cy = y + label_size + 10 + icon_r - draw_weather_icon(img, cx, cy, icon_r, entry["category"]) + draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb) temp_label = temp_labels[i] tempbbox = draw.textbbox((0, 0), temp_label, font=label_font) draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, label_font) @@ -249,7 +348,7 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg lbbox = draw.textbbox((0, 0), label, font=label_font) draw_text(img, (x0 + col_w // 2 - (lbbox[2] - lbbox[0]) // 2, y), label, label_font, MUTED) cx, cy = x0 + col_w // 2, y + label_size + 10 + icon_r - draw_weather_icon(img, cx, cy, icon_r, d["category"]) + draw_weather_icon(draw, cx, cy, icon_r, d["category"], palette_rgb) tbbox = draw.textbbox((0, 0), temps, font=label_font) draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, label_font) return img @@ -287,7 +386,8 @@ def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_r font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r) font = ImageFont.load_default(size=font_size) y = max(MARGIN, (target_h - (icon_r * 2 + 8)) // 2) - draw_weather_row(img, draw, MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units, show_labels=True) + draw_weather_row(img, draw, MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units, + show_labels=True, palette_rgb=palette_rgb) return img diff --git a/server/tests/test_weather_render.py b/server/tests/test_weather_render.py index c088243..2581c39 100644 --- a/server/tests/test_weather_render.py +++ b/server/tests/test_weather_render.py @@ -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