From 43e7e0ba176fec0ad0ca27e22a4db5b2f74578f7 Mon Sep 17 00:00:00 2001 From: gcw_4spBpAfv Date: Fri, 17 Apr 2026 18:30:50 +0800 Subject: [PATCH] new shoot algo --- app.yaml | 9 +- ...y_netcore.cpython-311-riscv64-linux-gnu.so | Bin 0 -> 176328 bytes cameraParameters.xml | 33 + config.py | 33 +- design_doc/command_record.md | 47 + design_doc/solution_record.md | 93 +- server.pem | 33 + shoot_manager.py | 362 +++++-- triangle_positions.json | 6 + triangle_target.py | 513 ++++++++++ vision.py | 944 ++++++++++++++++++ 11 files changed, 1976 insertions(+), 97 deletions(-) create mode 100644 archery_netcore.cpython-311-riscv64-linux-gnu.so create mode 100644 cameraParameters.xml create mode 100644 server.pem create mode 100644 triangle_positions.json create mode 100644 triangle_target.py create mode 100644 vision.py diff --git a/app.yaml b/app.yaml index 40b272a..16bdd0a 100644 --- a/app.yaml +++ b/app.yaml @@ -5,10 +5,13 @@ author: t11 icon: '' desc: t11 files: + - 4g_download_manager.py - app.yaml - archery_netcore.cpython-311-riscv64-linux-gnu.so + - aruco_detector.py - at_client.py - camera_manager.py + - cameraParameters.xml - config.py - hardware.py - laser_manager.py @@ -17,9 +20,13 @@ files: - network.py - ota_manager.py - power.py + - server.pem - shoot_manager.py - shot_id_generator.py - time_sync.py + - triangle_positions.json + - triangle_target.py - version.py - - vision.cpython-311-riscv64-linux-gnu.so + - vision.py + - wifi_config_httpd.py - wifi.py diff --git a/archery_netcore.cpython-311-riscv64-linux-gnu.so b/archery_netcore.cpython-311-riscv64-linux-gnu.so new file mode 100644 index 0000000000000000000000000000000000000000..1eb8d818e6e54c7e5f5a587cb45552e944171f65 GIT binary patch literal 176328 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 literal 0 HcmV?d00001 diff --git a/cameraParameters.xml b/cameraParameters.xml new file mode 100644 index 0000000..b41a66e --- /dev/null +++ b/cameraParameters.xml @@ -0,0 +1,33 @@ + + +"Sat Apr 11 12:05:27 2026" +29 + + 640 480 + + 3 + 3 +
d
+ + 2207.9058323074869 0. 328.90661220953149 0. 2207.9058323074869 + 205.49515894111076 0. 0. 1.
+ + 4 + 1 +
d
+ + 0. 11.687428265309892 3.6908895632668468 3.597571733110271
+ + 1 + 5 +
d
+ + -0.63036604771649651 3.3832710000807449 0. 0. -0.45113389267675552
+ + 5 + 1 +
d
+ + 0.025002349846111244 1.0651877135605927 0. 0. 0.04021252864120229
+0.28992233810828955 +
diff --git a/config.py b/config.py index 00bb188..62d2c41 100644 --- a/config.py +++ b/config.py @@ -36,17 +36,17 @@ WIFI_CONFIG_HTTP_PORT = 8080 # 默认 8080,避免占用 80 需 r WIFI_CONFIG_AP_IP = "192.168.66.1" # 与 MaixPy Wifi.start_ap 默认一致,手机访问 http://192.168.66.1:8080/ # ===== TCP over SSL(TLS) 配置 ===== -USE_TCP_SSL = False # True=按手册走 MSSLCFG/MIPCFG 绑定 SSL +USE_TCP_SSL = True # True=按手册走 MSSLCFG/MIPCFG 绑定 SSL TCP_LINK_ID = 2 # -TCP_SSL_PORT = 443 # TLS 端口(不一定必须 443,以服务器为准) +TCP_SSL_PORT = 50006 # TLS 端口(不一定必须 443,以服务器为准) # SSL profile SSL_ID = 1 # ssl_id=1 -SSL_AUTH_MODE = 0 # 1=单向认证(验证服务器),2=双向 +SSL_AUTH_MODE = 1 # 1=单向认证(验证服务器),2=双向 SSL_VERIFY_MODE = 1 # 0=不验(仅测试用);1=写入并使用 CA 证书 -SSL_CERT_FILENAME = "www.shelingxingqiu.com.crt" # 模组里证书名(MSSLCERTWR / MSSLCFG="cert" 用) -SSL_CERT_PATH = "/root/www.shelingxingqiu.com.crt" # 设备文件系统里 CA 证书路径(你自己放进去) +SSL_CERT_FILENAME = "server.pem" # 模组里证书名(MSSLCERTWR / MSSLCFG="cert" 用) +SSL_CERT_PATH = "/maixapp/apps/t11/server.pem" # 设备文件系统里 CA 证书路径(你自己放进去) # MIPOPEN 末尾的参数在不同固件里含义可能不同;按你手册例子保留 MIPOPEN_TAIL = ",,0" @@ -95,7 +95,7 @@ DEFAULT_LASER_POINT = (320, 245) # 默认激光中心点 # 硬编码激光点配置 HARDCODE_LASER_POINT = True # 是否使用硬编码的激光点(True=使用硬编码值,False=使用校准值) -HARDCODE_LASER_POINT_VALUE = (320, 245) # 硬编码的激光点坐标(315, 245) # # 硬编码的激光点坐标 (x, y) +HARDCODE_LASER_POINT_VALUE = (320, 296) # 硬编码的激光点坐标(315, 245) # # 硬编码的激光点坐标 (x, y) # 激光点检测配置 LASER_DETECTION_THRESHOLD = 140 # 红色通道阈值(默认120,可调整,范围建议:100-150) @@ -122,6 +122,27 @@ LASER_CAMERA_OFFSET_CM = 1.4 # 激光在摄像头下方的物理距离(厘米 IMAGE_CENTER_X = 320 # 图像中心 X 坐标 IMAGE_CENTER_Y = 240 # 图像中心 Y 坐标 +# ==================== 三角形四角标记:单应性偏移 + PnP 估距 ==================== +# 依赖 cameraParameters.xml(相机内参)与 triangle_positions.json(四角物方坐标,厘米或毫米见 JSON 约定)。 +# 部署时请把这两个文件放到 APP_DIR(与 main 同应用目录),或改下面路径为设备上的实际绝对路径。 +USE_TRIANGLE_OFFSET = True # False 时仅走黄心圆/椭圆 + 半径估距,不使用三角形路径 +CAMERA_CALIB_XML = APP_DIR + "/cameraParameters.xml" +TRIANGLE_POSITIONS_JSON = APP_DIR + "/triangle_positions.json" +# 检测到的三角形边长在图像中的像素范围,分辨率或靶纸占比变化时可微调 +TRIANGLE_SIZE_RANGE = (8, 500) +# 三角形检测兜底增强:CLAHE(更鲁棒但更慢)。默认关闭以优先速度。 +TRIANGLE_ENABLE_CLAHE_FALLBACK = False +# 三角形检测超时(毫秒)。超过该时间直接判失败,回退圆心算法(并行时不再等待)。 +TRIANGLE_TIMEOUT_MS = 1000 + +# 三角形检测性能/鲁棒性参数(偏向速度的默认值) +# 说明: +# - Otsu 是最快的全局阈值;adaptiveThreshold 更鲁棒但更慢 +# - filtered 候选过多时,枚举 C(n,4) 会变慢,需限幅 +TRIANGLE_EARLY_EXIT_CANDIDATES = 4 # 找到多少个候选就提前停止二值化尝试 +TRIANGLE_ADAPTIVE_BLOCK_SIZES = (11, 21) # 自适应阈值 blockSize 尝试列表;置空 () 可完全关闭 adaptiveThreshold +TRIANGLE_MAX_FILTERED_FOR_COMBO = 10 # 参与四点组合评分的最大候选数(超过则截断到最可能的一部分) + FLASH_LASER_WHILE_SHOOTING = True # 是否在拍摄时闪一下激光(True=闪,False=不闪) FLASH_LASER_DURATION_MS = 1000 # 闪一下激光的持续时间(毫秒) diff --git a/design_doc/command_record.md b/design_doc/command_record.md index 1156f54..f4f0527 100644 --- a/design_doc/command_record.md +++ b/design_doc/command_record.md @@ -36,3 +36,50 @@ printf 'AT+MHTTPDLFILE="http://static.shelingxingqiu.com/shoot/v1/main.py","down 4. wifi的启动条件,在 /boot 目录下,看看是否有 wifi.sta 和 wifi.ssid, wifi.pass 这些文件。其中 wifi.sta 是开关文件。 如果没有了它就不会启动wifi流程。具体的wifi流程 由 /etc/init.d/S30wifi 控制。它会判断 wifi.sta 是否存在,然后是否启动wifi,还是启动热点。 +5. 给自己的程序打包到基础镜像中,参考:https://wiki.sipeed.com/maixpy/doc/zh/pro/compile_os.html + 5.1. 按照链接中的步骤,去github上获取了基础镜像,这次使用的是 v4.12.4,把Assets中的下面几样东西下载下来,我是在windows的wsl中执行的,注意, + 假如是在windows中下载的文件,在wsl中编译会很慢,所以我采用的是直接在wsl中下载,放到wsl的自己的文件系统中。 + 1)maixcam-2025-12-31-maixpy-v4.12.4.img.xz + 2)maixcam_builtin_files.tar.xz + 3)MaixPy-4.12.4-py3-none-any.whl + 4)Source code(zip) + 5.2. 把自己的文件放到 buildtin_files中: + 1)我把项目文件目录 t11 放到了 maixcam_builtin_files\maixapp\apps 这个目录下。 + 2)为了能让它自启动,我把 auto_start.txt 放到了 maixcam_builtin_files\maixapp 这个目录下。 + + 5.3. 然后在解压后的源码中找到tools/os目录下 /home/saga/maixcam/MaixPy-4.12.4/tools/os/maixcam + 执行 + export MAIXCDK_PATH=/home/saga/maixcam/MaixCDK + 编译: + ./gen_os.sh ../../../../../maixcam/maixcam-2025-12-31-maixpy-v4.12.4.img ../../../../../maixcam/MaixPy-4.12.4-py3-none-any.whl ../../../../../maixcam/maixcam_builtin_files 0 maixcam + 注意,在编译过程中,也会去 github 下载内容,所以需要打开梯子。 + 5.4. 等待编译完成,会编译成镜像文件,然后根据 https://wiki.sipeed.com/hardware/zh/maixcam/os.html 这个指引来烧录系统。 + 5.5. 烧录完系统后,需要安装 runtime, 可以按照 https://wiki.sipeed.com/maixpy/doc/zh/README_no_screen.html 这个来升级运行库,或者直接在 Maixvision 中链接的时候安装 runtime。 + 5.6. 安装 runtime 之后,重启,我们的系统就会自己启动起来了。 + + 遇到问题: + /mnt/d/code/shooting/compile_maixcam/MaixPy-4.12.4/MaixPy-4.12.4/tools/os/maixcam/fuse2fs: error while loading shared libraries: libfuse.so.2: cannot open shared object file: No such file or directory + 解决办法: + 安装 libfuse2 + sudo apt update + sudo apt install libfuse2 + + 遇到问题: + python 缺少 yaml + 解决办法: + pip install pyyaml + + 遇到问题: + ./build_all.sh: line 56: maixtool: command not found + 解决办法: + export PATH="/mnt/d/code/MaixCDK/.venv/bin:$PATH" + + 遇到问题: + ./update_img.sh: line 80: mcopy: command not found + 解决办法: + sudo apt update + sudo apt install mtools + +6. 相机标定: +set OPENCV_FFMPEG_CAPTURE_OPTIONS="rtsp_transport;tcp" +opencv_interactive-calibration -t=chessboard -w=9 -h=6 -sz=0.025 -v="http://192.168.1.55:8000/stream" \ No newline at end of file diff --git a/design_doc/solution_record.md b/design_doc/solution_record.md index f76bdb6..0ca7831 100644 --- a/design_doc/solution_record.md +++ b/design_doc/solution_record.md @@ -102,4 +102,95 @@ WiFi 连接成功 尝试切换到 4G ↓ 上层检测到连接断开: - 重新 connect_server() → 自动选择 4G \ No newline at end of file + 重新 connect_server() → 自动选择 4G + +10. 现在使用的相机,其实是支持更大的分辨率的,比如说1920*1280,但是由于我们的图像处理,拍照处理之后很容易触发OOM。 + +11. 环数计算流程: +现在设备侧的目标是:算出箭点相对靶心的偏移(dx,dy),单位是物理厘米(cm),然后把它作为 x,y 上报给后端;后端再去算环。 +设备侧本身不直接算环数,它算的是偏移与距离,并上报。 + +算法流程(一次射箭从触发到上报) +1) 触发后取一帧图 + 在 process_shot() 里读取相机帧并调用 analyze_shot(frame) +2) 确定激光点(laser_point) + + analyze_shot() 第一步先确定激光点 (x,y)(像素坐标): + + 硬编码:config.HARDCODE_LASER_POINT=True → 用 laser_manager.laser_point + 已校准:laser_manager.has_calibrated_point() → 用校准值 + 动态模式:先 detect_circle_v3(frame, None) 粗估距离,再根据距离反推激光点 + 代码在: + + if config.HARDCODE_LASER_POINT: + ... + elif laser_manager.has_calibrated_point(): + ... + else: + _, _, _, _, best_radius1_temp, _ = detect_circle_v3(frame, None) + distance_m_first = estimate_distance(best_radius1_temp) ... + laser_point = laser_manager.calculate_laser_point_from_distance(distance_m_first) +3) 优先走三角形路径(成功就直接用于上报 x/y) +如果 config.USE_TRIANGLE_OFFSET=True,先尝试识别靶面四角三角形标记: + +if getattr(config, "USE_TRIANGLE_OFFSET", False): + K, dist_coef, pos = _get_triangle_calib() + img_rgb = image.image2cv(frame, False, False) + tri = try_triangle_scoring(img_rgb, (x, y), pos, K, dist_coef, ...) + if tri.get("ok"): + return {... "dx": tri["dx_cm"], "dy": tri["dy_cm"], "distance_m": tri.get("distance_m"), ...} +这一步里 try_triangle_scoring() 做了两件事(都在 triangle_target.py): + +单应性(homography):把激光点从图像坐标映射到靶面坐标系,得到(dx,dy)(cm) +PnP:用识别到的角点与相机标定,估算 相机到靶的距离 distance_m +关键代码: + +ok_h, tx, ty, _H = homography_calibration(...) +out["dx_cm"] = tx +out["dy_cm"] = -ty +out["distance_m"] = dist_m +out["distance_method"] = "pnp_triangle" +注意:这里 dy_cm 取了负号,是为了和现网约定一致(laser_manager.compute_laser_position 的坐标方向)。 + +4) 三角形失败 → 回退圆形/椭圆靶心检测(兜底) +如果三角形不可用或识别失败,就走传统靶心检测: + +detect_circle_v3(frame, laser_point) 找黄心/红心、半径、椭圆参数 +用 laser_manager.compute_laser_position() 把像素偏移换算成厘米偏移(dx,dy) +在 shoot_manager.py: + +result_img, center, radius, method, best_radius1, ellipse_params = detect_circle_v3(frame, laser_point) +if center and radius: + dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method) + distance_m = estimate_distance(best_radius1) ... +在 laser_manager.compute_laser_position()(核心换算逻辑): + +r = radius * 5 +target_x = (lx-cx)/r*100 +target_y = (ly-cy)/r*100 +return (target_x, -target_y) +这里 (像素差)/(radius*5)*100 是你们旧约定下的“像素→厘米”比例模型(并且 y 方向同样取负号)。 + +5) 上报数据:把(dx,dy) 作为 x/y 发给后端 +最终上报发生在 process_shot(),直接把 dx,dy 填到 inner_data["x"],["y"]: + +srv_x = round(float(dx), 4) if dx is not None else 200.0 +srv_y = round(float(dy), 4) if dy is not None else 200.0 +inner_data = { + "x": srv_x, + "y": srv_y, + "d": round((distance_m or 0.0) * 100), + "m": method if method else "no_target", + "offset_method": offset_method, + "distance_method": distance_method, + ... +} +network_manager.safe_enqueue(...) +x,y:物理厘米(cm) +d:相机到靶距离(m→cm,乘 100;三角形成功时来自 PnP) +m/offset_method/distance_method:标记本次用的算法路径(triangle / yellow / pnp 等) +后端收到 x,y 后,再用你之前给的 Go 公式 CalculateRingNumber(x,y,tenRingRadius) 计算环数。 + +你现在的“环数计算”实际依赖关系 +最好路径(快+稳):三角形 → dx,dy(单应性) + distance_m(PnP) +兜底路径:圆/椭圆靶心 → dx,dy(基于黄心半径比例/透视校正) + distance_m(黄心半径估距) diff --git a/server.pem b/server.pem new file mode 100644 index 0000000..f0f14b9 --- /dev/null +++ b/server.pem @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFwjCCA6qgAwIBAgIUAZIGjFLTekYI+IIquQ/87qLDuNAwDQYJKoZIhvcNAQEL +BQAwXjELMAkGA1UEBhMCQ04xDjAMBgNVBAgMBUxvY2FsMQ4wDAYDVQQHDAVMb2Nh +bDEOMAwGA1UECgwFTG9jYWwxHzAdBgNVBAMMFnd3dy5zaGVsaW5neGluZ3FpdS5j +b20wIBcNMjYwNDA3MDc0NDI2WhgPMjEyNjAzMTQwNzQ0MjZaMF4xCzAJBgNVBAYT +AkNOMQ4wDAYDVQQIDAVMb2NhbDEOMAwGA1UEBwwFTG9jYWwxDjAMBgNVBAoMBUxv +Y2FsMR8wHQYDVQQDDBZ3d3cuc2hlbGluZ3hpbmdxaXUuY29tMIICIjANBgkqhkiG +9w0BAQEFAAOCAg8AMIICCgKCAgEAvKRcWr8QeT1OzhMbWlHmqxmduE+e7r2Oet9I +mU4O888U1X1YKaIDnq+zqRCNteid3jrOWucDLReZzNnrZ4l3Jq9nbWuTwj9Y9vCq +ahW3K3BOhnuJ+qvqX2Izn1Z9iNCFhXnUaFy8+iP0nJNNIRXwg7ioKbY6+SaTbBzI +vfG33MjOmwnQlqZzdGyNpvieO9XzqVyRxeDen/LJf4Z1NocP2rOjqQC3dIDXOfBt +/ZOZymb4XwQ9b/t+6WJn9Zfycw0tp/7GqI+vqLDUMpipO4ahmybJPO02IhokZ09t +BnCXe0enLnMAshIipTxSaJEick9HnQVSUzF+9A1F0cCFAhS8cM/04aksfYsJD2xj +riiVHVoVo6tb0GJSCM+b0j9ObH9bDx3DKfy9EcqP25mJxWQTuT8G0oiyuxE5knjA +HL7yjwd5gVSuig+ACnxE3vITeVKtvyep7sD4tJqkN93t7OMeBRFMGsYpJ8w+8u6X ++9/RmMcOnuNcT/4HrOuAtlAnM1D44MSI1RLaOCJJ9evqhpWdktfn2Uv4gCnaTjUr +OiEU/G+lquST2kggjbcReLqkk+7yN3XkaR9dun4iV35WfEo1ENThVhLPGV61LaJq +PwbjltQlkcAFPJ1GJyE9FVO79bB51d0w/rlI/CcDUpTRMaXR35EmTjxvXOr/a/XI +56GUNaUCAwEAAaN2MHQwHQYDVR0OBBYEFH1HCDm4N7LMhIX2Fb2FXAfdyhwQMB8G +A1UdIwQYMBaAFH1HCDm4N7LMhIX2Fb2FXAfdyhwQMA8GA1UdEwEB/wQFMAMBAf8w +IQYDVR0RBBowGIIWd3d3LnNoZWxpbmd4aW5ncWl1LmNvbTANBgkqhkiG9w0BAQsF +AAOCAgEAG/PMwXCXJOaqCpU/LaY6w04ue6wk95RbPXf4JH4CrrLUfgyUmFlNNQPA +LuZSBRI6KUGkTvzuz/3ofZHVEin3CyE5NadB3UItpfA4Wl4r3jMPifIgnA/NT8xo +GE1gYaDbcfJNE8jy6GebjZekbVrPvCY9YgcUT2AmW5fcbnCTy+/iC7lf9MvvqHTJ +H5zvOp5nyWJYWYsvvif3Y7dp00ytg9I8/LSgUspKwB8qSWPWV8z4WsV6sc1mNqVS +nFBDkgzZxr4ZYlhVLzbSoab8D4A/z6riEMqv4S+oF5VkaJLhsN8vgHh9aPspCC3Q +zhcosH8XmNmJmT/X64FhhRqxAqX65WanVQABtBS/vsC+FAQDGMb3RkZSbLEnIlgj +bx/6bSkhHl+J2xIqA7tLvYhRSvM3H12X7VSVc+tkVzI5JoUSugZLxxRDGpYgkvRz +SPFCqb9eTn5ES5gnQX6+E+f/E/WQTmadolSbEppdxNZW7AaIUdQo0aFxFwctwhA2 +YNUG9oW2TXAZjSECyTo28NFkFfwBhpHWigFCANNCd8Nrn0k0YMuJOkqW5e4w3/24 +/IxM/C9K7aAx4S1XZ16Nvh5pZQduEGKTSUYMJ/uV26Mf4ZGroUfGB9tBguK5rYbL +UlRvtU9mkZPK04GbLsoo+8tZTDRtkuCiC19xk33XiitZrmavc24= +-----END CERTIFICATE----- diff --git a/shoot_manager.py b/shoot_manager.py index d80c75b..89c8fad 100644 --- a/shoot_manager.py +++ b/shoot_manager.py @@ -1,11 +1,44 @@ +import os +import threading + import config from camera_manager import camera_manager from laser_manager import laser_manager from logger_manager import logger_manager from network import network_manager -from power import get_bus_voltage, voltage_to_percent -from vision import estimate_distance, detect_circle_v3, save_shot_image -from maix import camera, display, image, app, time, uart, pinmap, i2c +from triangle_target import load_camera_from_xml, load_triangle_positions, try_triangle_scoring +from vision import estimate_distance, detect_circle_v3, enqueue_save_shot +from maix import image, time + +# 缓存相机标定与三角形位置,避免每次射箭重复读磁盘 +_tri_calib_cache = None + +def _get_triangle_calib(): + """返回 (K, dist, marker_positions);首次调用时从磁盘加载并缓存。""" + global _tri_calib_cache + if _tri_calib_cache is not None: + return _tri_calib_cache + calib_path = getattr(config, "CAMERA_CALIB_XML", "") + tri_json = getattr(config, "TRIANGLE_POSITIONS_JSON", "") + if not (os.path.isfile(calib_path) and os.path.isfile(tri_json)): + _tri_calib_cache = (None, None, None) + return _tri_calib_cache + K, dist = load_camera_from_xml(calib_path) + pos = load_triangle_positions(tri_json) + _tri_calib_cache = (K, dist, pos) + return _tri_calib_cache + + +def preload_triangle_calib(): + """ + 启动阶段预加载三角形标定与坐标文件,避免首次射箭触发时的读盘/解析开销。 + """ + try: + _get_triangle_calib() + except Exception: + # 预加载失败不影响主流程;射箭时会再次按需尝试 + pass + def analyze_shot(frame, laser_point=None): """ @@ -13,18 +46,18 @@ def analyze_shot(frame, laser_point=None): :param frame: 图像帧 :param laser_point: 激光点坐标 (x, y) :return: 包含分析结果的字典 + + 优先级: + 1. 三角形单应性(USE_TRIANGLE_OFFSET=True 时)— 成功则直接返回,跳过圆形检测 + 2. 圆形检测(三角形不可用或识别失败时兜底) """ logger = logger_manager.logger + from datetime import datetime - # 先检测靶心以获取距离(用于计算激光点) - result_img_temp, center_temp, radius_temp, method_temp, best_radius1_temp, ellipse_params_temp = detect_circle_v3( - frame, None) - - # 计算距离 - distance_m = estimate_distance(best_radius1_temp) if best_radius1_temp else None - - # 根据距离动态计算激光点坐标 + # ── Step 1: 确定激光点 ──────────────────────────────────────────────────── laser_point_method = None + distance_m_first = None + if config.HARDCODE_LASER_POINT: laser_point = laser_manager.laser_point laser_point_method = "hardcode" @@ -33,65 +66,128 @@ def analyze_shot(frame, laser_point=None): laser_point_method = "calibrated" if logger: logger.info(f"[算法] 使用校准值: {laser_manager.laser_point}") - elif distance_m and distance_m > 0: - laser_point = laser_manager.calculate_laser_point_from_distance(distance_m) - laser_point_method = "dynamic" - if logger: - logger.info(f"[算法] 使用比例尺: {laser_point}") else: - laser_point = laser_manager.laser_point - laser_point_method = "default" - if logger: - logger.info(f"[算法] 使用默认值: {laser_point}") + # 动态模式:先做一次无激光点检测以估算距离,再推算激光点 + _, _, _, _, best_radius1_temp, _ = detect_circle_v3(frame, None) + distance_m_first = estimate_distance(best_radius1_temp) if best_radius1_temp else None + if distance_m_first and distance_m_first > 0: + laser_point = laser_manager.calculate_laser_point_from_distance(distance_m_first) + laser_point_method = "dynamic" + if logger: + logger.info(f"[算法] 使用比例尺: {laser_point}") + else: + laser_point = laser_manager.laser_point + laser_point_method = "default" + if logger: + logger.info(f"[算法] 使用默认值: {laser_point}") if laser_point is None: - return { - "success": False, - "reason": "laser_point_not_initialized" - } + return {"success": False, "reason": "laser_point_not_initialized"} x, y = laser_point - # 绘制激光十字线 - color = image.Color(config.LASER_COLOR[0], config.LASER_COLOR[1], config.LASER_COLOR[2]) - frame.draw_line( - int(x - config.LASER_LENGTH), int(y), - int(x + config.LASER_LENGTH), int(y), - color, config.LASER_THICKNESS - ) - frame.draw_line( - int(x), int(y - config.LASER_LENGTH), - int(x), int(y + config.LASER_LENGTH), - color, config.LASER_THICKNESS - ) - frame.draw_circle(int(x), int(y), 1, color, config.LASER_THICKNESS) + # ── Step 2: 提前转换一次图像,两个检测线程共享(只读)──────────────────────── + img_cv = image.image2cv(frame, False, False) - # 重新检测靶心(使用计算出的激光点) - result_img, center, radius, method, best_radius1, ellipse_params = detect_circle_v3(frame, laser_point) + # ── Step 3: 检查三角形是否可用 ──────────────────────────────────────────────── + use_tri = getattr(config, "USE_TRIANGLE_OFFSET", False) + K = dist_coef = pos = None + if use_tri: + K, dist_coef, pos = _get_triangle_calib() + use_tri = K is not None and dist_coef is not None and pos - # 计算偏移与距离 - if center and radius: - dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method) - distance_m = estimate_distance(best_radius1) - else: + def _build_circle_result(cdata): + """从圆形检测结果构建 analyze_shot 返回值。""" + r_img, center, radius, method, best_radius1, ellipse_params = cdata dx, dy = None, None - distance_m = None + d_m = distance_m_first + if center and radius: + dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method) + d_m = estimate_distance(best_radius1) if best_radius1 else distance_m_first + return { + "success": True, + "result_img": r_img, + "center": center, "radius": radius, "method": method, + "best_radius1": best_radius1, "ellipse_params": ellipse_params, + "dx": dx, "dy": dy, "distance_m": d_m, + "laser_point": laser_point, "laser_point_method": laser_point_method, + "offset_method": "yellow_ellipse" if ellipse_params else "yellow_circle", + "distance_method": "yellow_radius", + } - # 返回分析结果 - return { - "success": True, - "result_img": result_img, - "center": center, - "radius": radius, - "method": method, - "best_radius1": best_radius1, - "ellipse_params": ellipse_params, - "dx": dx, - "dy": dy, - "distance_m": distance_m, - "laser_point": laser_point, - "laser_point_method": laser_point_method - } + if not use_tri: + # 三角形未配置,直接跑圆形检测 + return _build_circle_result( + detect_circle_v3(frame, laser_point, img_cv=img_cv) + ) + + # ── Step 4: 三角形 + 圆形并行检测 ───────────────────────────────────────────── + # 两个线程共享只读的 img_cv,互不干扰 + tri_result = {} + circle_result = {} + + def _run_triangle(): + try: + logger.info(f"[TRI] begin {datetime.now()}") + logger.info(f"[TRI] K: {K}, dist: {dist_coef}, pos: {pos}, {datetime.now()}") + tri = try_triangle_scoring( + img_cv, (x, y), pos, K, dist_coef, + size_range=getattr(config, "TRIANGLE_SIZE_RANGE", (8, 500)), + ) + logger.info(f"[TRI] tri: {tri}, {datetime.now()}") + tri_result['data'] = tri + except Exception as e: + logger.error(f"[TRI] 三角形路径异常: {e}") + tri_result['data'] = {'ok': False} + + def _run_circle(): + try: + circle_result['data'] = detect_circle_v3(frame, laser_point, img_cv=img_cv) + except Exception as e: + logger.error(f"[CIRCLE] 圆形检测异常: {e}") + circle_result['data'] = (frame, None, None, None, None, None) + + t_tri = threading.Thread(target=_run_triangle, daemon=True) + t_cir = threading.Thread(target=_run_circle, daemon=True) + t_tri.start() + t_cir.start() + + # 最多等待三角形 TRIANGLE_TIMEOUT_MS(默认 1000ms) + tri_timeout_s = float(getattr(config, "TRIANGLE_TIMEOUT_MS", 1000)) / 1000.0 + t_tri.join(timeout=tri_timeout_s) + if t_tri.is_alive(): + # 超时:直接放弃三角形结果,回退圆心(圆心线程通常已跑完) + logger.warning(f"[TRI] timeout>{tri_timeout_s:.2f}s,回退圆心算法") + t_cir.join() + return _build_circle_result( + circle_result.get('data') or (frame, None, None, None, None, None) + ) + + tri = tri_result.get('data', {}) + + if tri.get('ok'): + logger.info(f"[TRI] end {datetime.now()}") + return { + "success": True, + "result_img": frame, + "center": None, "radius": None, + "method": tri.get("offset_method") or "triangle_homography", + "best_radius1": None, "ellipse_params": None, + "dx": tri["dx_cm"], "dy": tri["dy_cm"], + "distance_m": tri.get("distance_m") or distance_m_first, + "laser_point": laser_point, "laser_point_method": laser_point_method, + "offset_method": tri.get("offset_method") or "triangle_homography", + "distance_method": tri.get("distance_method") or "pnp_triangle", + "tri_markers": tri.get("markers", []), + "tri_homography": tri.get("homography"), + } + + # 三角形失败,等圆形结果(已并行跑完,几乎无额外等待) + t_cir.join() + logger.info(f"[TRI] end(fallback) {datetime.now()}") + return _build_circle_result( + circle_result.get('data') or (frame, None, None, None, None, None) + ) def process_shot(adc_val): @@ -103,6 +199,7 @@ def process_shot(adc_val): logger = logger_manager.logger try: + network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True) frame = camera_manager.read_frame() # 调用算法分析 @@ -126,16 +223,21 @@ def process_shot(adc_val): distance_m = analysis_result["distance_m"] laser_point = analysis_result["laser_point"] laser_point_method = analysis_result["laser_point_method"] + offset_method = analysis_result.get("offset_method", "yellow_circle") + distance_method = analysis_result.get("distance_method", "yellow_radius") + tri_markers = analysis_result.get("tri_markers", []) + tri_homography = analysis_result.get("tri_homography") x, y = laser_point - camera_manager.show(result_img) + # 三角形路径成功时 center/radius 为空是正常的;此时用 triangle 方法名用于保存文件名与上报字段 m + if (not method) and tri_markers: + method = offset_method or "triangle_homography" - if not (center and radius) and logger: - logger.warning("[MAIN] 未检测到靶心,但会保存图像") + if config.SHOW_CAMERA_PHOTO_WHILE_SHOOTING: + camera_manager.show(result_img) - # 读取电量 - voltage = get_bus_voltage() - battery_percent = voltage_to_percent(voltage) + if dx is None and dy is None and logger: + logger.warning("[MAIN] 未检测到偏移量(三角形与圆形均失败),但会保存图像") # 生成射箭ID from shot_id_generator import shot_id_generator @@ -144,33 +246,30 @@ def process_shot(adc_val): if logger: logger.info(f"[MAIN] 射箭ID: {shot_id}") - # 保存图像 - save_shot_image( - result_img, - center, - radius, - method, - ellipse_params, - (x, y), - distance_m, - shot_id=shot_id, - photo_dir=config.PHOTO_DIR if config.SAVE_IMAGE_ENABLED else None - ) + laser_distance_m = None + laser_signal_quality = 0 + + # x,y 单位:物理厘米(compute_laser_position 与三角形单应性均输出物理 cm) + # 未检测到靶心时 x/y 用 200.0(脱靶标志) + srv_x = round(float(dx), 4) if dx is not None else 200.0 + srv_y = round(float(dy), 4) if dy is not None else 200.0 # 构造上报数据 inner_data = { "shot_id": shot_id, - "x": float(dx) if dx is not None else 200.0, - "y": float(dy) if dy is not None else 200.0, - "r": 90.0, + "x": srv_x, + "y": srv_y, + "r": 20.0, # 保留字段(服务端当前忽略,物理外环半径 cm) "d": round((distance_m or 0.0) * 100), - "d_laser": 0.0, - "d_laser_quality": 0, + "d_laser": round((laser_distance_m or 0.0) * 100), + "d_laser_quality": laser_signal_quality, "m": method if method else "no_target", "adc": adc_val, "laser_method": laser_point_method, "target_x": float(x), "target_y": float(y), + "offset_method": offset_method, + "distance_method": distance_method, } if ellipse_params: @@ -190,14 +289,99 @@ def process_shot(adc_val): report_data = {"cmd": 1, "data": inner_data} network_manager.safe_enqueue(report_data, msg_type=2, high=True) - if logger: - if center and radius: - logger.info(f"射箭事件已加入发送队列(已检测到靶心),ID: {shot_id}") - else: - logger.info(f"射箭事件已加入发送队列(未检测到靶心,已保存图像),ID: {shot_id}") + # 数据上报后再画标注,不干扰检测阶段的原始画面 + if result_img is not None: + # 1. 若有三角形标记,先用 cv2 画轮廓 / 顶点 / ID,再反推靶心位置 + if tri_markers: + import cv2 as _cv2 + import numpy as _np + _img_cv = image.image2cv(result_img, False, False) + + # 三角形轮廓 + 直角顶点 + ID + for _m in tri_markers: + _corners = _np.array(_m["corners"], dtype=_np.int32) + _cv2.polylines(_img_cv, [_corners], True, (0, 255, 0), 2) + _cx, _cy = int(_m["center"][0]), int(_m["center"][1]) + _cv2.circle(_img_cv, (_cx, _cy), 4, (0, 0, 255), -1) + _cv2.putText(_img_cv, f"T{_m['id']}", + (_cx - 18, _cy - 12), + _cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 0), 1) + + # 靶心(H_inv @ [0,0]):小红圆 + _center_px = None + if tri_homography is not None: + try: + _H_inv = _np.linalg.inv(tri_homography) + _c_img = _cv2.perspectiveTransform( + _np.array([[[0.0, 0.0]]], dtype=_np.float32), _H_inv)[0][0] + _ocx, _ocy = int(_c_img[0]), int(_c_img[1]) + _cv2.circle(_img_cv, (_ocx, _ocy), 5, (0, 0, 255), -1) # 实心 + _cv2.circle(_img_cv, (_ocx, _ocy), 9, (0, 0, 255), 1) # 外框 + _center_px = (_ocx, _ocy) + logger.info(f"[算法] 靶心: {_center_px}") + except Exception: + pass + + # 叠加信息:落点-圆心距离 / 相机-靶距离等 + try: + import math as _math + _lines = [] + if dx is not None and dy is not None: + _r_cm = _math.hypot(float(dx), float(dy)) + _lines.append(f"offset=({float(dx):.2f},{float(dy):.2f})cm |r|={_r_cm:.2f}cm") + if distance_m is not None: + _lines.append(f"cam_dist={float(distance_m):.2f}m ({distance_method})") + if method: + _lines.append(f"method={method}") + if _lines: + _y0 = 22 + for i, _t in enumerate(_lines): + _cv2.putText( + _img_cv, + _t, + (10, _y0 + i * 18), + _cv2.FONT_HERSHEY_SIMPLEX, + 0.5, + (0, 255, 0), + 1, + ) + except Exception: + pass + + result_img = image.cv2image(_img_cv, False, False) + + # 2. 激光十字线 + _lc = image.Color(config.LASER_COLOR[0], config.LASER_COLOR[1], config.LASER_COLOR[2]) + result_img.draw_line(int(x - config.LASER_LENGTH), int(y), + int(x + config.LASER_LENGTH), int(y), + _lc, config.LASER_THICKNESS) + result_img.draw_line(int(x), int(y - config.LASER_LENGTH), + int(x), int(y + config.LASER_LENGTH), + _lc, config.LASER_THICKNESS) + result_img.draw_circle(int(x), int(y), 1, _lc, config.LASER_THICKNESS) # 闪一下激光(射箭反馈) - laser_manager.flash_laser(1000) + if config.FLASH_LASER_WHILE_SHOOTING: + laser_manager.flash_laser(config.FLASH_LASER_DURATION_MS) + + # 保存图像(异步队列,与 main.py 一致) + enqueue_save_shot( + result_img, + center, + radius, + method, + ellipse_params, + (x, y), + distance_m, + shot_id=shot_id, + photo_dir=config.PHOTO_DIR if config.SAVE_IMAGE_ENABLED else None, + ) + + if logger: + if dx is not None and dy is not None: + logger.info(f"射箭事件已加入发送队列(偏移=({dx:.2f},{dy:.2f})cm),ID: {shot_id}") + else: + logger.info(f"射箭事件已加入发送队列(未检测到偏移,已保存图像),ID: {shot_id}") time.sleep_ms(100) except Exception as e: diff --git a/triangle_positions.json b/triangle_positions.json new file mode 100644 index 0000000..655e966 --- /dev/null +++ b/triangle_positions.json @@ -0,0 +1,6 @@ +{ + "0": [-20.0, -20.0, 0.0], + "1": [-20.0, 20.0, 0.0], + "2": [ 20.0, 20.0, 0.0], + "3": [ 20.0, -20.0, 0.0] +} diff --git a/triangle_target.py b/triangle_target.py new file mode 100644 index 0000000..8778750 --- /dev/null +++ b/triangle_target.py @@ -0,0 +1,513 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +靶纸四角等腰直角三角形:检测、单应性落点、PnP 估距。 +从 test/aruco_deteck.py 抽出,供主流程 shoot_manager 使用。 +""" +import json +import os +from itertools import combinations + +import cv2 +import numpy as np + + +def _log(msg): + try: + from logger_manager import logger_manager + if logger_manager.logger: + logger_manager.logger.info(msg) + except Exception: + pass + + +def load_camera_from_xml(path): + """读取 OpenCV FileStorage XML,返回 (camera_matrix, dist_coeffs) 或 (None, None)。""" + if not path or not os.path.isfile(path): + _log(f"[TRI] 标定文件不存在: {path}") + return None, None + try: + fs = cv2.FileStorage(path, cv2.FILE_STORAGE_READ) + K = fs.getNode("camera_matrix").mat() + dist = fs.getNode("distortion_coefficients").mat() + fs.release() + if K is None or K.size == 0: + return None, None + if dist is None or dist.size == 0: + dist = np.zeros((5, 1), dtype=np.float64) + return K, dist + except Exception as e: + _log(f"[TRI] 读取标定失败: {e}") + return None, None + + +def load_triangle_positions(path): + """加载 triangle_positions.json,返回 dict[int, [x,y,z]]。""" + if not path or not os.path.isfile(path): + _log(f"[TRI] 三角形位置文件不存在: {path}") + return None + with open(path, "r", encoding="utf-8") as f: + raw = json.load(f) + return {int(k): v for k, v in raw.items()} + + +def homography_calibration(marker_centers, marker_ids, marker_positions, impact_point_pixel): + target_points = [] + for mid in marker_ids: + pos = marker_positions.get(mid) + if pos is None: + return False, None, None, None + target_points.append([pos[0], pos[1]]) + + src_pts = np.array(marker_centers, dtype=np.float32) + dst_pts = np.array(target_points, dtype=np.float32) + H, _ = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, ransacReprojThreshold=1.0) + if H is None: + return False, None, None, None + + pt = np.array([[impact_point_pixel]], dtype=np.float32) + transformed = cv2.perspectiveTransform(pt, H) + target_x = float(transformed[0][0][0]) + target_y = float(transformed[0][0][1]) + return True, target_x, target_y, H + + +def complete_fourth_point(detected_ids, detected_centers, marker_positions): + target_order = [0, 1, 2, 3] + target_coords = {mid: marker_positions[mid][:2] for mid in target_order} + all_ids = set(target_coords.keys()) + missing_id = (all_ids - set(detected_ids)).pop() + + known_src = [] + known_dst = [] + for mid, pt in zip(detected_ids, detected_centers): + known_src.append(pt) + known_dst.append(target_coords[mid]) + + M_inv, _ = cv2.estimateAffine2D( + np.array(known_dst, dtype=np.float32), + np.array(known_src, dtype=np.float32), + ) + if M_inv is None: + return None + + missing_target = target_coords[missing_id] + missing_src_h = M_inv @ np.array([missing_target[0], missing_target[1], 1.0]) + missing_src = missing_src_h[:2] + + complete_centers = [] + for mid in target_order: + if mid == missing_id: + complete_centers.append(missing_src) + else: + idx = detected_ids.index(mid) + complete_centers.append(detected_centers[idx]) + + return complete_centers, target_order + + +def pnp_distance_meters(marker_ids, marker_centers_px, marker_positions, K, dist): + """ + 靶面原点 (0,0,0) 到相机光心的距离:||tvec||,object 单位为 cm 时 tvec 为 cm,返回米。 + """ + obj = [] + for mid in marker_ids: + p = marker_positions[mid] + obj.append([float(p[0]), float(p[1]), float(p[2])]) + obj_pts = np.array(obj, dtype=np.float64) + img_pts = np.array(marker_centers_px, dtype=np.float64) + + ok, rvec, tvec = cv2.solvePnP( + obj_pts, img_pts, K, dist, flags=cv2.SOLVEPNP_ITERATIVE + ) + if not ok: + return None + tvec = tvec.reshape(-1) + dist_cm = float(np.linalg.norm(tvec)) + return dist_cm / 100.0 + + +def detect_triangle_markers( + gray_image, + orig_gray=None, + size_range=(8, 500), + max_interior_gray=90, + dark_pixel_gray=80, + min_dark_ratio=0.70, + verbose=True, +): + # 读取可调参数(缺省值与 config.py 保持一致) + try: + import config as _cfg + early_exit = int(getattr(_cfg, "TRIANGLE_EARLY_EXIT_CANDIDATES", 4)) + block_sizes = tuple(getattr(_cfg, "TRIANGLE_ADAPTIVE_BLOCK_SIZES", (11, 21, 35))) + max_combo_n = int(getattr(_cfg, "TRIANGLE_MAX_FILTERED_FOR_COMBO", 10)) + except Exception: + early_exit = 4 + block_sizes = (11, 21, 35) + max_combo_n = 10 + + min_leg, max_leg = size_range + min_area = 0.5 * (min_leg ** 2) * 0.1 + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) + + def _check_shape(approx): + pts = approx.reshape(3, 2).astype(np.float32) + sides = [ + np.linalg.norm(pts[1] - pts[0]), + np.linalg.norm(pts[2] - pts[1]), + np.linalg.norm(pts[0] - pts[2]), + ] + order = sorted(range(3), key=lambda i: sides[i]) + leg1, leg2, hyp = sides[order[0]], sides[order[1]], sides[order[2]] + avg_leg = (leg1 + leg2) / 2 + + if not (min_leg <= avg_leg <= max_leg): + return None + if abs(leg1 - leg2) / (avg_leg + 1e-6) > 0.20: + return None + if abs(hyp - avg_leg * np.sqrt(2)) / (avg_leg * np.sqrt(2) + 1e-6) > 0.20: + return None + + edge_verts = [(0, 1), (1, 2), (2, 0)] + hv0, hv1 = edge_verts[order[2]] + right_v = 3 - hv0 - hv1 + right_pt = pts[right_v] + + v0 = pts[hv0] - right_pt + v1_vec = pts[hv1] - right_pt + cos_a = np.dot(v0, v1_vec) / ( + np.linalg.norm(v0) * np.linalg.norm(v1_vec) + 1e-6 + ) + if abs(cos_a) > 0.20: + return None + + return right_pt, avg_leg, pts + + def _color_ok(approx): + if orig_gray is None: + return True + mask = np.zeros(orig_gray.shape[:2], dtype=np.uint8) + cv2.fillPoly(mask, [approx], 255) + erode_k = max(1, int(min(orig_gray.shape[:2]) * 0.002)) + erode_k = min(erode_k, 5) + k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * erode_k + 1, 2 * erode_k + 1)) + mask_in = cv2.erode(mask, k, iterations=1) + if cv2.countNonZero(mask_in) < 20: + mask_in = mask + + mean_val = cv2.mean(orig_gray, mask=mask_in)[0] + ys, xs = np.where(mask_in > 0) + if len(xs) == 0: + return False + interior = orig_gray[ys, xs] + dark_ratio = float(np.mean(interior <= dark_pixel_gray)) + return (mean_val <= max_interior_gray) and (dark_ratio >= min_dark_ratio) + + def _extract_candidates(binary_img): + contours, _ = cv2.findContours(binary_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + found = [] + for cnt in contours: + if cv2.contourArea(cnt) < min_area: + continue + peri = cv2.arcLength(cnt, True) + approx = cv2.approxPolyDP(cnt, 0.05 * peri, True) + if len(approx) != 3: + continue + shape = _check_shape(approx) + if shape is None: + continue + if not _color_ok(approx): + continue + right_pt, avg_leg, pts = shape + center_px = np.mean(pts, axis=0).tolist() + dedup_key = f"{int(center_px[0] // 10)},{int(center_px[1] // 10)}" + found.append({ + "center_px": center_px, + "right_pt": right_pt.tolist(), + "corners": pts.tolist(), + "avg_leg": avg_leg, + "dedup_key": dedup_key, + }) + return found + + all_candidates = [] + seen_keys = set() + # 早退条件:不仅要数量够,还要候选分布足够分散(覆盖多个象限),避免误检集中导致提前退出 + h0, w0 = gray_image.shape[:2] + cx0, cy0 = w0 / 2.0, h0 / 2.0 + seen_quadrants = set() + # 4 个候选就够 4 角检测;3 个够 3 点补全,加 1 裕量 + _EARLY_EXIT = max(3, early_exit) + + def _add_from_binary(b): + b = cv2.morphologyEx(b, cv2.MORPH_CLOSE, kernel) + for c in _extract_candidates(b): + if c["dedup_key"] not in seen_keys: + seen_keys.add(c["dedup_key"]) + all_candidates.append(c) + # 象限统计:按图像中心划分 + tx, ty = c["center_px"] + if tx < cx0 and ty < cy0: + q = 0 + elif tx < cx0: + q = 1 + elif ty >= cy0: + q = 2 + else: + q = 3 + seen_quadrants.add(q) + + def _should_early_exit(): + # 至少覆盖 3 个象限 + 数量达到阈值,才认为“足够像四角”可停止更多尝试 + return (len(all_candidates) >= _EARLY_EXIT) and (len(seen_quadrants) >= 3) + + # 1. 最快:全局 Otsu(无需逐像素邻域计算,~10ms) + _, b_otsu = cv2.threshold(gray_image, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) + _add_from_binary(b_otsu) + + # 2. 只在 Otsu 不够时才跑自适应阈值(每次 ~100ms,尽早退出) + for block_size in block_sizes: + if _should_early_exit(): + break + if block_size is None: + continue + b = cv2.adaptiveThreshold( + gray_image, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, block_size, 4 + ) + _add_from_binary(b) + + if verbose: + _log(f"[TRI] 候选三角形共 {len(all_candidates)} 个(预过滤前)") + + if len(all_candidates) < 2: + return [] + + all_legs = [c["avg_leg"] for c in all_candidates] + med_leg = float(np.median(all_legs)) + filtered = [] + for c in all_candidates: + leg = c["avg_leg"] + if med_leg > 1e-6 and not (0.40 * med_leg <= leg <= 2.0 * med_leg): + continue + filtered.append(c) + + if len(filtered) < 2: + return [] + + # 候选过多时,四点组合枚举会变慢:截断到更可能的 max_combo_n 个候选 + if max_combo_n > 0 and len(filtered) > max_combo_n: + # 以 avg_leg 接近中位数优先(更符合四角同尺度) + med_leg = float(np.median([c["avg_leg"] for c in filtered])) + filtered = sorted(filtered, key=lambda c: abs(c["avg_leg"] - med_leg))[:max_combo_n] + + def _order_quad(pts_4): + by_y = sorted(range(4), key=lambda i: pts_4[i][1]) + top_pair = sorted(by_y[:2], key=lambda i: pts_4[i][0]) + bot_pair = sorted(by_y[2:], key=lambda i: pts_4[i][0]) + return top_pair[0], bot_pair[0], bot_pair[1], top_pair[1] + + def _score_quad(cands_4): + pts = [np.array(c["center_px"]) for c in cands_4] + legs = [c["avg_leg"] for c in cands_4] + tl, bl, br, tr = _order_quad(pts) + + diag1 = np.linalg.norm(pts[tl] - pts[br]) + diag2 = np.linalg.norm(pts[bl] - pts[tr]) + diag_ratio = max(diag1, diag2) / (min(diag1, diag2) + 1e-6) + + s_top = np.linalg.norm(pts[tl] - pts[tr]) + s_bot = np.linalg.norm(pts[bl] - pts[br]) + s_left = np.linalg.norm(pts[tl] - pts[bl]) + s_right = np.linalg.norm(pts[tr] - pts[br]) + h_ratio = max(s_top, s_bot) / (min(s_top, s_bot) + 1e-6) + v_ratio = max(s_left, s_right) / (min(s_left, s_right) + 1e-6) + + med_l = float(np.median(legs)) + leg_dev = max(abs(l - med_l) / (med_l + 1e-6) for l in legs) + + score = (diag_ratio - 1.0) * 3.0 + (h_ratio - 1.0) + (v_ratio - 1.0) + leg_dev * 2.0 + return score, (tl, bl, br, tr) + + assigned = None + if len(filtered) >= 4: + best_score = float("inf") + best_combo = None + best_order = None + + for combo in combinations(range(len(filtered)), 4): + cands = [filtered[i] for i in combo] + score, order = _score_quad(cands) + if score < best_score: + best_score = score + best_combo = combo + best_order = order + + if verbose: + _log(f"[TRI] 最优四边形: score={best_score:.3f}") + + if best_score < 3.0: + cands = [filtered[i] for i in best_combo] + tl, bl, br, tr = best_order + assigned = { + 0: cands[tl], + 1: cands[bl], + 2: cands[br], + 3: cands[tr], + } + + if assigned is None: + cx = np.mean([c["center_px"][0] for c in filtered]) + cy = np.mean([c["center_px"][1] for c in filtered]) + quadrant_map = {} + for c in filtered: + tx, ty = c["center_px"] + if tx < cx and ty < cy: + q = 0 + elif tx < cx: + q = 1 + elif ty >= cy: + q = 2 + else: + q = 3 + if q not in quadrant_map or c["avg_leg"] > quadrant_map[q]["avg_leg"]: + quadrant_map[q] = c + assigned = quadrant_map + + result = [] + for tid in sorted(assigned.keys()): + c = assigned[tid] + result.append({ + "id": tid, + "center": c["right_pt"], + "corners": c["corners"], + }) + return result + + +def try_triangle_scoring( + img_rgb, + laser_xy, + marker_positions, + camera_matrix, + dist_coeffs, + size_range=(8, 500), +): + """ + 尝试三角形单应性 + PnP 估距。 + img_rgb: RGB,与 laser_xy 同一像素坐标系。 + 返回 dict: + ok, dx_cm, dy_cm, distance_m, offset_method, distance_method + """ + out = { + "ok": False, + "dx_cm": None, + "dy_cm": None, + "distance_m": None, + "offset_method": None, + "distance_method": None, + } + if marker_positions is None or camera_matrix is None or dist_coeffs is None: + return out + + h_orig, w_orig = img_rgb.shape[:2] + + # 缩图加速:嵌入式 CPU 上图像处理耗时与面积成正比,缩到最长边 320px 可获得 ~4× 加速 + # 检测完后把像素坐标乘以 inv_scale 还原到原始分辨率,再送入单应性/PnP(与 K 标定分辨率一致) + MAX_DETECT_DIM = 320 + long_side = max(h_orig, w_orig) + if long_side > MAX_DETECT_DIM: + det_scale = MAX_DETECT_DIM / long_side + det_w = int(w_orig * det_scale) + det_h = int(h_orig * det_scale) + img_det = cv2.resize(img_rgb, (det_w, det_h), interpolation=cv2.INTER_LINEAR) + inv_scale = 1.0 / det_scale + size_range_det = (max(4, int(size_range[0] * det_scale)), + max(8, int(size_range[1] * det_scale))) + else: + img_det = img_rgb + inv_scale = 1.0 + size_range_det = size_range + + gray = cv2.cvtColor(img_det, cv2.COLOR_RGB2GRAY) + + # 快速路径:直接在原始灰度图上跑(内部先走 Otsu,几乎不耗时) + # 光照均匀时通常在这一步就找到 ≥3 个三角形,完全跳过 CLAHE + tri_markers = detect_triangle_markers( + gray, orig_gray=gray, size_range=size_range_det, verbose=True + ) + + if len(tri_markers) < 3: + # 慢速兜底:CLAHE 增强对比度后再试(光线不均 / 局部过暗时有效) + # 默认关闭以优先速度;由 config.TRIANGLE_ENABLE_CLAHE_FALLBACK 控制。 + try: + import config as _cfg + enable_clahe = bool(getattr(_cfg, "TRIANGLE_ENABLE_CLAHE_FALLBACK", False)) + except Exception: + enable_clahe = False + + if enable_clahe: + _log(f"[TRI] 快速路径不足{len(tri_markers)}个,启用CLAHE增强") + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + gray_clahe = clahe.apply(gray) + tri_markers = detect_triangle_markers( + gray_clahe, orig_gray=gray, size_range=size_range_det, verbose=True + ) + else: + _log(f"[TRI] 快速路径不足{len(tri_markers)}个,跳过CLAHE兜底(已关闭)") + + if len(tri_markers) < 3: + _log(f"[TRI] 三角形不足3个: {len(tri_markers)}") + return out + + # 将缩图坐标还原为原始分辨率(K 矩阵在原始分辨率下标定) + if inv_scale != 1.0: + for m in tri_markers: + m["center"] = [m["center"][0] * inv_scale, m["center"][1] * inv_scale] + m["corners"] = [[c[0] * inv_scale, c[1] * inv_scale] for c in m["corners"]] + + lx = float(np.clip(laser_xy[0], 0, w_orig - 1)) + ly = float(np.clip(laser_xy[1], 0, h_orig - 1)) + + if len(tri_markers) == 4: + tri_sorted = sorted(tri_markers, key=lambda m: m["id"]) + marker_ids = [m["id"] for m in tri_sorted] + marker_centers = [[float(m["center"][0]), float(m["center"][1])] for m in tri_sorted] + offset_tag = "triangle_homography" + else: + marker_ids_list = [m["id"] for m in tri_markers] + marker_centers_orig = [[float(m["center"][0]), float(m["center"][1])] for m in tri_markers] + comp = complete_fourth_point(marker_ids_list, marker_centers_orig, marker_positions) + if comp is None: + _log("[TRI] 3点补全第4点失败") + return out + marker_centers, marker_ids = comp + marker_centers = [[float(c[0]), float(c[1])] for c in marker_centers] + offset_tag = "triangle_homography_3pt" + + ok_h, tx, ty, _H = homography_calibration( + marker_centers, marker_ids, marker_positions, [lx, ly] + ) + if not ok_h: + _log("[TRI] 单应性失败") + return out + + # 与 laser_manager.compute_laser_position 现网约定一致:(x_cm, -y_cm_target) + out["dx_cm"] = tx + out["dy_cm"] = -ty + out["ok"] = True + out["offset_method"] = offset_tag + out["markers"] = tri_markers # 供上层绘制标注用 + out["homography"] = _H # 供上层反推靶心像素位置用 + + dist_m = pnp_distance_meters(marker_ids, marker_centers, marker_positions, camera_matrix, dist_coeffs) + if dist_m is not None and 0.3 < dist_m < 50.0: + out["distance_m"] = dist_m + out["distance_method"] = "pnp_triangle" + _log(f"[TRI] PnP 距离={dist_m:.2f}m, 偏移=({out['dx_cm']:.2f},{out['dy_cm']:.2f})cm") + else: + out["distance_m"] = None + out["distance_method"] = None + _log(f"[TRI] PnP 距离无效,回退黄心估距; 偏移=({out['dx_cm']:.2f},{out['dy_cm']:.2f})cm") + + return out diff --git a/vision.py b/vision.py new file mode 100644 index 0000000..f97a34b --- /dev/null +++ b/vision.py @@ -0,0 +1,944 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +视觉检测模块 +提供靶心检测、距离估算、图像保存等功能 +""" +import cv2 +import numpy as np +import os +import math +import threading +import queue +from maix import image +import config +from logger_manager import logger_manager + +# 导入ArUco检测器(如果启用) +if config.USE_ARUCO: + from aruco_detector import detect_target_with_aruco, aruco_detector + +# 存图队列 + worker +_save_queue = queue.Queue(maxsize=16) +_save_worker_started = False +_save_worker_lock = threading.Lock() + +def check_laser_point_sharpness(frame, laser_point=None, roi_size=30, threshold=100.0, ellipse_params=None): + """ + 检测激光点本身的清晰度(不是整个靶子) + + Args: + frame: 图像帧对象 + laser_point: 激光点坐标 (x, y),如果为None则自动查找 + roi_size: ROI区域大小(像素),默认30x30 + threshold: 清晰度阈值 + ellipse_params: 椭圆参数 ((center_x, center_y), (width, height), angle),用于限制激光点必须在椭圆内 + + Returns: + (is_sharp, sharpness_score, laser_pos): (是否清晰, 清晰度分数, 激光点坐标) + """ + try: + # 1. 如果没有提供激光点,先查找 + if laser_point is None: + from laser_manager import laser_manager + laser_point = laser_manager.find_red_laser(frame, ellipse_params=ellipse_params) + if laser_point is None: + logger_manager.logger.debug(f"未找到激光点") + return False, 0.0, None + + x, y = laser_point + + # 2. 转换为 OpenCV 格式 + img_cv = image.image2cv(frame, False, False) + h, w = img_cv.shape[:2] + + # 3. 提取 ROI 区域(激光点周围) + roi_half = roi_size // 2 + x_min = max(0, int(x) - roi_half) + x_max = min(w, int(x) + roi_half) + y_min = max(0, int(y) - roi_half) + y_max = min(h, int(y) + roi_half) + + roi = img_cv[y_min:y_max, x_min:x_max] + + if roi.size == 0: + return False, 0.0, laser_point + + # 4. 转换为灰度图(用于清晰度检测) + gray_roi = cv2.cvtColor(roi, cv2.COLOR_RGB2GRAY) + + # 5. 方法1:检测点的扩散程度(能量集中度) + # 计算中心区域的能量集中度 + center_x, center_y = roi.shape[1] // 2, roi.shape[0] // 2 + center_radius = min(5, roi.shape[0] // 4) # 中心区域半径 + + # 创建中心区域的掩码 + y_coords, x_coords = np.ogrid[:roi.shape[0], :roi.shape[1]] + center_mask = (x_coords - center_x)**2 + (y_coords - center_y)**2 <= center_radius**2 + + # 计算中心区域和周围区域的亮度 + center_brightness = gray_roi[center_mask].mean() + outer_mask = ~center_mask + outer_brightness = gray_roi[outer_mask].mean() if np.any(outer_mask) else 0 + + # 对比度(清晰的点对比度高) + contrast = abs(center_brightness - outer_brightness) + + # 6. 方法2:检测点的边缘锐度(使用拉普拉斯) + laplacian = cv2.Laplacian(gray_roi, cv2.CV_64F) + edge_sharpness = abs(laplacian).var() + + # 7. 方法3:检测点的能量集中度(方差) + # 清晰的点:能量集中在中心,方差小 + # 模糊的点:能量分散,方差大 + # 但我们需要的是:清晰的点中心亮度高,周围低,所以梯度大 + sobel_x = cv2.Sobel(gray_roi, cv2.CV_64F, 1, 0, ksize=3) + sobel_y = cv2.Sobel(gray_roi, cv2.CV_64F, 0, 1, ksize=3) + gradient = np.sqrt(sobel_x**2 + sobel_y**2) + gradient_sharpness = gradient.var() + + # 8. 组合多个指标 + # 对比度权重0.3,边缘锐度权重0.4,梯度权重0.3 + sharpness_score = (contrast * 0.3 + edge_sharpness * 0.4 + gradient_sharpness * 0.3) + + is_sharp = sharpness_score >= threshold + + logger = logger_manager.logger + if logger: + logger.debug(f"[VISION] 激光点清晰度: 位置=({x}, {y}), 对比度={contrast:.2f}, 边缘={edge_sharpness:.2f}, 梯度={gradient_sharpness:.2f}, 综合={sharpness_score:.2f}, 是否清晰={is_sharp}") + + return is_sharp, sharpness_score, laser_point + + except Exception as e: + logger = logger_manager.logger + if logger: + logger.error(f"[VISION] 激光点清晰度检测失败: {e}") + import traceback + logger.error(traceback.format_exc()) + return False, 0.0, laser_point + +def check_image_sharpness(frame, threshold=100.0, save_debug_images=False): + """ + 检查图像清晰度(针对圆形靶子优化,基于圆形边缘检测) + 检测靶心的圆形边缘,计算边缘区域的梯度清晰度 + + Args: + frame: 图像帧对象 + threshold: 清晰度阈值,低于此值认为图像模糊(默认100.0) + 可以根据实际情况调整: + - 清晰图像通常 > 200 + - 模糊图像通常 < 100 + - 中等清晰度 100-200 + save_debug_images: 是否保存调试图像(原始图和边缘图),默认False + + Returns: + (is_sharp, sharpness_score): (是否清晰, 清晰度分数) + """ + try: + logger_manager.logger.debug(f"begin") + # 转换为 OpenCV 格式 + img_cv = image.image2cv(frame, False, False) + logger_manager.logger.debug(f"after image2cv") + + # 转换为 HSV 颜色空间 + hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV) + h, s, v = cv2.split(hsv) + logger_manager.logger.debug(f"after HSV conversion") + + # 检测黄色区域(靶心) + # 调整饱和度策略:稍微增强,不要过度 + s_enhanced = np.clip(s * 1.1, 0, 255).astype(np.uint8) + hsv_enhanced = cv2.merge((h, s_enhanced, v)) + + # HSV 阈值范围(与 detect_circle_v3 保持一致) + lower_yellow = np.array([7, 80, 0]) + upper_yellow = np.array([32, 255, 255]) + mask_yellow = cv2.inRange(hsv_enhanced, lower_yellow, upper_yellow) + + # 形态学操作,填充小孔洞 + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) + mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel) + logger_manager.logger.debug(f"after yellow mask detection") + + # 计算边缘区域:扩展黄色区域,然后减去原始区域,得到边缘区域 + mask_dilated = cv2.dilate(mask_yellow, kernel, iterations=2) + mask_edge = cv2.subtract(mask_dilated, mask_yellow) # 边缘区域 + + # 计算边缘区域的像素数量 + edge_pixel_count = np.sum(mask_edge > 0) + logger_manager.logger.debug(f"edge pixel count: {edge_pixel_count}") + + # 如果检测不到边缘区域,使用全局梯度作为后备方案 + if edge_pixel_count < 100: + logger_manager.logger.debug(f"edge region too small, using global gradient") + # 使用 V 通道计算全局梯度 + sobel_v_x = cv2.Sobel(v, cv2.CV_64F, 1, 0, ksize=3) + sobel_v_y = cv2.Sobel(v, cv2.CV_64F, 0, 1, ksize=3) + gradient = np.sqrt(sobel_v_x**2 + sobel_v_y**2) + sharpness_score = gradient.var() + logger_manager.logger.debug(f"global gradient variance: {sharpness_score:.2f}") + else: + # 在边缘区域计算梯度清晰度 + # 使用 V(亮度)通道计算梯度,因为边缘在亮度上通常很明显 + sobel_v_x = cv2.Sobel(v, cv2.CV_64F, 1, 0, ksize=3) + sobel_v_y = cv2.Sobel(v, cv2.CV_64F, 0, 1, ksize=3) + gradient = np.sqrt(sobel_v_x**2 + sobel_v_y**2) + + # 只在边缘区域计算清晰度 + edge_gradient = gradient[mask_edge > 0] + + if len(edge_gradient) > 0: + # 计算边缘梯度的方差(清晰图像的边缘梯度变化大) + sharpness_score = edge_gradient.var() + # 也可以使用均值作为补充指标(清晰图像的边缘梯度均值也较大) + gradient_mean = edge_gradient.mean() + logger_manager.logger.debug(f"edge gradient: mean={gradient_mean:.2f}, var={sharpness_score:.2f}, pixels={len(edge_gradient)}") + else: + # 如果边缘区域没有有效梯度,使用全局梯度 + sharpness_score = gradient.var() + logger_manager.logger.debug(f"no edge gradient, using global: {sharpness_score:.2f}") + + # 保存调试图像(如果启用) + if save_debug_images: + try: + debug_dir = config.PHOTO_DIR + if debug_dir not in os.listdir("/root"): + try: + os.mkdir(debug_dir) + except: + pass + + # 生成文件名 + try: + all_images = [f for f in os.listdir(debug_dir) if f.endswith(('.bmp', '.jpg', '.jpeg'))] + img_count = len(all_images) + except: + img_count = 0 + + # 保存原始图像 + img_orig = image.cv2image(img_cv, False, False) + orig_filename = f"{debug_dir}/sharpness_debug_orig_{img_count:04d}.bmp" + img_orig.save(orig_filename) + + # # 保存边缘检测结果(可视化) + # # 创建可视化图像:原始图像 + 黄色区域 + 边缘区域 + # debug_img = img_cv.copy() + # # 在黄色区域绘制绿色 + # debug_img[mask_yellow > 0] = [0, 255, 0] # RGB格式,绿色 + # # 在边缘区域绘制红色 + # debug_img[mask_edge > 0] = [255, 0, 0] # RGB格式,红色 + + # debug_img_maix = image.cv2image(debug_img, False, False) + # debug_filename = f"{debug_dir}/sharpness_debug_edge_{img_count:04d}.bmp" + # debug_img_maix.save(debug_filename) + + # logger = logger_manager.logger + # if logger: + # logger.info(f"[VISION] 保存调试图像: {orig_filename}, {debug_filename}") + except Exception as e: + logger = logger_manager.logger + if logger: + logger.warning(f"[VISION] 保存调试图像失败: {e}") + import traceback + logger.error(traceback.format_exc()) + + is_sharp = sharpness_score >= threshold + + logger = logger_manager.logger + if logger: + logger.debug(f"[VISION] 清晰度检测: 分数={sharpness_score:.2f}, 边缘像素数={edge_pixel_count}, 是否清晰={is_sharp}, 阈值={threshold}") + + return is_sharp, sharpness_score + except Exception as e: + logger = logger_manager.logger + if logger: + logger.error(f"[VISION] 清晰度检测失败: {e}") + import traceback + logger.error(traceback.format_exc()) + # 出错时返回 False,避免使用模糊图像 + return False, 0.0 + +def save_calibration_image(frame, laser_pos, photo_dir=None): + """ + 保存激光校准图像(带标注) + 在找到的激光点位置绘制圆圈,便于检查算法是否正确 + + Args: + frame: 原始图像帧 + laser_pos: 找到的激光点坐标 (x, y) + photo_dir: 照片存储目录,如果为None则使用 config.PHOTO_DIR + + Returns: + str: 保存的文件路径,如果保存失败则返回 None + """ + # 检查是否启用图像保存 + if not config.SAVE_IMAGE_ENABLED: + return None + + if photo_dir is None: + photo_dir = config.PHOTO_DIR + + try: + # 确保照片目录存在 + try: + if photo_dir not in os.listdir("/root"): + os.mkdir(photo_dir) + except: + pass + + # 生成文件名 + try: + all_images = [f for f in os.listdir(photo_dir) if f.endswith(('.bmp', '.jpg', '.jpeg'))] + img_count = len(all_images) + except: + img_count = 0 + + x, y = laser_pos + filename = f"{photo_dir}/calibration_{int(x)}_{int(y)}_{img_count:04d}.bmp" + + logger = logger_manager.logger + if logger: + logger.info(f"保存校准图像: {filename}, 激光点: ({x}, {y})") + + # 转换图像为 OpenCV 格式以便绘制 + img_cv = image.image2cv(frame, False, False) + + # 绘制激光点圆圈(用绿色圆圈标出找到的激光点) + cv2.circle(img_cv, (int(x), int(y)), 10, (0, 255, 0), 2) # 外圈:绿色,半径10 + cv2.circle(img_cv, (int(x), int(y)), 5, (0, 255, 0), 2) # 中圈:绿色,半径5 + cv2.circle(img_cv, (int(x), int(y)), 2, (0, 255, 0), -1) # 中心点:绿色实心 + + # 可选:绘制十字线帮助定位 + cv2.line(img_cv, + (int(x - 20), int(y)), + (int(x + 20), int(y)), + (0, 255, 0), 1) # 水平线 + cv2.line(img_cv, + (int(x), int(y - 20)), + (int(x), int(y + 20)), + (0, 255, 0), 1) # 垂直线 + + # 转换回 MaixPy 图像格式并保存 + result_img = image.cv2image(img_cv, False, False) + result_img.save(filename) + + if logger: + logger.debug(f"校准图像已保存: {filename}") + + return filename + except Exception as e: + logger = logger_manager.logger + if logger: + logger.error(f"保存校准图像失败: {e}") + import traceback + logger.error(traceback.format_exc()) + return None + +# def detect_circle_v3(frame, laser_point=None): +# """检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本 +# 增加红色圆圈检测,验证黄色圆圈是否为真正的靶心 +# 如果提供 laser_point,会选择最接近激光点的目标 + +# Args: +# frame: 图像帧 +# laser_point: 激光点坐标 (x, y),用于多目标场景下的目标选择 + +# Returns: +# (result_img, best_center, best_radius, method, best_radius1, ellipse_params) +# """ +# img_cv = image.image2cv(frame, False, False) + +# best_center = best_radius = best_radius1 = method = None +# ellipse_params = None + +# # HSV 黄色掩码检测(模糊靶心) +# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV) +# h, s, v = cv2.split(hsv) + +# # 调整饱和度策略:稍微增强,不要过度 +# s = np.clip(s * 1.1, 0, 255).astype(np.uint8) + +# hsv = cv2.merge((h, s, v)) + +# # 放宽 HSV 阈值范围(针对模糊图像的关键调整) +# lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色 +# upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满 + +# mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow) + +# # 调整形态学操作 +# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) +# mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel) + +# contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + +# # 存储所有有效的黄色-红色组合 +# valid_targets = [] + +# if contours_yellow: +# for cnt_yellow in contours_yellow: +# area = cv2.contourArea(cnt_yellow) +# perimeter = cv2.arcLength(cnt_yellow, True) + +# # 计算圆度 +# if perimeter > 0: +# circularity = (4 * np.pi * area) / (perimeter * perimeter) +# else: +# circularity = 0 + +# logger = logger_manager.logger +# if area > 50 and circularity > 0.7: +# if logger: +# logger.info(f"[target] -> 面积:{area}, 圆度:{circularity:.2f}") +# # 尝试拟合椭圆 +# yellow_center = None +# yellow_radius = None +# yellow_ellipse = None + +# if len(cnt_yellow) >= 5: +# (x, y), (width, height), angle = cv2.fitEllipse(cnt_yellow) +# yellow_ellipse = ((x, y), (width, height), angle) +# axes_minor = min(width, height) +# radius = axes_minor / 2 +# yellow_center = (int(x), int(y)) +# yellow_radius = int(radius) +# else: +# (x, y), radius = cv2.minEnclosingCircle(cnt_yellow) +# yellow_center = (int(x), int(y)) +# yellow_radius = int(radius) +# yellow_ellipse = None + +# # 如果检测到黄色圆圈,再检测红色圆圈进行验证 +# if yellow_center and yellow_radius: +# # HSV 红色掩码检测(红色在HSV中跨越0度,需要两个范围) +# # 红色范围1: 0-10度(接近0度的红色) +# lower_red1 = np.array([0, 80, 0]) +# upper_red1 = np.array([10, 255, 255]) +# mask_red1 = cv2.inRange(hsv, lower_red1, upper_red1) + +# # 红色范围2: 170-180度(接近180度的红色) +# lower_red2 = np.array([170, 80, 0]) +# upper_red2 = np.array([180, 255, 255]) +# mask_red2 = cv2.inRange(hsv, lower_red2, upper_red2) + +# # 合并两个红色掩码 +# mask_red = cv2.bitwise_or(mask_red1, mask_red2) + +# # 形态学操作 +# kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) +# mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red) + +# contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + +# found_valid_red = False + +# if contours_red: +# # 找到所有符合条件的红色圆圈 +# for cnt_red in contours_red: +# area_red = cv2.contourArea(cnt_red) +# perimeter_red = cv2.arcLength(cnt_red, True) + +# if perimeter_red > 0: +# circularity_red = (4 * np.pi * area_red) / (perimeter_red * perimeter_red) +# else: +# circularity_red = 0 + +# # 红色圆圈也应该有一定的圆度 +# if area_red > 50 and circularity_red > 0.6: +# # 计算红色圆圈的中心和半径 +# if len(cnt_red) >= 5: +# (x_red, y_red), (w_red, h_red), angle_red = cv2.fitEllipse(cnt_red) +# radius_red = min(w_red, h_red) / 2 +# red_center = (int(x_red), int(y_red)) +# red_radius = int(radius_red) +# else: +# (x_red, y_red), radius_red = cv2.minEnclosingCircle(cnt_red) +# red_center = (int(x_red), int(y_red)) +# red_radius = int(radius_red) + +# # 计算黄色和红色圆心的距离 +# if red_center: +# dx = yellow_center[0] - red_center[0] +# dy = yellow_center[1] - red_center[1] +# distance = np.sqrt(dx*dx + dy*dy) + +# # 圆心距离阈值:应该小于黄色半径的某个倍数(比如1.5倍) +# max_distance = yellow_radius * 1.5 + +# # 红色圆圈应该比黄色圆圈大(外圈) +# if distance < max_distance and red_radius > yellow_radius * 0.8: +# found_valid_red = True +# logger = logger_manager.logger +# if logger: +# logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}") + +# # 记录这个有效目标 +# valid_targets.append({ +# 'center': yellow_center, +# 'radius': yellow_radius, +# 'ellipse': yellow_ellipse, +# 'area': area +# }) +# break + +# if not found_valid_red: +# logger = logger_manager.logger +# if logger: +# logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别") + +# # 从所有有效目标中选择最佳目标 +# if valid_targets: +# if laser_point: +# # 如果有激光点,选择最接近激光点的目标 +# best_target = None +# min_distance = float('inf') +# for target in valid_targets: +# dx = target['center'][0] - laser_point[0] +# dy = target['center'][1] - laser_point[1] +# distance = np.sqrt(dx*dx + dy*dy) +# if distance < min_distance: +# min_distance = distance +# best_target = target +# if best_target: +# best_center = best_target['center'] +# best_radius = best_target['radius'] +# ellipse_params = best_target['ellipse'] +# method = "v3_ellipse_red_validated_laser_selected" +# best_radius1 = best_radius * 5 +# else: +# # 如果没有激光点,选择面积最大的目标 +# best_target = max(valid_targets, key=lambda t: t['area']) +# best_center = best_target['center'] +# best_radius = best_target['radius'] +# ellipse_params = best_target['ellipse'] +# method = "v3_ellipse_red_validated" +# best_radius1 = best_radius * 5 + +# result_img = image.cv2image(img_cv, False, False) +# return result_img, best_center, best_radius, method, best_radius1, ellipse_params + +def detect_circle_v3(frame, laser_point=None, img_cv=None): + """检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本 + 增加红色圆圈检测,验证黄色圆圈是否为真正的靶心 + 如果提供 laser_point,会选择最接近激光点的目标 + 优化: + 1. 缩图到 MAX_DET_DIM 后再做 HSV/形态学,最长边 640->320 可获得 ~4x 加速 + 2. 红色掩码在黄色轮廓循环外只计算一次,避免 N 次重复计算 + 3. img_cv 可由外部传入(与其他线程共享转换结果),为 None 时自动转换 + Args: + frame: 图像帧(img_cv 为 None 时使用) + laser_point: 激光点坐标 (x, y),用于多目标场景下的目标选择 + img_cv: 已转换的 numpy BGR/RGB 图像;不为 None 时跳过 image2cv 转换 + Returns: + (result_img, best_center, best_radius, method, best_radius1, ellipse_params) + """ + if img_cv is None: + img_cv = image.image2cv(frame, False, False) + logger = logger_manager.logger + from datetime import datetime + logger.debug(f"[detect_circle_v3] begin {datetime.now()}") + # -- 1. 缩图加速(与三角形路径保持一致) + h_orig, w_orig = img_cv.shape[:2] + MAX_DET_DIM = 320 + long_side = max(h_orig, w_orig) + if long_side > MAX_DET_DIM: + det_scale = MAX_DET_DIM / long_side + img_det = cv2.resize(img_cv, (int(w_orig * det_scale), int(h_orig * det_scale)), + interpolation=cv2.INTER_LINEAR) + inv_scale = 1.0 / det_scale # 检测坐标 -> 原始坐标的倍率 + else: + img_det = img_cv + inv_scale = 1.0 + + # 激光点映射到检测分辨率 + lp_det = None + if laser_point is not None: + lp_det = (laser_point[0] / inv_scale, laser_point[1] / inv_scale) + best_center = best_radius = best_radius1 = method = None + ellipse_params = None + + logger.debug(f"[detect_circle_v3] step 1 fin {datetime.now()}") + + # -- 2. HSV + 黄色掩码 + hsv = cv2.cvtColor(img_det, cv2.COLOR_RGB2HSV) + h, s, v = cv2.split(hsv) + s = np.clip(s * 1.1, 0, 255).astype(np.uint8) + hsv = cv2.merge((h, s, v)) + lower_yellow = np.array([7, 80, 0]) + upper_yellow = np.array([32, 255, 255]) + mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow) + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) + mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel) + + logger.debug(f"[detect_circle_v3] step 2 fin {datetime.now()}") + + # -- 3. 红色掩码:在循环外只算一次 + mask_red = cv2.bitwise_or( + cv2.inRange(hsv, np.array([0, 80, 0]), np.array([10, 255, 255])), + cv2.inRange(hsv, np.array([170, 80, 0]), np.array([180, 255, 255])), + ) + kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) + mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red) + contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + # 预先把红色轮廓筛选成 (center, radius) 列表,后续直接查表 + red_candidates = [] + for cnt_r in contours_red: + ar = cv2.contourArea(cnt_r) + if ar <= 50: + continue + pr = cv2.arcLength(cnt_r, True) + if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.6: + continue + if len(cnt_r) >= 5: + (xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r) + red_candidates.append({"center": (int(xr), int(yr)), "radius": int(min(wr, hr) / 2)}) + else: + (xr, yr), rr = cv2.minEnclosingCircle(cnt_r) + red_candidates.append({"center": (int(xr), int(yr)), "radius": int(rr)}) + + logger.debug(f"[detect_circle_v3] step 3 fin {datetime.now()}") + + # -- 4. 黄色轮廓循环(复用上面的红色候选列表) + contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + valid_targets = [] + for cnt_yellow in contours_yellow: + area = cv2.contourArea(cnt_yellow) + if area <= 50: + continue + perimeter = cv2.arcLength(cnt_yellow, True) + if perimeter <= 0: + continue + circularity = (4 * np.pi * area) / (perimeter * perimeter) + if circularity <= 0.7: + continue + if logger: + logger.info(f"[target] -> 面积:{area:.1f}, 圆度:{circularity:.2f}") + if len(cnt_yellow) >= 5: + (x, y), (width, height), angle = cv2.fitEllipse(cnt_yellow) + yellow_ellipse = ((x, y), (width, height), angle) + yellow_center = (int(x), int(y)) + yellow_radius = int(min(width, height) / 2) + else: + (x, y), radius = cv2.minEnclosingCircle(cnt_yellow) + yellow_center = (int(x), int(y)) + yellow_radius = int(radius) + yellow_ellipse = None + # 在预筛好的红色候选中匹配 + matched = False + for rc in red_candidates: + ddx = yellow_center[0] - rc["center"][0] + ddy = yellow_center[1] - rc["center"][1] + dist_centers = math.hypot(ddx, ddy) + if dist_centers < yellow_radius * 1.5 and rc["radius"] > yellow_radius * 0.8: + if logger: + logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), " + f"红心({rc['center']}), 距离:{dist_centers:.1f}, " + f"黄半径:{yellow_radius}, 红半径:{rc['radius']}") + valid_targets.append({ + "center": yellow_center, + "radius": yellow_radius, + "ellipse": yellow_ellipse, + "area": area, + }) + matched = True + break + if not matched and logger: + logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别") + + logger.debug(f"[detect_circle_v3] step 4 fin {datetime.now()}") + + # -- 5. 选最佳目标,坐标还原到原始分辨率 + if valid_targets: + if lp_det: + best_target = min(valid_targets, + key=lambda t: (t["center"][0] - lp_det[0]) ** 2 + + (t["center"][1] - lp_det[1]) ** 2) + method = "v3_ellipse_red_validated_laser_selected" + else: + best_target = max(valid_targets, key=lambda t: t["area"]) + method = "v3_ellipse_red_validated" + bc = best_target["center"] + br = best_target["radius"] + be = best_target["ellipse"] + if inv_scale != 1.0: + best_center = (int(bc[0] * inv_scale), int(bc[1] * inv_scale)) + best_radius = int(br * inv_scale) + if be is not None: + (ex, ey), (ew, eh), ea = be + be = ((ex * inv_scale, ey * inv_scale), + (ew * inv_scale, eh * inv_scale), ea) + else: + best_center = bc + best_radius = br + ellipse_params = be + best_radius1 = best_radius * 5 + result_img = image.cv2image(img_cv, False, False) + logger.debug(f"[detect_circle_v3] step 5 fin {datetime.now()}") + return result_img, best_center, best_radius, method, best_radius1, ellipse_params + +def estimate_distance(pixel_radius): + """根据像素半径估算实际距离(单位:米)""" + if not pixel_radius: + return 0.0 + return (config.REAL_RADIUS_CM * config.FOCAL_LENGTH_PIX) / pixel_radius / 100.0 + +def estimate_pixel(physical_distance_cm, target_distance_m): + """ + 根据物理距离和目标距离计算对应的像素偏移 + + Args: + physical_distance_cm: 物理世界中的距离(厘米),例如激光与摄像头的距离 + target_distance_m: 目标距离(米),例如到靶心的距离 + + Returns: + float: 对应的像素偏移 + """ + if not target_distance_m or target_distance_m <= 0: + return 0.0 + # 公式:像素偏移 = (物理距离_米) * 焦距_像素 / 目标距离_米 + return (physical_distance_cm / 100.0) * config.FOCAL_LENGTH_PIX / target_distance_m + + +def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params, + laser_point, distance_m, shot_id=None, photo_dir=None): + """ + 内部实现:在 img_cv (numpy HWC RGB) 上绘制标注并保存。 + 由 save_shot_image(同步)和存图 worker(异步)调用。 + """ + if not config.SAVE_IMAGE_ENABLED: + return None + if photo_dir is None: + photo_dir = config.PHOTO_DIR + try: + try: + if photo_dir not in os.listdir("/root"): + os.mkdir(photo_dir) + except Exception: + pass + + x, y = laser_point + if shot_id: + # 之前是用 center/radius 判定 no_target;但三角形路径会返回 center=None(正常) + # 这里改为:只要 method 有值,就按 method 命名;否则才回退 no_target + method_str = (method or "").strip() + if method_str: + filename = f"{photo_dir}/shot_{shot_id}_{method_str}.bmp" + else: + filename = f"{photo_dir}/shot_{shot_id}_no_target.bmp" + else: + try: + all_images = [f for f in os.listdir(photo_dir) if f.endswith(('.bmp', '.jpg', '.jpeg'))] + img_count = len(all_images) + except Exception: + img_count = 0 + if center is None or radius is None: + method_str = "no_target" + distance_str = "000" + else: + method_str = method or "unknown" + distance_str = str(round((distance_m or 0.0) * 100)) + filename = f"{photo_dir}/{method_str}_{int(x)}_{int(y)}_{distance_str}_{img_count:04d}.bmp" + + logger = logger_manager.logger + if logger: + if shot_id: + logger.info(f"[VISION] 保存射箭图像,ID: {shot_id}, 文件名: {filename}") + if center and radius: + logger.info(f"结果 -> 圆心: {center}, 半径: {radius}, 方法: {method}") + if ellipse_params: + (ec, (ew, eh), ea) = ellipse_params + logger.info(f"椭圆 -> 中心: ({ec[0]:.1f}, {ec[1]:.1f}), 长轴: {max(ew, eh):.1f}, 短轴: {min(ew, eh):.1f}, 角度: {ea:.1f}°") + else: + logger.info(f"结果 -> 未检测到靶心,保存原始图像(激光点: ({x}, {y}))") + + # laser_color = (config.LASER_COLOR[0], config.LASER_COLOR[1], config.LASER_COLOR[2]) + # cross_thickness = int(max(getattr(config, "LASER_THICKNESS", 1), 1)) + # cross_length = int(max(getattr(config, "LASER_LENGTH", 10), 10)) + # cv2.line(img_cv, (int(x - cross_length), int(y)), (int(x + cross_length), int(y)), laser_color, cross_thickness) + # cv2.line(img_cv, (int(x), int(y - cross_length)), (int(x), int(y + cross_length)), laser_color, cross_thickness) + # cv2.circle(img_cv, (int(x), int(y)), 1, laser_color, cross_thickness) + # ring_thickness = 1 + # cv2.circle(img_cv, (int(x), int(y)), 10, laser_color, ring_thickness) + # cv2.circle(img_cv, (int(x), int(y)), 5, laser_color, ring_thickness) + # cv2.circle(img_cv, (int(x), int(y)), 2, laser_color, -1) + + if center and radius: + cx, cy = center + if ellipse_params: + (ell_center, (width, height), angle) = ellipse_params + cx_ell, cy_ell = int(ell_center[0]), int(ell_center[1]) + cv2.ellipse(img_cv, (cx_ell, cy_ell), (int(width / 2), int(height / 2)), angle, 0, 360, (0, 255, 0), 2) + cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1) + minor_length = min(width, height) / 2 + minor_angle = angle + 90 if width >= height else angle + minor_angle_rad = math.radians(minor_angle) + dx_minor = minor_length * math.cos(minor_angle_rad) + dy_minor = minor_length * math.sin(minor_angle_rad) + pt1 = (int(cx_ell - dx_minor), int(cy_ell - dy_minor)) + pt2 = (int(cx_ell + dx_minor), int(cy_ell + dy_minor)) + cv2.line(img_cv, pt1, pt2, (0, 0, 255), 2) + else: + cv2.circle(img_cv, (cx, cy), radius, (0, 0, 255), 2) + cv2.circle(img_cv, (cx, cy), 2, (0, 0, 255), -1) + cv2.line(img_cv, (int(x), int(y)), (cx, cy), (255, 255, 0), 1) + + out = image.cv2image(img_cv, False, False) + out.save(filename) + if logger: + if center and radius: + logger.debug(f"图像已保存(含靶心标注): {filename}") + else: + logger.debug(f"图像已保存(无靶心,含激光十字线): {filename}") + + # 清理旧图片:如果目录下图片超过100张,删除最老的 + try: + image_files = [] + for f in os.listdir(photo_dir): + if f.endswith(('.bmp', '.jpg', '.jpeg')): + filepath = os.path.join(photo_dir, f) + try: + mtime = os.path.getmtime(filepath) + image_files.append((mtime, filepath, f)) + except Exception: + pass + + from config import MAX_IMAGES + if len(image_files) > MAX_IMAGES: + image_files.sort(key=lambda x: x[0]) + to_delete = len(image_files) - MAX_IMAGES + deleted_count = 0 + for _, filepath, fname in image_files[:to_delete]: + try: + os.remove(filepath) + deleted_count += 1 + if logger: + logger.debug(f"[VISION] 删除旧图片: {fname}") + except Exception as e: + if logger: + logger.warning(f"[VISION] 删除旧图片失败 {fname}: {e}") + if logger and deleted_count > 0: + logger.info(f"[VISION] 已清理 {deleted_count} 张旧图片,当前剩余 {MAX_IMAGES} 张") + except Exception as e: + if logger: + logger.warning(f"[VISION] 清理旧图片时出错(可忽略): {e}") + + return filename + except Exception as e: + logger = logger_manager.logger + if logger: + logger.error(f"保存图像失败: {e}") + import traceback + logger.error(traceback.format_exc()) + return None + + +def _save_worker_loop(): + """存图 worker:从队列取任务并调用 _save_shot_image_impl。""" + while True: + try: + item = _save_queue.get() + if item is None: + break + _save_shot_image_impl(*item) + except Exception as e: + logger = logger_manager.logger + if logger: + logger.error(f"[VISION] 存图 worker 异常: {e}") + import traceback + logger.error(traceback.format_exc()) + finally: + try: + _save_queue.task_done() + except Exception: + pass + + +def start_save_shot_worker(): + """启动存图 worker 线程(应在程序初始化时调用一次)。""" + global _save_worker_started + with _save_worker_lock: + if _save_worker_started: + return + _save_worker_started = True + t = threading.Thread(target=_save_worker_loop, daemon=True) + t.start() + logger = logger_manager.logger + if logger: + logger.info("[VISION] 存图 worker 线程已启动") + + +def enqueue_save_shot(result_img, center, radius, method, ellipse_params, + laser_point, distance_m, shot_id=None, photo_dir=None): + """ + 将存图任务放入队列,由 worker 异步保存。主线程传入 result_img 的复制,不阻塞。 + """ + if not config.SAVE_IMAGE_ENABLED: + return + if photo_dir is None: + photo_dir = config.PHOTO_DIR + try: + img_cv = image.image2cv(result_img, False, False) + img_copy = np.copy(img_cv) + except Exception as e: + logger = logger_manager.logger + if logger: + logger.error(f"[VISION] enqueue_save_shot 复制图像失败: {e}") + return + task = (img_copy, center, radius, method, ellipse_params, laser_point, distance_m, shot_id, photo_dir) + try: + _save_queue.put_nowait(task) + except queue.Full: + logger = logger_manager.logger + if logger: + logger.warning("[VISION] 存图队列已满,跳过本次保存") + + +def save_shot_image(result_img, center, radius, method, ellipse_params, + laser_point, distance_m, shot_id=None, photo_dir=None): + """ + 保存射击图像(带标注)。同步调用,会阻塞。 + 主流程建议使用 enqueue_save_shot;此处保留供校准、测试等场景使用。 + """ + if not config.SAVE_IMAGE_ENABLED: + return None + if photo_dir is None: + photo_dir = config.PHOTO_DIR + try: + img_cv = image.image2cv(result_img, False, False) + return _save_shot_image_impl(img_cv, center, radius, method, ellipse_params, + laser_point, distance_m, shot_id, photo_dir) + except Exception as e: + logger = logger_manager.logger + if logger: + logger.error(f"[VISION] save_shot_image 转换图像失败: {e}") + return None + + +def detect_target(frame, laser_point=None): + """ + 统一的靶心检测接口,根据配置自动选择检测方法 + + Args: + frame: MaixPy图像帧 + laser_point: 激光点坐标(可选) + + Returns: + (result_img, center, radius, method, best_radius1, ellipse_params) + 与detect_circle_v3保持相同的返回格式 + """ + logger = logger_manager.logger + + if config.USE_ARUCO: + # 使用ArUco检测 + if logger: + logger.debug("[VISION] 使用ArUco标记检测靶心") + + # 延迟导入以避免循环依赖 + from aruco_detector import detect_target_with_aruco + return detect_target_with_aruco(frame, laser_point) + else: + # 使用传统黄色靶心检测 + if logger: + logger.debug("[VISION] 使用传统黄色靶心检测") + return detect_circle_v3(frame, laser_point) +