Skip to content

Commit 0581cb3

Browse files
feat(lcd): add fill and antialias support for line graphs
1 parent a22b1bc commit 0581cb3

3 files changed

Lines changed: 118 additions & 29 deletions

File tree

library/lcd/color.py

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,42 @@
44
from PIL import ImageColor
55

66
RGBColor = Tuple[int, int, int]
7+
RGBAColor = Tuple[int, int, int, int]
78

8-
# Color can be an RGB tuple (RGBColor), or a string in any of these formats:
9+
# Color can be an RGB tuple (RGBColor), RGBA tuple (RGBAColor), or a string in any of these formats:
910
# - "r, g, b" (e.g. "255, 0, 0"), as is found in the themes' yaml settings
10-
# - any of the formats supported by PIL: https://pillow.readthedocs.io/en/stable/reference/ImageColor.html
11+
# - "r, g, b, a" (e.g. "255, 0, 0, 128") for RGBA with alpha channel
12+
# - any of the formats supported by PIL: https://pillow.readthedocs.io/en/stable/reference/ImageColor.html
1113
#
1214
# For example, here are multiple ways to write the pure red color:
1315
# - (255, 0, 0)
1416
# - "255, 0, 0"
1517
# - "#ff0000"
1618
# - "red"
1719
# - "hsl(0, 100%, 50%)"
18-
Color = Union[str, RGBColor]
20+
Color = Union[str, RGBColor, RGBAColor]
1921

20-
def parse_color(color: Color) -> RGBColor:
22+
23+
def parse_color(color: Color, allow_alpha: bool = False) -> Union[RGBColor, RGBAColor]:
2124
# even if undocumented, let's be nice and accept a list in lieu of a tuple
2225
if isinstance(color, tuple) or isinstance(color, list):
23-
if len(color) != 3:
24-
raise ValueError("RGB color must have 3 values")
25-
return (int(color[0]), int(color[1]), int(color[2]))
26+
if len(color) == 3:
27+
return (int(color[0]), int(color[1]), int(color[2]))
28+
elif len(color) == 4 and allow_alpha:
29+
return (int(color[0]), int(color[1]), int(color[2]), int(color[3]))
30+
elif len(color) == 4:
31+
# Strip alpha if not allowed
32+
return (int(color[0]), int(color[1]), int(color[2]))
33+
else:
34+
raise ValueError("Color must have 3 or 4 values")
2635

2736
if not isinstance(color, str):
28-
raise ValueError("Color must be either an RGB tuple or a string")
37+
raise ValueError("Color must be either an RGB(A) tuple or a string")
2938

30-
# Try to parse it as our custom "r, g, b" format
31-
rgb = color.split(',')
32-
if len(rgb) == 3:
33-
r, g, b = rgb
39+
# Try to parse it as our custom "r, g, b" or "r, g, b, a" format
40+
components = color.split(',')
41+
if len(components) == 3:
42+
r, g, b = components
3443
try:
3544
rgbcolor = (int(r.strip()), int(g.strip()), int(b.strip()))
3645
except ValueError:
@@ -39,10 +48,21 @@ def parse_color(color: Color) -> RGBColor:
3948
pass
4049
else:
4150
return rgbcolor
51+
elif len(components) == 4:
52+
r, g, b, a = components
53+
try:
54+
if allow_alpha:
55+
return (int(r.strip()), int(g.strip()), int(b.strip()), int(a.strip()))
56+
else:
57+
return (int(r.strip()), int(g.strip()), int(b.strip()))
58+
except ValueError:
59+
# at least one element can't be converted to int, we continue to
60+
# try parsing as a PIL color
61+
pass
4262

4363
# fallback as a PIL color
4464
rgbcolor = ImageColor.getrgb(color)
45-
if len(rgbcolor) == 4:
65+
if len(rgbcolor) == 4 and not allow_alpha:
4666
return (rgbcolor[0], rgbcolor[1], rgbcolor[2])
4767
return rgbcolor
4868

