99 lines
4.0 KiB
Python
99 lines
4.0 KiB
Python
from __future__ import annotations
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
OTA_INDICATOR_BRIGHTNESS = 40
|
|
OTA_CANVAS_SIZE = 64
|
|
OTA_CONTENT_SIZE = 58
|
|
OTA_CONTENT_OFFSET = (OTA_CANVAS_SIZE - OTA_CONTENT_SIZE) // 2
|
|
|
|
|
|
def _draw_binary_text(
|
|
image: Image.Image,
|
|
position: tuple[int, int],
|
|
text: str,
|
|
*,
|
|
font: ImageFont.ImageFont,
|
|
fill: tuple[int, int, int],
|
|
) -> tuple[int, int, int, int]:
|
|
probe = ImageDraw.Draw(image)
|
|
bbox = probe.textbbox((0, 0), text, font=font)
|
|
width = max(1, bbox[2] - bbox[0])
|
|
height = max(1, bbox[3] - bbox[1])
|
|
mask = Image.new("L", (width, height), 0)
|
|
ImageDraw.Draw(mask).text((-bbox[0], -bbox[1]), text, font=font, fill=255)
|
|
# Small system labels must be LED pixels, not dim antialiasing samples.
|
|
mask = mask.point(lambda value: 255 if value >= 64 else 0)
|
|
left = position[0] + bbox[0]
|
|
top = position[1] + bbox[1]
|
|
image.paste(fill, (left, top), mask)
|
|
return left, top, left + width, top + height
|
|
|
|
|
|
def _resize_preserving_lit_pixels(image: Image.Image, size: tuple[int, int]) -> Image.Image:
|
|
"""Shrink a pixel composition without dropping one-pixel strokes."""
|
|
source = image.convert("RGB")
|
|
target = Image.new("RGB", size, (0, 0, 0))
|
|
source_pixels = source.load()
|
|
target_pixels = target.load()
|
|
source_width, source_height = source.size
|
|
target_width, target_height = size
|
|
# Forward-map every lit source pixel once. Pillow's inverse nearest-neighbor
|
|
# shrink skips some source rows/columns, which is exactly what breaks thin
|
|
# strokes such as the top of "T".
|
|
for source_y in range(source_height):
|
|
target_y = min(target_height - 1, int((source_y + 0.5) * target_height / source_height))
|
|
for source_x in range(source_width):
|
|
pixel = source_pixels[source_x, source_y]
|
|
if pixel == (0, 0, 0):
|
|
continue
|
|
target_x = min(target_width - 1, int((source_x + 0.5) * target_width / source_width))
|
|
if max(pixel) > max(target_pixels[target_x, target_y]):
|
|
target_pixels[target_x, target_y] = pixel
|
|
return target
|
|
|
|
|
|
def render_ota_indicator(percent: int = 0, stage: str = "准备更新") -> Image.Image:
|
|
value = max(0, min(100, int(percent)))
|
|
content = Image.new("RGB", (OTA_CANVAS_SIZE, OTA_CANVAS_SIZE), (0, 0, 0))
|
|
draw = ImageDraw.Draw(content)
|
|
cyan = (34, 220, 220)
|
|
dim = (24, 70, 76)
|
|
white = (245, 250, 250)
|
|
|
|
draw.rounded_rectangle((15, 4, 48, 32), radius=5, outline=cyan, width=2)
|
|
draw.polygon(((32, 8), (23, 18), (28, 18), (28, 27), (36, 27), (36, 18), (41, 18)), fill=cyan)
|
|
font = ImageFont.load_default()
|
|
# One source-pixel space prevents the coverage shrink from joining adjacent
|
|
# glyph edges into the misleading "OIA" seen on the device.
|
|
ota_label = "O T A"
|
|
label_box = draw.textbbox((0, 0), ota_label, font=font)
|
|
label_width = label_box[2] - label_box[0]
|
|
_draw_binary_text(
|
|
content,
|
|
((OTA_CANVAS_SIZE - label_width) // 2, 35),
|
|
ota_label,
|
|
font=font,
|
|
fill=white,
|
|
)
|
|
draw.rectangle((6, 48, 57, 55), outline=dim, width=1)
|
|
fill_width = round(49 * value / 100)
|
|
if fill_width:
|
|
draw.rectangle((7, 49, 6 + fill_width, 54), fill=cyan)
|
|
label = f"{value}%"
|
|
percent_box = draw.textbbox((0, 0), label, font=font)
|
|
percent_width = percent_box[2] - percent_box[0]
|
|
left = (OTA_CANVAS_SIZE - percent_width) // 2
|
|
# Position from the measured bottom so the source glyph is never clipped.
|
|
top = OTA_CANVAS_SIZE - percent_box[3]
|
|
_draw_binary_text(content, (left, top), label, font=font, fill=white)
|
|
|
|
# The stage changes a tiny activity marker even when the percentage is fixed.
|
|
marker = sum(stage.encode("utf-8")) % 4
|
|
draw.rectangle((52 + marker * 2, 37, 53 + marker * 2, 39), fill=cyan)
|
|
|
|
scaled = _resize_preserving_lit_pixels(content, (OTA_CONTENT_SIZE, OTA_CONTENT_SIZE))
|
|
image = Image.new("RGB", (OTA_CANVAS_SIZE, OTA_CANVAS_SIZE), (0, 0, 0))
|
|
image.paste(scaled, (OTA_CONTENT_OFFSET, OTA_CONTENT_OFFSET))
|
|
return image
|