triangle algo refind

This commit is contained in:
gcw_4spBpAfv
2026-04-24 18:38:03 +08:00
parent 8efe1ae5c5
commit fe3e26e21d
7 changed files with 211 additions and 94 deletions

View File

@@ -11,6 +11,15 @@ APP_VERSION = VERSION
APP_DIR = "/maixapp/apps/t11" APP_DIR = "/maixapp/apps/t11"
LOCAL_FILENAME = APP_DIR + "/main_tmp.py" LOCAL_FILENAME = APP_DIR + "/main_tmp.py"
# ==================== 相机配置 ====================
# 相机初始化分辨率CameraManager / main.py 使用)
CAMERA_WIDTH = 640
CAMERA_HEIGHT = 480
# 三角形检测缩图比例:默认按相机最长边缩到 1/2性能更稳可按需调整
# 取值范围建议 (0.25 ~ 1.0]1.0 表示不缩图
TRIANGLE_DETECT_SCALE = 0.5
# ==================== 服务器配置 ==================== # ==================== 服务器配置 ====================
# SERVER_IP = "stcp.shelingxingqiu.com" # SERVER_IP = "stcp.shelingxingqiu.com"
SERVER_IP = "www.shelingxingqiu.com" SERVER_IP = "www.shelingxingqiu.com"

View File

@@ -72,7 +72,7 @@ printf 'AT+MHTTPDLFILE="http://static.shelingxingqiu.com/shoot/v1/main.py","down
遇到问题: 遇到问题:
./build_all.sh: line 56: maixtool: command not found ./build_all.sh: line 56: maixtool: command not found
解决办法: 解决办法:
export PATH="/mnt/d/code/MaixCDK/.venv/bin:$PATH" pip install maixtool
遇到问题: 遇到问题:
./update_img.sh: line 80: mcopy: command not found ./update_img.sh: line 80: mcopy: command not found

View File

@@ -221,4 +221,56 @@ Why it works:
MIPSEND enters prompt mode (>) — after the >, the AT parser treats ALL bytes as data, including CR/LF MIPSEND enters prompt mode (>) — after the >, the AT parser treats ALL bytes as data, including CR/LF
We construct the complete HTTP request ourselves (headers + Content-Length + multipart body) with real CRLF bytes We construct the complete HTTP request ourselves (headers + Content-Length + multipart body) with real CRLF bytes
Key bug found during integration: _send_chunk() wrapped calls in self.at._cmd_lock, but self.at.send() also acquires the same lock internally — threading.Lock() is not reentrant, causing deadlock. Fixed by removing the outer lock (the network_manager.get_uart_lock() already provides thread safety).Trade-off: UART is locked during the entire upload, so heartbeats pause. For small JPEG files (~2-80KB), this is 5-20 seconds — acceptable if server heartbeat timeout is generous Key bug found during integration: _send_chunk() wrapped calls in self.at._cmd_lock, but self.at.send() also acquires the same lock internally — threading.Lock() is not reentrant, causing deadlock. Fixed by removing the outer lock (the network_manager.get_uart_lock() already provides thread safety).Trade-off: UART is locked during the entire upload, so heartbeats pause. For small JPEG files (~2-80KB), this is 5-20 seconds — acceptable if server heartbeat timeout is generous
13. 算环数算法1「黄心 + 红心」椭圆/圆:主要在 vision.py 的 detect_circle_v3() 里完成:颜色先用 HSV 做掩码,再在轮廓上做面积、圆度筛选,黄圈用椭圆拟合,红圈预先筛成候选,最后用几何关系配对。
1. 黄色怎么判、范围是什么?
图像先转 HSVcv2.COLOR_RGB2HSV注意输入是 RGB
饱和度 S 整体乘 1.1 并限制在 0255让黄色更「显」一点
黄色 inRangeOpenCV HSVH 多为 0179
通道 下限 上限
H 7 32
S 80 255
V 0 255
在黄掩码上找轮廓后,还要满足:面积 > 50圆度 > 0.7circularity = 4π·面积/周长²),且点数 ≥5 才 fitEllipse 当黄心椭圆。
2. 红色怎么判、范围是什么?
红色在 HSV 里跨 0°所以用 两段 H 做并集:
两段分别是:
H 010S 80255V 0255
H 170180S 80255V 0255
红轮廓候选:面积 > 50圆度 > 0.6(比黄略松),再拟合椭圆或最小外接圆得到圆心和半径。
3. 「黄心」和「红心」怎样算一对?(几何范围)
对每个黄圈,在红色候选里找第一个满足:
两圆心距离 dist_centers < yellow_radius * 1.5
红半径 red_radius > yellow_radius * 0.8(红在外圈、略大)
dist_centers = math.hypot(ddx, ddy)
if dist_centers < yellow_radius * 1.5 and rc["radius"] > yellow_radius * 0.8:
小结黄色 = HSV H∈[7,32]、S80 S 放大 1.1+ 形态学闭运算 + 面积/圆度红色 = 两段 H010 170180)、S80 + 闭运算 + 面积/圆度配对用 同心/包含 的距离与半径比例阈值若你还关心 laser_manager.py 激光红点的另一套阈值LASER_*那是另一条链路和靶心黄/ HSV 可以分开看
14. 算环数算法2
使用单应性矩阵计算镜头中心点照片中心像素到虚拟平面的转换它不需要知道相机在 3D 空间中的具体位置直接通过单应性矩阵 H的逆运算 2D 像素翻译成虚拟平面上的 2D 坐标
转换的本质2D 2D 查字典
单应性变换Homography是平面到平面的映射它不处理 3D 空间中的投影线”,而是直接建立图像像素 (u,v) 与虚拟平面坐标 (x,y) 的一一对应关系
你可以把单应性矩阵 H想象成一本翻译字典”:
正变换 H已知靶纸上的真实位置 (x,y)查字典得到它在照片上哪个像素 (u,v)。
逆变换 H1已知照片上的像素 (u,v)如镜头中心点查字典反推它在靶纸上的真实位置 (x,y)。
这个虚拟平面就是你的靶纸平面Z=0 的世界坐标系)。算法没有在物理上移动任何点只是在做坐标系的换算
详细步骤镜头中心点如何落地
相机分辨率是 640x480镜头中心点光轴与图像的交点通常是 (u0,v0)=(320,240)。
1. 输入镜头中心点像素
2. 核心运算乘以逆矩阵
通过 4 个黑色三角形的角点已知真实坐标计算出了单应性矩阵 H现在使用它的逆矩阵 H1
3. 输出虚拟平面上的落点物理坐标
计算后你会得到(xhit,yhit)
这就是镜头中心点对应的靶纸上的真实位置单位毫米)。
4. 计算环数
由于虚拟平面原点 (0,0)就是靶纸圆心直接计算欧氏距离。​
这个 d就是箭着点偏离圆心的真实物理距离直接用于环数判定