library/lcd/lcd_comm.py

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -381,28 +381,55 @@ def DisplayLineGraph(self, x: int, y: int, width: int, height: int,
381381
axis_font_size: int = 10,
382382
background_color: Color = (255, 255, 255),
383383
background_image: Optional[str] = None,
384-
axis_minmax_format: str = "{:0.0f}"):
384+
axis_minmax_format: str = "{:0.0f}",
385+
fill: bool = False,
386+
fill_color: Optional[Color] = None,
387+
antialias: bool = False):
385388
# Generate a plot graph and display it
386389
# Provide the background image path to display plot graph with transparent background
390+
# fill: if True, fills the area under the line graph
391+
# fill_color: color for the fill area (with optional alpha for transparency)
392+
# antialias: if True, uses 2x supersampling for smoother lines
387393

388394
line_color = parse_color(line_color)
389395
axis_color = parse_color(axis_color)
390396
background_color = parse_color(background_color)
397+
if fill_color is not None:
398+
fill_color = parse_color(fill_color, allow_alpha=True)
399+
else:
400+
# Default fill color: line color with 50% opacity
401+
fill_color = line_color
391402

392403
assert x <= self.get_width(), 'Progress bar X coordinate must be <= display width'
393404
assert y <= self.get_height(), 'Progress bar Y coordinate must be <= display height'
394405
assert x + width <= self.get_width(), 'Progress bar width exceeds display width'
395406
assert y + height <= self.get_height(), 'Progress bar height exceeds display height'
396407

408+
# For antialiasing, work at 2x resolution
409+
scale = 2 if antialias else 1
410+
work_width = width * scale
411+
work_height = height * scale
412+
work_line_width = line_width * scale
413+
414+
original_background = None
397415
if background_image is None:
398416
# A bitmap is created with solid background
399-
graph_image = Image.new('RGB', (width, height), background_color)
417+
graph_image = Image.new('RGB', (work_width, work_height), background_color)
400418
else:
401419
# A bitmap is created from provided background image
402420
graph_image = self.open_image(background_image)
403-
404421
# Crop bitmap to keep only the plot graph background
405422
graph_image = graph_image.crop(box=(x, y, x + width, y + height))
423+
if antialias:
424+
graph_image = graph_image.resize(
425+
(work_width, work_height), Image.Resampling.LANCZOS
426+
)
427+
graph_image = graph_image.convert('RGB')
428+
429+
# Keep a copy of the background for fill compositing
430+
if fill:
431+
original_background = graph_image.copy()
432+
graph_image = graph_image.convert('RGBA')
406433

