Files
Inkycal/test_display.py
2025-11-30 21:17:02 +01:00

650 lines
26 KiB
Python

#!/usr/bin/env python3
"""
Universal E-Paper Display Test Script for Inkycal
Tests displays with various patterns for validation
Supports both color (3-color) and black/white displays
"""
import sys
import time
import json
import argparse
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
import logging
sys.path.insert(0, str(Path(__file__).parent))
from inkycal.display.display import Display
from inkycal.display.supported_models import supported_models
from inkycal.settings import Settings
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class UniversalDisplayTest:
def __init__(self, model=None, auto_detect=True):
"""Initialize the display test class
Args:
model: Display model name (optional)
auto_detect: Try to detect from settings.json if model not provided
"""
self.model = model
self.display = None
self.width = None
self.height = None
self.is_colour = False
# Color definitions
self.WHITE = (255, 255, 255)
self.BLACK = (0, 0, 0)
self.RED = (255, 0, 0)
# Auto-detect model if needed
if not self.model and auto_detect:
self.model = self._auto_detect_model()
if not self.model:
raise ValueError("No display model specified. Use --model or ensure settings.json exists")
# Validate and get display info
self._validate_model()
# Initialize display
self._init_display()
def _auto_detect_model(self):
"""Try to detect display model from settings.json"""
for settings_path in Settings.SETTINGS_JSON_PATHS:
settings_file = Path(settings_path)
if settings_file.exists():
try:
with open(settings_file, 'r') as f:
settings = json.load(f)
model = settings.get('model')
if model:
logger.info(f"Auto-detected model '{model}' from {settings_path}")
return model
except Exception as e:
logger.warning(f"Could not read {settings_path}: {e}")
logger.warning("Could not auto-detect display model from settings.json")
return None
def _validate_model(self):
"""Validate the display model and get its properties"""
if self.model not in supported_models:
logger.error(f"Model '{self.model}' not supported")
logger.info("Supported models:")
for model_name in sorted(supported_models.keys()):
width, height = supported_models[model_name]
color_info = " (colour)" if "colour" in model_name.lower() else ""
logger.info(f" - {model_name}: {width}x{height}{color_info}")
raise ValueError(f"Unsupported model: {self.model}")
# Get display dimensions
self.width, self.height = supported_models[self.model]
# Check if it's a color display
self.is_colour = "colour" in self.model.lower() or "color" in self.model.lower()
logger.info(f"Display model: {self.model}")
logger.info(f"Resolution: {self.width}x{self.height}")
logger.info(f"Type: {'Colour (3-color)' if self.is_colour else 'Black/White'}")
def _init_display(self):
"""Initialize the display hardware"""
try:
logger.info(f"Initializing {self.model} display...")
self.display = Display(self.model)
logger.info(f"Display initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize display: {e}")
raise
def clear_display(self):
"""Clear the display to white"""
logger.info("Clearing display...")
black_img = Image.new('RGB', (self.width, self.height), self.WHITE)
if self.is_colour:
red_img = Image.new('RGB', (self.width, self.height), self.WHITE)
self.display.render(black_img, red_img)
else:
self.display.render(black_img)
logger.info("Display cleared")
def test_solid_colors(self):
"""Test solid color fills"""
logger.info("Testing solid colors...")
# Test 1: Full black screen
logger.info("Test 1: Full black screen")
black_img = Image.new('RGB', (self.width, self.height), self.BLACK)
if self.is_colour:
red_img = Image.new('RGB', (self.width, self.height), self.WHITE)
self.display.render(black_img, red_img)
else:
self.display.render(black_img)
time.sleep(5)
# Test 2: Full white screen
logger.info("Test 2: Full white screen")
black_img = Image.new('RGB', (self.width, self.height), self.WHITE)
if self.is_colour:
red_img = Image.new('RGB', (self.width, self.height), self.WHITE)
self.display.render(black_img, red_img)
else:
self.display.render(black_img)
time.sleep(5)
# Test 3: Full red screen (color displays only)
if self.is_colour:
logger.info("Test 3: Full red/color screen")
black_img = Image.new('RGB', (self.width, self.height), self.WHITE)
red_img = Image.new('RGB', (self.width, self.height), self.RED)
self.display.render(black_img, red_img)
time.sleep(5)
def test_color_sections(self):
"""Test display with color sections"""
if self.is_colour:
logger.info("Testing color sections (thirds)...")
else:
logger.info("Testing black/white sections (halves)...")
# Create images
black_img = Image.new('RGB', (self.width, self.height), self.WHITE)
draw_black = ImageDraw.Draw(black_img)
if self.is_colour:
red_img = Image.new('RGB', (self.width, self.height), self.WHITE)
draw_red = ImageDraw.Draw(red_img)
# Divide screen into three vertical sections
section_width = self.width // 3
# Left section: Black
draw_black.rectangle([0, 0, section_width, self.height], fill=self.BLACK)
# Middle section: White (already white)
# Right section: Red
draw_red.rectangle([section_width * 2, 0, self.width, self.height], fill=self.RED)
self.display.render(black_img, red_img)
else:
# For B/W displays: half black, half white
section_width = self.width // 2
draw_black.rectangle([0, 0, section_width, self.height], fill=self.BLACK)
self.display.render(black_img)
logger.info("Color sections displayed")
time.sleep(5)
def test_checkerboard(self):
logger.info("Testing checkerboard pattern...")
black_img = Image.new('RGB', (self.width, self.height), self.WHITE)
draw_black = ImageDraw.Draw(black_img)
if self.is_colour:
red_img = Image.new('RGB', (self.width, self.height), self.WHITE)
draw_red = ImageDraw.Draw(red_img)
# Adjust square size based on display size
square_size = max(20, min(50, self.width // 20))
for y in range(0, self.height, square_size):
for x in range(0, self.width, square_size):
if self.is_colour:
# For color displays: cycle through black, white, red
pattern = ((x // square_size) + (y // square_size)) % 3
if pattern == 0:
draw_black.rectangle([x, y, x + square_size, y + square_size], fill=self.BLACK)
elif pattern == 1:
draw_red.rectangle([x, y, x + square_size, y + square_size], fill=self.RED)
else:
# For B/W displays: simple checkerboard
if ((x // square_size) + (y // square_size)) % 2 == 0:
draw_black.rectangle([x, y, x + square_size, y + square_size], fill=self.BLACK)
if self.is_colour:
self.display.render(black_img, red_img)
else:
self.display.render(black_img)
logger.info("Checkerboard pattern displayed")
time.sleep(5)
def test_geometric_shapes(self):
logger.info("Testing geometric shapes...")
black_img = Image.new('RGB', (self.width, self.height), self.WHITE)
draw_black = ImageDraw.Draw(black_img)
if self.is_colour:
red_img = Image.new('RGB', (self.width, self.height), self.WHITE)
draw_red = ImageDraw.Draw(red_img)
# Scale shapes based on display size
scale = min(self.width, self.height) / 400
# Black circle
circle_size = int(100 * scale)
draw_black.ellipse([50, 50, 50 + circle_size, 50 + circle_size], fill=self.BLACK)
# Rectangle (red for color, black for B/W)
rect_x = int(200 * scale)
rect_size = int(100 * scale)
if self.is_colour:
draw_red.rectangle([rect_x, 50, rect_x + rect_size, 50 + rect_size], fill=self.RED)
else:
draw_black.rectangle([rect_x, 50, rect_x + rect_size, 50 + rect_size], fill=self.BLACK)
# Cross lines
draw_black.line([0, self.height//2, self.width, self.height//2], fill=self.BLACK, width=3)
draw_black.line([self.width//2, 0, self.width//2, self.height], fill=self.BLACK, width=3)
# Diagonal lines (red for color displays)
if self.is_colour:
draw_red.line([0, 0, self.width, self.height], fill=self.RED, width=2)
draw_red.line([self.width, 0, 0, self.height], fill=self.RED, width=2)
else:
draw_black.line([0, 0, self.width, self.height], fill=self.BLACK, width=1)
draw_black.line([self.width, 0, 0, self.height], fill=self.BLACK, width=1)
if self.is_colour:
self.display.render(black_img, red_img)
else:
self.display.render(black_img)
logger.info("Geometric shapes displayed")
time.sleep(5)
def test_text_rendering(self):
logger.info("Testing text rendering...")
black_img = Image.new('RGB', (self.width, self.height), self.WHITE)
draw_black = ImageDraw.Draw(black_img)
if self.is_colour:
red_img = Image.new('RGB', (self.width, self.height), self.WHITE)
draw_red = ImageDraw.Draw(red_img)
# Scale font sizes based on display size
scale = min(self.width, self.height) / 400
# Try to load fonts
try:
font_small = ImageFont.truetype(f"{Settings.FONT_PATH}/NotoSans-Light.ttf", int(16 * scale))
font_medium = ImageFont.truetype(f"{Settings.FONT_PATH}/NotoSans-Regular.ttf", int(24 * scale))
font_large = ImageFont.truetype(f"{Settings.FONT_PATH}/NotoSans-Bold.ttf", int(36 * scale))
except:
logger.warning("Custom fonts not found, using default")
font_small = ImageFont.load_default()
font_medium = ImageFont.load_default()
font_large = ImageFont.load_default()
y_offset = 20
# Title
draw_black.text((20, y_offset), "E-Paper Display Test", font=font_large, fill=self.BLACK)
y_offset += int(50 * scale)
# Display info
info_text = [
f"Model: {self.model}",
f"Resolution: {self.width} x {self.height}",
f"Type: {'Colour' if self.is_colour else 'Black/White'}",
f"Time: {time.strftime('%Y-%m-%d %H:%M:%S')}"
]
for i, text in enumerate(info_text):
if self.is_colour and i % 2 == 1:
draw_red.text((20, y_offset), text, font=font_medium, fill=self.RED)
else:
draw_black.text((20, y_offset), text, font=font_medium, fill=self.BLACK)
y_offset += int(35 * scale)
# Sample text
y_offset += int(20 * scale)
draw_black.text((20, y_offset), "The quick brown fox jumps over the lazy dog",
font=font_small, fill=self.BLACK)
if self.is_colour:
y_offset += int(25 * scale)
draw_red.text((20, y_offset), "0123456789 !@#$%^&*()",
font=font_small, fill=self.RED)
if self.is_colour:
self.display.render(black_img, red_img)
else:
self.display.render(black_img)
logger.info("Text rendering displayed")
time.sleep(5)
def test_gradient_bars(self):
logger.info("Testing gradient/dither patterns...")
black_img = Image.new('RGB', (self.width, self.height), self.WHITE)
draw_black = ImageDraw.Draw(black_img)
if self.is_colour:
red_img = Image.new('RGB', (self.width, self.height), self.WHITE)
draw_red = ImageDraw.Draw(red_img)
# Create horizontal bars with different patterns
num_bars = 4 if self.is_colour else 3
bar_height = self.height // num_bars
# Bar 1: Dithered black gradient
for x in range(self.width):
if x % 2 == 0 or x < self.width // 3:
draw_black.line([(x, 0), (x, bar_height)], fill=self.BLACK)
# Bar 2: For color displays, dithered red
if self.is_colour:
for x in range(self.width):
if x % 2 == 0 or x < self.width // 3:
draw_red.line([(x, bar_height), (x, bar_height * 2)], fill=self.RED)
bar_start = 2
else:
bar_start = 1
# Vertical stripes
stripe_width = 10
for x in range(0, self.width, stripe_width * 2):
draw_black.rectangle([x, bar_height * bar_start, x + stripe_width, bar_height * (bar_start + 1)],
fill=self.BLACK)
# Fine checkerboard at bottom
for x in range(0, self.width, 4):
for y in range(bar_height * (num_bars - 1), self.height, 4):
if ((x // 4) + (y // 4)) % 2 == 0:
draw_black.rectangle([x, y, x + 4, y + 4], fill=self.BLACK)
if self.is_colour:
self.display.render(black_img, red_img)
else:
self.display.render(black_img)
logger.info("Gradient/dither patterns displayed")
time.sleep(5)
def test_calibration_pattern(self):
"""Display calibration pattern for alignment testing"""
logger.info("Testing calibration pattern...")
black_img = Image.new('RGB', (self.width, self.height), self.WHITE)
draw_black = ImageDraw.Draw(black_img)
if self.is_colour:
red_img = Image.new('RGB', (self.width, self.height), self.WHITE)
draw_red = ImageDraw.Draw(red_img)
# Draw border
draw_black.rectangle([0, 0, self.width-1, self.height-1], outline=self.BLACK, width=3)
# Inner border (red for color displays)
if self.is_colour:
draw_red.rectangle([10, 10, self.width-11, self.height-11], outline=self.RED, width=2)
else:
draw_black.rectangle([10, 10, self.width-11, self.height-11], outline=self.BLACK, width=1)
# Center crosshair
center_x = self.width // 2
center_y = self.height // 2
cross_size = min(50, self.width // 10)
draw_black.line([center_x - cross_size, center_y, center_x + cross_size, center_y],
fill=self.BLACK, width=2)
draw_black.line([center_x, center_y - cross_size, center_x, center_y + cross_size],
fill=self.BLACK, width=2)
# Corner markers
marker_size = min(50, self.width // 10)
# Top-left
draw_black.line([0, marker_size, marker_size, marker_size], fill=self.BLACK, width=2)
draw_black.line([marker_size, 0, marker_size, marker_size], fill=self.BLACK, width=2)
# Top-right
draw_black.line([self.width - marker_size, 0, self.width - marker_size, marker_size],
fill=self.BLACK, width=2)
draw_black.line([self.width - marker_size, marker_size, self.width, marker_size],
fill=self.BLACK, width=2)
# Bottom corners (red for color displays)
if self.is_colour:
# Bottom-left
draw_red.line([0, self.height - marker_size, marker_size, self.height - marker_size],
fill=self.RED, width=2)
draw_red.line([marker_size, self.height - marker_size, marker_size, self.height],
fill=self.RED, width=2)
# Bottom-right
draw_red.line([self.width - marker_size, self.height - marker_size,
self.width - marker_size, self.height], fill=self.RED, width=2)
draw_red.line([self.width - marker_size, self.height - marker_size,
self.width, self.height - marker_size], fill=self.RED, width=2)
else:
# Bottom-left
draw_black.line([0, self.height - marker_size, marker_size, self.height - marker_size],
fill=self.BLACK, width=2)
draw_black.line([marker_size, self.height - marker_size, marker_size, self.height],
fill=self.BLACK, width=2)
# Bottom-right
draw_black.line([self.width - marker_size, self.height - marker_size,
self.width - marker_size, self.height], fill=self.BLACK, width=2)
draw_black.line([self.width - marker_size, self.height - marker_size,
self.width, self.height - marker_size], fill=self.BLACK, width=2)
# Grid
grid_spacing = max(50, min(100, self.width // 10))
for x in range(0, self.width, grid_spacing):
draw_black.line([x, 0, x, self.height], fill=self.BLACK, width=1)
for y in range(0, self.height, grid_spacing):
draw_black.line([0, y, self.width, y], fill=self.BLACK, width=1)
if self.is_colour:
self.display.render(black_img, red_img)
else:
self.display.render(black_img)
logger.info("Calibration pattern displayed")
time.sleep(5)
def run_calibration_cycles(self, cycles=3):
"""Run calibration cycles to refresh the display"""
logger.info(f"Running {cycles} calibration cycles...")
for i in range(cycles):
logger.info(f"Calibration cycle {i+1}/{cycles}")
# Black
black_img = Image.new('RGB', (self.width, self.height), self.BLACK)
if self.is_colour:
red_img = Image.new('RGB', (self.width, self.height), self.WHITE)
self.display.render(black_img, red_img)
else:
self.display.render(black_img)
time.sleep(2)
# White
black_img = Image.new('RGB', (self.width, self.height), self.WHITE)
if self.is_colour:
red_img = Image.new('RGB', (self.width, self.height), self.WHITE)
self.display.render(black_img, red_img)
else:
self.display.render(black_img)
time.sleep(2)
# Red (color displays only)
if self.is_colour:
black_img = Image.new('RGB', (self.width, self.height), self.WHITE)
red_img = Image.new('RGB', (self.width, self.height), self.RED)
self.display.render(black_img, red_img)
time.sleep(2)
logger.info("Calibration cycles complete")
def run_all_tests(self, delay_between_tests=3):
"""Run all test patterns in sequence"""
logger.info(f"Starting comprehensive display test for {self.model}...")
tests = [
("Clear Display", self.clear_display),
("Solid Colors", self.test_solid_colors),
("Color Sections", self.test_color_sections),
("Checkerboard Pattern", self.test_checkerboard),
("Geometric Shapes", self.test_geometric_shapes),
("Text Rendering", self.test_text_rendering),
("Gradient/Dither Patterns", self.test_gradient_bars),
("Calibration Pattern", self.test_calibration_pattern),
]
for test_name, test_func in tests:
logger.info(f"\n--- Running: {test_name} ---")
try:
test_func()
time.sleep(delay_between_tests)
except Exception as e:
logger.error(f"Test '{test_name}' failed: {e}")
continue
logger.info("\nAll tests completed!")
def cleanup(self):
"""Clean up display resources"""
logger.info("Cleaning up...")
if self.display:
self.clear_display()
logger.info("Cleanup complete")
def list_supported_displays():
"""List all supported display models"""
print("\nSupported E-Paper Display Models:")
print("-" * 50)
# Separate color and B/W displays
color_displays = []
bw_displays = []
for model_name in sorted(supported_models.keys()):
if model_name == "image_file":
continue # Skip the virtual display
width, height = supported_models[model_name]
info = f"{model_name}: {width}x{height}"
if "colour" in model_name.lower() or "color" in model_name.lower():
color_displays.append(info)
else:
bw_displays.append(info)
print("\nColor Displays (3-color: black/white/red):")
for display in color_displays:
print(f" - {display}")
print("\nBlack/White Displays:")
for display in bw_displays:
print(f" - {display}")
print("\nVirtual Display (for testing without hardware):")
print(f" - image_file: {supported_models['image_file'][0]}x{supported_models['image_file'][1]}")
print("\nUsage: python test_display.py --model <model_name>")
print(" or: python test_display.py (auto-detect from settings.json)")
def main():
"""
$ Main function to run display tests
$ python test_display.py --model epd_7_in_5_colour --test all
"""
"""Main function to run display tests"""
parser = argparse.ArgumentParser(description='Universal E-Paper Display Test Script')
parser.add_argument('--model', type=str, default=None,
help='Display model name (e.g., epd_7_in_5_colour, epd_12_in_48_colour_V2)')
parser.add_argument('--test', type=str, default='all',
choices=['all', 'solid', 'sections', 'checkerboard', 'shapes',
'text', 'gradient', 'calibration', 'cycles'],
help='Specific test to run (default: all)')
parser.add_argument('--cycles', type=int, default=3,
help='Number of calibration cycles (default: 3)')
parser.add_argument('--delay', type=int, default=3,
help='Delay between tests in seconds (default: 3)')
parser.add_argument('--list', action='store_true',
help='List all supported display models')
parser.add_argument('--no-auto', action='store_true',
help='Disable auto-detection from settings.json')
args = parser.parse_args()
# List supported displays if requested
if args.list:
list_supported_displays()
return 0
# Create test instance
try:
tester = UniversalDisplayTest(
model=args.model,
auto_detect=not args.no_auto
)
except Exception as e:
logger.error(f"Failed to initialize display test: {e}")
logger.error("\nTroubleshooting:")
logger.error("1. Make sure you're running on a Raspberry Pi with display connected")
logger.error("2. Check that SPI is enabled (sudo raspi-config)")
logger.error("3. Try with sudo if you get permission errors")
logger.error("4. Use --list to see all supported models")
logger.error("5. Use --model to specify your display model explicitly")
return 1
try:
# Run requested test
if args.test == 'all':
tester.run_all_tests(delay_between_tests=args.delay)
elif args.test == 'solid':
tester.test_solid_colors()
elif args.test == 'sections':
tester.test_color_sections()
elif args.test == 'checkerboard':
tester.test_checkerboard()
elif args.test == 'shapes':
tester.test_geometric_shapes()
elif args.test == 'text':
tester.test_text_rendering()
elif args.test == 'gradient':
tester.test_gradient_bars()
elif args.test == 'calibration':
tester.test_calibration_pattern()
elif args.test == 'cycles':
tester.run_calibration_cycles(cycles=args.cycles)
# Always clear display at the end
time.sleep(2)
tester.cleanup()
except KeyboardInterrupt:
logger.info("\nTest interrupted by user")
tester.cleanup()
return 0
except Exception as e:
logger.error(f"Test failed: {e}")
tester.cleanup()
return 1
return 0
if __name__ == "__main__":
sys.exit(main())