View File

@@ -100,8 +100,7 @@ def cmd_str():
init_ina226() init_ina226()
# 4. 初始化显示和相机 # 4. 初始化显示和相机
camera_manager.init_camera(640, 480) camera_manager.init_camera(getattr(config, "CAMERA_WIDTH", 640), getattr(config, "CAMERA_HEIGHT", 480))
# camera_manager.init_camera(1280,720)
camera_manager.init_display() camera_manager.init_display()
# ==================== 第二阶段:软件初始化 ==================== # ==================== 第二阶段:软件初始化 ====================

View File

@@ -1489,56 +1489,48 @@ class NetworkManager:
qiniu_key = key.rstrip("/") + "/" + archive_filename qiniu_key = key.rstrip("/") + "/" + archive_filename
self.logger.info(f"[LOG_UPLOAD] 日志归档已生成: {archive_path}, qiniu_key: {qiniu_key}") self.logger.info(f"[LOG_UPLOAD] 日志归档已生成: {archive_path}, qiniu_key: {qiniu_key}")
# 2) 自动检测网络类型
if self._network_type == "wifi" and self.is_wifi_connected():
mode = "wifi"
else:
mode = "4g"
self.logger.info(f"[LOG_UPLOAD] Using {mode} path, archive: {archive_path}")
try: try:
# 2) WiFi 优先:只要 WiFi 已连接就先尝试 WiFi失败再回落到 4G if mode == "wifi":
wifi_tried = False # ---- WiFi path: 使用 requests 库上传 ----
wifi_ok = False import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
if self.is_wifi_connected(): with open(archive_path, 'rb') as f:
wifi_tried = True files = {'file': (archive_filename, f, 'application/octet-stream')}
self.logger.info(f"[LOG_UPLOAD] Using wifi path (preferred), archive: {archive_path}") data = {'token': upload_token, 'key': qiniu_key}
try: wifi_upload_url = upload_url.replace('https://', 'http://', 1)
# ---- WiFi path: 使用 requests 库上传 ---- self.logger.info(f"[LOG_UPLOAD] WiFi upload URL: {wifi_upload_url}")
import requests response = requests.post(wifi_upload_url, files=files, data=data, timeout=120, verify=False)
import urllib3 response.raise_for_status()
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) result_json = response.json()
uploaded_key = result_json.get('key', qiniu_key)
with open(archive_path, 'rb') as f: self.logger.info(f"[LOG_UPLOAD] WiFi upload ok: key={uploaded_key}")
files = {'file': (archive_filename, f, 'application/octet-stream')}
data = {'token': upload_token, 'key': qiniu_key}
wifi_upload_url = upload_url.replace('https://', 'http://', 1)
self.logger.info(f"[LOG_UPLOAD] WiFi upload URL: {wifi_upload_url}")
response = requests.post(wifi_upload_url, files=files, data=data, timeout=120, verify=False)
response.raise_for_status()
result_json = response.json()
uploaded_key = result_json.get('key', qiniu_key)
self.logger.info(f"[LOG_UPLOAD] WiFi upload ok: key={uploaded_key}") access_url = None
if outlink:
access_url = f"https://{outlink}/{uploaded_key}"
access_url = None response_data = {
if outlink: "result": "log_upload_ok",
access_url = f"https://{outlink}/{uploaded_key}" "key": uploaded_key,
"via": "wifi",
}
if access_url:
response_data["url"] = access_url
response_data = { self.safe_enqueue(response_data, 2)
"result": "log_upload_ok",
"key": uploaded_key,
"via": "wifi",
}
if access_url:
response_data["url"] = access_url
self.safe_enqueue(response_data, 2)
wifi_ok = True
except Exception as e:
# WiFi 上传失败不影响主链路:记录原因并回落 4G
self.logger.warning(f"[LOG_UPLOAD] WiFi upload failed, fallback to 4g: {e}")
if not wifi_ok:
if not wifi_tried:
self.logger.info(f"[LOG_UPLOAD] WiFi not connected, using 4g path, archive: {archive_path}")
else:
self.logger.info(f"[LOG_UPLOAD] Using 4g fallback path, archive: {archive_path}")
else:
# ---- 4G path: 使用 FourGUploadManager AT命令上传 ---- # ---- 4G path: 使用 FourGUploadManager AT命令上传 ----
import importlib.util import importlib.util
spec = importlib.util.spec_from_file_location( spec = importlib.util.spec_from_file_location(
@@ -1578,8 +1570,11 @@ class NetworkManager:
}, 2) }, 2)
except Exception as e: except Exception as e:
self.logger.error(f"[LOG_UPLOAD] upload exception: {e}") self.logger.error(f"[LOG_UPLOAD] upload exception ({mode}): {e}")
self.safe_enqueue({"result": "log_upload_failed", "reason": str(e)[:100]}, 2) self.safe_enqueue({
"result": "log_upload_failed",
"reason": str(e)[:100]
}, 2)
finally: finally:
# 清理临时归档文件 # 清理临时归档文件
try: try:
@@ -1600,57 +1595,51 @@ class NetworkManager:
shoot_id: 射击ID shoot_id: 射击ID
outlink: 外链域名可选用于构建访问URL outlink: 外链域名可选用于构建访问URL
""" """
# WiFi 优先(独立于 TCP 主链路):只要 WiFi 已连接就先走 WiFi失败再回落 4G # 自动检测网络类型,选择上传路径
mode = "wifi" if self.is_wifi_connected() else "4g" if self._network_type == "wifi" and self.is_wifi_connected():
mode = "wifi"
else:
mode = "4g"
self.logger.info(f"[IMAGE_UPLOAD] Using {mode} path, image: {image_path}") self.logger.info(f"[IMAGE_UPLOAD] Using {mode} path, image: {image_path}")
try: try:
wifi_ok = False
if mode == "wifi": if mode == "wifi":
try: # ---- WiFi path: 使用 requests 库上传 ----
# ---- WiFi path: 使用 requests 库上传 ---- import requests
import requests import urllib3
import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
with open(image_path, 'rb') as f: with open(image_path, 'rb') as f:
files = {'file': (os.path.basename(image_path), f, 'application/octet-stream')} files = {'file': (os.path.basename(image_path), f, 'application/octet-stream')}
data = {'token': upload_token, 'key': key} data = {'token': upload_token, 'key': key}
# 将 HTTPS 转为 HTTP(设备端 SSL 兼容性) # 测试:将HTTPS转为HTTP
wifi_upload_url = upload_url.replace('https://', 'http://', 1) wifi_upload_url = upload_url.replace('https://', 'http://', 1)
self.logger.info(f"[IMAGE_UPLOAD] WiFi upload URL: {wifi_upload_url}") self.logger.info(f"[IMAGE_UPLOAD] WiFi upload URL: {wifi_upload_url}")
response = requests.post(wifi_upload_url, files=files, data=data, timeout=120, verify=False) response = requests.post(wifi_upload_url, files=files, data=data, timeout=120, verify=False)
response.raise_for_status() response.raise_for_status()
result_json = response.json() result_json = response.json()
uploaded_key = result_json.get('key', key) uploaded_key = result_json.get('key', key)
self.logger.info(f"[IMAGE_UPLOAD] WiFi upload ok: key={uploaded_key}") self.logger.info(f"[IMAGE_UPLOAD] WiFi upload ok: key={uploaded_key}")
access_url = None access_url = None
if outlink: if outlink:
access_url = f"https://{outlink}/{uploaded_key}" access_url = f"https://{outlink}/{uploaded_key}"
response_data = { response_data = {
"result": "image_upload_ok", "result": "image_upload_ok",
"shootId": shoot_id, "shootId": shoot_id,
"key": uploaded_key, "key": uploaded_key,
"via": "wifi", "via": "wifi",
} }
if access_url: if access_url:
response_data["url"] = access_url response_data["url"] = access_url
self.safe_enqueue(response_data, 2) self.safe_enqueue(response_data, 2)
wifi_ok = True
except Exception as e:
self.logger.warning(f"[IMAGE_UPLOAD] WiFi upload failed, fallback to 4g: {e}")
if not wifi_ok: else:
# ---- 4G path: 使用 FourGUploadManager AT命令上传 ---- # ---- 4G path: 使用 FourGUploadManager AT命令上传 ----
if mode != "4g":
self.logger.info(f"[IMAGE_UPLOAD] Using 4g fallback path, image: {image_path}")
import importlib.util import importlib.util
spec = importlib.util.spec_from_file_location( spec = importlib.util.spec_from_file_location(
"four_g_upload_manager", "four_g_upload_manager",

View File

@@ -165,13 +165,28 @@ def analyze_shot(frame, laser_point=None):
tri = tri_result.get('data', {}) tri = tri_result.get('data', {})
if tri.get('ok'): # 保险校验:避免三角形返回 nan/inf 或退化点仍被上报
try:
import numpy as _np
tri_ok = bool(tri.get('ok'))
if tri_ok:
dxv = tri.get("dx_cm")
dyv = tri.get("dy_cm")
H = tri.get("homography")
if not _np.isfinite(dxv) or not _np.isfinite(dyv):
tri_ok = False
elif H is not None and not _np.all(_np.isfinite(H)):
tri_ok = False
except Exception:
tri_ok = bool(tri.get('ok'))
if tri_ok:
logger.info(f"[TRI] end {datetime.now()} — 使用三角形结果(dx={tri['dx_cm']:.2f},dy={tri['dy_cm']:.2f}cm)") logger.info(f"[TRI] end {datetime.now()} — 使用三角形结果(dx={tri['dx_cm']:.2f},dy={tri['dy_cm']:.2f}cm)")
return { return {
"success": True, "success": True,
"result_img": frame, "result_img": frame,
"center": None, "radius": None, "center": None, "radius": None,
"method": tri.get("offset_method") or "triangle_homography", "method": "triangle_homography",
"best_radius1": None, "ellipse_params": None, "best_radius1": None, "ellipse_params": None,
"dx": tri["dx_cm"], "dy": tri["dy_cm"], "dx": tri["dx_cm"], "dy": tri["dy_cm"],
"distance_m": tri.get("distance_m") or distance_m_first, "distance_m": tri.get("distance_m") or distance_m_first,
@@ -231,7 +246,7 @@ def process_shot(adc_val):
# 三角形路径成功时 center/radius 为空是正常的;此时用 triangle 方法名用于保存文件名与上报字段 m # 三角形路径成功时 center/radius 为空是正常的;此时用 triangle 方法名用于保存文件名与上报字段 m
if (not method) and tri_markers: if (not method) and tri_markers:
method = offset_method or "triangle_homography" method = "triangle_homography"
if config.SHOW_CAMERA_PHOTO_WHILE_SHOOTING: if config.SHOW_CAMERA_PHOTO_WHILE_SHOOTING:
camera_manager.show(result_img) camera_manager.show(result_img)

View File

@@ -491,9 +491,17 @@ def try_triangle_scoring(
h_orig, w_orig = img_rgb.shape[:2] h_orig, w_orig = img_rgb.shape[:2]
# 缩图加速:嵌入式 CPU 上图像处理耗时与面积成正比,缩到最长边 320px 可获得 ~4× 加速 # 缩图加速:嵌入式 CPU 上图像处理耗时与面积成正比
# 不再写死 320/640默认按相机最长边缩到 1/2由 config.TRIANGLE_DETECT_SCALE 控制)。
# 检测完后把像素坐标乘以 inv_scale 还原到原始分辨率,再送入单应性/PnP与 K 标定分辨率一致) # 检测完后把像素坐标乘以 inv_scale 还原到原始分辨率,再送入单应性/PnP与 K 标定分辨率一致)
MAX_DETECT_DIM = 640 try:
import config as _cfg
scale = float(getattr(_cfg, "TRIANGLE_DETECT_SCALE", 0.5))
except Exception:
scale = 0.5
if not (0.05 <= scale <= 1.0):
scale = 0.5
MAX_DETECT_DIM = max(64, int(max(h_orig, w_orig) * scale))
long_side = max(h_orig, w_orig) long_side = max(h_orig, w_orig)
if long_side > MAX_DETECT_DIM: if long_side > MAX_DETECT_DIM:
det_scale = MAX_DETECT_DIM / long_side det_scale = MAX_DETECT_DIM / long_side
@@ -564,6 +572,40 @@ def try_triangle_scoring(
marker_centers = [[float(c[0]), float(c[1])] for c in marker_centers] marker_centers = [[float(c[0]), float(c[1])] for c in marker_centers]
offset_tag = "triangle_homography_3pt" offset_tag = "triangle_homography_3pt"
# ---------- 结果有效性校验(防 nan/inf 与退化角点) ----------
try:
import config as _cfg
min_center_dist_px = float(getattr(_cfg, "TRIANGLE_MIN_CENTER_DIST_PX", 3.0))
max_dist_m = float(getattr(_cfg, "TRIANGLE_MAX_DISTANCE_M", 20.0))
except Exception:
min_center_dist_px = 3.0
max_dist_m = 20.0
def _all_finite(v) -> bool:
try:
return bool(np.all(np.isfinite(v)))
except Exception:
return False
# 1) 4 个角点中心不能退化/重复(两两距离要大于阈值)
try:
pts = np.array(marker_centers, dtype=np.float64).reshape(-1, 2)
ok_centers = True
for i in range(len(pts)):
for j in range(i + 1, len(pts)):
if float(np.linalg.norm(pts[i] - pts[j])) <= min_center_dist_px:
ok_centers = False
break
if not ok_centers:
break
if not ok_centers:
_log(f"[TRI] 角点退化/重复center_dist <= {min_center_dist_px:.1f}px判定三角形失败")
return out
except Exception:
# 校验异常时不信任结果,直接回退
_log("[TRI] 角点校验异常,判定三角形失败")
return out
ok_h, tx, ty, _H = homography_calibration( ok_h, tx, ty, _H = homography_calibration(
marker_centers, marker_ids, marker_positions, [lx, ly] marker_centers, marker_ids, marker_positions, [lx, ly]
) )
@@ -571,6 +613,16 @@ def try_triangle_scoring(
_log("[TRI] 单应性失败") _log("[TRI] 单应性失败")
return out return out
# 2) 单应性矩阵必须是有限数
if (not _all_finite(_H)):
_log("[TRI] 单应性出现 nan/inf判定三角形失败")
return out
# 3) dx/dy 必须是有限数
if (not _all_finite([tx, ty])):
_log("[TRI] 偏移出现 nan/inf判定三角形失败")
return out
# 与 laser_manager.compute_laser_position 现网约定一致:(x_cm, -y_cm_target) # 与 laser_manager.compute_laser_position 现网约定一致:(x_cm, -y_cm_target)
out["dx_cm"] = tx out["dx_cm"] = tx
out["dy_cm"] = -ty out["dy_cm"] = -ty
@@ -580,7 +632,8 @@ def try_triangle_scoring(
out["homography"] = _H # 供上层反推靶心像素位置用 out["homography"] = _H # 供上层反推靶心像素位置用
dist_m = pnp_distance_meters(marker_ids, marker_centers, marker_positions, camera_matrix, dist_coeffs) 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: # 4) distance_m 若存在也必须是有限数且在合理范围(默认 <20m
if dist_m is not None and _all_finite([dist_m]) and 0.3 < dist_m < max_dist_m:
out["distance_m"] = dist_m out["distance_m"] = dist_m
out["distance_method"] = "pnp_triangle" out["distance_method"] = "pnp_triangle"
_log(f"[TRI] PnP 距离={dist_m:.2f}m, 偏移=({out['dx_cm']:.2f},{out['dy_cm']:.2f})cm") _log(f"[TRI] PnP 距离={dist_m:.2f}m, 偏移=({out['dx_cm']:.2f},{out['dy_cm']:.2f})cm")