mirror of
https://github.com/m5stack/MicroPythonOS.git
synced 2026-05-20 11:51:27 -07:00
Camera: support more resolutions
It's a bit unstable, as it crashes if the settings button is clicked after startup, but not when closing and then re-opening. Seems to work for 640x480, including QR decoding.
This commit is contained in:
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Analyze RGB565 screenshots for color correctness.
|
||||
|
||||
Usage:
|
||||
python3 analyze_screenshot.py screenshot.raw [width] [height]
|
||||
|
||||
Checks:
|
||||
- Color channel distribution (detect pale/washed out colors)
|
||||
- Histogram analysis
|
||||
- Average brightness
|
||||
- Color saturation levels
|
||||
"""
|
||||
|
||||
import sys
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
def rgb565_to_rgb888(pixel):
|
||||
"""Convert RGB565 pixel to RGB888."""
|
||||
r5 = (pixel >> 11) & 0x1F
|
||||
g6 = (pixel >> 5) & 0x3F
|
||||
b5 = pixel & 0x1F
|
||||
|
||||
r8 = (r5 << 3) | (r5 >> 2)
|
||||
g8 = (g6 << 2) | (g6 >> 4)
|
||||
b8 = (b5 << 3) | (b5 >> 2)
|
||||
|
||||
return r8, g8, b8
|
||||
|
||||
def analyze_screenshot(filepath, width=320, height=240):
|
||||
"""Analyze RGB565 screenshot file."""
|
||||
print(f"Analyzing: {filepath}")
|
||||
print(f"Dimensions: {width}x{height}")
|
||||
|
||||
# Read raw data
|
||||
try:
|
||||
with open(filepath, 'rb') as f:
|
||||
data = f.read()
|
||||
except FileNotFoundError:
|
||||
print(f"ERROR: File not found: {filepath}")
|
||||
return
|
||||
|
||||
expected_size = width * height * 2
|
||||
if len(data) != expected_size:
|
||||
print(f"ERROR: File size mismatch. Expected {expected_size}, got {len(data)}")
|
||||
print(f" Note: Expected size is for {width}x{height} RGB565 format")
|
||||
return
|
||||
|
||||
# Parse RGB565 pixels
|
||||
pixels = []
|
||||
for i in range(0, len(data), 2):
|
||||
# Little-endian RGB565
|
||||
pixel = struct.unpack('<H', data[i:i+2])[0]
|
||||
r, g, b = rgb565_to_rgb888(pixel)
|
||||
pixels.append((r, g, b))
|
||||
|
||||
# Convert to simple list of lists for statistics
|
||||
red_values = [p[0] for p in pixels]
|
||||
green_values = [p[1] for p in pixels]
|
||||
blue_values = [p[2] for p in pixels]
|
||||
|
||||
# Statistics
|
||||
print(f"\nColor Channel Statistics:")
|
||||
print(f" Red - min: {min(red_values):3d}, max: {max(red_values):3d}, avg: {sum(red_values)/len(red_values):.1f}")
|
||||
print(f" Green - min: {min(green_values):3d}, max: {max(green_values):3d}, avg: {sum(green_values)/len(green_values):.1f}")
|
||||
print(f" Blue - min: {min(blue_values):3d}, max: {max(blue_values):3d}, avg: {sum(blue_values)/len(blue_values):.1f}")
|
||||
|
||||
# Check for pale colors (low saturation)
|
||||
avg_brightness = (sum(red_values) + sum(green_values) + sum(blue_values)) / (len(pixels) * 3)
|
||||
|
||||
# Calculate average saturation (max channel - min channel for each pixel)
|
||||
saturations = []
|
||||
for r, g, b in pixels:
|
||||
max_channel = max(r, g, b)
|
||||
min_channel = min(r, g, b)
|
||||
saturations.append(max_channel - min_channel)
|
||||
|
||||
max_channel_diff = sum(saturations) / len(saturations)
|
||||
|
||||
print(f"\nQuality Metrics:")
|
||||
print(f" Average brightness: {avg_brightness:.1f}")
|
||||
print(f" Average saturation: {max_channel_diff:.1f}")
|
||||
|
||||
if max_channel_diff < 30:
|
||||
print(" ⚠ WARNING: Low saturation detected (colors may appear pale/washed out)")
|
||||
|
||||
if avg_brightness > 200:
|
||||
print(" ⚠ WARNING: Very high brightness (overexposed)")
|
||||
elif avg_brightness < 40:
|
||||
print(" ⚠ WARNING: Very low brightness (underexposed)")
|
||||
|
||||
# Simple histogram (10 bins)
|
||||
print(f"\nChannel Histograms:")
|
||||
for channel_name, channel_values in [('Red', red_values), ('Green', green_values), ('Blue', blue_values)]:
|
||||
print(f" {channel_name}:")
|
||||
|
||||
# Create 10 bins
|
||||
bins = [0] * 10
|
||||
for val in channel_values:
|
||||
bin_idx = min(9, val // 26) # 256 / 10 ≈ 26
|
||||
bins[bin_idx] += 1
|
||||
|
||||
for i, count in enumerate(bins):
|
||||
bar_length = int((count / len(channel_values)) * 50)
|
||||
bar = '█' * bar_length
|
||||
bin_start = i * 26
|
||||
bin_end = (i + 1) * 26 - 1
|
||||
print(f" {bin_start:3d}-{bin_end:3d}: {bar} ({count})")
|
||||
|
||||
# Detect common YUV conversion issues
|
||||
print(f"\nYUV Conversion Checks:")
|
||||
|
||||
# Check if colors are clamped (many pixels at 0 or 255)
|
||||
clamped_count = sum(1 for r, g, b in pixels if r == 0 or r == 255 or g == 0 or g == 255 or b == 0 or b == 255)
|
||||
total_pixels = len(pixels)
|
||||
clamp_percent = (clamped_count / total_pixels) * 100
|
||||
print(f" Clamped pixels: {clamp_percent:.1f}%")
|
||||
if clamp_percent > 5:
|
||||
print(" ⚠ WARNING: High clamp rate suggests color conversion overflow")
|
||||
|
||||
# Check for green tint (common YUYV issue)
|
||||
avg_red = sum(red_values) / len(red_values)
|
||||
avg_green = sum(green_values) / len(green_values)
|
||||
avg_blue = sum(blue_values) / len(blue_values)
|
||||
|
||||
green_dominance = avg_green - ((avg_red + avg_blue) / 2)
|
||||
if green_dominance > 20:
|
||||
print(f" ⚠ WARNING: Green channel dominance ({green_dominance:.1f}) - possible YUYV U/V swap")
|
||||
|
||||
# Sample pixels for visual inspection
|
||||
print(f"\nSample Pixels (first 10):")
|
||||
for i in range(min(10, len(pixels))):
|
||||
r, g, b = pixels[i]
|
||||
print(f" Pixel {i}: RGB({r:3d}, {g:3d}, {b:3d})")
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 analyze_screenshot.py <screenshot.raw> [width] [height]")
|
||||
print("")
|
||||
print("Examples:")
|
||||
print(" python3 analyze_screenshot.py camera_capture.raw")
|
||||
print(" python3 analyze_screenshot.py camera_640x480.raw 640 480")
|
||||
sys.exit(1)
|
||||
|
||||
filepath = sys.argv[1]
|
||||
width = int(sys.argv[2]) if len(sys.argv) > 2 else 320
|
||||
height = int(sys.argv[3]) if len(sys.argv) > 3 else 240
|
||||
|
||||
analyze_screenshot(filepath, width, height)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Graphical test for Camera app settings functionality.
|
||||
|
||||
This test verifies that:
|
||||
1. The camera app settings button can be clicked without crashing
|
||||
2. The settings dialog opens correctly
|
||||
3. Resolution can be changed without causing segfault
|
||||
4. The camera continues to work after resolution change
|
||||
|
||||
This specifically tests the fixes for:
|
||||
- Segfault when clicking settings button
|
||||
- Pale colors after resolution change
|
||||
- Buffer size mismatches
|
||||
|
||||
Usage:
|
||||
Desktop: ./tests/unittest.sh tests/test_graphical_camera_settings.py
|
||||
Device: ./tests/unittest.sh tests/test_graphical_camera_settings.py --ondevice
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import lvgl as lv
|
||||
import mpos.apps
|
||||
import mpos.ui
|
||||
import os
|
||||
from mpos.ui.testing import (
|
||||
wait_for_render,
|
||||
capture_screenshot,
|
||||
find_label_with_text,
|
||||
find_button_with_text,
|
||||
verify_text_present,
|
||||
print_screen_labels,
|
||||
simulate_click,
|
||||
get_widget_coords
|
||||
)
|
||||
|
||||
|
||||
class TestGraphicalCameraSettings(unittest.TestCase):
|
||||
"""Test suite for Camera app settings verification."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures before each test method."""
|
||||
# Check if webcam module is available
|
||||
try:
|
||||
import webcam
|
||||
self.has_webcam = True
|
||||
except:
|
||||
try:
|
||||
import camera
|
||||
self.has_webcam = False # Has internal camera instead
|
||||
except:
|
||||
self.skipTest("No camera module available (webcam or internal)")
|
||||
|
||||
# Get absolute path to screenshots directory
|
||||
import sys
|
||||
if sys.platform == "esp32":
|
||||
self.screenshot_dir = "tests/screenshots"
|
||||
else:
|
||||
self.screenshot_dir = "../tests/screenshots"
|
||||
|
||||
# Ensure screenshots directory exists
|
||||
try:
|
||||
os.mkdir(self.screenshot_dir)
|
||||
except OSError:
|
||||
pass # Directory already exists
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up after each test method."""
|
||||
# Navigate back to launcher (closes the camera app)
|
||||
try:
|
||||
mpos.ui.back_screen()
|
||||
wait_for_render(10) # Allow navigation and cleanup to complete
|
||||
except:
|
||||
pass # Already on launcher or error
|
||||
|
||||
def test_settings_button_click_no_crash(self):
|
||||
"""
|
||||
Test that clicking the settings button doesn't cause a segfault.
|
||||
|
||||
This is the critical test that verifies the fix for the segfault
|
||||
that occurred when clicking settings due to stale image_dsc.data pointer.
|
||||
|
||||
Steps:
|
||||
1. Start camera app
|
||||
2. Wait for camera to initialize
|
||||
3. Capture initial screenshot
|
||||
4. Click settings button (top-right corner)
|
||||
5. Verify settings dialog opened
|
||||
6. If we get here without crash, test passes
|
||||
"""
|
||||
print("\n=== Testing settings button click (no crash) ===")
|
||||
|
||||
# Start the Camera app
|
||||
result = mpos.apps.start_app("com.micropythonos.camera")
|
||||
self.assertTrue(result, "Failed to start Camera app")
|
||||
|
||||
# Wait for camera to initialize and first frame to render
|
||||
wait_for_render(iterations=30)
|
||||
|
||||
# Get current screen
|
||||
screen = lv.screen_active()
|
||||
|
||||
# Debug: Print all text on screen
|
||||
print("\nInitial screen labels:")
|
||||
print_screen_labels(screen)
|
||||
|
||||
# Capture screenshot before clicking settings
|
||||
screenshot_path = f"{self.screenshot_dir}/camera_before_settings.raw"
|
||||
print(f"\nCapturing initial screenshot: {screenshot_path}")
|
||||
capture_screenshot(screenshot_path, width=320, height=240)
|
||||
|
||||
# Find and click settings button
|
||||
# The settings button is positioned at TOP_RIGHT with offset (0, 60)
|
||||
# On a 320x240 screen, this is approximately x=260, y=90
|
||||
# We'll click slightly inside the button to ensure we hit it
|
||||
settings_x = 290 # Right side of screen, inside the 60px button
|
||||
settings_y = 90 # 60px down from top, center of 60px button
|
||||
|
||||
print(f"\nClicking settings button at ({settings_x}, {settings_y})")
|
||||
simulate_click(settings_x, settings_y, press_duration_ms=100)
|
||||
|
||||
# Wait for settings dialog to appear
|
||||
wait_for_render(iterations=20)
|
||||
|
||||
# Get screen again (might have changed after navigation)
|
||||
screen = lv.screen_active()
|
||||
|
||||
# Debug: Print labels after clicking
|
||||
print("\nScreen labels after clicking settings:")
|
||||
print_screen_labels(screen)
|
||||
|
||||
# Verify settings screen opened
|
||||
# Look for "Camera Settings" or "resolution" text
|
||||
has_settings_ui = (
|
||||
verify_text_present(screen, "Camera Settings") or
|
||||
verify_text_present(screen, "Resolution") or
|
||||
verify_text_present(screen, "resolution") or
|
||||
verify_text_present(screen, "Save") or
|
||||
verify_text_present(screen, "Cancel")
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
has_settings_ui,
|
||||
"Settings screen did not open (no expected UI elements found)"
|
||||
)
|
||||
|
||||
# Capture screenshot of settings dialog
|
||||
screenshot_path = f"{self.screenshot_dir}/camera_settings_dialog.raw"
|
||||
print(f"\nCapturing settings dialog screenshot: {screenshot_path}")
|
||||
capture_screenshot(screenshot_path, width=320, height=240)
|
||||
|
||||
# If we got here without segfault, the test passes!
|
||||
print("\n✓ Settings button clicked successfully without crash!")
|
||||
|
||||
def test_resolution_change_no_crash(self):
|
||||
"""
|
||||
Test that changing resolution doesn't cause a crash.
|
||||
|
||||
This tests the full resolution change workflow:
|
||||
1. Start camera app
|
||||
2. Open settings
|
||||
3. Change resolution
|
||||
4. Save settings
|
||||
5. Verify camera continues working
|
||||
|
||||
This verifies fixes for:
|
||||
- Segfault during reconfiguration
|
||||
- Buffer size mismatches
|
||||
- Stale data pointers
|
||||
"""
|
||||
print("\n=== Testing resolution change (no crash) ===")
|
||||
|
||||
# Start the Camera app
|
||||
result = mpos.apps.start_app("com.micropythonos.camera")
|
||||
self.assertTrue(result, "Failed to start Camera app")
|
||||
|
||||
# Wait for camera to initialize
|
||||
wait_for_render(iterations=30)
|
||||
|
||||
# Click settings button
|
||||
print("\nOpening settings...")
|
||||
simulate_click(290, 90, press_duration_ms=100)
|
||||
wait_for_render(iterations=20)
|
||||
|
||||
screen = lv.screen_active()
|
||||
|
||||
# Try to find the dropdown/resolution selector
|
||||
# The CameraSettingsActivity creates a dropdown widget
|
||||
# Let's look for any dropdown on screen
|
||||
print("\nLooking for resolution dropdown...")
|
||||
|
||||
# Find all clickable objects (dropdowns are clickable)
|
||||
# We'll try clicking in the middle area where the dropdown should be
|
||||
# Dropdown is typically centered, so around x=160, y=120
|
||||
dropdown_x = 160
|
||||
dropdown_y = 120
|
||||
|
||||
print(f"Clicking dropdown area at ({dropdown_x}, {dropdown_y})")
|
||||
simulate_click(dropdown_x, dropdown_y, press_duration_ms=100)
|
||||
wait_for_render(iterations=15)
|
||||
|
||||
# The dropdown should now be open showing resolution options
|
||||
# Let's capture what we see
|
||||
screenshot_path = f"{self.screenshot_dir}/camera_dropdown_open.raw"
|
||||
print(f"Capturing dropdown screenshot: {screenshot_path}")
|
||||
capture_screenshot(screenshot_path, width=320, height=240)
|
||||
|
||||
screen = lv.screen_active()
|
||||
print("\nScreen after opening dropdown:")
|
||||
print_screen_labels(screen)
|
||||
|
||||
# Try to select a different resolution
|
||||
# Options are typically stacked vertically
|
||||
# Let's click a bit lower to select a different option
|
||||
option_x = 160
|
||||
option_y = 150 # Below the current selection
|
||||
|
||||
print(f"\nSelecting different resolution at ({option_x}, {option_y})")
|
||||
simulate_click(option_x, option_y, press_duration_ms=100)
|
||||
wait_for_render(iterations=15)
|
||||
|
||||
# Now find and click the Save button
|
||||
print("\nLooking for Save button...")
|
||||
save_button = find_button_with_text(lv.screen_active(), "Save")
|
||||
|
||||
if save_button:
|
||||
coords = get_widget_coords(save_button)
|
||||
print(f"Found Save button at {coords}")
|
||||
simulate_click(coords['center_x'], coords['center_y'], press_duration_ms=100)
|
||||
else:
|
||||
# Fallback: Save button is typically at bottom-left
|
||||
# Based on CameraSettingsActivity code: ALIGN.BOTTOM_LEFT
|
||||
print("Save button not found via text, trying bottom-left corner")
|
||||
simulate_click(80, 220, press_duration_ms=100)
|
||||
|
||||
# Wait for reconfiguration to complete
|
||||
print("\nWaiting for reconfiguration...")
|
||||
wait_for_render(iterations=30)
|
||||
|
||||
# Capture screenshot after reconfiguration
|
||||
screenshot_path = f"{self.screenshot_dir}/camera_after_resolution_change.raw"
|
||||
print(f"Capturing post-change screenshot: {screenshot_path}")
|
||||
capture_screenshot(screenshot_path, width=320, height=240)
|
||||
|
||||
# If we got here without segfault, the test passes!
|
||||
print("\n✓ Resolution changed successfully without crash!")
|
||||
|
||||
# Verify camera is still showing something
|
||||
screen = lv.screen_active()
|
||||
# The camera app should still be active (not crashed back to launcher)
|
||||
# We can check this by looking for camera-specific UI elements
|
||||
# or just the fact that we haven't crashed
|
||||
|
||||
print("\n✓ Camera app still running after resolution change!")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Note: Don't include unittest.main() - handled by unittest.sh
|
||||
pass
|
||||
Reference in New Issue
Block a user