diff --git a/examples/ext_dev/sensors/thermal160/thermal160.py b/examples/ext_dev/sensors/thermal160/thermal160.py new file mode 100644 index 00000000..e072df75 --- /dev/null +++ b/examples/ext_dev/sensors/thermal160/thermal160.py @@ -0,0 +1,138 @@ +import numpy as np +import time +from maix import app, image, display +from maix.peripheral import uart +from maix.sys import device_name + +PMOD_W = 160 +PMOD_H = 120 +FRAME_SIZE = PMOD_W * PMOD_H +SKIP_COUNT = 10 + +CMAP = True # 渲染管线分支路由开关:True为热成像伪彩映射,False为原生灰度零拷贝 + +class HardwareHAL: + _PORT_REGISTRY = { + "MaixCAM2": "/dev/ttyS2", + # 以下设备将在后续逐步提供支持 + "MaixCAM": None, + "MaixCAM-Pro": None, + } + _device = "" + + @classmethod + def serial_port(cls): + dn = device_name() + if not isinstance(dn, str) or not dn.strip(): + raise TypeError(f"Invalid device identifier received: '{dn}'") + port = cls._PORT_REGISTRY.get(dn) + if port is None: + raise RuntimeError(f"Platform mismatch: Device '{dn}' is not supported") + cls._device = dn + return port + +def main(): + disp = display.Display() + disp.set_hmirror(True) + disp.set_vflip(False) + + devices = uart.list_devices() + if not devices: + print("Error: No available UART devices found! Hardware HAL execution aborted.") + return + + hw = HardwareHAL() + port_name = hw.serial_port() + serial = uart.UART(port=port_name, baudrate=2000000) + + try: + if HardwareHAL._device == "MaixCAM2": + serial.write(b'\x44') + serial.close() + time.sleep(0.1) + serial = uart.UART(port=port_name, baudrate=4000000) + + lut = np.array(image.cmap_colors_rgb(image.CMap.THERMAL_IRONBOW), dtype=np.uint8) + color_buf = np.zeros((PMOD_H, PMOD_W, 3), dtype=np.uint8) + buffer = bytearray() + skip = 0 + frame_count = 0 + + fps_window_size = 30 + frame_timestamps = [] + fps_ema = 0.0 + ema_alpha = 0.2 + + print("System: Data pump and rendering pipeline successfully initialized.") + + while not app.need_exit(): + chunk = serial.read(4096, timeout=10) + if not chunk: + continue + buffer.extend(chunk) + + while True: + idx = buffer.find(b'\xFF') + if idx == -1: + buffer.clear() + break + + if len(buffer) - (idx + 1) >= FRAME_SIZE: + frame_data = buffer[idx + 1 : idx + 1 + FRAME_SIZE] + + err_idx = frame_data.find(b'\xFF') + if err_idx != -1: + print(f"Warning: Protocol violation! Unexpected 0xFF found in payload at offset {err_idx}. Resyncing...") + del buffer[:idx + 1 + err_idx] + continue + + if skip <= SKIP_COUNT: + skip += 1 + else: + try: + img = image.from_bytes(PMOD_W, PMOD_H, image.Format.FMT_GRAYSCALE, frame_data) + except TypeError: + img = image.from_bytes(PMOD_W, PMOD_H, image.Format.FMT_GRAYSCALE, bytes(frame_data)) + + if CMAP: + img.gaussian(1) + gray_np = image.image2cv(img, ensure_bgr=False, copy=False).squeeze() + np.take(lut, gray_np, axis=0, out=color_buf) + img_disp = image.cv2image(color_buf, bgr=False, copy=False) + else: + img_disp = img + disp.show(img_disp) + + current_time = time.time() + frame_timestamps.append(current_time) + if len(frame_timestamps) > fps_window_size: + frame_timestamps.pop(0) + if len(frame_timestamps) > 1: + window_duration = frame_timestamps[-1] - frame_timestamps[0] + if window_duration > 0: + window_fps = (len(frame_timestamps) - 1) / window_duration + if fps_ema == 0.0: + fps_ema = window_fps + else: + fps_ema = (ema_alpha * window_fps) + ((1.0 - ema_alpha) * fps_ema) + if frame_count % 10 == 0: + osd_text = f"FPS: {fps_ema:.2f}" + print(osd_text) + + frame_count += 1 + buffer = buffer[idx + 1 + FRAME_SIZE:] + else: + if idx > 0: + buffer = buffer[idx:] + break + + except Exception as e: + print(f"Fatal: Unhandled pipeline exception: {str(e)}") + raise + finally: + if 'serial' in locals() and serial: + serial.close() + print("System: UART resource securely released via interrupt vector.") + +if __name__ == "__main__": + main() diff --git a/projects/app_thermal160_camera/.gitignore b/projects/app_thermal160_camera/.gitignore new file mode 100644 index 00000000..5d81e310 --- /dev/null +++ b/projects/app_thermal160_camera/.gitignore @@ -0,0 +1,4 @@ +.claude/ +.claudeignore +.ruff_cache/ +bak/ diff --git a/projects/app_thermal160_camera/README.md b/projects/app_thermal160_camera/README.md new file mode 100644 index 00000000..7be218b8 --- /dev/null +++ b/projects/app_thermal160_camera/README.md @@ -0,0 +1,33 @@ +# MaixCAM Thermal 160 实时热成像监控 + + 这是一个基于 MaixPy v4 框架开发的实时热成像监控程序,专门为 MaixCAM2(及后续支持型号)设计。该程序通过 UART 接收 160x120 + 分辨率的热成像原始数据,并实时渲染为具有 Ironbow(铁红) 伪彩映射的视频流。 + +## 硬件要求 + +* 设备:MaixCAM2, 可参考该APP 代码移植到其他能够使用串口外设的平台 +* 传感器:支持 PMOD 接口或 UART 输出的 160x120 像素热成像模组。 +* 连接方式: + * MaixCAM2 默认使用 /dev/ttyS2。 + * 波特率:初始 2,000,000,握手后最高可跳变至 4,000,000。 + +## 配置说明 + + 在代码顶层可以根据需要调整以下常量: + +* CMAP = True:设为 True 显示彩色热成像,False 则显示原始灰度图(零拷贝,性能更高)。 +* SKIP_COUNT = 10:启动时跳过的初始帧数,用于稳定传感器数据。 + +## 协议简介 + + 程序期望的 UART 数据格式为: + +* 帧头:0xFF +* 负载:19,200 字节 (160 * 120) 的单字节灰度数据。 +* 校验:负载中不包含 0xFF。 +总计单包大小为 19201 bytes + +## 注意事项 + +* MaixCAM2 兼容性:程序包含针对 MaixCAM2 的波特率切换指令 (0x44),使用其他串口设备时请根据实际通讯协议修改 HardwareHAL 类。 +* 资源释放:程序通过 finally 块确保在退出时安全释放 UART 资源。 diff --git a/projects/app_thermal160_camera/README_EN.md b/projects/app_thermal160_camera/README_EN.md new file mode 100644 index 00000000..3bb3a9c8 --- /dev/null +++ b/projects/app_thermal160_camera/README_EN.md @@ -0,0 +1,33 @@ +# MaixCAM Thermal 160 Real-time Thermal Imaging Monitoring + +This is a real-time thermal imaging monitoring application developed based on the MaixPy v4 framework, specifically designed for MaixCAM2 (and future supported models). The application receives raw thermal imaging data at 160x120 resolution via UART and renders it as a real-time video stream with Ironbow pseudo-color mapping. + +## Hardware Requirements + +* Device: MaixCAM2, code can be ported to other platforms that support UART peripherals +* Sensor: Thermal imaging module with PMOD interface or UART output supporting 160x120 pixels. +* Connection method: + * MaixCAM2 defaults to /dev/ttyS2. + * Baud rate: Initial 2,000,000, can jump to up to 4,000,000 after handshake. + +## Configuration + +Adjust the following constants at the top of the code as needed: + +* CMAP = True: Set to True to display color thermal imaging, False to display original grayscale (zero-copy, higher performance). +* SKIP_COUNT = 10: Number of initial frames to skip on startup, used to stabilize sensor data. + +## Protocol Overview + +The expected UART data format is: + +* Header: 0xFF +* Payload: 19,200 bytes (160 * 120) of single-byte grayscale data. +* Checksum: Payload does not contain 0xFF. +* Total packet size: 19201 bytes + +## Notes + +* MaixCAM2 Compatibility: The program includes baud rate switching commands (0x44) specifically for MaixCAM2. When using other UART devices, please modify the HardwareHAL class according to the actual communication protocol. +* Resource Release: The program ensures safe UART resource release on exit through the finally block. + diff --git a/projects/app_thermal160_camera/app.yaml b/projects/app_thermal160_camera/app.yaml new file mode 100644 index 00000000..0b3eea0c --- /dev/null +++ b/projects/app_thermal160_camera/app.yaml @@ -0,0 +1,12 @@ +id: thermal160_camera +name: Thermal160 Camera +name[zh]: 热成像仪160 +version: 1.0.0 +icon: assets/thermal.json +author: Sipeed Ltd +desc: Thermal Camera +desc[zh]: 热成像仪 +include: + - assets/thermal.json + - app.yaml + - main.py diff --git a/projects/app_thermal160_camera/assets/thermal.json b/projects/app_thermal160_camera/assets/thermal.json new file mode 100644 index 00000000..9e0597a4 --- /dev/null +++ b/projects/app_thermal160_camera/assets/thermal.json @@ -0,0 +1 @@ +{"v":"4.8.0","meta":{"g":"LottieFiles AE 1.0.0","a":"Bas Milius","k":"Meteocons, Weather icons, Icon set","d":"Thermometer - Meteocons.com","tc":""},"fr":60,"ip":0,"op":360,"w":512,"h":512,"nm":"thermometer","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"thermometer","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[256,256,0],"ix":2},"a":{"a":0,"k":[256,256,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[4,-24],[32,-24]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[4,-88],[32,-88]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[4,-56],[32,-56]],"c":false},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,-19.305],[30.928,0],[0,31.389],[-14.496,10.272],[0,0],[-17.673,0],[0,-17.937],[0,0]],"o":[[0,31.389],[-30.928,0],[0,-19.305],[0,0],[0,-17.937],[17.673,0],[0,0],[14.496,10.272]],"v":[[56,79.164],[0,136],[-56,79.164],[-32,32.559],[-32,-103.522],[0,-136],[32,-103.522],[32,32.559]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.796078443527,0.835294127464,0.882352948189,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":6,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[256,256],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"thermometer-glass","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[255.798,213],[255.798,336]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":30,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[255.985,236],[255.985,336]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":60,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[255.798,213],[255.798,336]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":90,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[255.985,236],[255.985,336]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":120,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[255.798,213],[255.798,336]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":150,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[255.985,236],[255.985,336]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":180,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[255.798,213],[255.798,336]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":210,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[255.985,236],[255.985,336]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":240,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[255.798,213],[255.798,336]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":270,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[255.985,236],[255.985,336]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":300,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[255.798,213],[255.798,336]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":330,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[255.985,236],[255.985,336]],"c":false}]},{"t":359,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[255.798,213],[255.798,336]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.937254905701,0.266666680574,0.266666680574,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":24,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,19.882],[19.882,0],[0,-19.882],[-19.882,0]],"o":[[0,-19.882],[-19.882,0],[0,19.882],[19.882,0]],"v":[[292,336],[256,300],[220,336],[256,372]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.937254905701,0.266666680574,0.266666680574,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[256,336],"ix":2},"a":{"a":0,"k":[256,336],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"thermometer-mercury","np":4,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":360,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/projects/app_thermal160_camera/main.py b/projects/app_thermal160_camera/main.py new file mode 100644 index 00000000..c9c4ae51 --- /dev/null +++ b/projects/app_thermal160_camera/main.py @@ -0,0 +1,311 @@ +import logging +import time +from typing import Tuple + +import numpy as np +import cv2 +from maix import app, image, display, touchscreen +from maix.peripheral import uart +from maix.sys import device_name + +PMOD_W = 160 +PMOD_H = 120 +FRAME_SIZE = PMOD_W * PMOD_H +SKIP_COUNT = 10 +CMAP = True # 渲染管线分支路由开关:True为热成像伪彩映射,False为原生灰度零拷贝 + +# 配置常量 +BAUDRATE_INIT = 2000000 +BAUDRATE_HIGH = 4000000 +FPS_WINDOW_SIZE = 30 +EMA_ALPHA = 0.2 +SERIAL_TIMEOUT = 1 +FONT_SCALE = 2.0 + +# UART 配置 +UART_BUFFER_SIZE = 4096 # 读取缓冲区大小 +RETRY_DELAY = 0.1 # 重试延迟(秒) + +# UI 配置 +BACK_BUTTON_WIDTH_RATIO = 0.1 # 返回按钮宽度占比 + +# 资源路径 +DEFAULT_ICON_PATH = "/maixapp/share/icon/ret.png" + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +def is_in_button(x: int, y: int, btn_pos: Tuple[int, int, int, int]) -> bool: + return (btn_pos[0] < x < btn_pos[0] + btn_pos[2] and + btn_pos[1] < y < btn_pos[1] + btn_pos[3]) + +def get_back_btn_img(width: int) -> Tuple[image.Image, int, int]: + ret_width = int(width * BACK_BUTTON_WIDTH_RATIO) + img_back = image.load(DEFAULT_ICON_PATH) + w, h = (ret_width, img_back.height() * ret_width // img_back.width()) + if w % 2 != 0: + w += 1 + if h % 2 != 0: + h += 1 + img_back = img_back.resize(w, h) + img_back = img_back.rotate(180) + return img_back, w, h + +class HardwareHAL: + """Hardware Abstraction Layer for thermal camera device management. + + Provides platform-specific serial port configuration and device detection. + """ + + _PORT_REGISTRY = { + "MaixCAM2": "/dev/ttyS2", + "MaixCAM": None, + "MaixCAM-Pro": None, + } + _device = "" + + @classmethod + def serial_port(cls) -> str: + dn = device_name() + if not isinstance(dn, str) or not dn.strip(): + raise TypeError(f"Invalid device identifier received: '{dn}'") + port = cls._PORT_REGISTRY.get(dn) + if port is None: + raise RuntimeError(f"Platform mismatch: Device '{dn}' is not supported") + cls._device = dn + return port + +def main() -> None: + disp = display.Display() + disp.set_hmirror(True) + disp.set_vflip(False) + ts = touchscreen.TouchScreen() + + img_back, img_back_w, img_back_h = get_back_btn_img(disp.width()) + back_rect = [0, 0, img_back_w, img_back_h] + + # CMAP 按钮(动态更新,延迟初始化) + img_cmap: image.Image = None + cmap_rect = [ + disp.width() - int(disp.width() * BACK_BUTTON_WIDTH_RATIO), + disp.height() - 0, + 1, + 1, + ] # 占位,后续用 init_cmap_btn 填充 + + def init_cmap_btn(label: str) -> None: + nonlocal img_cmap, cmap_rect + w, h = img_back_w, img_back_h + img_cmap = image.Image(w, h, image.Format.FMT_RGB888) + img_cmap.draw_rect(0, 0, w, h, image.COLOR_BLACK, -1) + char_h = 15 + text_y = (h - char_h) // 2 + text_x = max(2, (w - len(label) * 6) // 2) + img_cmap.draw_string(text_x, text_y, label, image.COLOR_WHITE, scale=0.6) + img_cmap = img_cmap.rotate(180) + cmap_rect = [disp.width() - w, disp.height() - h, w, h] + + init_cmap_btn("CMAP") + + devices = uart.list_devices() + if not devices: + logger.error( + "Error: No available UART devices found! " + "Hardware HAL execution aborted." + ) + return + + hw = HardwareHAL() + port_name = hw.serial_port() + serial = uart.UART(port=port_name, baudrate=BAUDRATE_INIT) + + try: + if HardwareHAL._device == "MaixCAM2": + serial.write(b'\x44') + serial.close() + time.sleep(0.1) + serial = uart.UART(port=port_name, baudrate=BAUDRATE_HIGH) + + def _safe_colormap(name: str, fallback_id: int): + """安全获取 colormap ID,不支持时返回 None。""" + cp = getattr(cv2, name, fallback_id) + test = np.arange(256, dtype=np.uint8).reshape(1, 256) + try: + cv2.applyColorMap(test, cp) + return (name.replace("COLORMAP_", "").lower(), cp) + except Exception: + return None + + cmap_options = [] + for _name, _fid in [ + ("COLORMAP_HOT", 0), + ("COLORMAP_COOL", 1), + ("COLORMAP_DEEPGREEN", 15), + ("COLORMAP_MAGMA", 13), + ("COLORMAP_TURBO", 20), + ]: + entry = _safe_colormap(_name, _fid) + if entry: + cmap_options.append(entry) + + if not cmap_options: + logger.error("Error: No supported colormaps available.") + return + + cmap_idx = 0 + + def get_cv2_lut(idx): + _, cp = cmap_options[idx] + gray = np.arange(256, dtype=np.uint8).reshape(1, 256) + colored = cv2.applyColorMap(gray, cp) + return cv2.cvtColor(colored, cv2.COLOR_BGR2RGB).reshape(256, 3) + + cmap_abbrev = {"hot": "HOT", "cool": "COOL", "deepgreen": "DGRN", "magma": "MAGM", "turbo": "TRBO"} + + def redraw_cmap_btn() -> None: + name = cmap_options[cmap_idx][0] + label = cmap_abbrev.get(name, name[:5].upper()) + init_cmap_btn(label) + + lut = get_cv2_lut(cmap_idx) + color_buf = np.zeros((PMOD_H, PMOD_W, 3), dtype=np.uint8) + + # 预分配显示缓冲区,避免循环内频繁创建对象导致撕裂 + disp_w, disp_h = disp.width(), disp.height() + disp_buffer = np.zeros((disp_h, disp_w, 3), dtype=np.uint8) + + buffer = bytearray() + skip = 0 + frame_count = 0 + + frame_timestamps = [] + fps_ema = 0.0 + + logger.info( + "System: Data pump and rendering pipeline successfully initialized." + ) + + capture_start = False + while not app.need_exit(): + x, y, pressed = ts.read() + if is_in_button(x, y, back_rect): + app.set_exit_flag(True) + break + + if pressed and is_in_button(x, y, cmap_rect): + cmap_idx = (cmap_idx + 1) % len(cmap_options) + lut = get_cv2_lut(cmap_idx) + redraw_cmap_btn() + logger.info(f"System: Switched to colormap {cmap_options[cmap_idx][0]}") + time.sleep(0.2) # 防抖 + + chunk = serial.read(UART_BUFFER_SIZE, timeout=SERIAL_TIMEOUT) + if not chunk and not capture_start: + main_img = image.Image(disp.width(), disp.height(), image.Format.FMT_RGB888) + main_img.draw_rect( + 0, 0, disp.width(), disp.height(), image.COLOR_BLACK, -1 + ) + msg = "thermal160 device not found" + char_w = 10 * FONT_SCALE + char_h = 20 * FONT_SCALE + x_msg = int((disp.width() - len(msg) * char_w) // 2) + y_msg = int((disp.height() - char_h) // 2) + main_img.draw_string( + x_msg, y_msg, msg, image.COLOR_WHITE, scale=FONT_SCALE + ) + # 方向:统一旋转 180 度,并绘制按钮 + main_img = main_img.rotate(180) + main_img.draw_image(disp.width() - img_back_w, disp.height() - img_back_h, img_back) + if img_cmap is not None: + main_img.draw_image(0, 0, img_cmap) + disp.show(main_img) + continue + + if frame_count == 0: + logger.info("System: First data chunk received.") + buffer.extend(chunk) + + while True: + idx = buffer.find(b'\xFF') + if idx == -1: + buffer.clear() + break + + if len(buffer) - (idx + 1) >= FRAME_SIZE: + frame_data = buffer[idx + 1 : idx + 1 + FRAME_SIZE] + + err_idx = frame_data.find(b'\xFF') + if err_idx != -1: + logger.warning( + f"Warning: Protocol violation! Unexpected 0xFF found in " + f"payload at offset {err_idx}. Resyncing..." + ) + del buffer[:idx + 1 + err_idx] + continue + + if skip <= SKIP_COUNT: + skip += 1 + else: + # 直接将帧数据转为 numpy 数组,避免创建 Image 对象 + gray_np = np.frombuffer(frame_data, dtype=np.uint8).reshape(PMOD_H, PMOD_W) + + if CMAP: + # 高斯模糊(使用 OpenCV 加速) + gray_blurred = cv2.GaussianBlur(gray_np, (3, 3), 1) + # 颜色映射到预分配缓冲区 + np.take(lut, gray_blurred, axis=0, out=color_buf) + # 直接缩放到显示缓冲区,避免中间对象创建 + cv2.resize(color_buf, (disp_w, disp_h), dst=disp_buffer, interpolation=cv2.INTER_LINEAR) + else: + # 灰度图:先缩放单通道,然后复制到三通道 + gray_resized = cv2.resize(gray_np, (disp_w, disp_h), interpolation=cv2.INTER_LINEAR) + disp_buffer[:,:,0] = gray_resized + disp_buffer[:,:,1] = gray_resized + disp_buffer[:,:,2] = gray_resized + + # 创建显示图像 + img_disp = image.cv2image(disp_buffer, bgr=False, copy=False) + img_disp.draw_image(disp_w - img_back_w, disp_h - img_back_h, img_back) + if img_cmap is not None: + img_disp.draw_image(0, 0, img_cmap) + + capture_start = True + disp.show(img_disp) + + current_time = time.time() + frame_timestamps.append(current_time) + if len(frame_timestamps) > FPS_WINDOW_SIZE: + frame_timestamps.pop(0) + if len(frame_timestamps) > 1: + window_duration = frame_timestamps[-1] - frame_timestamps[0] + if window_duration > 0: + window_fps = ((len(frame_timestamps) - 1) / window_duration) + if fps_ema == 0.0: + fps_ema = window_fps + else: + fps_ema = ((EMA_ALPHA * window_fps) + ((1.0 - EMA_ALPHA) * fps_ema)) + if frame_count % 10 == 0: + osd_text = f"FPS: {fps_ema:.2f}" + logger.info(osd_text) + + frame_count += 1 + buffer = buffer[idx + 1 + FRAME_SIZE:] + else: + if idx > 0: + buffer = buffer[idx:] + break + + except Exception as e: + logger.error(f"Fatal: Unhandled pipeline exception: {str(e)}") + raise + finally: + if serial is not None: + serial.close() + logger.info("System: UART resource securely released via interrupt vector.") + +if __name__ == "__main__": + main() diff --git a/projects/app_thermal256_camera/app.yaml b/projects/app_thermal256_camera/app.yaml index a82cdfed..a8228831 100644 --- a/projects/app_thermal256_camera/app.yaml +++ b/projects/app_thermal256_camera/app.yaml @@ -1,8 +1,8 @@ -id: thermal256_camera -name: Thermal256 Camera -name[zh]: 热成像仪256 +id: thermal160_camera +name: Thermal160 Camera +name[zh]: 热成像仪160 version: 1.0.0 -icon: assets/thermal.json +icon: '' author: Sipeed Ltd desc: Thermal Camera desc[zh]: 热成像仪 @@ -10,3 +10,6 @@ include: - assets/thermal.json - app.yaml - main.py +files: + - assets/thermal.json + - main.py