Files
eink-push/renderer.py
YANG JIANKUAN 9b39eacecf feat: 渲染升级为黑/白/红三色,红作稀缺强调点缀
新设备在黑白外支持红色。将 renderer 画布从 'L' 灰度改为 'RGB' 三色,
RED=(255,0,0) 作为设备红通道的明确信号;fontmode='1' 保持点阵纯色不发糊。

红只承载语义、占墨极小(实测占墨迹 <10%),点缀四处:
- 定位图标(红水滴)
- 天气图标的太阳与闪电(云/雨/雪/雾/月仍黑)
- 环比/同比「涨」的三角与百分比数值(红涨;跌/标签仍黑)
- 趋势图主指标(流水)折线 + 末点红点 + 图例同步红

整图仅三种纯色,无灰阶、不抖动;dither 保持 false。
同步更新 CLAUDE.md / README.md 的三色约束与红点缀清单。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 18:19:38 +08:00

346 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
渲染层 —— 将数据绘制成 400x300 三色(黑/白/红)墨水屏仪表盘。
设计要点:
- 三色 BWR仍无真灰阶灰阶只能抖动成网点弃用底色黑白红作为稀缺强调色点缀。
红只承载语义——方向(涨)、警示、寻路锚点——占墨极小,靠构图 + 字号 + 留白建立层级。
- Fusion Pixel 点阵字体,按整数倍尺寸(12/24/48)渲染fontmode='1' 关闭抗锯齿 → 像素锐利。
- 重点数据用「伪粗体」(偏移叠绘)加粗,仍对齐像素网格。
布局:上=大时钟+日期 / 天气;中=橘喵今日经营三列;下=近30天三指标趋势折线图。
红点缀清单:时钟冒号 / 定位图标 / 天气图标的太阳与闪电 / 两处标题方块■ / 环比同比“涨”三角 / 趋势图主指标(单量)折线。
"""
import math
from PIL import Image, ImageDraw, ImageFont
import config
# 三色墨水屏(BWR)调色板:黑 / 白 / 纯正红。
# RED 用 (255,0,0) 作为设备红通道的明确信号——硬阈值量化下会干净地落到红色,不会被误判为黑。
# 设计准则:红是稀缺强调色,仅用于「方向/状态、警示、寻路锚点」,占墨比例应很小(≈全图 <10%),其余一律黑白。
BLACK, WHITE, RED = (0, 0, 0), (255, 255, 255), (255, 0, 0)
class DashboardRenderer:
def __init__(self, font_path=config.FONT_PATH, size=(config.WIDTH, config.HEIGHT)):
self.W, self.H = size
self.F12 = ImageFont.truetype(font_path, 12)
self.F24 = ImageFont.truetype(font_path, 24)
self.F48 = ImageFont.truetype(font_path, 48)
# ---------- 基础绘制工具 ----------
def _tw(self, s, ft):
return self.d.textlength(s, font=ft)
def _draw(self, x, y, s, ft, fill, bold):
# 伪粗体:点阵字体单一字重,按 1~bold px 偏移叠绘加粗笔画
self.d.text((x, y), s, font=ft, fill=fill)
for dx in range(1, bold + 1):
self.d.text((x + dx, y), s, font=ft, fill=fill)
def text(self, x, y, s, ft, fill=BLACK, bold=0):
self._draw(x, y, s, ft, fill, bold)
def rtext(self, xr, y, s, ft, fill=BLACK, bold=0):
self._draw(xr - self._tw(s, ft) - bold, y, s, ft, fill, bold)
def ctext(self, cx, y, s, ft, fill=BLACK, bold=0):
self._draw(cx - (self._tw(s, ft) + bold) / 2, y, s, ft, fill, bold)
def hdash(self, x0, x1, y, dash=4, gap=4, fill=BLACK):
x = x0
while x < x1:
self.d.line([x, y, min(x + dash, x1), y], fill=fill, width=1)
x += dash + gap
def vdash(self, x, y0, y1, dash=4, gap=4, fill=BLACK):
y = y0
while y < y1:
self.d.line([x, y, x, min(y + dash, y1)], fill=fill, width=1)
y += dash + gap
def tri(self, cx, cy, sz, up=True, fill=BLACK):
if up:
pts = [(cx, cy - sz / 2), (cx - sz / 2, cy + sz / 2), (cx + sz / 2, cy + sz / 2)]
else:
pts = [(cx, cy + sz / 2), (cx - sz / 2, cy - sz / 2), (cx + sz / 2, cy - sz / 2)]
self.d.polygon(pts, fill=fill)
# ---------- 组件 ----------
def _cmp_line(self, cx, y, label, pair, fg=BLACK):
"""环比/同比一行:标签 + 涨跌三角 + 数值整体居中。direction 为 None 时不画三角。"""
direction, val = pair
lw = self._tw(label, self.F12); gap = 5; nw = self._tw(val, self.F12)
trw, tgap = (8, 4) if direction else (0, 0)
x = cx - (lw + gap + trw + tgap + nw) / 2
self.text(x, y, label, self.F12, fill=fg); x += lw + gap
# 方向语义配色(红涨):涨 → 箭头与百分比数值一起红;跌/无 → 黑(标签始终黑)
up = direction == "up"
val_color = RED if up else fg
if direction:
# 三角中心对齐 F12 数字墨迹竖直中心(y+8.5),否则箭头偏高
self.tri(x + trw / 2, y + 8.5, 7, up=up, fill=val_color); x += trw + tgap
self.text(x, y, val, self.F12, fill=val_color)
def _title_bar(self, y, title, upd):
"""标题栏:■ 标题(左) + 上次更新 HH:mm"""
sq, sq_y = 6, y + 4
self.d.rectangle([10, sq_y, 10 + sq, sq_y + sq], fill=BLACK)
self.text(10 + sq + 6, y, title, self.F12)
self.rtext(self.W - 10, y, f"上次更新 {upd}", self.F12)
def _loc_pin(self, x, y, w=8, fg=BLACK, bg=WHITE):
"""定位图标:实心水滴(圆头+尖尾) + 白色挖孔1-bit 下轮廓清晰。
(x,y) 为头部圆左上角w 为头径,总高约 w*1.5。"""
cx, cy = x + w / 2, y + w / 2 # 头部圆心
self.d.ellipse([x, y, x + w, y + w], fill=fg) # 头部实心圆
self.d.polygon([(x, cy), (x + w, cy), (cx, y + w + w * 0.5)], fill=fg) # 尾部尖
hr = w * 0.2
self.d.ellipse([cx - hr, cy - hr, cx + hr, cy + hr], fill=bg) # 头部白孔
@staticmethod
def _weather_category(icon):
"""和风天气 icon 代码 → 可枚举类别。无法识别时回退 'cloudy'
参考 https://dev.qweather.com/docs/resource/icons/
100=晴日 150=晴夜 / 101~103,151~153=多云 / 104,154=阴 /
302~304=雷阵雨 / 300~399=雨 / 400~499=雪 / 500~599=雾霾沙尘。"""
try:
c = int(icon)
except (TypeError, ValueError):
return "cloudy"
if c == 100:
return "sunny"
if c == 150:
return "clear_night"
if c in (101, 102, 103, 151, 152, 153):
return "cloudy"
if c in (104, 154):
return "overcast"
if c in (302, 303, 304):
return "thunder"
if 300 <= c <= 399:
return "rain"
if 400 <= c <= 499:
return "snow"
if 500 <= c <= 599:
return "fog"
return "cloudy"
def _sun(self, cx, cy, r, s, fg, rays=True):
"""太阳:圆 + 8 道光芒(光芒可关)。"""
S = lambda v: v * s
self.d.ellipse([cx - r, cy - r, cx + r, cy + r], outline=fg, width=2)
if rays:
for a in range(0, 360, 45):
rad = math.radians(a)
self.d.line([cx + math.cos(rad) * (r + S(3)), cy + math.sin(rad) * (r + S(3)),
cx + math.cos(rad) * (r + S(7)), cy + math.sin(rad) * (r + S(7))], fill=fg, width=2)
def _cloud(self, x, cy, s, fg, bg):
"""一朵云cy 为云体中线高度bg 填充内部以盖住其后的太阳光芒。"""
S = lambda v: v * s
self.d.ellipse([x + S(4), cy - S(2), x + S(20), cy + S(12)], fill=bg)
self.d.ellipse([x + S(14), cy - S(8), x + S(34), cy + S(12)], fill=bg)
self.d.rectangle([x + S(6), cy + S(4), x + S(34), cy + S(12)], fill=bg)
self.d.arc([x + S(2), cy - S(4), x + S(22), cy + S(14)], 90, 270, fill=fg, width=2)
self.d.arc([x + S(12), cy - S(10), x + S(36), cy + S(14)], 270, 90, fill=fg, width=2)
self.d.line([x + S(9), cy + S(13), x + S(29), cy + S(13)], fill=fg, width=2)
def _weather_icon(self, x, y, category, s=1.0, fg=BLACK, bg=WHITE, accent=None):
"""按天气类别画图标s 为缩放系数,原生约 36x38左上角 x,y
accent 用于「有能量」的元素——太阳、闪电——着红;云/雨/雪/雾/月保持 fg(黑)。"""
S = lambda v: v * s
accent = accent or fg
if category == "sunny":
self._sun(x + S(18), y + S(19), S(11), s, accent)
return
if category == "clear_night": # 月牙:实心圆挖去偏上右的背景圆
mx, my, r = x + S(18), y + S(19), S(13)
self.d.ellipse([mx - r, my - r, mx + r, my + r], fill=fg)
ox, oy, r2 = mx + S(6), my - S(5), S(12)
self.d.ellipse([ox - r2, oy - r2, ox + r2, oy + r2], fill=bg)
return
if category == "overcast": # 阴:单独一朵云,居中偏下
self._cloud(x, y + S(20), s, fg, bg)
return
if category == "fog": # 雾/霾/沙尘:横向雾线,长短交错
for i, dy in enumerate((9, 17, 25, 33)):
inset = S(5) if i % 2 else 0
self.d.line([x + inset, y + S(dy), x + S(34) - inset, y + S(dy)], fill=fg, width=2)
return
if category != "rain" and category != "snow" and category != "thunder": # 多云及未识别:太阳 + 云
self._sun(x + S(10), y + S(9), S(6), s, accent) # 探出的太阳着红
self._cloud(x, y + S(24), s, fg, bg)
return
# 以下三类:云在上,降水/天气符号在云下方
cb = y + S(15) + S(13) # 云底 y
self._cloud(x, y + S(15), s, fg, bg)
if category == "rain":
for dx in (9, 18, 27):
self.d.line([x + S(dx) + S(3), cb + S(3), x + S(dx) - S(1), cb + S(10)], fill=fg, width=2)
elif category == "snow":
for dx in (10, 19, 28):
fx, fy, rr = x + S(dx), cb + S(7), S(4)
for a in (0, 60, 120):
rad = math.radians(a)
self.d.line([fx - math.cos(rad) * rr, fy - math.sin(rad) * rr,
fx + math.cos(rad) * rr, fy + math.sin(rad) * rr], fill=fg, width=1)
elif category == "thunder":
bx = x + S(18)
self.d.polygon([(bx - S(1), cb + S(2)), (bx + S(5), cb + S(2)), (bx + S(1), cb + S(8)),
(bx + S(6), cb + S(8)), (bx - S(4), cb + S(18)), (bx, cb + S(9)),
(bx - S(4), cb + S(9))], fill=accent) # 闪电着红作警示
# 线型样式:(实线段长, 间隙长);实线用 dash=0 表示连续
_STYLES = {"solid": (0, 0), "dashed": (7, 4), "dotted": (1, 2)}
def _styled_polyline(self, pts, style, fill=BLACK):
"""按线型(solid/dashed/dotted)沿折线绘制。点线/虚线靠等距步进采样。"""
dash, gap = self._STYLES.get(style, (0, 0))
if dash == 0: # 实线:直接连线
for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
self.d.line([x0, y0, x1, y1], fill=fill, width=1)
return
period, drawn = dash + gap, 0.0 # drawn: 当前所在周期内已走过的距离
for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
seg = math.hypot(x1 - x0, y1 - y0)
if seg == 0:
continue
ux, uy = (x1 - x0) / seg, (y1 - y0) / seg
t = 0.0
while t < seg:
phase = drawn % period
if phase < dash: # 处于“实”阶段 → 画一小段
step = min(dash - phase, seg - t)
sx, sy = x0 + ux * t, y0 + uy * t
ex, ey = x0 + ux * (t + step), y0 + uy * (t + step)
if dash <= 1: # 点线:画方点更醒目
self.d.point((round(sx), round(sy)), fill=fill)
else:
self.d.line([sx, sy, ex, ey], fill=fill, width=1)
t += step; drawn += step
else: # 处于“虚”阶段 → 跳过
step = min(period - phase, seg - t)
t += step; drawn += step
def _style_sample(self, x, y, style, w=22, fill=BLACK):
"""图例用的线型样例(一小段水平线)。"""
self._styled_polyline([(x, y), (x + w, y)], style, fill=fill)
def _trend_chart(self, y0, trend):
"""近30天三指标折线图标题(左) + 紧凑图例(右) + 折线 + X轴(仅月/日)。
各指标按自身极值独立归一化,故只表达趋势形状,不可横向比绝对值。"""
dates, series = trend["dates"], trend["series"]
leg_y = y0
# ---- 标题(左):■ 近30天趋势 ----
sq, sq_y = 6, leg_y + 4
self.d.rectangle([10, sq_y, 10 + sq, sq_y + sq], fill=BLACK)
self.text(10 + sq + 6, leg_y, "橘喵·30天趋势", self.F12)
# 无数据:画占位符 "--",跳过图例/折线/X轴
if not series or not dates or not any(s.get("data") for s in series):
self.ctext(self.W / 2, leg_y + 36, "--", self.F24)
return
# ---- 图例(右):紧凑、整体右对齐,从右往左逐项排布 ----
SAMP, G1, GAP = 16, 8, 10 # 样例线长 / 线与名间隙 / 图例项间隙
x = self.W - 10
for s in reversed(series):
c = RED if s.get("k") == "流水" else BLACK # 主指标(流水)图例同步着红
self.rtext(x, leg_y, s["k"], self.F12, fill=c) # 指标名右端对齐 x
x -= self._tw(s["k"], self.F12) + G1
self._style_sample(x - SAMP, leg_y + 7, s["style"], fill=c)
x -= SAMP + GAP
# ---- 绘图区 ----
px0, px1 = 14, self.W - 10
py0, py1 = leg_y + 22, leg_y + 22 + 56 # 顶/底
self.hdash(px0, px1, py1, dash=2, gap=3) # 基线
n = len(dates)
xstep = (px1 - px0) / (n - 1) if n > 1 else 0
for s in series:
color = RED if s.get("k") == "流水" else BLACK # 主指标(流水)为主角,着红领读
data = s["data"]
lo, hi = min(data), max(data)
span = (hi - lo) or 1
pad = 4 # 上下留白,避免贴边
pts = []
for i, v in enumerate(data):
x = px0 + xstep * i
yv = py1 - pad - (v - lo) / span * (py1 - py0 - 2 * pad)
pts.append((x, yv))
self._styled_polyline(pts, s["style"], fill=color)
if color == RED and pts: # 主指标最新值标红点:“今日 · 你在此处”
ex, ey = pts[-1]
self.d.ellipse([ex - 2.5, ey - 2.5, ex + 2.5, ey + 2.5], fill=RED)
# ---- X 轴标签:仅首 / 中 / 末三个日期 ----
xl_y = py1 + 3
self.text(px0, xl_y, dates[0], self.F12)
self.ctext((px0 + px1) / 2, xl_y, dates[n // 2], self.F12)
self.rtext(px1, xl_y, dates[-1], self.F12)
# ---------- 入口 ----------
def render(self, data):
"""根据 data 渲染并返回 PIL.Image'RGB' 模式,仅黑/白/红三色)。"""
self.img = Image.new("RGB", (self.W, self.H), WHITE)
self.d = ImageDraw.Draw(self.img)
self.d.fontmode = "1" # 关闭抗锯齿点阵锐利RGB 上仍生效,纯色描边不发糊)
dt, wt, mao = data["date"], data["weather"], data["mao"]
# A. Hero大时钟 + 日期(时钟上移使「顶部留白 ≈ 与日期间距」对称)
self.text(10, 0, dt["time"], self.F48, bold=2)
self.text(12, 64, f'{dt["week"]} {dt["greg"]}', self.F12)
# B. 天气:右对齐文字 + 居中的 28° + 放大右靠的图标
DIV_Y = 90 # 天气区底部分割线
loc, temp, cr = wt["loc"], wt["temp"], f'{wt["cond"]} {wt["range"]}'
self.rtext(390, 8, loc, self.F12)
self.rtext(390, 30, temp, self.F24, bold=1)
self.rtext(390, 64, cr, self.F12)
# 定位图标:置于 loc 文字左侧,留间距并与文字竖直居中
PIN_W, PIN_GAP = 8, 6
loc_pin_x = 390 - self._tw(loc, self.F12) - PIN_GAP - PIN_W
self._loc_pin(loc_pin_x, 10, PIN_W, fg=RED) # 定位图标:红水滴,地图别针的通用语汇
s = 1.5
iw, ih = 36 * s, 38 * s # 图标原生约 36x38
text_left = min(loc_pin_x,
390 - self._tw(temp, self.F24) - 1,
390 - self._tw(cr, self.F12))
icon_y = (DIV_Y - ih) / 2 # 顶部(0)与分割线正中:上下留白相等
self._weather_icon(text_left - 12 - iw, icon_y, self._weather_category(wt.get("icon")), s=s, accent=RED)
self.hdash(8, 392, DIV_Y)
# C. 橘喵今日经营:三列(数值 + 环比/同比)
self._title_bar(96, "橘喵·今日实时", mao["upd"])
cols = mao["cols"]; n = len(cols)
ax0, ax1 = 8, 392
cw = (ax1 - ax0) / n
for i in range(1, n):
self.vdash(ax0 + cw * i, 128, 192)
for i, c in enumerate(cols):
cx = ax0 + cw * (i + 0.5)
self.ctext(cx, 114, c["k"], self.F12)
self.ctext(cx, 130, c["v"], self.F24, bold=1)
self._cmp_line(cx, 160, "环比", c["hb"])
self._cmp_line(cx, 178, "同比", c["tb"])
self.hdash(8, 392, 200)
# D. 近30天三指标趋势折线图
self._trend_chart(204, mao["trend"])
return self.img
def render_to_file(self, data, path):
img = self.render(data)
import os
os.makedirs(os.path.dirname(path), exist_ok=True)
img.save(path)
return path