407434
# if autoscale is enabled, define new min/max value to "zoom" the graph
408435
if autoscale:
@@ -419,16 +446,16 @@ def DisplayLineGraph(self, x: int, y: int, width: int, height: int,
419446
min_value = max(trueMin - 5, min_value)
420447
max_value = min(trueMax + 5, max_value)
421448

422-
step = width / len(values)
449+
step = work_width / len(values)
423450
# pre compute yScale multiplier value
424-
yScale = (height / (max_value - min_value)) if (max_value - min_value) != 0 else 0
451+
yScale = ((work_height - 1) / (max_value - min_value)) if (max_value - min_value) != 0 else 1
425452

426453
plotsX = []
427454
plotsY = []
428455
count = 0
429456
for value in values:
430457
if not math.isnan(value):
431-
# Don't let the set value exceed our min or max value, this is bad :)
458+
# Don't let the set value exceed our min or max value, this is bad :)
432459
if value < min_value:
433460
value = min_value
434461
elif max_value < value:
@@ -437,32 +464,71 @@ def DisplayLineGraph(self, x: int, y: int, width: int, height: int,
437464
assert min_value <= value <= max_value, 'Plot point value shall be between min and max'
438465

439466
plotsX.append(count * step)
440-
plotsY.append(height - (value - min_value) * yScale)
467+
# Calculate Y position: 0 at top (max_value), work_height-1 at bottom (min_value)
468+
plotsY.append((work_height - 1) - (value - min_value) * yScale)
441469

442470
count += 1
443471

444472
# Draw plot graph
445-
draw = ImageDraw.Draw(graph_image)
446-
draw.line(list(zip(plotsX, plotsY)), fill=line_color, width=line_width)
473+
draw = ImageDraw.Draw(graph_image, 'RGBA' if fill else None)
474+
475+
# Fill area under the line if enabled
476+
if fill and len(plotsX) > 1:
477+
# Create polygon points: line points + bottom corners
478+
fill_points = list(zip(plotsX, plotsY))
479+
# Add bottom-right and bottom-left corners to close the polygon
480+
# Use work_height (not work_height-1) to ensure fill reaches the very bottom
481+
fill_points.append((plotsX[-1], work_height))
482+
fill_points.append((plotsX[0], work_height))
483+
# Draw filled polygon with semi-transparent color
484+
if len(fill_color) == 3:
485+
# Add alpha channel for transparency (default 80 opacity)
486+
fill_rgba = (fill_color[0], fill_color[1], fill_color[2], 80)
487+
elif len(fill_color) == 4:
488+
fill_rgba = fill_color
489+
else:
490+
fill_rgba = (*fill_color[:3], 80)
491+
draw.polygon(fill_points, fill=fill_rgba)
492+
493+
# Draw the line on top
494+
if len(plotsX) > 1:
495+
draw.line(list(zip(plotsX, plotsY)), fill=line_color, width=work_line_width)
447496

448497
if graph_axis:
449498
# Draw axis
450-
draw.line([0, height - 1, width - 1, height - 1], fill=axis_color)
451-
draw.line([0, 0, 0, height - 1], fill=axis_color)
499+
draw.line([0, work_height - 1, work_width - 1, work_height - 1], fill=axis_color)
500+
draw.line([0, 0, 0, work_height - 1], fill=axis_color)
452501

453502
# Draw Legend
454-
draw.line([0, 0, 1, 0], fill=axis_color)
503+
draw.line([0, 0, 1 * scale, 0], fill=axis_color)
455504
text = axis_minmax_format.format(max_value)
456-
ttfont = self.open_font(axis_font, axis_font_size)
505+
ttfont = self.open_font(axis_font, axis_font_size * scale)
457506
_, top, right, bottom = ttfont.getbbox(text)
458-
draw.text((2, 0 - top), text,
507+
draw.text((2 * scale, 0 - top), text,
459508
font=ttfont, fill=axis_color)
460509

461510
text = axis_minmax_format.format(min_value)
462511
_, top, right, bottom = ttfont.getbbox(text)
463-
draw.text((width - 1 - right, height - 2 - bottom), text,
512+
draw.text((work_width - 1 - right, work_height - 2 * scale - bottom), text,
464513
font=ttfont, fill=axis_color)
465514

515+
# Scale down for antialiasing
516+
if antialias:
517+
graph_image = graph_image.resize((width, height), Image.Resampling.LANCZOS)
518+
if original_background is not None:
519+
original_background = original_background.resize((width, height), Image.Resampling.LANCZOS)
520+
521+
# Convert back to RGB if needed
522+
if fill and graph_image.mode == 'RGBA':
523+
# Composite with original background (not solid color)
524+
if original_background is not None:
525+
original_background.paste(graph_image, mask=graph_image.split()[3])
526+
graph_image = original_background
527+
else:
528+
bg = Image.new('RGB', graph_image.size, background_color)
529+
bg.paste(graph_image, mask=graph_image.split()[3])
530+
graph_image = bg
531+
466532
self.DisplayPILImage(graph_image, x, y)
467533

468534
def DrawRadialDecoration(self, draw: ImageDraw.ImageDraw, angle: float, radius: float, width: float, color: Tuple[int, int, int] = (0, 0, 0)):

library/stats.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,10 @@ def display_themed_line_graph(theme_data, values):
239239
axis_font=config.FONTS_DIR + theme_data.get("AXIS_FONT", "roboto/Roboto-Black.ttf"),
240240
axis_font_size=theme_data.get("AXIS_FONT_SIZE", 10),
241241
background_color=theme_data.get("BACKGROUND_COLOR", (0, 0, 0)),
242-
background_image=get_theme_file_path(theme_data.get("BACKGROUND_IMAGE", None))
242+
background_image=get_theme_file_path(theme_data.get("BACKGROUND_IMAGE", None)),
243+
fill=theme_data.get("FILL", False),
244+
fill_color=theme_data.get("FILL_COLOR", None),
245+
antialias=theme_data.get("ANTIALIAS", False)
243246
)
244247

245248

0 commit comments

Comments
 (0)