From 90a014d1616fa329f1e052e3e3c2696ec180485d Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Mon, 27 Jul 2026 17:32:51 +0000 Subject: [PATCH] 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. --- docs/widgets.md | 16 +- server/app/calendar_render.py | 6 +- server/app/weather_icons/SOURCE.md | 34 ---- server/app/weather_icons/clear.gif | Bin 2181 -> 0 bytes server/app/weather_icons/cloudy.gif | Bin 2081 -> 0 bytes server/app/weather_icons/fog.gif | Bin 2972 -> 0 bytes server/app/weather_icons/partly_cloudy.gif | Bin 2661 -> 0 bytes server/app/weather_icons/rain.gif | Bin 2573 -> 0 bytes server/app/weather_icons/snow.gif | Bin 2763 -> 0 bytes server/app/weather_icons/thunderstorm.gif | Bin 2829 -> 0 bytes server/app/weather_render.py | 176 ++++++++++++++++----- server/tests/test_weather_render.py | 104 +++++++----- 12 files changed, 217 insertions(+), 119 deletions(-) delete mode 100644 server/app/weather_icons/SOURCE.md delete mode 100644 server/app/weather_icons/clear.gif delete mode 100644 server/app/weather_icons/cloudy.gif delete mode 100644 server/app/weather_icons/fog.gif delete mode 100644 server/app/weather_icons/partly_cloudy.gif delete mode 100644 server/app/weather_icons/rain.gif delete mode 100644 server/app/weather_icons/snow.gif delete mode 100644 server/app/weather_icons/thunderstorm.gif 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 15f51e569fd8153bb2b73bcc6e6beb1ebf37ab74..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2181 zcmd5+X;@Qd7QSJT$R@#nFrr)nXh5=(gd{*At58G}M8z4b#N-MY$YK&8(oTz@h?TV> z+E%PkY_S#v(E)MPh&n7*qzXvY7QwQJR&Cu$ZfSz=GJBK zje5lV#M1LVVtI~OUYJ`G%&m#=&99N^->l;u)}K$q{Z;S}2P~I9G#`sGpNO-ZS`Ghn z#B%8)>yt6$`F(ik3;0Sg^74^&>Q~eK-VP-)A`onVX~GkrU=)vDT>`xNoQR zK|eg)Y#nWbzb~_NW+PS@zVRhIP;I$f2oE<|x{TJl-N>`M@U=?#cC)1`*W4yS%+Jhi z%i&vxkmvW{TMgEy%;|1cOHDT0={_=zTE=%@3P)$H#bEh2m<`yi~z(XB~bzj zWd<2KN5vSmGI=Is05?GCN_8~h;i;Lm8K+gI4A(PnPFp$~g)` z%sXI|k!w_GRgggj8dX_pJ=YjbkSR19ATGML9a9M4D-lCxG=XQk0Bx88jSd2tiT!L1rYW^?@@MgpgjYQ)&%LjT*FBlx@&t8=?uQ zr+^sV-NbgJcGcE?J|=nqhz>Lu1hBGc;OIu#yE5*C^ErO|Cw~R-Qki z6+${?4x|w4G%D~_e!0rG;0xFcA)iB!5b+p15uHZkFnB^Sk0GM+c&JO^V!~@(#s88s z3hE5SMvng?%ghnVK-=;S_0i3n#{;R+7o$U;1_o^&7W4GW7tfzPee(FXUmrbu@XP&s zQ+FpP#>alXGdgm6_@^Il-MsO`(DlKA{%cpi@4M1_`MXO!-CdpkzIfq$$2rs4GpGM` zs{LeJ>pxE%Z#mZ7bo5B$w}-zu)bRCJ2kXDAt3B|={+fNCfA;C#J=MFbc70M=QC?PB zvh(8|#oLRvZ7nR=lK;_%|M=kjyv@ekoNR+$w@Ir}tFkixuFOc^2r1+nWNGW)TbKH` zzphPLvwBtX$|R{|#qz{u3Gc?oEw#~!&x?(DCps#U%V9^bn2c~bjY=Vt!j^=F5Em~B z4hkd$fc^^?_|5-|ug^ShFZ^6joW~q@H&++u*;uDpjt=&A7yt-(>45&FK$xxL*?Kl6i*O>5XAyR>lgYn5_+CD*2?qi8Sf1K(iz#g_-lNx!T|aPtu(*J2 z$3Eo+bkf~Sag(0OfcH*csZlc2R%usGbWSmivgpn+#A78vY29>8B{7`u+YmD8fOnIY zJW6#Qo4^w3#bO>nz+F2y`#t{N#V67V4=04Ufw|HjXFB+}kzcQ3N$Tt_k5TCs zH@|)BVn;tspX1u=*`Lyv!7HsXQJd7Kn>!uT{a9f-Ji$IRg~WZFJyX6 z8}C%QXZYg}0{fjEH%DIdh&_KaCnd7Z)Fo>i2wgBCfK5xXV{eXWuTQfdDjMBf=7wF5 zO z09=#2!!`|zF)z}1_!U3em6GRt=kx9=Y03e6kKiIY#)Eq+!@j_ww@p$&_6PK)*>3KH z3%I4&%5vAibNm2O#7Pj?SqK2<-H56AJQw#qkNsdsMPIekaSvddlULM^IQQQB#x8sR zM~^$g7P(Fp*O+{AGH97zbv|JN=2Z9GRw{UCF_1C11=^#l;}d-iQ#-JJL6XlJYO*Bu R-|WZ7&Oh9=&=U(p{toyaOIiQ` diff --git a/server/app/weather_icons/cloudy.gif b/server/app/weather_icons/cloudy.gif deleted file mode 100644 index 464868b777cfedc65879a7a4b668ce20a669de37..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2081 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 D*K1ZD diff --git a/server/app/weather_icons/partly_cloudy.gif b/server/app/weather_icons/partly_cloudy.gif deleted file mode 100644 index 8de16ce3774e44f32e424a72e296d95644b659b7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2661 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 diff --git a/server/app/weather_icons/rain.gif b/server/app/weather_icons/rain.gif deleted file mode 100644 index 818a10cf8e301ac0a8564b2c8eadbcea2257588a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2573 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 WXgQuJ=(&E**K1)U^8gVG?D`L#$zpo| 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