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