From 7e4585e91e57671cb708c6f89338c3a65058fa5e Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 24 Nov 2025 23:35:50 +0100 Subject: [PATCH] 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. --- c_mpos/src/webcam.c | 196 +++++++++++-- .../assets/camera_app.py | 67 +++-- tests/analyze_screenshot.py | 150 ++++++++++ tests/test_graphical_camera_settings.py | 258 ++++++++++++++++++ 4 files changed, 631 insertions(+), 40 deletions(-) create mode 100755 tests/analyze_screenshot.py create mode 100644 tests/test_graphical_camera_settings.py diff --git a/c_mpos/src/webcam.c b/c_mpos/src/webcam.c index 8b0e9194..ca067735 100644 --- a/c_mpos/src/webcam.c +++ b/c_mpos/src/webcam.c @@ -50,12 +50,25 @@ static void yuyv_to_rgb565(unsigned char *yuyv, uint16_t *rgb565, int in_width, for (int x = 0; x < out_width; x++) { int src_x = (int)(x * x_ratio) + crop_x_offset; int src_y = (int)(y * y_ratio) + crop_y_offset; - int src_index = (src_y * in_width + src_x) * 2; - int y0 = yuyv[src_index]; - int u = yuyv[src_index + 1]; - int v = yuyv[src_index + 3]; + // YUYV format: Y0 U Y1 V (4 bytes for 2 pixels) + // Ensure we're aligned to even pixel boundary + int src_x_even = (src_x / 2) * 2; + int src_base_index = (src_y * in_width + src_x_even) * 2; + // Extract Y, U, V values + int y0; + if (src_x % 2 == 0) { + // Even pixel: use Y0 + y0 = yuyv[src_base_index]; + } else { + // Odd pixel: use Y1 + y0 = yuyv[src_base_index + 2]; + } + int u = yuyv[src_base_index + 1]; + int v = yuyv[src_base_index + 3]; + + // YUV to RGB conversion (ITU-R BT.601) int c = y0 - 16; int d = u - 128; int e = v - 128; @@ -64,10 +77,12 @@ static void yuyv_to_rgb565(unsigned char *yuyv, uint16_t *rgb565, int in_width, int g = (298 * c - 100 * d - 208 * e + 128) >> 8; int b = (298 * c + 516 * d + 128) >> 8; + // Clamp to valid range r = r < 0 ? 0 : (r > 255 ? 255 : r); g = g < 0 ? 0 : (g > 255 ? 255 : g); b = b < 0 ? 0 : (b > 255 ? 255 : b); + // Convert to RGB565 uint16_t r5 = (r >> 3) & 0x1F; uint16_t g6 = (g >> 2) & 0x3F; uint16_t b5 = (b >> 3) & 0x1F; @@ -91,8 +106,23 @@ static void yuyv_to_grayscale(unsigned char *yuyv, unsigned char *gray, int in_w for (int x = 0; x < out_width; x++) { int src_x = (int)(x * x_ratio) + crop_x_offset; int src_y = (int)(y * y_ratio) + crop_y_offset; - int src_index = (src_y * in_width + src_x) * 2; - gray[y * out_width + x] = yuyv[src_index]; + + // YUYV format: Y0 U Y1 V (4 bytes for 2 pixels) + // Ensure we're aligned to even pixel boundary + int src_x_even = (src_x / 2) * 2; + int src_base_index = (src_y * in_width + src_x_even) * 2; + + // Extract Y value + unsigned char y_val; + if (src_x % 2 == 0) { + // Even pixel: use Y0 + y_val = yuyv[src_base_index]; + } else { + // Odd pixel: use Y1 + y_val = yuyv[src_base_index + 2]; + } + + gray[y * out_width + x] = y_val; } } } @@ -342,14 +372,25 @@ static mp_obj_t webcam_capture_frame(mp_obj_t self_in, mp_obj_t format) { MP_DEFINE_CONST_FUN_OBJ_2(webcam_capture_frame_obj, webcam_capture_frame); static mp_obj_t webcam_reconfigure(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { - // NOTE: This function only changes OUTPUT resolution (what Python receives). - // The INPUT resolution (what the webcam captures from V4L2) remains fixed at 640x480. - // The conversion functions will crop/scale from input to output resolution. - // TODO: Add support for changing input resolution (requires V4L2 reinit) + /* + * Reconfigure webcam resolution. + * + * Supports changing both INPUT resolution (V4L2 capture format) and + * OUTPUT resolution (conversion buffers). If input resolution changes, + * this will stop streaming, reconfigure V4L2, and restart streaming. + * + * Parameters: + * input_width, input_height: V4L2 capture resolution (optional) + * output_width, output_height: Output buffer resolution (optional) + * + * If not specified, dimensions remain unchanged. + */ - enum { ARG_self, ARG_output_width, ARG_output_height }; + enum { ARG_self, ARG_input_width, ARG_input_height, ARG_output_width, ARG_output_height }; static const mp_arg_t allowed_args[] = { { MP_QSTR_self, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_input_width, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_input_height, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, { MP_QSTR_output_width, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, { MP_QSTR_output_height, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, }; @@ -360,26 +401,135 @@ static mp_obj_t webcam_reconfigure(size_t n_args, const mp_obj_t *pos_args, mp_m webcam_obj_t *self = MP_OBJ_TO_PTR(args[ARG_self].u_obj); // Get new dimensions (keep current if not specified) - int new_width = args[ARG_output_width].u_int; - int new_height = args[ARG_output_height].u_int; + int new_input_width = args[ARG_input_width].u_int; + int new_input_height = args[ARG_input_height].u_int; + int new_output_width = args[ARG_output_width].u_int; + int new_output_height = args[ARG_output_height].u_int; - if (new_width == 0) new_width = self->output_width; - if (new_height == 0) new_height = self->output_height; + if (new_input_width == 0) new_input_width = self->input_width; + if (new_input_height == 0) new_input_height = self->input_height; + if (new_output_width == 0) new_output_width = self->output_width; + if (new_output_height == 0) new_output_height = self->output_height; // Validate dimensions - if (new_width <= 0 || new_height <= 0 || new_width > 1920 || new_height > 1920) { + if (new_input_width <= 0 || new_input_height <= 0 || new_input_width > 1920 || new_input_height > 1920) { + mp_raise_ValueError(MP_ERROR_TEXT("Invalid input dimensions")); + } + if (new_output_width <= 0 || new_output_height <= 0 || new_output_width > 1920 || new_output_height > 1920) { mp_raise_ValueError(MP_ERROR_TEXT("Invalid output dimensions")); } - // If dimensions changed, reallocate buffers - if (new_width != self->output_width || new_height != self->output_height) { + bool input_changed = (new_input_width != self->input_width || new_input_height != self->input_height); + bool output_changed = (new_output_width != self->output_width || new_output_height != self->output_height); + + // If input resolution changed, need to reconfigure V4L2 + if (input_changed) { + WEBCAM_DEBUG_PRINT("Reconfiguring V4L2: %dx%d -> %dx%d\n", + self->input_width, self->input_height, + new_input_width, new_input_height); + + // 1. Stop streaming + enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + if (ioctl(self->fd, VIDIOC_STREAMOFF, &type) < 0) { + WEBCAM_DEBUG_PRINT("STREAMOFF failed: %s\n", strerror(errno)); + mp_raise_OSError(errno); + } + + // 2. Unmap old buffers + for (int i = 0; i < NUM_BUFFERS; i++) { + if (self->buffers[i] != MAP_FAILED && self->buffers[i] != NULL) { + munmap(self->buffers[i], self->buffer_length); + self->buffers[i] = MAP_FAILED; + } + } + + // 3. Set new V4L2 format + struct v4l2_format fmt = {0}; + fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + fmt.fmt.pix.width = new_input_width; + fmt.fmt.pix.height = new_input_height; + fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV; + fmt.fmt.pix.field = V4L2_FIELD_ANY; + + if (ioctl(self->fd, VIDIOC_S_FMT, &fmt) < 0) { + WEBCAM_DEBUG_PRINT("S_FMT failed: %s\n", strerror(errno)); + mp_raise_OSError(errno); + } + + // Verify format was set (driver may adjust dimensions) + if (fmt.fmt.pix.width != new_input_width || fmt.fmt.pix.height != new_input_height) { + WEBCAM_DEBUG_PRINT("Warning: Driver adjusted format to %dx%d\n", + fmt.fmt.pix.width, fmt.fmt.pix.height); + new_input_width = fmt.fmt.pix.width; + new_input_height = fmt.fmt.pix.height; + } + + // 4. Request new buffers + struct v4l2_requestbuffers req = {0}; + req.count = NUM_BUFFERS; + req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + req.memory = V4L2_MEMORY_MMAP; + + if (ioctl(self->fd, VIDIOC_REQBUFS, &req) < 0) { + WEBCAM_DEBUG_PRINT("REQBUFS failed: %s\n", strerror(errno)); + mp_raise_OSError(errno); + } + + // 5. Map new buffers + for (int i = 0; i < NUM_BUFFERS; i++) { + struct v4l2_buffer buf = {0}; + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + buf.memory = V4L2_MEMORY_MMAP; + buf.index = i; + + if (ioctl(self->fd, VIDIOC_QUERYBUF, &buf) < 0) { + WEBCAM_DEBUG_PRINT("QUERYBUF failed: %s\n", strerror(errno)); + mp_raise_OSError(errno); + } + + self->buffer_length = buf.length; + self->buffers[i] = mmap(NULL, buf.length, PROT_READ | PROT_WRITE, + MAP_SHARED, self->fd, buf.m.offset); + + if (self->buffers[i] == MAP_FAILED) { + WEBCAM_DEBUG_PRINT("mmap failed: %s\n", strerror(errno)); + mp_raise_OSError(errno); + } + } + + // 6. Queue buffers + for (int i = 0; i < NUM_BUFFERS; i++) { + struct v4l2_buffer buf = {0}; + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + buf.memory = V4L2_MEMORY_MMAP; + buf.index = i; + + if (ioctl(self->fd, VIDIOC_QBUF, &buf) < 0) { + WEBCAM_DEBUG_PRINT("QBUF failed: %s\n", strerror(errno)); + mp_raise_OSError(errno); + } + } + + // 7. Restart streaming + if (ioctl(self->fd, VIDIOC_STREAMON, &type) < 0) { + WEBCAM_DEBUG_PRINT("STREAMON failed: %s\n", strerror(errno)); + mp_raise_OSError(errno); + } + + // Update stored input dimensions + self->input_width = new_input_width; + self->input_height = new_input_height; + } + + // If output resolution changed (or input changed which may affect output), reallocate output buffers + if (output_changed || input_changed) { // Free old buffers free(self->gray_buffer); free(self->rgb565_buffer); // Update dimensions - self->output_width = new_width; - self->output_height = new_height; + self->output_width = new_output_width; + self->output_height = new_output_height; // Allocate new buffers self->gray_buffer = (unsigned char *)malloc(self->output_width * self->output_height * sizeof(unsigned char)); @@ -392,10 +542,12 @@ static mp_obj_t webcam_reconfigure(size_t n_args, const mp_obj_t *pos_args, mp_m self->rgb565_buffer = NULL; mp_raise_OSError(MP_ENOMEM); } - - WEBCAM_DEBUG_PRINT("Webcam reconfigured to %dx%d\n", self->output_width, self->output_height); } + WEBCAM_DEBUG_PRINT("Webcam reconfigured: input %dx%d, output %dx%d\n", + self->input_width, self->input_height, + self->output_width, self->output_height); + return mp_const_none; } MP_DEFINE_CONST_FUN_OBJ_KW(webcam_reconfigure_obj, 1, webcam_reconfigure); diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 6890f0f9..aba00151 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -82,7 +82,7 @@ class CameraApp(Activity): # Settings button settings_button = lv.button(main_screen) settings_button.set_size(60,60) - settings_button.align(lv.ALIGN.TOP_LEFT, 0, 0) + settings_button.align(lv.ALIGN.TOP_RIGHT, 0, 60) settings_label = lv.label(settings_button) settings_label.set_text(lv.SYMBOL.SETTINGS) settings_label.center() @@ -167,7 +167,7 @@ class CameraApp(Activity): self.finish() - def onStop(self, screen): + def onPause(self, screen): print("camera app backgrounded, cleaning up...") if self.capture_timer: self.capture_timer.delete() @@ -289,26 +289,48 @@ class CameraApp(Activity): # Reload resolution preference self.load_resolution_preference() - # Recreate image descriptor with new dimensions - self.image_dsc["header"]["w"] = self.width - self.image_dsc["header"]["h"] = self.height - self.image_dsc["header"]["stride"] = self.width * 2 - self.image_dsc["data_size"] = self.width * self.height * 2 + # CRITICAL: Pause capture timer to prevent race conditions during reconfiguration + if self.capture_timer: + self.capture_timer.delete() + self.capture_timer = None + print("Capture timer paused") + + # Clear stale data pointer to prevent segfault during LVGL rendering + self.image_dsc.data = None + self.current_cam_buffer = None + print("Image data cleared") + + # Update image descriptor with new dimensions + # Note: image_dsc is an LVGL struct, use attribute access not dictionary access + self.image_dsc.header.w = self.width + self.image_dsc.header.h = self.height + self.image_dsc.header.stride = self.width * 2 + self.image_dsc.data_size = self.width * self.height * 2 + print(f"Image descriptor updated to {self.width}x{self.height}") # Reconfigure camera if active if self.cam: if self.use_webcam: - print(f"Reconfiguring webcam to {self.width}x{self.height}") - webcam.reconfigure(self.cam, output_width=self.width, output_height=self.height) + print(f"Reconfiguring webcam: input={self.width}x{self.height}, output={self.width}x{self.height}") + # Configure both V4L2 input and output to the same resolution for best quality + webcam.reconfigure( + self.cam, + input_width=self.width, + input_height=self.height, + output_width=self.width, + output_height=self.height + ) + # Resume capture timer for webcam + self.capture_timer = lv.timer_create(self.try_capture, 100, None) + print("Webcam reconfigured (V4L2 + output buffers), capture timer resumed") else: # For internal camera, need to reinitialize print(f"Reinitializing internal camera to {self.width}x{self.height}") - if self.capture_timer: - self.capture_timer.delete() self.cam.deinit() self.cam = init_internal_cam(self.width, self.height) if self.cam: self.capture_timer = lv.timer_create(self.try_capture, 100, None) + print("Internal camera reinitialized, capture timer resumed") self.set_image_size() @@ -319,14 +341,23 @@ class CameraApp(Activity): self.current_cam_buffer = webcam.capture_frame(self.cam, "rgb565") elif self.cam.frame_available(): self.current_cam_buffer = self.cam.capture() + if self.current_cam_buffer and len(self.current_cam_buffer): - self.image_dsc.data = self.current_cam_buffer - #image.invalidate() # does not work so do this: - self.image.set_src(self.image_dsc) - if not self.use_webcam: - self.cam.free_buffer() # Free the old buffer - if self.keepliveqrdecoding: - self.qrdecode_one() + # Defensive check: verify buffer size matches expected dimensions + expected_size = self.width * self.height * 2 # RGB565 = 2 bytes per pixel + actual_size = len(self.current_cam_buffer) + + if actual_size == expected_size: + self.image_dsc.data = self.current_cam_buffer + #image.invalidate() # does not work so do this: + self.image.set_src(self.image_dsc) + if not self.use_webcam: + self.cam.free_buffer() # Free the old buffer + if self.keepliveqrdecoding: + self.qrdecode_one() + else: + print(f"Warning: Buffer size mismatch! Expected {expected_size} bytes, got {actual_size} bytes") + print(f" Resolution: {self.width}x{self.height}, discarding frame") except Exception as e: print(f"Camera capture exception: {e}") diff --git a/tests/analyze_screenshot.py b/tests/analyze_screenshot.py new file mode 100755 index 00000000..328c19e0 --- /dev/null +++ b/tests/analyze_screenshot.py @@ -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(' 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 [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) diff --git a/tests/test_graphical_camera_settings.py b/tests/test_graphical_camera_settings.py new file mode 100644 index 00000000..53ff3426 --- /dev/null +++ b/tests/test_graphical_camera_settings.py @@ -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