Review record RC-048 (Go, autonomous mode, not independent). Task: MIL-009#1 Task: MIL-009#2 Task: MIL-009#3 Task: MIL-009#4 Task: MIL-009#5 Task: MIL-009#6 Task: MIL-009#7 Task: MIL-009#8
428 lines
14 KiB
Python
428 lines
14 KiB
Python
"""Generate the PNG assets of the flash card program.
|
|
|
|
@file generate_images.py
|
|
@brief Draws card_front, card_back, right and wrong as antialiased RGBA PNGs,
|
|
plus the program icon flash_card.ico.
|
|
Uses the standard library only; run it to regenerate the assets.
|
|
"""
|
|
|
|
import argparse
|
|
import math
|
|
import struct
|
|
import sys
|
|
import zlib
|
|
from collections.abc import Callable, Sequence
|
|
from pathlib import Path
|
|
|
|
_SRC_DIR = Path(__file__).resolve().parents[1] / "src"
|
|
if str(_SRC_DIR) not in sys.path:
|
|
sys.path.insert(0, str(_SRC_DIR))
|
|
|
|
from flash_card.constants import ( # noqa: E402
|
|
ASSETS_DIR,
|
|
CARD_BACK_IMAGE_NAME,
|
|
CARD_FRONT_IMAGE_NAME,
|
|
CARD_HEIGHT,
|
|
CARD_WIDTH,
|
|
ICON_FILE_NAME,
|
|
RIGHT_IMAGE_NAME,
|
|
WRONG_IMAGE_NAME,
|
|
)
|
|
|
|
Color = tuple[int, int, int]
|
|
Point = tuple[float, float]
|
|
Segment = tuple[Point, Point]
|
|
Box = tuple[float, float, float, float]
|
|
Span = tuple[float, float] | None
|
|
|
|
_SUPERSAMPLE = 4
|
|
_CARD_RADIUS = 60
|
|
_CARD_INSET = 2
|
|
_SHADOW_OFFSET = 10
|
|
_SHADOW_BLUR = 5
|
|
_SHADOW_COLOR: Color = (70, 70, 70)
|
|
_SHADOW_OPACITY = 0.4
|
|
_CARD_FRONT_COLOR: Color = (255, 255, 255)
|
|
_CARD_BACK_COLOR: Color = (0x91, 0xC2, 0xAF)
|
|
_BUTTON_SIZE = 100
|
|
_BUTTON_RADIUS = 28
|
|
_STROKE_WIDTH = 11.0
|
|
_WHITE: Color = (255, 255, 255)
|
|
_RIGHT_GRADIENT: tuple[Color, Color] = ((0xA5, 0xE0, 0x60), (0x2F, 0xBF, 0x71))
|
|
_WRONG_GRADIENT: tuple[Color, Color] = ((0xFF, 0x6B, 0x8B), (0xD9, 0x5A, 0x55))
|
|
_CHECK_SEGMENTS: tuple[Segment, ...] = (
|
|
((27.0, 52.0), (43.0, 68.0)),
|
|
((43.0, 68.0), (74.0, 33.0)),
|
|
)
|
|
_CROSS_SEGMENTS: tuple[Segment, ...] = (
|
|
((32.0, 32.0), (68.0, 68.0)),
|
|
((68.0, 32.0), (32.0, 68.0)),
|
|
)
|
|
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
_ICON_SIZE = 256
|
|
_ICON_HEADER_SIZE = 6
|
|
_ICON_ENTRY_SIZE = 16
|
|
_ICON_BITS_PER_PIXEL = 32
|
|
|
|
|
|
class _Canvas:
|
|
"""Premultiplied float RGBA canvas that shapes are composited onto."""
|
|
|
|
def __init__(self, width: int, height: int) -> None:
|
|
"""Create a fully transparent canvas.
|
|
|
|
@param width Width in pixels.
|
|
@param height Height in pixels.
|
|
"""
|
|
self.width = width
|
|
self.height = height
|
|
size = width * height
|
|
self.red = [0.0] * size
|
|
self.green = [0.0] * size
|
|
self.blue = [0.0] * size
|
|
self.alpha = [0.0] * size
|
|
|
|
def paint(
|
|
self,
|
|
mask: list[float],
|
|
color_at: Callable[[int], Color],
|
|
opacity: float = 1.0,
|
|
) -> None:
|
|
"""Composite a coverage mask over the canvas.
|
|
|
|
@param mask Coverage per pixel in 0..1, row-major.
|
|
@param color_at Colour for a pixel row index.
|
|
@param opacity Extra opacity applied to the whole mask.
|
|
"""
|
|
for y in range(self.height):
|
|
r, g, b = color_at(y)
|
|
for i in range(y * self.width, (y + 1) * self.width):
|
|
a = min(1.0, mask[i]) * opacity
|
|
if a <= 0.0:
|
|
continue
|
|
keep = 1.0 - a
|
|
self.red[i] = r * a + self.red[i] * keep
|
|
self.green[i] = g * a + self.green[i] * keep
|
|
self.blue[i] = b * a + self.blue[i] * keep
|
|
self.alpha[i] = a + self.alpha[i] * keep
|
|
|
|
def to_rgba_bytes(self) -> bytes:
|
|
"""Return the pixels as straight-alpha 8-bit RGBA rows.
|
|
|
|
@return The raw RGBA bytes, row-major.
|
|
"""
|
|
out = bytearray()
|
|
pixels = zip(self.red, self.green, self.blue, self.alpha, strict=True)
|
|
for r, g, b, a in pixels:
|
|
if a <= 0.0:
|
|
out += b"\x00\x00\x00\x00"
|
|
continue
|
|
out.append(min(255, round(r / a)))
|
|
out.append(min(255, round(g / a)))
|
|
out.append(min(255, round(b / a)))
|
|
out.append(min(255, round(a * 255)))
|
|
return bytes(out)
|
|
|
|
|
|
def _rounded_rect_span(box: Box, radius: float, y: float) -> Span:
|
|
"""Return the horizontal extent of a rounded rectangle at height y.
|
|
|
|
@param box Left, top, right, bottom of the rectangle.
|
|
@param radius Corner radius.
|
|
@param y Vertical sample position.
|
|
@return The (start, end) x range, or None when y is outside.
|
|
"""
|
|
left, top, right, bottom = box
|
|
if y < top or y > bottom:
|
|
return None
|
|
if y < top + radius:
|
|
centre_y = top + radius
|
|
elif y > bottom - radius:
|
|
centre_y = bottom - radius
|
|
else:
|
|
return (left, right)
|
|
inset = radius - math.sqrt(max(0.0, radius * radius - (centre_y - y) ** 2))
|
|
return (left + inset, right - inset)
|
|
|
|
|
|
def _rounded_rect_mask(width: int, height: int, box: Box, radius: float) -> list[float]:
|
|
"""Rasterise a rounded rectangle with 4x vertical supersampling.
|
|
|
|
Horizontal coverage of each sub-row is computed exactly.
|
|
|
|
@param width Mask width.
|
|
@param height Mask height.
|
|
@param box Left, top, right, bottom of the rectangle.
|
|
@param radius Corner radius.
|
|
@return Coverage per pixel in 0..1.
|
|
"""
|
|
mask = [0.0] * (width * height)
|
|
weight = 1.0 / _SUPERSAMPLE
|
|
for py in range(height):
|
|
base = py * width
|
|
for sub in range(_SUPERSAMPLE):
|
|
span = _rounded_rect_span(box, radius, py + (sub + 0.5) * weight)
|
|
if span is None:
|
|
continue
|
|
start = max(span[0], 0.0)
|
|
end = min(span[1], float(width))
|
|
for px in range(int(start), min(width, math.ceil(end))):
|
|
overlap = min(end, px + 1.0) - max(start, float(px))
|
|
mask[base + px] += overlap * weight
|
|
return mask
|
|
|
|
|
|
def _distance_to_segment(px: float, py: float, segment: Segment) -> float:
|
|
"""Return the distance from a point to a line segment.
|
|
|
|
@param px Point x.
|
|
@param py Point y.
|
|
@param segment Segment start and end.
|
|
@return The Euclidean distance.
|
|
"""
|
|
a, b = segment
|
|
dx, dy = b[0] - a[0], b[1] - a[1]
|
|
length_sq = dx * dx + dy * dy
|
|
t = ((px - a[0]) * dx + (py - a[1]) * dy) / length_sq if length_sq else 0.0
|
|
t = max(0.0, min(1.0, t))
|
|
return math.hypot(px - (a[0] + t * dx), py - (a[1] + t * dy))
|
|
|
|
|
|
def _stroke_mask(
|
|
width: int, height: int, segments: Sequence[Segment], stroke_width: float
|
|
) -> list[float]:
|
|
"""Rasterise thick round-capped lines with 4x4 supersampling.
|
|
|
|
@param width Mask width.
|
|
@param height Mask height.
|
|
@param segments Line segments.
|
|
@param stroke_width Line thickness in pixels.
|
|
@return Coverage per pixel in 0..1.
|
|
"""
|
|
mask = [0.0] * (width * height)
|
|
half = stroke_width / 2.0
|
|
weight = 1.0 / (_SUPERSAMPLE * _SUPERSAMPLE)
|
|
for segment in segments:
|
|
a, b = segment
|
|
x_min = max(0, int(min(a[0], b[0]) - half) - 1)
|
|
x_max = min(width, math.ceil(max(a[0], b[0]) + half) + 1)
|
|
y_min = max(0, int(min(a[1], b[1]) - half) - 1)
|
|
y_max = min(height, math.ceil(max(a[1], b[1]) + half) + 1)
|
|
for py in range(y_min, y_max):
|
|
for px in range(x_min, x_max):
|
|
hits = 0
|
|
for sy in range(_SUPERSAMPLE):
|
|
for sx in range(_SUPERSAMPLE):
|
|
x = px + (sx + 0.5) / _SUPERSAMPLE
|
|
y = py + (sy + 0.5) / _SUPERSAMPLE
|
|
if _distance_to_segment(x, y, segment) <= half:
|
|
hits += 1
|
|
index = py * width + px
|
|
mask[index] = max(mask[index], hits * weight)
|
|
return mask
|
|
|
|
|
|
def _box_blur(mask: list[float], width: int, height: int, radius: int) -> list[float]:
|
|
"""Blur a mask with a separable box filter.
|
|
|
|
@param mask Coverage per pixel.
|
|
@param width Mask width.
|
|
@param height Mask height.
|
|
@param radius Blur radius in pixels.
|
|
@return The blurred mask.
|
|
"""
|
|
window = 2 * radius + 1
|
|
horizontal = [0.0] * len(mask)
|
|
for y in range(height):
|
|
row = mask[y * width : (y + 1) * width]
|
|
total = sum(row[: radius + 1])
|
|
for x in range(width):
|
|
horizontal[y * width + x] = total / window
|
|
if x + radius + 1 < width:
|
|
total += row[x + radius + 1]
|
|
if x - radius >= 0:
|
|
total -= row[x - radius]
|
|
result = [0.0] * len(mask)
|
|
for x in range(width):
|
|
column = horizontal[x::width]
|
|
total = sum(column[: radius + 1])
|
|
for y in range(height):
|
|
result[y * width + x] = total / window
|
|
if y + radius + 1 < height:
|
|
total += column[y + radius + 1]
|
|
if y - radius >= 0:
|
|
total -= column[y - radius]
|
|
return result
|
|
|
|
|
|
def _solid(color: Color) -> Callable[[int], Color]:
|
|
"""Return a colour function that always gives one colour.
|
|
|
|
@param color The colour.
|
|
@return A function from row index to colour.
|
|
"""
|
|
return lambda _row: color
|
|
|
|
|
|
def _vertical_gradient(
|
|
top: Color, bottom: Color, height: int
|
|
) -> Callable[[int], Color]:
|
|
"""Return a colour function for a top to bottom gradient.
|
|
|
|
@param top Colour at the first row.
|
|
@param bottom Colour at the last row.
|
|
@param height Number of rows.
|
|
@return A function from row index to colour.
|
|
"""
|
|
|
|
def color_at(row: int) -> Color:
|
|
t = (row + 0.5) / height
|
|
r, g, b = (
|
|
round(c0 + (c1 - c0) * t) for c0, c1 in zip(top, bottom, strict=True)
|
|
)
|
|
return (r, g, b)
|
|
|
|
return color_at
|
|
|
|
|
|
def _render_card(fill: Color) -> _Canvas:
|
|
"""Draw a card with a soft drop shadow.
|
|
|
|
@param fill Fill colour of the card.
|
|
@return The canvas.
|
|
"""
|
|
width, height = CARD_WIDTH, CARD_HEIGHT
|
|
margin = _SHADOW_OFFSET + _SHADOW_BLUR + _CARD_INSET
|
|
box: Box = (
|
|
float(_CARD_INSET),
|
|
float(_CARD_INSET),
|
|
float(width - margin),
|
|
float(height - margin),
|
|
)
|
|
shadow_box: Box = (
|
|
box[0] + _SHADOW_OFFSET,
|
|
box[1] + _SHADOW_OFFSET,
|
|
box[2] + _SHADOW_OFFSET,
|
|
box[3] + _SHADOW_OFFSET,
|
|
)
|
|
shadow = _rounded_rect_mask(width, height, shadow_box, _CARD_RADIUS)
|
|
shadow = _box_blur(shadow, width, height, _SHADOW_BLUR)
|
|
canvas = _Canvas(width, height)
|
|
canvas.paint(shadow, _solid(_SHADOW_COLOR), _SHADOW_OPACITY)
|
|
card = _rounded_rect_mask(width, height, box, _CARD_RADIUS)
|
|
canvas.paint(card, _solid(fill))
|
|
return canvas
|
|
|
|
|
|
def _render_button(
|
|
gradient: tuple[Color, Color],
|
|
segments: Sequence[Segment],
|
|
size: int = _BUTTON_SIZE,
|
|
) -> _Canvas:
|
|
"""Draw a rounded gradient square with a white symbol.
|
|
|
|
@param gradient Top and bottom colour.
|
|
@param segments Line segments of the symbol, drawn for a 100 px button.
|
|
@param size Side of the square in pixels; the design is scaled to it.
|
|
@return The canvas.
|
|
"""
|
|
scale = size / _BUTTON_SIZE
|
|
scaled = [
|
|
((a[0] * scale, a[1] * scale), (b[0] * scale, b[1] * scale))
|
|
for a, b in segments
|
|
]
|
|
canvas = _Canvas(size, size)
|
|
box: Box = (0.0, 0.0, float(size), float(size))
|
|
square = _rounded_rect_mask(size, size, box, _BUTTON_RADIUS * scale)
|
|
canvas.paint(square, _vertical_gradient(gradient[0], gradient[1], size))
|
|
symbol = _stroke_mask(size, size, scaled, _STROKE_WIDTH * scale)
|
|
canvas.paint(symbol, _solid(_WHITE))
|
|
return canvas
|
|
|
|
|
|
def encode_png(width: int, height: int, rgba: bytes) -> bytes:
|
|
"""Encode raw RGBA pixels as a PNG file.
|
|
|
|
@param width Image width.
|
|
@param height Image height.
|
|
@param rgba Row-major straight-alpha RGBA bytes.
|
|
@return The PNG file content.
|
|
"""
|
|
stride = width * 4
|
|
raw = b"".join(b"\x00" + rgba[y * stride : (y + 1) * stride] for y in range(height))
|
|
|
|
def chunk(kind: bytes, data: bytes) -> bytes:
|
|
crc = zlib.crc32(kind + data) & 0xFFFFFFFF
|
|
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", crc)
|
|
|
|
header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
|
|
return (
|
|
_PNG_SIGNATURE
|
|
+ chunk(b"IHDR", header)
|
|
+ chunk(b"IDAT", zlib.compress(raw, 9))
|
|
+ chunk(b"IEND", b"")
|
|
)
|
|
|
|
|
|
def encode_ico(png: bytes, size: int) -> bytes:
|
|
"""Wrap one PNG image in an ICO container.
|
|
|
|
@param png The PNG file content.
|
|
@param size Image side in pixels; 256 is stored as 0.
|
|
@return The ICO file content.
|
|
"""
|
|
stored = 0 if size >= _ICON_SIZE else size
|
|
offset = _ICON_HEADER_SIZE + _ICON_ENTRY_SIZE
|
|
header = struct.pack("<HHH", 0, 1, 1)
|
|
entry = struct.pack(
|
|
"<BBBBHHII", stored, stored, 0, 0, 1, _ICON_BITS_PER_PIXEL, len(png), offset
|
|
)
|
|
return header + entry + png
|
|
|
|
|
|
def generate_images(output_dir: Path) -> list[Path]:
|
|
"""Write the four PNG assets and the icon into a folder.
|
|
|
|
@param output_dir Folder to write into; created when missing.
|
|
@return The paths of the written files.
|
|
"""
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
images = {
|
|
CARD_FRONT_IMAGE_NAME: _render_card(_CARD_FRONT_COLOR),
|
|
CARD_BACK_IMAGE_NAME: _render_card(_CARD_BACK_COLOR),
|
|
RIGHT_IMAGE_NAME: _render_button(_RIGHT_GRADIENT, _CHECK_SEGMENTS),
|
|
WRONG_IMAGE_NAME: _render_button(_WRONG_GRADIENT, _CROSS_SEGMENTS),
|
|
}
|
|
written: list[Path] = []
|
|
for name, canvas in images.items():
|
|
path = output_dir / name
|
|
data = encode_png(canvas.width, canvas.height, canvas.to_rgba_bytes())
|
|
path.write_bytes(data)
|
|
written.append(path)
|
|
icon = _render_button(_RIGHT_GRADIENT, _CHECK_SEGMENTS, _ICON_SIZE)
|
|
png = encode_png(icon.width, icon.height, icon.to_rgba_bytes())
|
|
icon_path = output_dir / ICON_FILE_NAME
|
|
icon_path.write_bytes(encode_ico(png, _ICON_SIZE))
|
|
written.append(icon_path)
|
|
return written
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
"""Run the generator from the command line.
|
|
|
|
@param argv Command line arguments, or None for sys.argv.
|
|
@return The exit code, 0 on success.
|
|
"""
|
|
parser = argparse.ArgumentParser(description="Generate the flash card images.")
|
|
parser.add_argument(
|
|
"--output-dir", type=Path, default=ASSETS_DIR, help="folder for the PNG files"
|
|
)
|
|
args = parser.parse_args(argv)
|
|
for path in generate_images(args.output_dir):
|
|
sys.stdout.write(f"wrote {path}\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|