You've already forked MicroPythonOS
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:
+174
-22
@@ -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);
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
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