Micropython as a means of creating embedded system programs is becoming increasingly popular and gaining popularity. Often the limiting factor is the size of the operating memory or flash memory. Therefore, various images of the micropython system often have limited functionality by omitting some functions. Very often these are image and letter fonts that take up a lot of memory. Such a case is the image of micropython used for the popular esp32 series microcomputer in combination with the LVGL library.
The described library helps solve this problem and the illustrative image shows a screen of a laboratory source using this library.
I based my research on the following criteria:
- minimum possible need for operating memory
- minimum possible consumption of program memory (flash or sd card)
- good ability to redraw the screen
- minimize flicker
- possibility of enlarging, reducing characters
- possibility of combining any fonts and symbols
I used my own library containing the following code:
# large_font.py - Library for large digit displays with zoom capability import lvgl as lv import os, gc class DisplayManager: def __init__(self, width=800, height=480): self.WIDTH = width self.HEIGHT = height self.current_screen = None def create_screen(self, bg_color=lv.color_hex(0x000000)): """Create a new screen with optional background color""" screen = lv.obj() screen.set_size(self.WIDTH, self.HEIGHT) screen.set_style_bg_color(bg_color, lv.PART.MAIN) return screen def show_screen(self, screen): """Display the specified screen""" screen.set_parent(lv.scr_act()) self.current_screen = screen lv.scr_load(screen) class DigitDisplay: def __init__(self, display_manager, x_pos, y_pos, suffix=None, zoom=128): self.dm = display_manager self.img_pool = {} # Image pool for all digits self.current_screen = None self.x_pos = x_pos self.y_pos = y_pos self.base_digit_spacing = 50 # Základný rozostup pri zoom=255 (100%) self.base_comma_offset = 25 # Základný offset desatinnej čiarky pri zoom=255 self.base_suffix_offset = 5 # Základný offset sufixu pri zoom=255 self.max_digits = 4 # For 4-digit display self.img_usage = {} self.suffix = suffix self.zoom = zoom # Zoom factor (255 = 100%, 128 = 50%) # Vypočítame aktuálne rozostupy podľa zoomu self._update_spacing() def init_digits(self, screen): """Initialize display with parent screen""" self.current_screen = screen self._load_digit_images() def _load_bmp(self, filename): """Load BMP image from file""" try: with open(filename, 'rb') as f: data = f.read() if data[0:2] != b'BM': raise ValueError("Not a valid BMP file") width = int.from_bytes(data[18:22], 'little') height = int.from_bytes(data[22:26], 'little') bpp = int.from_bytes(data[28:30], 'little') data_offset = int.from_bytes(data[10:14], 'little') if bpp != 24: raise ValueError("Only 24-bit BMP supported") row_padding = (4 - (width * 3) % 4) % 4 img_data = bytearray(width * height * 2) for y in range(height): for x in range(width): bmp_pos = data_offset + (height - 1 - y) * (width * 3 + row_padding) + x * 3 b, g, r = data[bmp_pos], data[bmp_pos+1], data[bmp_pos+2] rgb565 = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3) img_data[(y * width + x) * 2] = rgb565 & 0xFF img_data[(y * width + x) * 2 + 1] = (rgb565 >> 8) & 0xFF img_dsc = lv.img_dsc_t({ 'data_size': len(img_data), 'data': img_data, 'header': { 'w': width, 'h': height, 'cf': lv.img.CF.TRUE_COLOR } }) img = lv.img(self.current_screen) img.set_src(img_dsc) img.set_pos(-1000, -1000) # Hide initially return img except Exception as e: print(f"Error loading {filename}: {e}") return None def _load_digit_images(self): """Load all digit images into pool""" digit_files = { '0': '30.bmp', '1': '31.bmp', '2': '32.bmp', '3': '33.bmp', '4': '34.bmp', '5': '35.bmp', '6': '36.bmp', '7': '37.bmp', '8': '38.bmp', '9': '39.bmp', ',': '2e.bmp', 'V': 'V.bmp', 'A': 'A.bmp' } for digit, filename in digit_files.items(): self.img_pool[digit] = [] original_img = self._load_bmp(filename) if not original_img: print(f"Warning: Failed to load '{digit}' from {filename}") continue src = original_img.get_src() original_img.set_pos(-1000, -1000) self.img_pool[digit].append(original_img) # Create extra copies for each digit position for _ in range(self.max_digits - 1): img = lv.img(self.current_screen) img.set_src(src) img.set_pos(-1000, -1000) self.img_pool[digit].append(img) def _get_next_img(self, digit): """Get next available image for digit from pool""" if digit not in self.img_usage: self.img_usage[digit] = 0 index = self.img_usage[digit] pool = self.img_pool.get(digit, []) if index < len(pool): img = pool[index] self.img_usage[digit] += 1 return img else: print(f"Not enough images for digit '{digit}'!") return None def display_value(self, int_part, frac_part): """Display a numeric value with decimal point""" str_int = f"{int_part:02d}" # 2-digit integer part str_frac = f"{frac_part:02d}" # 2-digit fractional part all_digits = list(str_int + str_frac) # Combine all digits self.img_usage = {} # Reset image usage tracking # Pred zobrazením prepočítame rozostupy podľa aktuálneho zoomu self._update_spacing() # Display integer part digits (first 2 digits) for i in range(2): digit = all_digits[i] pos_x = self.x_pos + i * self.digit_spacing # Hide leading zero if i == 0 and digit == '0': for other_digit, pool in self.img_pool.items(): for other_img in pool: if other_img.get_x() == pos_x: other_img.set_pos(-1000, self.y_pos) continue img = self._get_next_img(digit) if img: # Hide any other image at this position for other_digit, pool in self.img_pool.items(): for other_img in pool: if other_img is not img and other_img.get_x() == pos_x: other_img.set_pos(-1000, self.y_pos) img.set_pos(pos_x, self.y_pos) img.set_zoom(self.zoom) # Display decimal comma comma_pos_x = self.x_pos + 2 * self.digit_spacing comma_img = self._get_next_img(',') if comma_img: comma_img.set_pos(comma_pos_x, self.y_pos) comma_img.set_zoom(self.zoom) # Display fractional part digits (last 2 digits) for i in range(2, 4): digit = all_digits[i] pos_x = self.x_pos + (i) * self.digit_spacing + self.comma_offset img = self._get_next_img(digit) if img: for other_digit, pool in self.img_pool.items(): for other_img in pool: if other_img is not img and other_img.get_x() == pos_x: other_img.set_pos(-1000, self.y_pos) img.set_pos(pos_x, self.y_pos) img.set_zoom(self.zoom) # Display suffix if specified if self.suffix in self.img_pool: suffix_img = self._get_next_img(self.suffix) if suffix_img: suffix_x = self.x_pos + 4 * self.digit_spacing + self.comma_offset + self.suffix_offset # Nastavenie priamej pozície pre suffix suffix_img.set_pos(suffix_x, self.y_pos) suffix_img.set_zoom(self.zoom) def _update_spacing(self): """Prepočíta rozostupy medzi číslicami podľa aktuálneho zoomu""" zoom_factor = self.zoom / 255.0 # 255 = 100%, 128 = 50% self.digit_spacing = int(self.base_digit_spacing * zoom_factor) self.comma_offset = int(self.base_comma_offset * zoom_factor) self.suffix_offset = int(self.base_suffix_offset * zoom_factor) def set_zoom(self, zoom_factor): """Set zoom factor for all digits (255 = 100%)""" self.zoom = zoom_factor self._update_spacing() # Prepočítame rozostupy po zmene zoomu def create_4_digit_displays(dm, screen, zoom=255): """Create 4 digit displays with specified zoom level (255 = 100%)""" digit_displays = [] positions = [ (70, 50), # Top left (V) (70, 200), # Top right (V) (70, 300), # Bottom left (A) (70, 370), # Bottom right (A) ] suffixes = ['V', 'V', 'A', 'A'] for (x, y), suffix in zip(positions, suffixes): disp = DigitDisplay(dm, x, y, suffix=suffix) disp.set_zoom(zoom) disp.init_digits(screen) digit_displays.append(disp) return digit_displays def refresh_digit_displays(digit_displays, num1, num2, num3, num4): """Refresh all 4 digit displays with new values""" values = [ (num1 // 100, num1 % 100), # First display (voltage) (num2 // 100, num2 % 100), # Second display (voltage) (num3 // 100, num3 % 100), # Third display (current) (num4 // 100, num4 % 100) # Fourth display (current) ] for disp, (int_p, frac_p) in zip(digit_displays, values): disp.display_value(int_p, frac_p) return values
Solution :
I used a method where the displayed characters are images. Each character is a BMP image. The image must be 24 bit, otherwise the LVGL library cannot import it. I chose images of 50x96 points. whose size is around 10kB. With large displayed characters, the number of them on the screen that can be meaningfully placed is limited anyway, so in the function def _load_digit_images(self) I defined the characters and the corresponding bmp files that will be displayed somewhere on the screen. The number of loaded images can be changed arbitrarily. Each image is loaded into memory only once. But we need to display it x times in different places. For this purpose, I used the lvgl.scr_load() function call, which creates an image object, but only using a reference to an already loaded BMP image, which significantly saves RAM. For each displayed position, I loaded images into the pool that could potentially be required for display at that position. Now, all I need to do is swap the images at that position. It's simple, but slow and the display refresh "flashes terribly". The solution is that all characters are loaded into a pile at that position and those that are not currently displayed are shifted to a position outside the display area, which is fast and efficient and can be done simply by calling the lvgl.img.set_offset_x() function. To optimize speed, only characters where a change is required are redrawn on the screen. This approach allows for fast changes (about 40 large image characters on the screen per second). Another advantage is the ability to enlarge or reduce the loops and thus achieve the display of any character size.
The disadvantage of this approach is that we are manipulating BMP images that have a fixed foreground and background color, so changing the background transparency or the color of the characters or background by changing the LVGL attributes is not possible.
Example of use in the main program, which serves as the main application to display four different values at different zoom levels, for illustrative purposes:
import lvgl as lv
import display
import large_fonts
display.init()
# Initialize display manager
dm = large_fonts.DisplayManager()
# Create screen
screen = dm.create_screen()
dm.show_screen(screen)
# Create 4 digit displays with different zoom levels
displays = large_fonts.create_4_digit_displays(dm, screen, zoom=128)
displays[0].set_zoom(512) # 200% zoom
displays[1].set_zoom(255) # 100% zoom
displays[2].set_zoom(128) # 50% zoom
displays[3].set_zoom(64) # 25% zoom
# Update displays with values
large_fonts.refresh_digit_displays(displays, 1234, 5678, 9012, 3456)
import snapshot
Results can be see on followinf screenshot:
Description of classes, functions, atributes:
Documentation for large_fonts.py Library
Introduction
The large_fonts.py library provides tools for displaying large digits on screens using bitmap images. It's designed for use with the LVGL library and allows smooth resizing of displayed digits using zoom functionality.
DisplayManager Class
Description
Base class for display and screen management.
Attributes
- WIDTH (int): Display width in pixels (default 800)
- HEIGHT (int): Display height in pixels (default 480)
- current_screen (lv.obj): Reference to currently displayed screen
Methods
create_screen(bg_color=lv.color_hex(0x000000))
Creates a new screen with given background color.
Parameters:
- bg_color: Background color (default black)
Returns:
- lv.obj: Created screen object
show_screen(screen)
Displays the specified screen.
Parameters:
- screen: Screen object to display
DigitDisplay Class
Description
Class for displaying large digits with zoom capability and decimal point.
Attributes
- img_pool (dict): Pool of images for digits
- current_screen (lv.obj): Parent screen
- x_pos, y_pos (int): Display position
- base_digit_spacing (int): Base digit spacing at 100% zoom (255)
- base_comma_offset (int): Base decimal point offset at 100% zoom
- base_suffix_offset (int): Base suffix offset at 100% zoom
- max_digits (int): Maximum number of digits (default 4)
- img_usage (dict): Image usage tracking
- suffix (str): Display suffix (e.g. 'V', 'A')
- zoom (int): Current zoom value (255 = 100%)
- digit_spacing (int): Current digit spacing (calculated from zoom)
- comma_offset (int): Current decimal point offset
- suffix_offset (int): Current suffix offset
Methods
init_digits(screen)
Initializes the display with given parent screen.
Parameters:
- screen: Parent screen object
display_value(int_part, frac_part)
Displays a numeric value with decimal point.
Parameters:
- int_part: Integer part (2 digits)
- frac_part: Fractional part (2 digits)
set_zoom(zoom_factor)
Sets zoom scale for all digits.
Parameters:
- zoom_factor: Zoom value (255 = 100%, 128 = 50%)
_update_spacing()
Internal method for recalculating spacing based on current zoom.
_load_bmp(filename)
Loads BMP image from file.
Parameters:
- filename: Filename to load
Returns:
- lv.img or None on error
_load_digit_images()
Loads all required digit images into pool.
_get_next_img(digit)
Gets next available image for given digit from pool.
Parameters:
- digit: Digit character ('0'-'9', ',')
Returns:
- lv.img or None if not available
Functions
create_4_digit_displays(dm, screen, zoom=255)
Creates 4 digit displays (2 for voltage, 2 for current) with given zoom scale.
Parameters:
- dm: DisplayManager instance
- screen: Target screen
- zoom: Initial zoom value (default 255 = 100%)
Returns:
- list: List of 4 DigitDisplay instances
refresh_digit_displays(digit_displays, num1, num2, num3, num4)
Updates all 4 displays with new values.
Parameters:
- digit_displays: List of displays from create_4_digit_displays
- num1, num2, num3, num4: New values for displays (as integers, e.g. 1234 = 12.34)
Returns:
- list: List of values in format [(int_part, frac_part), ...]
Usage Example
import large_fonts
import lvgl as lv
Initialization
dm = large_fonts.DisplayManager()
screen = dm.create_screen()
dm.show_screen(screen)
Create displays with 150% zoom
displays = large_fonts.create_4_digit_displays(dm, screen, zoom=382)
Display values
large_fonts.refresh_digit_displays(displays, 1234, 5678, 9012, 3456)
Change zoom
for disp in displays:
disp.set_zoom(200) # ~80% of original size
Notes
- Library requires BMP files with digits in working directory
- Zoom value 255 corresponds to 100% size (1:1)
- For proper display, call lv.task_handler() in main loop
- Digits are shown/hidden by moving them on/off screen (x=-1000)
...