Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 33 additions & 13 deletions library/lcd/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,42 @@
from PIL import ImageColor

RGBColor = Tuple[int, int, int]
RGBAColor = Tuple[int, int, int, int]

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

def parse_color(color: Color) -> RGBColor:

def parse_color(color: Color, allow_alpha: bool = False) -> Union[RGBColor, RGBAColor]:
# even if undocumented, let's be nice and accept a list in lieu of a tuple
if isinstance(color, tuple) or isinstance(color, list):
if len(color) != 3:
raise ValueError("RGB color must have 3 values")
return (int(color[0]), int(color[1]), int(color[2]))
if len(color) == 3:
return (int(color[0]), int(color[1]), int(color[2]))
elif len(color) == 4 and allow_alpha:
return (int(color[0]), int(color[1]), int(color[2]), int(color[3]))
elif len(color) == 4:
# Strip alpha if not allowed
return (int(color[0]), int(color[1]), int(color[2]))
else:
raise ValueError("Color must have 3 or 4 values")

if not isinstance(color, str):
raise ValueError("Color must be either an RGB tuple or a string")
raise ValueError("Color must be either an RGB(A) tuple or a string")

# Try to parse it as our custom "r, g, b" format
rgb = color.split(',')
if len(rgb) == 3:
r, g, b = rgb
# Try to parse it as our custom "r, g, b" or "r, g, b, a" format
components = color.split(',')
if len(components) == 3:
r, g, b = components
try:
rgbcolor = (int(r.strip()), int(g.strip()), int(b.strip()))
except ValueError:
Expand All @@ -39,10 +48,21 @@ def parse_color(color: Color) -> RGBColor:
pass
else:
return rgbcolor
elif len(components) == 4:
r, g, b, a = components
try:
if allow_alpha:
return (int(r.strip()), int(g.strip()), int(b.strip()), int(a.strip()))
else:
return (int(r.strip()), int(g.strip()), int(b.strip()))
except ValueError:
# at least one element can't be converted to int, we continue to
# try parsing as a PIL color
pass

# fallback as a PIL color
rgbcolor = ImageColor.getrgb(color)
if len(rgbcolor) == 4:
if len(rgbcolor) == 4 and not allow_alpha:
return (rgbcolor[0], rgbcolor[1], rgbcolor[2])
return rgbcolor

96 changes: 81 additions & 15 deletions library/lcd/lcd_comm.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,28 +381,55 @@ def DisplayLineGraph(self, x: int, y: int, width: int, height: int,
axis_font_size: int = 10,
background_color: Color = (255, 255, 255),
background_image: Optional[str] = None,
axis_minmax_format: str = "{:0.0f}"):
axis_minmax_format: str = "{:0.0f}",
fill: bool = False,
fill_color: Optional[Color] = None,
antialias: bool = False):
# Generate a plot graph and display it
# Provide the background image path to display plot graph with transparent background
# fill: if True, fills the area under the line graph
# fill_color: color for the fill area (with optional alpha for transparency)
# antialias: if True, uses 2x supersampling for smoother lines

line_color = parse_color(line_color)
axis_color = parse_color(axis_color)
background_color = parse_color(background_color)
if fill_color is not None:
fill_color = parse_color(fill_color, allow_alpha=True)
else:
# Default fill color: line color with 50% opacity
fill_color = line_color

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

# For antialiasing, work at 2x resolution
scale = 2 if antialias else 1
work_width = width * scale
work_height = height * scale
work_line_width = line_width * scale

original_background = None
if background_image is None:
# A bitmap is created with solid background
graph_image = Image.new('RGB', (width, height), background_color)
graph_image = Image.new('RGB', (work_width, work_height), background_color)
else:
# A bitmap is created from provided background image
graph_image = self.open_image(background_image)

# Crop bitmap to keep only the plot graph background
graph_image = graph_image.crop(box=(x, y, x + width, y + height))
if antialias:
graph_image = graph_image.resize(
(work_width, work_height), Image.Resampling.LANCZOS
)
graph_image = graph_image.convert('RGB')

# Keep a copy of the background for fill compositing
if fill:
original_background = graph_image.copy()
graph_image = graph_image.convert('RGBA')

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

step = width / len(values)
step = work_width / len(values)
# pre compute yScale multiplier value
yScale = (height / (max_value - min_value)) if (max_value - min_value) != 0 else 0
yScale = ((work_height - 1) / (max_value - min_value)) if (max_value - min_value) != 0 else 1

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

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

count += 1

# Draw plot graph
draw = ImageDraw.Draw(graph_image)
draw.line(list(zip(plotsX, plotsY)), fill=line_color, width=line_width)
draw = ImageDraw.Draw(graph_image, 'RGBA' if fill else None)

# Fill area under the line if enabled
if fill and len(plotsX) > 1:
# Create polygon points: line points + bottom corners
fill_points = list(zip(plotsX, plotsY))
# Add bottom-right and bottom-left corners to close the polygon
# Use work_height (not work_height-1) to ensure fill reaches the very bottom
fill_points.append((plotsX[-1], work_height))
fill_points.append((plotsX[0], work_height))
# Draw filled polygon with semi-transparent color
if len(fill_color) == 3:
# Add alpha channel for transparency (default 80 opacity)
fill_rgba = (fill_color[0], fill_color[1], fill_color[2], 80)
elif len(fill_color) == 4:
fill_rgba = fill_color
else:
fill_rgba = (*fill_color[:3], 80)
draw.polygon(fill_points, fill=fill_rgba)

# Draw the line on top
if len(plotsX) > 1:
draw.line(list(zip(plotsX, plotsY)), fill=line_color, width=work_line_width)

if graph_axis:
# Draw axis
draw.line([0, height - 1, width - 1, height - 1], fill=axis_color)
draw.line([0, 0, 0, height - 1], fill=axis_color)
draw.line([0, work_height - 1, work_width - 1, work_height - 1], fill=axis_color)
draw.line([0, 0, 0, work_height - 1], fill=axis_color)

# Draw Legend
draw.line([0, 0, 1, 0], fill=axis_color)
draw.line([0, 0, 1 * scale, 0], fill=axis_color)
text = axis_minmax_format.format(max_value)
ttfont = self.open_font(axis_font, axis_font_size)
ttfont = self.open_font(axis_font, axis_font_size * scale)
_, top, right, bottom = ttfont.getbbox(text)
draw.text((2, 0 - top), text,
draw.text((2 * scale, 0 - top), text,
font=ttfont, fill=axis_color)

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

# Scale down for antialiasing
if antialias:
graph_image = graph_image.resize((width, height), Image.Resampling.LANCZOS)
if original_background is not None:
original_background = original_background.resize((width, height), Image.Resampling.LANCZOS)

# Convert back to RGB if needed
if fill and graph_image.mode == 'RGBA':
# Composite with original background (not solid color)
if original_background is not None:
original_background.paste(graph_image, mask=graph_image.split()[3])
graph_image = original_background
else:
bg = Image.new('RGB', graph_image.size, background_color)
bg.paste(graph_image, mask=graph_image.split()[3])
graph_image = bg

self.DisplayPILImage(graph_image, x, y)

def DrawRadialDecoration(self, draw: ImageDraw.ImageDraw, angle: float, radius: float, width: float, color: Tuple[int, int, int] = (0, 0, 0)):
Expand Down
5 changes: 4 additions & 1 deletion library/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,10 @@ def display_themed_line_graph(theme_data, values):
axis_font=config.FONTS_DIR + theme_data.get("AXIS_FONT", "roboto/Roboto-Black.ttf"),
axis_font_size=theme_data.get("AXIS_FONT_SIZE", 10),
background_color=theme_data.get("BACKGROUND_COLOR", (0, 0, 0)),
background_image=get_theme_file_path(theme_data.get("BACKGROUND_IMAGE", None))
background_image=get_theme_file_path(theme_data.get("BACKGROUND_IMAGE", None)),
fill=theme_data.get("FILL", False),
fill_color=theme_data.get("FILL_COLOR", None),
antialias=theme_data.get("ANTIALIAS", False)
)


Expand Down