Compare commits
65
Commits
ead2060ab3
...
2.16.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b62327413f | ||
|
|
165eeff64e | ||
|
|
a184ff7d55 | ||
|
|
054e9e6d90 | ||
|
|
ae339889c2 | ||
|
|
b94b0f2e55 | ||
|
|
e82941a161 | ||
|
|
6556cfcf74 | ||
|
|
abbf30d7c0 | ||
|
|
5cf752bb3f | ||
|
|
6d8de56bfa | ||
|
|
aee1a92760 | ||
|
|
c34efed6f9 | ||
|
|
226394d3ed | ||
|
|
b169618b16 | ||
|
|
5ab4ef2944 | ||
|
|
577ff02c04 | ||
|
|
82d0008257 | ||
|
|
373eeb786a | ||
|
|
4500e62647 | ||
|
|
49a84e80e1 | ||
|
|
9654b79cec | ||
|
|
1ea8c64a40 | ||
|
|
9dd6fef6f8 | ||
|
|
860f9c84c3 | ||
|
|
1a0bfd54f7 | ||
|
|
c46cf5c567 | ||
|
|
0d69a01a1f | ||
|
|
583748fda3 | ||
|
|
d508478c73 | ||
|
|
30c7200a7a | ||
|
|
959635f461 | ||
|
|
86cd8cd46e | ||
|
|
26ed3c1523 | ||
|
|
aa16676c74 | ||
|
|
99614fe321 | ||
|
|
2ad2836d77 | ||
|
|
801453fbdb | ||
|
|
c754dff4ad | ||
|
|
47018fcd69 | ||
|
|
afa99f598b | ||
|
|
e90ea5154c | ||
|
|
b895ea819c | ||
|
|
1a1dac6b8f | ||
|
|
541418fd60 | ||
|
|
dff5096164 | ||
|
|
8b580fc732 | ||
|
|
f9123889f2 | ||
|
|
9fd1c961e4 | ||
|
|
4ea15567c2 | ||
|
|
ef16c7e037 | ||
|
|
4b94e03413 | ||
|
|
0a1c7cff5c | ||
|
|
bd5ebdaa43 | ||
|
|
a090579db9 | ||
|
|
5e7db5e271 | ||
|
|
4a3b111ce4 | ||
|
|
fe3e26e21d | ||
|
|
8efe1ae5c5 | ||
|
|
12fac4ea1c | ||
|
|
1bace88f37 | ||
|
|
ba5ca7e0b3 | ||
|
|
e030f3a194 | ||
|
|
43e7e0ba17 | ||
|
|
0ee970d8bd |
@@ -0,0 +1 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
/cpp_ext/build/
|
/cpp_ext/build/
|
||||||
/.cursor/
|
/.cursor/
|
||||||
/dist/
|
/dist/
|
||||||
|
.idea
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
Generated
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="jdk" jdkName="yolov8" jdkType="Python SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
<component name="PyDocumentationSettings">
|
||||||
|
<option name="format" value="PLAIN" />
|
||||||
|
<option name="myDocStringFormat" value="Plain" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
Generated
+7
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Black">
|
||||||
|
<option name="sdkName" value="yolov8" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" project-jdk-name="yolov8" project-jdk-type="Python SDK" />
|
||||||
|
</project>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/archery.iml" filepath="$PROJECT_DIR$/.idea/archery.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"cmake.sourceDirectory": "E:/code/code/code/new/new/new/archery/cpp_ext"
|
||||||
|
}
|
||||||
@@ -255,6 +255,10 @@ class DownloadManager4G:
|
|||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
host = parsed.hostname
|
host = parsed.hostname
|
||||||
path = parsed.path or "/"
|
path = parsed.path or "/"
|
||||||
|
if parsed.query:
|
||||||
|
path = f"{path}?{parsed.query}"
|
||||||
|
if parsed.fragment:
|
||||||
|
path = f"{path}#{parsed.fragment}"
|
||||||
if not host:
|
if not host:
|
||||||
return False, "bad_url (no host)"
|
return False, "bad_url (no host)"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
4G Image Upload Manager
|
||||||
|
Uploads images to Qiniu cloud via ML307R 4G module TCP socket (MIPOPEN + MIPSEND).
|
||||||
|
|
||||||
|
AT Command Sequence (ML307R TCP socket POST):
|
||||||
|
AT+MIPCALL=1,1 // Ensure PDP context active
|
||||||
|
AT+MIPCLOSE=<id> // Close old socket (ignore error)
|
||||||
|
AT+MIPOPEN=<id>,"TCP","<host>",80 // Open TCP socket
|
||||||
|
// Wait for +MIPOPEN: <id>,0 (success)
|
||||||
|
AT+MIPSEND=<id>,<len> // Send data
|
||||||
|
// Wait for ">" prompt, then write raw bytes
|
||||||
|
// Repeat MIPSEND for all chunks
|
||||||
|
// Wait for +MIPURC: "rtcp" response
|
||||||
|
AT+MIPCLOSE=<id> // Close socket
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from maix import time
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from logger_manager import logger_manager
|
||||||
|
from hardware import hardware_manager
|
||||||
|
|
||||||
|
# Multipart form boundary (simple alphanumeric to avoid AT command parser issues)
|
||||||
|
BOUNDARY = "QiniuFormBoundary" + hex(int(time.time()))[2:]
|
||||||
|
# Chunk size for MIPSEND (max 1024 to avoid AT line buffer limits)
|
||||||
|
SEND_CHUNK = 1024
|
||||||
|
# Socket ID for upload (dedicated to avoid conflict with main app TCP)
|
||||||
|
UPLOAD_SOCK_ID = 3
|
||||||
|
|
||||||
|
|
||||||
|
class FourGUploadManager:
|
||||||
|
"""4G image upload manager using ML307R TCP socket (MIPOPEN + MIPSEND)"""
|
||||||
|
|
||||||
|
def __init__(self, at_client):
|
||||||
|
"""Initialize with AT client instance"""
|
||||||
|
self.at = at_client
|
||||||
|
self.logger = logger_manager.logger
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ logging
|
||||||
|
def _log(self, msg):
|
||||||
|
try:
|
||||||
|
self.logger.debug("[4G-UL] " + msg)
|
||||||
|
except Exception:
|
||||||
|
print("[4G-UL] " + msg)
|
||||||
|
|
||||||
|
def _log_info(self, msg):
|
||||||
|
try:
|
||||||
|
self.logger.info("[4G-UL] " + msg)
|
||||||
|
except Exception:
|
||||||
|
print("[4G-UL] " + msg)
|
||||||
|
|
||||||
|
def _log_error(self, msg):
|
||||||
|
try:
|
||||||
|
self.logger.error("[4G-UL] " + msg)
|
||||||
|
except Exception:
|
||||||
|
print("[4G-UL] " + msg)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- helpers
|
||||||
|
def _ensure_pdp(self):
|
||||||
|
"""Ensure PDP context is active; returns (ok, ip)"""
|
||||||
|
r = self.at.send("AT+CGPADDR=1", "OK", 3000)
|
||||||
|
m = re.search(r'\+CGPADDR:\s*1,"([^"]+)"', r)
|
||||||
|
ip = m.group(1) if m else ""
|
||||||
|
if ip and ip != "0.0.0.0":
|
||||||
|
return True, ip
|
||||||
|
self.at.send("AT+MIPCALL=1,1", "OK", 15000)
|
||||||
|
for _ in range(10):
|
||||||
|
r = self.at.send("AT+CGPADDR=1", "OK", 3000)
|
||||||
|
m = re.search(r'\+CGPADDR:\s*1,"([^"]+)"', r)
|
||||||
|
ip = m.group(1) if m else ""
|
||||||
|
if ip and ip != "0.0.0.0":
|
||||||
|
return True, ip
|
||||||
|
time.sleep(1)
|
||||||
|
return False, ip
|
||||||
|
|
||||||
|
def _is_error(self, resp):
|
||||||
|
"""Check AT response for any error indicators"""
|
||||||
|
return "ERROR" in resp or "CME ERROR" in resp
|
||||||
|
|
||||||
|
# --------------------------------------------------------- multipart body
|
||||||
|
def _build_multipart_body(self, image_path, upload_token, key):
|
||||||
|
"""
|
||||||
|
Build multipart/form-data body as bytes for Qiniu upload.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- token : Qiniu upload token
|
||||||
|
- key : object key in bucket
|
||||||
|
- file : binary image data
|
||||||
|
"""
|
||||||
|
boundary = BOUNDARY.encode()
|
||||||
|
|
||||||
|
with open(image_path, "rb") as f:
|
||||||
|
file_data = f.read()
|
||||||
|
|
||||||
|
filename = os.path.basename(image_path)
|
||||||
|
ext = os.path.splitext(image_path)[1].lower()
|
||||||
|
ct_map = {
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".bmp": "image/bmp",
|
||||||
|
".webp": "image/webp",
|
||||||
|
}
|
||||||
|
content_type = ct_map.get(ext, "application/octet-stream")
|
||||||
|
|
||||||
|
body = bytearray()
|
||||||
|
|
||||||
|
# -- token field --
|
||||||
|
body += b"--" + boundary + b"\r\n"
|
||||||
|
body += b'Content-Disposition: form-data; name="token"\r\n'
|
||||||
|
body += b"\r\n"
|
||||||
|
body += upload_token.encode("utf-8") + b"\r\n"
|
||||||
|
|
||||||
|
# -- key field --
|
||||||
|
body += b"--" + boundary + b"\r\n"
|
||||||
|
body += b'Content-Disposition: form-data; name="key"\r\n'
|
||||||
|
body += b"\r\n"
|
||||||
|
body += key.encode("utf-8") + b"\r\n"
|
||||||
|
|
||||||
|
# -- file field --
|
||||||
|
body += b"--" + boundary + b"\r\n"
|
||||||
|
body += (
|
||||||
|
b'Content-Disposition: form-data; name="file"; filename="'
|
||||||
|
+ filename.encode("utf-8")
|
||||||
|
+ b'"\r\n'
|
||||||
|
)
|
||||||
|
body += b"Content-Type: " + content_type.encode("utf-8") + b"\r\n"
|
||||||
|
body += b"\r\n"
|
||||||
|
body += file_data + b"\r\n"
|
||||||
|
|
||||||
|
# -- closing boundary --
|
||||||
|
body += b"--" + boundary + b"--\r\n"
|
||||||
|
|
||||||
|
return bytes(body)
|
||||||
|
|
||||||
|
# --------------------------------------------------- TCP socket helpers
|
||||||
|
def _close_socket(self, sock_id):
|
||||||
|
"""Close socket, ignore CME ERROR 55 (already closed)"""
|
||||||
|
try:
|
||||||
|
resp = self.at.send("AT+MIPCLOSE=" + str(sock_id), "OK", 5000)
|
||||||
|
self._log("socket " + str(sock_id) + " closed: " + resp)
|
||||||
|
except Exception as e:
|
||||||
|
# Ignore CME ERROR 55 (socket not open)
|
||||||
|
self._log("socket close (may already be closed): " + str(e))
|
||||||
|
|
||||||
|
def _open_socket(self, sock_id, host, port):
|
||||||
|
"""
|
||||||
|
Open TCP socket to host:port.
|
||||||
|
Returns (success, error_msg)
|
||||||
|
"""
|
||||||
|
cmd = 'AT+MIPOPEN=' + str(sock_id) + ',"TCP","' + host + '",' + str(port)
|
||||||
|
resp = self.at.send(cmd, "OK", 15000)
|
||||||
|
|
||||||
|
if self._is_error(resp):
|
||||||
|
return False, "MIPOPEN failed: " + resp
|
||||||
|
|
||||||
|
# Wait for +MIPOPEN: <id>,0 (success) or +MIPOPEN: <id>,<error_code>
|
||||||
|
# The URC may come in the same response or separately
|
||||||
|
mipopen_pattern = r"\+MIPOPEN:\s*" + str(sock_id) + r",(\d+)"
|
||||||
|
m = re.search(mipopen_pattern, resp)
|
||||||
|
|
||||||
|
if m:
|
||||||
|
result_code = int(m.group(1))
|
||||||
|
if result_code == 0:
|
||||||
|
return True, ""
|
||||||
|
else:
|
||||||
|
return False, "MIPOPEN error code: " + str(result_code)
|
||||||
|
|
||||||
|
# If not in initial response, wait for URC
|
||||||
|
try:
|
||||||
|
urc_resp = self.at.send("", "+MIPOPEN:", 15000)
|
||||||
|
m = re.search(mipopen_pattern, urc_resp)
|
||||||
|
if m:
|
||||||
|
result_code = int(m.group(1))
|
||||||
|
if result_code == 0:
|
||||||
|
return True, ""
|
||||||
|
else:
|
||||||
|
return False, "MIPOPEN error code: " + str(result_code)
|
||||||
|
except Exception as e:
|
||||||
|
return False, "MIPOPEN URC timeout: " + str(e)
|
||||||
|
|
||||||
|
return False, "MIPOPEN no response"
|
||||||
|
|
||||||
|
def _send_chunk(self, sock_id, chunk):
|
||||||
|
"""
|
||||||
|
Send a single chunk via MIPSEND.
|
||||||
|
Thread safety is provided by the outer network_manager.get_uart_lock().
|
||||||
|
NOTE: Do NOT add self.at._cmd_lock here — self.at.send() already
|
||||||
|
acquires it internally and threading.Lock is not reentrant.
|
||||||
|
Returns (success, error_msg)
|
||||||
|
"""
|
||||||
|
chunk_len = len(chunk)
|
||||||
|
|
||||||
|
# Step 1: Send AT+MIPSEND command and wait for ">" prompt
|
||||||
|
cmd = "AT+MIPSEND=" + str(sock_id) + "," + str(chunk_len)
|
||||||
|
try:
|
||||||
|
resp = self.at.send(cmd, ">", 3000)
|
||||||
|
if ">" not in resp:
|
||||||
|
return False, "MIPSEND no > prompt: " + resp
|
||||||
|
except Exception as e:
|
||||||
|
return False, "MIPSEND > prompt error: " + str(e)
|
||||||
|
|
||||||
|
# Step 2: Write raw binary bytes directly to UART
|
||||||
|
# Must be done immediately after ">" prompt, no lock re-acquisition
|
||||||
|
try:
|
||||||
|
self.at.uart.write(chunk)
|
||||||
|
except Exception as e:
|
||||||
|
return False, "MIPSEND write error: " + str(e)
|
||||||
|
|
||||||
|
# Step 3: Wait for OK or SEND OK confirmation
|
||||||
|
try:
|
||||||
|
confirm_resp = self.at.send("", "OK", 8000)
|
||||||
|
if self._is_error(confirm_resp):
|
||||||
|
return False, "MIPSEND confirmation error: " + confirm_resp
|
||||||
|
except Exception as e:
|
||||||
|
return False, "MIPSEND confirmation timeout: " + str(e)
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def _send_data(self, sock_id, data):
|
||||||
|
"""
|
||||||
|
Send data in chunks via MIPSEND.
|
||||||
|
Returns (success, error_msg)
|
||||||
|
"""
|
||||||
|
total_len = len(data)
|
||||||
|
offset = 0
|
||||||
|
chunk_num = 0
|
||||||
|
|
||||||
|
while offset < total_len:
|
||||||
|
end = min(offset + SEND_CHUNK, total_len)
|
||||||
|
chunk = data[offset:end]
|
||||||
|
|
||||||
|
ok, err = self._send_chunk(sock_id, chunk)
|
||||||
|
if not ok:
|
||||||
|
return False, "Chunk " + str(chunk_num) + " failed: " + err
|
||||||
|
|
||||||
|
chunk_num += 1
|
||||||
|
offset = end
|
||||||
|
|
||||||
|
if chunk_num % 10 == 0 or offset >= total_len:
|
||||||
|
self._log(
|
||||||
|
"send progress: "
|
||||||
|
+ str(offset) + "/" + str(total_len)
|
||||||
|
+ " bytes (" + str(chunk_num) + " chunks)"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._log("all data sent: " + str(chunk_num) + " chunks, " + str(total_len) + " bytes")
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def _wait_for_response(self, sock_id, timeout_ms=30000):
|
||||||
|
"""
|
||||||
|
Wait for +MIPURC: "rtcp" response.
|
||||||
|
Returns (success, status_code, body, error_msg)
|
||||||
|
"""
|
||||||
|
pattern = r'\+MIPURC:\s*"rtcp",\s*' + str(sock_id) + r',\s*(\d+),'
|
||||||
|
t0 = time.ticks_ms()
|
||||||
|
|
||||||
|
while time.ticks_diff(time.ticks_ms(), t0) < timeout_ms:
|
||||||
|
try:
|
||||||
|
# Try to get response with short timeout
|
||||||
|
resp = self.at.send("", "+MIPURC:", 1000)
|
||||||
|
m = re.search(pattern, resp)
|
||||||
|
if m:
|
||||||
|
data_len = int(m.group(1))
|
||||||
|
# Extract HTTP response data after the URC header
|
||||||
|
# Format: +MIPURC: "rtcp",<sock_id>,<len>,<data>
|
||||||
|
urc_end = resp.find("+MIPURC:")
|
||||||
|
if urc_end >= 0:
|
||||||
|
# Find the data after the length field
|
||||||
|
match_end = m.end()
|
||||||
|
http_data = resp[match_end:match_end + data_len]
|
||||||
|
|
||||||
|
# Parse HTTP status line
|
||||||
|
status_match = re.search(r"HTTP/\d\.\d\s+(\d+)", http_data)
|
||||||
|
status_code = int(status_match.group(1)) if status_match else None
|
||||||
|
|
||||||
|
# Extract body (after headers)
|
||||||
|
header_end = http_data.find("\r\n\r\n")
|
||||||
|
if header_end >= 0:
|
||||||
|
body = http_data[header_end + 4:]
|
||||||
|
else:
|
||||||
|
body = http_data
|
||||||
|
|
||||||
|
return True, status_code, body, ""
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
time.sleep_ms(100)
|
||||||
|
|
||||||
|
return False, None, "", "Response timeout"
|
||||||
|
|
||||||
|
def _build_http_request(self, host, body_bytes):
|
||||||
|
"""
|
||||||
|
Build full HTTP POST request as bytes.
|
||||||
|
"""
|
||||||
|
headers = (
|
||||||
|
"POST / HTTP/1.1\r\n"
|
||||||
|
"Host: " + host + "\r\n"
|
||||||
|
"Content-Type: multipart/form-data; boundary=" + BOUNDARY + "\r\n"
|
||||||
|
"Content-Length: " + str(len(body_bytes)) + "\r\n"
|
||||||
|
"Connection: close\r\n"
|
||||||
|
"\r\n"
|
||||||
|
)
|
||||||
|
return headers.encode("utf-8") + body_bytes
|
||||||
|
|
||||||
|
# ============================================================ public API
|
||||||
|
def upload_file(self, file_path, upload_url, upload_token, key):
|
||||||
|
"""Generic file upload to Qiniu cloud via 4G TCP socket POST.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Local path to any file
|
||||||
|
upload_url: Qiniu upload URL
|
||||||
|
upload_token: Qiniu upload token
|
||||||
|
key: File key in Qiniu bucket
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with 'success' bool and 'key'/'error' fields
|
||||||
|
"""
|
||||||
|
return self.upload_image(file_path, upload_url, upload_token, key)
|
||||||
|
|
||||||
|
def upload_image(self, image_path, upload_url, upload_token, key):
|
||||||
|
"""
|
||||||
|
Upload image to Qiniu cloud via 4G TCP socket POST.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: Local path to image file
|
||||||
|
upload_url: Qiniu upload URL (e.g., "https://upload.qiniup.com")
|
||||||
|
upload_token: Qiniu upload token
|
||||||
|
key: File key in Qiniu (e.g., "shootPic/device01/shoot01.png")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with 'success' bool and 'key'/'error' fields
|
||||||
|
"""
|
||||||
|
if not self.at:
|
||||||
|
return {"success": False, "error": "AT client not available"}
|
||||||
|
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
return {"success": False, "error": "Image file not found: " + image_path}
|
||||||
|
|
||||||
|
# Force HTTP for 4G module (extract hostname, use port 80)
|
||||||
|
parsed = urlparse(upload_url)
|
||||||
|
host = parsed.hostname
|
||||||
|
if not host:
|
||||||
|
return {"success": False, "error": "Invalid upload URL: " + upload_url}
|
||||||
|
|
||||||
|
if upload_url.lower().startswith("https://"):
|
||||||
|
self._log_info("Converted HTTPS->HTTP for 4G module")
|
||||||
|
|
||||||
|
file_size = os.path.getsize(image_path)
|
||||||
|
self._log_info(
|
||||||
|
"upload: " + image_path + " (" + str(file_size) + "B) -> "
|
||||||
|
+ host + " key=" + key
|
||||||
|
)
|
||||||
|
|
||||||
|
from network import network_manager
|
||||||
|
with network_manager.get_uart_lock():
|
||||||
|
try:
|
||||||
|
# ---- Step 1: Ensure PDP context ----
|
||||||
|
ok_pdp, ip = self._ensure_pdp()
|
||||||
|
if not ok_pdp:
|
||||||
|
return {"success": False, "error": "PDP not ready (ip=" + str(ip) + ")"}
|
||||||
|
|
||||||
|
# ---- Step 2: Close old socket ----
|
||||||
|
self._close_socket(UPLOAD_SOCK_ID)
|
||||||
|
|
||||||
|
# ---- Step 3: Open TCP socket ----
|
||||||
|
ok, err = self._open_socket(UPLOAD_SOCK_ID, host, 80)
|
||||||
|
if not ok:
|
||||||
|
return {"success": False, "error": "Socket open failed: " + err}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ---- Step 4: Build multipart body and HTTP request ----
|
||||||
|
body = self._build_multipart_body(image_path, upload_token, key)
|
||||||
|
http_request = self._build_http_request(host, body)
|
||||||
|
self._log("HTTP request size: " + str(len(http_request)) + " bytes")
|
||||||
|
|
||||||
|
# ---- Step 5: Send data via MIPSEND ----
|
||||||
|
ok, err = self._send_data(UPLOAD_SOCK_ID, http_request)
|
||||||
|
if not ok:
|
||||||
|
return {"success": False, "error": "Send failed: " + err}
|
||||||
|
|
||||||
|
# ---- Step 6: Wait for response ----
|
||||||
|
ok, status_code, resp_body, err = self._wait_for_response(UPLOAD_SOCK_ID)
|
||||||
|
if not ok:
|
||||||
|
return {"success": False, "error": "Response error: " + err}
|
||||||
|
|
||||||
|
# ---- Step 7: Parse response ----
|
||||||
|
if status_code is None:
|
||||||
|
return {"success": False, "error": "No HTTP status in response"}
|
||||||
|
|
||||||
|
if 200 <= status_code < 300:
|
||||||
|
try:
|
||||||
|
resp_json = json.loads(resp_body)
|
||||||
|
resp_key = resp_json.get("key", key)
|
||||||
|
self._log_info("upload success: key=" + resp_key + " code=" + str(status_code))
|
||||||
|
return {"success": True, "key": resp_key}
|
||||||
|
except Exception as e:
|
||||||
|
self._log_error("response parse error: " + str(e))
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"key": key,
|
||||||
|
"raw": resp_body,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
self._log_error(
|
||||||
|
"HTTP error: code=" + str(status_code) + " body=" + resp_body[:200]
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "HTTP " + str(status_code),
|
||||||
|
"response": resp_body,
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# ---- Step 8: Always close socket ----
|
||||||
|
self._close_socket(UPLOAD_SOCK_ID)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._log_error("upload exception: " + str(e))
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ====================================================================== demo
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Demo usage — requires actual ML307R 4G module hardware to run.
|
||||||
|
print("FourGUploadManager - requires ML307R 4G module hardware")
|
||||||
|
print()
|
||||||
|
print("Usage:")
|
||||||
|
print(" from hardware import hardware_manager")
|
||||||
|
print(" from at_client import ATClient")
|
||||||
|
print(" from maix import uart")
|
||||||
|
print()
|
||||||
|
print(" # Initialize UART and AT client (normally done in hardware init)")
|
||||||
|
print(" uart4g = uart.UART('/dev/ttyS1', 115200, ...)")
|
||||||
|
print(" at_client = ATClient(uart4g)")
|
||||||
|
print(" at_client.start()")
|
||||||
|
print()
|
||||||
|
print(" # Upload image to Qiniu")
|
||||||
|
print(" uploader = FourGUploadManager(at_client)")
|
||||||
|
print(" result = uploader.upload_image(")
|
||||||
|
print(" image_path='/maixapp/apps/t11/shoot.png',")
|
||||||
|
print(" upload_url='https://upload.qiniup.com',")
|
||||||
|
print(" upload_token='<qiniu_upload_token>',")
|
||||||
|
print(" key='shootPic/device01/shoot01.png'")
|
||||||
|
print(" )")
|
||||||
|
print(" print('Upload result:', result)")
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,14 +4,13 @@ from maix import time
|
|||||||
a = adc.ADC(0, adc.RES_BIT_12)
|
a = adc.ADC(0, adc.RES_BIT_12)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
raw_data = a.read()
|
# raw_data = a.read()
|
||||||
print(f"ADC raw data:{raw_data}")
|
# print(f"ADC raw data:{raw_data}")
|
||||||
# if raw_data > 2450:
|
# if raw_data > 2450:
|
||||||
# print(f"ADC raw data:{raw_data}")
|
# print(f"ADC raw data:{raw_data}")
|
||||||
# elif raw_data < 2000:
|
# elif raw_data < 2000:
|
||||||
# print(f"ADC raw data:{raw_data}")
|
# print(f"ADC raw data:{raw_data}")
|
||||||
# time.sleep_ms(50)
|
time.sleep_ms(1)
|
||||||
|
|
||||||
# vol = a.read_vol()
|
vol = int(a.read_vol() * 10) / 10
|
||||||
|
print(f"ADC vol:{vol:.1f}, {time.time():.4f}")
|
||||||
# print(f"ADC vol:{vol}")
|
|
||||||
|
|||||||
@@ -1,25 +1,37 @@
|
|||||||
id: t11
|
id: t11
|
||||||
name: t11
|
name: t11
|
||||||
version: 1.2.10
|
version: 2.16.1
|
||||||
author: t11
|
author: t11
|
||||||
icon: ''
|
icon: ''
|
||||||
desc: t11
|
desc: t11
|
||||||
files:
|
files:
|
||||||
|
- 4g_download_manager.py
|
||||||
|
- 4g_upload_manager.py
|
||||||
- app.yaml
|
- app.yaml
|
||||||
- archery_netcore.cpython-311-riscv64-linux-gnu.so
|
- archery_netcore.cpython-311-riscv64-linux-gnu.so
|
||||||
- at_client.py
|
- at_client.py
|
||||||
- camera_manager.py
|
- camera_manager.py
|
||||||
|
- cameraParameters.xml
|
||||||
|
- charging_exit.sh
|
||||||
- config.py
|
- config.py
|
||||||
- hardware.py
|
- hardware.py
|
||||||
|
- laser_detector.py
|
||||||
- laser_manager.py
|
- laser_manager.py
|
||||||
- logger_manager.py
|
- logger_manager.py
|
||||||
- main.py
|
- main.py
|
||||||
- network.py
|
- network.py
|
||||||
|
- ota_curl.sh
|
||||||
- ota_manager.py
|
- ota_manager.py
|
||||||
- power.py
|
- power.py
|
||||||
|
- server.pem
|
||||||
- shoot_manager.py
|
- shoot_manager.py
|
||||||
- shot_id_generator.py
|
- shot_id_generator.py
|
||||||
|
- target_roi_yolo.py
|
||||||
- time_sync.py
|
- time_sync.py
|
||||||
|
- triangle_positions.json
|
||||||
|
- triangle_target.py
|
||||||
- version.py
|
- version.py
|
||||||
- vision.cpython-311-riscv64-linux-gnu.so
|
- vision.py
|
||||||
|
- wifi_config_httpd.py
|
||||||
- wifi.py
|
- wifi.py
|
||||||
|
- wpa_supplicant_conf.py
|
||||||
|
|||||||
Binary file not shown.
+7
-6
@@ -76,10 +76,11 @@ class ATClient:
|
|||||||
"""
|
"""
|
||||||
expect_b = expect.encode() if isinstance(expect, str) else expect
|
expect_b = expect.encode() if isinstance(expect, str) else expect
|
||||||
with self._cmd_lock:
|
with self._cmd_lock:
|
||||||
# 初始化等待
|
with self._q_lock:
|
||||||
self._waiting = True
|
# 初始化等待
|
||||||
self._expect = expect_b
|
self._waiting = True
|
||||||
self._resp = b""
|
self._expect = expect_b
|
||||||
|
self._resp = b""
|
||||||
|
|
||||||
# 发送
|
# 发送
|
||||||
if cmd:
|
if cmd:
|
||||||
@@ -300,8 +301,8 @@ class ATClient:
|
|||||||
if len(self._rx) > 512 * 1024:
|
if len(self._rx) > 512 * 1024:
|
||||||
self._rx = self._rx[-256 * 1024:]
|
self._rx = self._rx[-256 * 1024:]
|
||||||
else:
|
else:
|
||||||
if len(self._rx) > 16384:
|
if len(self._rx) > 32768:
|
||||||
self._rx = self._rx[-4096:]
|
self._rx = self._rx[-16384:]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<opencv_storage>
|
||||||
|
<calibrationDate>"Sat Apr 11 12:05:27 2026"</calibrationDate>
|
||||||
|
<framesCount>29</framesCount>
|
||||||
|
<cameraResolution>
|
||||||
|
640 480</cameraResolution>
|
||||||
|
<camera_matrix type_id="opencv-matrix">
|
||||||
|
<rows>3</rows>
|
||||||
|
<cols>3</cols>
|
||||||
|
<dt>d</dt>
|
||||||
|
<data>
|
||||||
|
2207.9058323074869 0. 328.90661220953149 0. 2207.9058323074869
|
||||||
|
205.49515894111076 0. 0. 1.</data></camera_matrix>
|
||||||
|
<camera_matrix_std_dev type_id="opencv-matrix">
|
||||||
|
<rows>4</rows>
|
||||||
|
<cols>1</cols>
|
||||||
|
<dt>d</dt>
|
||||||
|
<data>
|
||||||
|
0. 11.687428265309892 3.6908895632668468 3.597571733110271</data></camera_matrix_std_dev>
|
||||||
|
<distortion_coefficients type_id="opencv-matrix">
|
||||||
|
<rows>1</rows>
|
||||||
|
<cols>5</cols>
|
||||||
|
<dt>d</dt>
|
||||||
|
<data>
|
||||||
|
-0.63036604771649651 3.3832710000807449 0. 0. -0.45113389267675552</data></distortion_coefficients>
|
||||||
|
<distortion_coefficients_std_dev type_id="opencv-matrix">
|
||||||
|
<rows>5</rows>
|
||||||
|
<cols>1</cols>
|
||||||
|
<dt>d</dt>
|
||||||
|
<data>
|
||||||
|
0.025002349846111244 1.0651877135605927 0. 0. 0.04021252864120229</data></distortion_coefficients_std_dev>
|
||||||
|
<avg_reprojection_error>0.28992233810828955</avg_reprojection_error>
|
||||||
|
</opencv_storage>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# The application supplies its own PID. Refuse broad or malformed targets.
|
||||||
|
TARGET_PID="$1"
|
||||||
|
LASER_DEVICE="${2:-/dev/ttyS1}"
|
||||||
|
LASER_BAUD="${3:-9600}"
|
||||||
|
|
||||||
|
turn_off_laser() {
|
||||||
|
if [ ! -c "$LASER_DEVICE" ]; then
|
||||||
|
echo "[CHARGE] laser serial device not found: $LASER_DEVICE" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
stty -F "$LASER_DEVICE" "$LASER_BAUD" raw -echo 2>/dev/null || return 1
|
||||||
|
printf '\252\000\001\276\000\001\000\000\300' > "$LASER_DEVICE"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$TARGET_PID" in
|
||||||
|
''|*[!0-9]*)
|
||||||
|
echo "[CHARGE] invalid application pid: $TARGET_PID" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$TARGET_PID" -le 1 ]; then
|
||||||
|
echo "[CHARGE] refusing to terminate pid: $TARGET_PID" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# First request laser-off while the application still owns the initialized UART.
|
||||||
|
turn_off_laser || true
|
||||||
|
|
||||||
|
kill -TERM "$TARGET_PID" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Wait up to two seconds for a graceful exit, then force termination.
|
||||||
|
WAIT_COUNT=0
|
||||||
|
while kill -0 "$TARGET_PID" 2>/dev/null && [ "$WAIT_COUNT" -lt 20 ]; do
|
||||||
|
sleep 0.1
|
||||||
|
WAIT_COUNT=$((WAIT_COUNT + 1))
|
||||||
|
done
|
||||||
|
if kill -0 "$TARGET_PID" 2>/dev/null; then
|
||||||
|
kill -KILL "$TARGET_PID" 2>/dev/null || true
|
||||||
|
sleep 0.1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Send laser-off again after the application releases the UART.
|
||||||
|
turn_off_laser || true
|
||||||
@@ -9,68 +9,77 @@ from version import VERSION
|
|||||||
# ==================== 应用配置 ====================
|
# ==================== 应用配置 ====================
|
||||||
APP_VERSION = VERSION
|
APP_VERSION = VERSION
|
||||||
APP_DIR = "/maixapp/apps/t11"
|
APP_DIR = "/maixapp/apps/t11"
|
||||||
LOCAL_FILENAME = "/maixapp/apps/t11/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.4
|
||||||
|
|
||||||
# ==================== 服务器配置 ====================
|
# ==================== 服务器配置 ====================
|
||||||
# SERVER_IP = "stcp.shelingxingqiu.com"
|
# SERVER_IP = "stcp.shelingxingqiu.com"
|
||||||
SERVER_IP = "www.shelingxingqiu.com"
|
SERVER_IP = "www.shelingxingqiu.com"
|
||||||
SERVER_PORT = 50005
|
SERVER_PORT = 50005
|
||||||
HEARTBEAT_INTERVAL = 15 # 心跳间隔(秒)
|
HEARTBEAT_INTERVAL = 5 # 心跳间隔(秒)
|
||||||
|
|
||||||
# WiFi 质量评估(开机先尝试 WiFi;质量差且 4G 可用则切到 4G,本次上电直至关机锁定 4G)
|
# WiFi 质量评估(开机先尝试 WiFi;质量差且 4G 可用则切到 4G,本次上电直至关机锁定 4G)
|
||||||
WIFI_QUALITY_RTT_SAMPLES = 3 # 到业务服务器 TCP 建连耗时采样次数,取中位数
|
WIFI_QUALITY_RTT_SAMPLES = 3 # 到业务服务器 TCP 建连耗时采样次数,取中位数
|
||||||
WIFI_QUALITY_RTT_BAD_MS = 600.0 # 中位数超过此值认为延迟过高
|
WIFI_QUALITY_RTT_BAD_MS = 600.0 # 中位数超过此值认为延迟过高
|
||||||
WIFI_QUALITY_RTT_WARN_MS = 350.0 # 与 RSSI 联合:超过此值且信号弱也判为差
|
WIFI_QUALITY_RTT_WARN_MS = 350.0 # 与 RSSI 联合:超过此值且信号弱也判为差
|
||||||
WIFI_QUALITY_RSSI_BAD_DBM = -80.0 # 低于此 dBm(更负更差)视为信号弱
|
WIFI_QUALITY_RSSI_BAD_DBM = -80.0 # 低于此 dBm(更负更差)视为信号弱
|
||||||
WIFI_QUALITY_USE_RSSI = True # 是否把 RSSI 纳入综合判定(False 则仅看 RTT)
|
WIFI_QUALITY_USE_RSSI = True # 是否把 RSSI 纳入综合判定
|
||||||
|
|
||||||
# WiFi 热点配网(手机连设备 AP,浏览器提交路由器 SSID/密码;仅 GET/POST,标准库 socket)
|
# WiFi 热点配网(手机连设备 AP,浏览器提交路由器 SSID/密码;仅 GET/POST,标准库 socket)
|
||||||
WIFI_CONFIG_AP_FALLBACK = True # # WiFi 配网失败时,是否退回热点模式,并等待重新配网
|
WIFI_CONFIG_AP_FALLBACK = False # # WiFi 配网失败时,是否退回热点模式,并等待重新配网
|
||||||
WIFI_AP_FALLBACK_WAIT_SEC = 5 # 等待5秒后再检测STA/4G
|
WIFI_AP_FALLBACK_WAIT_SEC = 5 # 等待5秒后再检测STA/4G
|
||||||
WIFI_CONFIG_AP_TIMEOUT = 5 # 热点模式超时时间(秒)
|
WIFI_CONFIG_AP_TIMEOUT = 5 # 热点模式超时时间(秒)
|
||||||
WIFI_CONFIG_AP_ENABLED = True # True=启动时开热点并起迷你 HTTP 配网服务
|
WIFI_CONFIG_AP_ENABLED = False # True=启动时开热点并起迷你 HTTP 配网服务
|
||||||
WIFI_CONFIG_AP_SSID = "ArcherySetup" # 设备发出的热点名称
|
WIFI_CONFIG_AP_SSID = "ArcherySetup" # 设备发出的热点名称
|
||||||
WIFI_CONFIG_AP_PASSWORD = "12345678" # 热点密码(WPA2 通常至少 8 位)
|
WIFI_CONFIG_AP_PASSWORD = "12345678" # 热点密码(WPA2 通常至少 8 位)
|
||||||
WIFI_CONFIG_HTTP_HOST = "0.0.0.0" # HTTP 监听地址
|
WIFI_CONFIG_HTTP_HOST = "0.0.0.0" # HTTP 监听地址
|
||||||
WIFI_CONFIG_HTTP_PORT = 8080 # 默认 8080,避免占用 80 需 root
|
WIFI_CONFIG_HTTP_PORT = 8080 # 默认 8080,避免占用 80 需 root
|
||||||
WIFI_CONFIG_AP_IP = "192.168.66.1" # 与 MaixPy Wifi.start_ap 默认一致,手机访问 http://192.168.66.1:8080/
|
WIFI_CONFIG_AP_IP = "192.168.66.1" # 与 MaixPy Wifi.start_ap 默认一致,手机访问 http://192.168.66.1:8080/
|
||||||
|
# 这个地址需要和 /boot/wifi.ipv4_prefix 配合,才能正确访问。
|
||||||
|
# 比如说 /boot/wifi.ipv4_prefix 需要写成 192.168.66
|
||||||
# ===== TCP over SSL(TLS) 配置 =====
|
# ===== 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_LINK_ID = 2 #
|
||||||
TCP_SSL_PORT = 443 # TLS 端口(不一定必须 443,以服务器为准)
|
TCP_SSL_PORT = 50006 # TLS 端口(不一定必须 443,以服务器为准)
|
||||||
|
|
||||||
# SSL profile
|
# SSL profile
|
||||||
SSL_ID = 1 # ssl_id=1
|
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_VERIFY_MODE = 1 # 0=不验(仅测试用);1=写入并使用 CA 证书
|
||||||
|
|
||||||
SSL_CERT_FILENAME = "www.shelingxingqiu.com.crt" # 模组里证书名(MSSLCERTWR / MSSLCFG="cert" 用)
|
SSL_CERT_FILENAME = "server.pem" # 模组里证书名(MSSLCERTWR / MSSLCFG="cert" 用)
|
||||||
SSL_CERT_PATH = "/root/www.shelingxingqiu.com.crt" # 设备文件系统里 CA 证书路径(你自己放进去)
|
SSL_CERT_PATH = APP_DIR + "/server.pem" # 设备文件系统里 CA 证书路径(你自己放进去)
|
||||||
# MIPOPEN 末尾的参数在不同固件里含义可能不同;按你手册例子保留
|
# MIPOPEN 末尾的参数在不同固件里含义可能不同;按你手册例子保留
|
||||||
MIPOPEN_TAIL = ",,0"
|
MIPOPEN_TAIL = ",,0"
|
||||||
|
|
||||||
# ==================== 文件路径配置 ====================
|
# ==================== 文件路径配置 ====================
|
||||||
CONFIG_FILE = "/root/laser_config.json"
|
CONFIG_FILE = "/root/laser_config.json"
|
||||||
LOG_FILE = "/maixapp/apps/t11/app.log"
|
LOG_FILE = APP_DIR + "/app.log"
|
||||||
BACKUP_BASE = "/maixapp/apps/t11/backups"
|
BACKUP_BASE = APP_DIR + "/backups"
|
||||||
|
|
||||||
# ==================== 硬件配置 ====================
|
# ==================== 硬件配置 ====================
|
||||||
# WiFi模块开关(True=有WiFi模块,False=无WiFi模块)
|
|
||||||
HAS_WIFI_MODULE = True # 根据实际硬件情况设置
|
|
||||||
|
|
||||||
# UART配置
|
# UART配置
|
||||||
UART4G_DEVICE = "/dev/ttyS2"
|
UART4G_DEVICE = "/dev/ttyS2"
|
||||||
UART4G_BAUDRATE = 115200
|
UART4G_BAUDRATE = 115200
|
||||||
DISTANCE_SERIAL_DEVICE = "/dev/ttyS1"
|
DISTANCE_SERIAL_DEVICE = "/dev/ttyS1"
|
||||||
DISTANCE_SERIAL_BAUDRATE = 9600
|
DISTANCE_SERIAL_BAUDRATE = 9600
|
||||||
|
|
||||||
# I2C配置(根据WiFi模块开关自动选择)
|
# I2C:板载 WiFi 方案固定 I2C5,引脚 A15(SCL) / A27(SDA),供 INA226 等
|
||||||
# 无WiFi模块:I2C_BUS_NUM = 1,引脚:P18(I2C1_SCL), P21(I2C1_SDA)
|
I2C_BUS_NUM = 5
|
||||||
# 有WiFi模块:I2C_BUS_NUM = 5,引脚:A15(I2C5_SCL), A27(I2C5_SDA)
|
|
||||||
I2C_BUS_NUM = 5 if HAS_WIFI_MODULE else 1
|
|
||||||
|
|
||||||
INA226_ADDR = 0x40
|
INA226_ADDR = 0x40
|
||||||
|
# False=完全不访问 INA226(无电源计量板或未供电时避免 ~2.5s writeto 重试与底层 write failed 日志);量产有芯片时设为 True
|
||||||
|
INA226_ENABLE = True
|
||||||
|
# True=整总线 I2C scan 探测 INA226(在部分平台上极慢,可达 ~90s+);False=仅对 INA226_ADDR 快速探测(writeto 空写)
|
||||||
|
INA226_PROBE_FULL_BUS_SCAN = False
|
||||||
REG_CONFIGURATION = 0x00
|
REG_CONFIGURATION = 0x00
|
||||||
REG_BUS_VOLTAGE = 0x02
|
REG_BUS_VOLTAGE = 0x02
|
||||||
REG_CURRENT = 0x04 # 电流寄存器
|
REG_CURRENT = 0x04 # 电流寄存器
|
||||||
@@ -95,7 +104,7 @@ DEFAULT_LASER_POINT = (320, 245) # 默认激光中心点
|
|||||||
|
|
||||||
# 硬编码激光点配置
|
# 硬编码激光点配置
|
||||||
HARDCODE_LASER_POINT = True # 是否使用硬编码的激光点(True=使用硬编码值,False=使用校准值)
|
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)
|
LASER_DETECTION_THRESHOLD = 140 # 红色通道阈值(默认120,可调整,范围建议:100-150)
|
||||||
@@ -122,7 +131,176 @@ LASER_CAMERA_OFFSET_CM = 1.4 # 激光在摄像头下方的物理距离(厘米
|
|||||||
IMAGE_CENTER_X = 320 # 图像中心 X 坐标
|
IMAGE_CENTER_X = 320 # 图像中心 X 坐标
|
||||||
IMAGE_CENTER_Y = 240 # 图像中心 Y 坐标
|
IMAGE_CENTER_Y = 240 # 图像中心 Y 坐标
|
||||||
|
|
||||||
FLASH_LASER_WHILE_SHOOTING = True # 是否在拍摄时闪一下激光(True=闪,False=不闪)
|
# ==================== 三角形四角标记:单应性偏移 + PnP 估距 ====================
|
||||||
|
# 依赖 cameraParameters.xml(相机内参)与 triangle_positions.json(四角物方坐标,厘米或毫米见 JSON 约定)。
|
||||||
|
# 部署时请把这两个文件放到 APP_DIR(与 main 同应用目录),或改下面路径为设备上的实际绝对路径。
|
||||||
|
USE_TRIANGLE_OFFSET = False # False 时仅走黄心圆/椭圆 + 半径估距,不使用三角形路径
|
||||||
|
CAMERA_CALIB_XML = APP_DIR + "/cameraParameters.xml"
|
||||||
|
TRIANGLE_POSITIONS_JSON = APP_DIR + "/triangle_positions.json"
|
||||||
|
# 检测到的三角形边长在图像中的像素范围,分辨率或靶纸占比变化时可微调
|
||||||
|
TRIANGLE_SIZE_RANGE = (8, 500)
|
||||||
|
# PnP 距离合理性检查(可选):超出范围时认为本次检测有误,回退圆心算法
|
||||||
|
# 设为 0 表示不启用(主要防线是单应矩阵 sx/sy 比值检查,无需提前知道距离)
|
||||||
|
# 如果射箭距离很固定,可设具体范围(如 min=2.5, max=6.0)作为额外保险
|
||||||
|
TRIANGLE_DISTANCE_MIN_M = 0.0 # 0=不启用下限检查
|
||||||
|
TRIANGLE_DISTANCE_MAX_M = 0.0 # 0=不启用上限检查
|
||||||
|
# 三角形检测兜底增强:CLAHE(更鲁棒但更慢)。颜色阈值修复后通常不需要,保持关闭以优先速度。
|
||||||
|
TRIANGLE_ENABLE_CLAHE_FALLBACK = False
|
||||||
|
# 三角形检测调试:保存 Otsu 二值化图像(临时调试用,定位后关闭)
|
||||||
|
TRIANGLE_SAVE_DEBUG_IMAGE = False
|
||||||
|
# 三角形颜色过滤阈值(三角形内部灰度判定)
|
||||||
|
# 如果三角形标记印刷较浅/环境较亮,可放宽:
|
||||||
|
# max_interior_gray: 三角形内部平均灰度上限(越大越宽松,90→130 适应浅色印刷)
|
||||||
|
# dark_pixel_gray: "暗像素"灰度判定阈值(越大越宽松,80→130)
|
||||||
|
# min_dark_ratio: 暗像素占比下限(越小越宽松,0.70→0.30)
|
||||||
|
TRIANGLE_MAX_INTERIOR_GRAY = 130
|
||||||
|
TRIANGLE_DARK_PIXEL_GRAY = 130
|
||||||
|
TRIANGLE_MIN_DARK_RATIO = 0.30
|
||||||
|
# 三角形相对对比度阈值:内部比周围暗多少灰度值才认为有效(0=禁用相对对比度)
|
||||||
|
TRIANGLE_MIN_CONTRAST_DIFF = 15
|
||||||
|
# 三角形形状约束容差(等腰直角判定松紧度)
|
||||||
|
# 增大可容忍轮廓轻微变形(印刷不均、阴影局部切角),减少"差一点点就失败"的漏检
|
||||||
|
# 建议范围:0.20(原始/严格) ~ 0.30(宽松);超过 0.35 容易误检非三角形
|
||||||
|
TRIANGLE_SHAPE_LEG_TOLERANCE = 0.25 # 两直角边长度比例容差(原 0.20)
|
||||||
|
TRIANGLE_SHAPE_HYP_TOLERANCE = 0.25 # 斜边与期望长度比例容差(原 0.20)
|
||||||
|
TRIANGLE_SHAPE_COS_TOLERANCE = 0.25 # 直角余弦绝对值上限(原 0.20,越小越严格)
|
||||||
|
# 三角形检测主超时(毫秒):join 等待子线程的最长时间。
|
||||||
|
# 整段 try_triangle_scoring 含「多路径二值化 + C(n,4) 四角评分 + 单应性 + PnP」,往往比黄心圆检测慢。
|
||||||
|
# 建议设为实测最坏耗时的 1.2 倍;超时后圆心检测仍会并行跑完,跑完后若三角形已结束则优先用三角形。
|
||||||
|
TRIANGLE_TIMEOUT_MS = 1000
|
||||||
|
# True=打印各阶段耗时(ms),用于定位瓶颈;稳定后可 False 减少日志
|
||||||
|
TRIANGLE_TIMING_LOG = True
|
||||||
|
# True=Stage2 每个子框内传统三角失败时打一条统计(Otsu/Adaptive 下轮廓数与各拒绝原因计数)
|
||||||
|
TRIANGLE_LOG_STAGE2_PATCH_REJECT = True
|
||||||
|
|
||||||
|
# 仅检出 3 个真实三角时:是否在预测位置附近做小 ROI(Otsu/adaptive)再搜第 4 个真实三角。
|
||||||
|
# False=跳过该搜索,直接用几何推算的虚拟第 4 点(offset_method=triangle_homography_3pt),省 ~10~120ms;若实测偏移可接受可关。
|
||||||
|
TRIANGLE_FOURTH_ROI_SEARCH_ENABLE = False
|
||||||
|
|
||||||
|
# ── 轻量锐化(Unsharp Mask)──────────────────────────────────────────────────
|
||||||
|
# 目的:轻度/中度模糊时增强边缘,让三角形轮廓更易被 approxPolyDP 检出。
|
||||||
|
# 严重运动模糊时反而会放大噪声,建议搭配 sharpness 检测自动触发(见下)。
|
||||||
|
# YOLO 裁切后图已较清晰时可 False,省去 Unsharp 开销并减轻振铃。
|
||||||
|
TRIANGLE_SHARPEN_ENABLE = False # False=关闭锐化(彻底跳过计算,最省时)
|
||||||
|
# 仅当帧清晰度(Laplacian 方差)低于此值时才锐化;高于此值说明图片本身够清晰,不动
|
||||||
|
# 0=总是锐化;建议 50~150;对应日志中 [TRI] sharpness=xxx
|
||||||
|
TRIANGLE_SHARPEN_THRESHOLD = 0.0 # 0=总是锐化(不做 Laplacian 判断,省去计算)
|
||||||
|
# Unsharp Mask 高斯核 sigma(越大锐化越强,通常 1.0~3.0)
|
||||||
|
TRIANGLE_SHARPEN_SIGMA = 2.0
|
||||||
|
# Unsharp Mask 强度系数(越大锐化越猛,通常 1.2~2.0;>2 易产生振铃)
|
||||||
|
TRIANGLE_SHARPEN_STRENGTH = 1.5
|
||||||
|
|
||||||
|
# 三角形检测用灰度来源(ROI 裁切、缩放到 img_det 之后;与 vision 一致按 RGB 输入)
|
||||||
|
# rgb — 常规 cv2.cvtColor RGB2GRAY
|
||||||
|
# v_suppress — HSV 的 V:亮度 >= TRIANGLE_HSV_V_SUPPRESS_ABOVE 的像素灰度强制为 255,压制黄/红/蓝等亮环后再走原有 Otsu 流水线
|
||||||
|
# fallback_v_suppress — 先用 rgb 跑 detect;若检出三角形 <3,再用 v_suppress 重跑一遍(省平均耗时,坏帧可多救一点)
|
||||||
|
# try_both — rgb 与 v_suppress 各完整跑一遍 detect_triangle_markers,取检出数更多一侧(平局保留 rgb);耗时约 2 倍,用于对比效果
|
||||||
|
TRIANGLE_GRAY_MODE = "v_suppress"
|
||||||
|
TRIANGLE_HSV_V_SUPPRESS_ABOVE = 200 # 0~255;偏高则环残留多,偏低则可能伤到暗三角边缘,建议 180~220 扫一圈
|
||||||
|
|
||||||
|
# 三角形检测性能/鲁棒性参数(偏向速度的默认值)
|
||||||
|
# 说明:
|
||||||
|
# - Otsu 是最快的全局阈值;adaptiveThreshold 更鲁棒但更慢
|
||||||
|
# - filtered 候选过多时,枚举 C(n,4) 会变慢,需限幅
|
||||||
|
TRIANGLE_EARLY_EXIT_CANDIDATES = 3 # 找到3个候选即停(第4个由几何推算);原来4需跑完全adaptive
|
||||||
|
TRIANGLE_ADAPTIVE_BLOCK_SIZES = (11,) # 只用1个block_size;原(11,21)跑两遍adaptive
|
||||||
|
TRIANGLE_MAX_FILTERED_FOR_COMBO = 10 # 参与四点组合评分的最大候选数(超过则截断到最可能的一部分)
|
||||||
|
|
||||||
|
# ROI 局部阈值:四个象限各自 Otsu(+ 可选 ROI 内 adaptive),再合并候选。
|
||||||
|
# 顺序:紧接在全局 Otsu 之后、整图 adaptive 之前(见 triangle_target.detect_triangle_markers)。
|
||||||
|
# 用途:阴阳脸/大阴影下往往比「先整图 adaptive」更省时间且更稳;整图 adaptive 最慢,作补充。
|
||||||
|
#
|
||||||
|
# YOLO 已裁到靶区时,整幅小图上单一全局 Otsu 容易把环与四角揉在一个阈值里;可跳过第一轮「全局轮廓提取」,
|
||||||
|
# 直接进入下面四象限 ROI Otsu(仍会算全局 b_otsu 供 relaxed approxPolyDP 回退)。整图模式勿开。
|
||||||
|
TRIANGLE_SKIP_GLOBAL_OTSU_EXTRACT_ON_YOLO_ROI = True
|
||||||
|
|
||||||
|
TRIANGLE_ROI_ENABLED = False
|
||||||
|
TRIANGLE_ROI_MIN_CANDIDATES = 3 # 候选数低于此值时启用 ROI 局部阈值(需至少 3 个点才能三角解算)
|
||||||
|
TRIANGLE_ROI_OVERLAP_RATIO = 0.08 # 象限 ROI 的重叠比例(避免角标落在分割边界被切断)
|
||||||
|
TRIANGLE_ROI_USE_ADAPTIVE = False # ROI 内关闭 adaptive(只跑ROI Otsu,省去4×adaptive);遇到阴阳脸再开
|
||||||
|
|
||||||
|
# 多路径融合:不同二值化路径若得到相近中心(dedup 格点),累加 path_votes,后续优先参与四点组合。
|
||||||
|
TRIANGLE_MULTI_PATH_VOTE = True
|
||||||
|
|
||||||
|
# 失败回退(仍不足 TRIANGLE_FALLBACK_MIN_CANDIDATES 时按序尝试,每条仅在前序仍不足时执行)
|
||||||
|
TRIANGLE_FALLBACK_MIN_CANDIDATES = 3
|
||||||
|
# 对同一幅 Otsu 二值图用更宽松的 approxPolyDP,找回被“切角”的轮廓
|
||||||
|
TRIANGLE_FALLBACK_RELAXED_EPS = True
|
||||||
|
TRIANGLE_RELAXED_POLY_EPS_SCALE = 1.65
|
||||||
|
# Black-hat(顶帽逆):突出比周围暗的斑块,再 Otsu;对阴影/照度不均往往有效,略慢于纯 Otsu
|
||||||
|
TRIANGLE_FALLBACK_BLACKHAT = True
|
||||||
|
TRIANGLE_BLACKHAT_KERNEL_FRAC = 0.018 # 核大小 ≈ min(h,w)*frac,取奇数,范围约 [7, 31]
|
||||||
|
|
||||||
|
# ── YOLO(NPU) 靶环 ROI → 裁剪后再跑三角形(减小 CPU 处理面积)──────────────────
|
||||||
|
# 日志里 net_in=W×H 来自 .mud 模型(det.input_width/height),不是这里配置的。
|
||||||
|
TRIANGLE_YOLO_ROI_ENABLE = True
|
||||||
|
TRIANGLE_YOLO_MODEL_PATH = APP_DIR + "/model_270139.mud"
|
||||||
|
# 参与 ROI 的类别:多类时只填「整靶/靶环」的 id;不要填角标类,否则 union 仍可对,但 largest 会偏小。
|
||||||
|
TRIANGLE_YOLO_RING_CLASS_IDS = (0,)
|
||||||
|
TRIANGLE_YOLO_CONF_TH = 0.7
|
||||||
|
TRIANGLE_YOLO_IOU_TH = 0.45
|
||||||
|
# YOLO 首次/临界帧可能在高阈值下 0 框;启用后仅在 0 候选时用较低阈值重试一次。
|
||||||
|
# 后续仍会经过 min_box_side、ROI aspect、三角形几何校验,避免直接放大假阳性。
|
||||||
|
TRIANGLE_YOLO_RETRY_ON_EMPTY = True
|
||||||
|
TRIANGLE_YOLO_RETRY_CONF_TH = 0.5
|
||||||
|
TRIANGLE_YOLO_ROI_MARGIN_FRAC = 0.11
|
||||||
|
# union: 所有候选框外接矩形(一类多框:环+四角);largest: 只取面积最大的框
|
||||||
|
TRIANGLE_YOLO_ROI_MERGE_MODE = "union"
|
||||||
|
# native: Maix 已将框映射到相机分辨率;letterbox: 框在网络输入坐标需逆变换(重复映射会出细条 ROI)
|
||||||
|
TRIANGLE_YOLO_COORD_MODE = "native"
|
||||||
|
# 参与 ROI 合并前丢弃过小的框(低 conf 时边角 1×1 假阳性)
|
||||||
|
TRIANGLE_YOLO_MIN_BOX_SIDE_PX = 8
|
||||||
|
TRIANGLE_YOLO_REJECT_BAD_ROI = True
|
||||||
|
# try_triangle_scoring 收到 ROI 后裁剪的最小边长(像素),过小则退回整图
|
||||||
|
TRIANGLE_CROP_ROI_MIN_SIDE_PX = 64
|
||||||
|
# 射箭保存图 / 预览上绘制 YOLO 靶环 ROI 矩形 (x0,y0,x1,y1),核对是否裁准;不需要时改 False
|
||||||
|
TRIANGLE_YOLO_DRAW_ROI_ON_SHOT = True
|
||||||
|
# 物方采样调试:以靶心为中心,取半径 15cm 的圆周样本点,用于黑/白颜色对比
|
||||||
|
TRIANGLE_SAMPLE_RADIUS_CM = 15.0
|
||||||
|
TRIANGLE_SAMPLE_ANGLES_DEG = (0, 90, 180, 270)
|
||||||
|
TRIANGLE_SAMPLE_PATCH_HALF_PX = 2
|
||||||
|
# 开机阶段预加载 YOLO detector;detect 使用 dual_buff=False,避免返回上一帧结果。
|
||||||
|
TRIANGLE_YOLO_PRELOAD_ON_BOOT = False
|
||||||
|
|
||||||
|
# ── 第二段 YOLO:仅在 Stage1 裁切出的靶环图上推理(与合成 stage2 训练数据一致)→ 子框内传统算法取直角点 ──
|
||||||
|
# Stage1 靶环裁切内如何找黑三角标记(对比耗时时可切换):
|
||||||
|
# "yolo" — 调 Stage2 黑三角模型得子框,再子框内传统提取(需 TRIANGLE_BLACK_YOLO_ENABLE=True)。
|
||||||
|
# "traditional" — 不调 Stage2 模型;仅在 Stage1 ROI 整幅上跑传统 detect_triangle_markers(与 yolo 路径对比用)。
|
||||||
|
TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE = "traditional"
|
||||||
|
# True 时每箭另打一枪端到端耗时:yolo_ring + yolo_black + try_triangle_scoring 墙钟(毫秒)
|
||||||
|
TRIANGLE_LOG_E2E_TIMING = True
|
||||||
|
TRIANGLE_BLACK_YOLO_ENABLE = True
|
||||||
|
TRIANGLE_BLACK_YOLO_MODEL_PATH = APP_DIR + "/model_270820.mud"
|
||||||
|
TRIANGLE_BLACK_YOLO_CLASS_IDS = (0,)
|
||||||
|
TRIANGLE_BLACK_YOLO_CONF_TH = 0.5
|
||||||
|
TRIANGLE_BLACK_YOLO_IOU_TH = 0.45
|
||||||
|
# Maix YOLOv5 detect 返回的框已映射到传入的 Stage1 裁切图坐标;contain/letterbox 是模型内部预处理。
|
||||||
|
TRIANGLE_BLACK_YOLO_COORD_MODE = "native"
|
||||||
|
# 子框相对 YOLO 框的扩展(在靶环裁切图坐标系下),利于传统算法取边
|
||||||
|
TRIANGLE_BLACK_YOLO_BOX_MARGIN_FRAC = 0.08
|
||||||
|
TRIANGLE_BLACK_YOLO_MIN_BOX_SIDE_PX = 6.0
|
||||||
|
# 子框传统检测不足 3 个时是否回退为「整幅靶环 ROI」上的原 detect_triangle_markers
|
||||||
|
TRIANGLE_BLACK_YOLO_FALLBACK_ON_PATCH_FAIL = True
|
||||||
|
# Stage2 子框内传统提取使用的灰度(有缩略时默认在 Stage1 全分辨率灰度上切片):
|
||||||
|
# "rgb" — 仅用 RGB→灰度(不再做 Unsharp、不做 V 抑制),最省 CPU(推荐子框已对准黑三角时)。
|
||||||
|
# "global" — 与整幅 ROI 三角流程同一张 gray(含 TRIANGLE_GRAY_MODE 的 v_suppress 与锐化);更稳但更耗时。
|
||||||
|
TRIANGLE_BLACK_YOLO_PATCH_GRAY_SOURCE = "rgb"
|
||||||
|
# Stage2 子框内轮廓→三角形:approxPolyDP 的 ε=周长×FRAC×mult。边模糊时略增大 FRAC 或保留多级 mult。
|
||||||
|
TRIANGLE_PATCH_APPROXPOLY_FRAC = 0.055
|
||||||
|
TRIANGLE_PATCH_APPROXPOLY_RELAX_MULTS = (1.0, 1.3, 1.65)
|
||||||
|
# Otsu/Adaptive 前对子框灰度轻模糊:0=关闭;3 或 5=Gaussian ksize(须为奇数),压锯齿利于收成 3 顶点
|
||||||
|
TRIANGLE_PATCH_PRE_BLUR_KSIZE = 0
|
||||||
|
TRIANGLE_BLACK_YOLO_PRELOAD_ON_BOOT = True
|
||||||
|
# 每箭是否在日志中打印黑三角 detect 统计(raw/类过滤/是否在环内);调通后可 False 减日志
|
||||||
|
TRIANGLE_BLACK_YOLO_LOG_EACH_SHOT = True
|
||||||
|
# True=每次射箭将 Stage1 裁切图(黑三角模型输入)存为 JPEG;调试用,量产请 False
|
||||||
|
TRIANGLE_BLACK_YOLO_SAVE_ROI_CROP = True
|
||||||
|
# 存盘目录;空字符串表示使用 PHOTO_DIR + "/stage2_roi"
|
||||||
|
TRIANGLE_BLACK_YOLO_ROI_CROP_DIR = ""
|
||||||
|
# 存盘 JPEG 上绘制 Stage2(黑三角 YOLO)最终子框(绿框 + s2_0… 标签)
|
||||||
|
TRIANGLE_BLACK_YOLO_SAVE_ROI_DRAW_BOXES = True
|
||||||
|
|
||||||
|
FLASH_LASER_WHILE_SHOOTING = False # 是否在拍摄时闪一下激光(True=闪,False=不闪)
|
||||||
FLASH_LASER_DURATION_MS = 1000 # 闪一下激光的持续时间(毫秒)
|
FLASH_LASER_DURATION_MS = 1000 # 闪一下激光的持续时间(毫秒)
|
||||||
|
|
||||||
# ==================== 显示配置 ====================
|
# ==================== 显示配置 ====================
|
||||||
@@ -130,10 +308,19 @@ LASER_COLOR = (0, 255, 0) # RGB颜色
|
|||||||
LASER_THICKNESS = 1
|
LASER_THICKNESS = 1
|
||||||
LASER_LENGTH = 2
|
LASER_LENGTH = 2
|
||||||
|
|
||||||
|
# ==================== 队列大小限制(防止内存泄漏) ====================
|
||||||
|
MAX_SEND_QUEUE_SIZE = 500 # 发送队列上限
|
||||||
|
MAX_TCP_PAYLOADS = 500 # AT TCP 载荷缓存上限
|
||||||
|
MAX_HTTP_EVENTS = 200 # AT HTTP 事件缓存上限
|
||||||
|
LOG_QUEUE_MAXSIZE = 10000 # 日志队列上限
|
||||||
|
MAX_CMD_THREADS = 10 # 并发命令线程上限(防止服务器下发命令时无限创建线程)
|
||||||
|
|
||||||
# ==================== 图像保存配置 ====================
|
# ==================== 图像保存配置 ====================
|
||||||
SAVE_IMAGE_ENABLED = True # 是否保存图像(True=保存,False=不保存)
|
SAVE_IMAGE_ENABLED = False # 是否保存图像(True=保存,False=不保存)
|
||||||
PHOTO_DIR = "/root/phot" # 照片存储目录
|
PHOTO_DIR = "/root/phot" # 照片存储目录
|
||||||
MAX_IMAGES = 1000
|
MAX_IMAGES = 1000
|
||||||
|
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
|
||||||
|
TRIANGLE_BLACK_YOLO_STAGE2_ROI_MAX_IMAGES = None
|
||||||
|
|
||||||
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = False # 是否在拍摄时显示摄像头图像(True=显示,False=不显示),建议在连着USB测试过程中打开
|
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = False # 是否在拍摄时显示摄像头图像(True=显示,False=不显示),建议在连着USB测试过程中打开
|
||||||
|
|
||||||
@@ -142,67 +329,29 @@ MAX_BACKUPS = 5
|
|||||||
LOG_MAX_BYTES = 10 * 1024 * 1024 # 10MB
|
LOG_MAX_BYTES = 10 * 1024 * 1024 # 10MB
|
||||||
LOG_BACKUP_COUNT = 5
|
LOG_BACKUP_COUNT = 5
|
||||||
|
|
||||||
# ==================== 引脚映射配置 ====================
|
# ==================== 引脚映射配置(板载 WiFi,I2C5)====================
|
||||||
# 无WiFi模块的引脚映射(I2C1)
|
PIN_MAPPINGS = {
|
||||||
PIN_MAPPINGS_NO_WIFI = {
|
|
||||||
"A18": "UART1_RX",
|
|
||||||
"A19": "UART1_TX",
|
|
||||||
"A29": "UART2_RX",
|
|
||||||
"A28": "UART2_TX",
|
|
||||||
"P18": "I2C1_SCL",
|
|
||||||
"P21": "I2C1_SDA",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 有WiFi模块的引脚映射(I2C5)
|
|
||||||
PIN_MAPPINGS_WITH_WIFI = {
|
|
||||||
"A18": "UART1_RX",
|
"A18": "UART1_RX",
|
||||||
"A19": "UART1_TX",
|
"A19": "UART1_TX",
|
||||||
"A29": "UART2_RX",
|
"A29": "UART2_RX",
|
||||||
"A28": "UART2_TX",
|
"A28": "UART2_TX",
|
||||||
"A15": "I2C5_SCL",
|
"A15": "I2C5_SCL",
|
||||||
"A27": "I2C5_SDA",
|
"A27": "I2C5_SDA",
|
||||||
"A24": "GPIOA24", # 电源板的引脚
|
"A24": "GPIOA24", # 电源板关机控制
|
||||||
}
|
}
|
||||||
|
|
||||||
# 根据WiFi模块开关选择引脚映射
|
|
||||||
PIN_MAPPINGS = PIN_MAPPINGS_WITH_WIFI if HAS_WIFI_MODULE else PIN_MAPPINGS_NO_WIFI
|
|
||||||
|
|
||||||
# ==================== ArUco标定配置 ====================
|
|
||||||
USE_ARUCO = False # 是否使用ArUco标定(True=使用ArUco,False=使用传统黄色靶心检测)
|
|
||||||
|
|
||||||
# ArUco标记配置
|
|
||||||
if USE_ARUCO:
|
|
||||||
import cv2
|
|
||||||
ARUCO_DICT_TYPE = cv2.aruco.DICT_4X4_50 # ArUco字典类型
|
|
||||||
ARUCO_MARKER_SIZE_MM = 40 # ArUco标记边长(毫米)
|
|
||||||
ARUCO_MARKER_IDS = [0, 1, 2, 3] # 四个角的ArUco标记ID
|
|
||||||
|
|
||||||
# 靶纸物理尺寸(毫米)
|
|
||||||
TARGET_PAPER_SIZE_MM = 400 # 靶纸边长 400mm x 400mm
|
|
||||||
|
|
||||||
# ArUco标记在靶纸上的中心坐标(毫米,以靶纸中心为原点)
|
|
||||||
# 靶纸坐标系:中心(0,0),X向右,Y向下(图像坐标系)
|
|
||||||
# 四个角位置:(20,20), (20,380), (380,380), (380,20)
|
|
||||||
# 转换为以中心为原点的坐标:
|
|
||||||
# 左上角(0): (-180, -180) -> 实际(20,20)相对于中心(200,200) = (-180,-180)
|
|
||||||
# 右上角(1): (180, -180) -> 实际(380,20)相对于中心 = (180,-180)
|
|
||||||
# 右下角(2): (180, 180) -> 实际(380,380)相对于中心 = (180,180)
|
|
||||||
# 左下角(3): (-180, 180) -> 实际(20,380)相对于中心 = (-180,180)
|
|
||||||
ARUCO_MARKER_POSITIONS_MM = {
|
|
||||||
0: (-180, -180), # 左上角
|
|
||||||
1: (180, -180), # 右上角
|
|
||||||
2: (180, 180), # 右下角
|
|
||||||
3: (-180, 180), # 左下角
|
|
||||||
}
|
|
||||||
|
|
||||||
# 靶心(黄心)在靶纸上的位置(毫米,相对于靶纸中心)
|
|
||||||
# 标准靶纸靶心就在正中心
|
|
||||||
TARGET_CENTER_OFFSET_MM = (0, 0)
|
|
||||||
|
|
||||||
# ArUco检测参数
|
|
||||||
ARUCO_MIN_MARKER_PERIMETER_RATE = 0.03 # 最小标记周长比例(相对于图像)
|
|
||||||
ARUCO_CORNER_REFINEMENT_METHOD = cv2.aruco.CORNER_REFINE_SUBPIX # 角点精修方法
|
|
||||||
|
|
||||||
# ==================== 电源配置 ====================
|
# ==================== 电源配置 ====================
|
||||||
AUTO_POWER_OFF_IN_SECONDS = 10 * 60 # 自动关机时间(秒),0表示不自动关机
|
AUTO_POWER_OFF_IN_SECONDS = 10 * 60 # 自动关机时间(秒),0表示不自动关机
|
||||||
|
|
||||||
|
# 实机数据:正常放电约为正电流,插入充电线后约为负电流。
|
||||||
|
CHARGING_SHUTDOWN_ENABLED = True # True=充电时退出应用,False=关闭充电关机功能
|
||||||
|
CHARGING_DIAGNOSTIC_LOG_ENABLED = False
|
||||||
|
CHARGING_CHECK_INTERVAL_MS = 5000
|
||||||
|
CHARGING_CURRENT_THRESHOLD_MA = 100.0
|
||||||
|
CHARGING_CONFIRM_COUNT = 2
|
||||||
|
CHARGING_NOTIFY_TIMEOUT_MS = 30000
|
||||||
|
CHARGING_EXIT_SCRIPT = APP_DIR + "/charging_exit.sh"
|
||||||
|
|
||||||
|
BATTERY_SOC_LPF_ALPHA = 0.5
|
||||||
|
BATTERY_SOC_AVG_WINDOW = 5
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ add_library(archery_netcore MODULE
|
|||||||
utils.cpp
|
utils.cpp
|
||||||
decrypt_ota_file.cpp
|
decrypt_ota_file.cpp
|
||||||
msg_handler.cpp
|
msg_handler.cpp
|
||||||
|
tcp_ssl_password.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(archery_netcore PRIVATE
|
target_include_directories(archery_netcore PRIVATE
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
#include "native_logger.hpp"
|
#include "native_logger.hpp"
|
||||||
#include "decrypt_ota_file.hpp"
|
#include "decrypt_ota_file.hpp"
|
||||||
#include "utils.hpp"
|
#include "utils.hpp"
|
||||||
|
#include "tcp_ssl_password.hpp"
|
||||||
|
|
||||||
namespace py = pybind11;
|
namespace py = pybind11;
|
||||||
using json = nlohmann::json;
|
using json = nlohmann::json;
|
||||||
@@ -61,6 +62,14 @@ PYBIND11_MODULE(archery_netcore, m) {
|
|||||||
|
|
||||||
m.def("get_config", &get_config, "Get system configuration");
|
m.def("get_config", &get_config, "Get system configuration");
|
||||||
|
|
||||||
|
m.def(
|
||||||
|
"calculate_tcp_ssl_password",
|
||||||
|
&netcore::calculate_tcp_ssl_password,
|
||||||
|
"Calculate TCP SSL password: hex(md5(hex(md5(device_id)) + iccid))",
|
||||||
|
py::arg("device_id"),
|
||||||
|
py::arg("iccid")
|
||||||
|
);
|
||||||
|
|
||||||
m.def(
|
m.def(
|
||||||
"decrypt_ota_file",
|
"decrypt_ota_file",
|
||||||
[](const std::string& input_path, const std::string& output_zip_path) {
|
[](const std::string& input_path, const std::string& output_zip_path) {
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
#include <pybind11/pybind11.h>
|
|
||||||
#include <pybind11/stl.h> // 支持 std::vector, std::map 等
|
|
||||||
#include <nlohmann/json.hpp>
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <openssl/evp.h>
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <openssl/evp.h>
|
||||||
#include "native_logger.hpp"
|
#include "native_logger.hpp"
|
||||||
|
|
||||||
namespace netcore{
|
namespace netcore{
|
||||||
@@ -18,11 +15,11 @@ namespace netcore{
|
|||||||
constexpr size_t kOtaMagicLen = 7;
|
constexpr size_t kOtaMagicLen = 7;
|
||||||
constexpr size_t kGcmNonceLen = 12;
|
constexpr size_t kGcmNonceLen = 12;
|
||||||
constexpr size_t kGcmTagLen = 16;
|
constexpr size_t kGcmTagLen = 16;
|
||||||
|
constexpr size_t kHeaderLen = kOtaMagicLen + kGcmNonceLen;
|
||||||
|
// 分块解密,避免整包读入导致 RAM 峰值约为「文件大小×2」(小内存设备易 OOM)
|
||||||
|
constexpr size_t kDecryptChunk = 65536;
|
||||||
|
|
||||||
// 固定 32-byte AES-256-GCM key(提高被直接查看的成本;不是绝对安全)
|
|
||||||
// 注意:需要与打包端传入的 --aead-key-hex 保持一致。
|
|
||||||
static std::array<uint8_t, 32> ota_key_bytes() {
|
static std::array<uint8_t, 32> ota_key_bytes() {
|
||||||
// 简单拆分混淆:key = a XOR b
|
|
||||||
static const std::array<uint8_t, 32> a = {
|
static const std::array<uint8_t, 32> a = {
|
||||||
0x92,0x99,0x4d,0x06,0x6f,0xb6,0xa6,0x3d,0x85,0x08,0xbe,0x73,0x5e,0x73,0x4d,0x8a,
|
0x92,0x99,0x4d,0x06,0x6f,0xb6,0xa6,0x3d,0x85,0x08,0xbe,0x73,0x5e,0x73,0x4d,0x8a,
|
||||||
0x53,0x88,0xe6,0x99,0xfc,0x10,0x29,0xb9,0x16,0x9b,0xe7,0x0c,0x65,0x21,0x1c,0xce
|
0x53,0x88,0xe6,0x99,0xfc,0x10,0x29,0xb9,0x16,0x9b,0xe7,0x0c,0x65,0x21,0x1c,0xce
|
||||||
@@ -36,56 +33,45 @@ namespace netcore{
|
|||||||
return k;
|
return k;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool read_file_all(const std::string& path, std::vector<uint8_t>& out) {
|
|
||||||
std::ifstream ifs(path, std::ios::binary);
|
|
||||||
if (!ifs) return false;
|
|
||||||
ifs.seekg(0, std::ios::end);
|
|
||||||
std::streampos size = ifs.tellg();
|
|
||||||
if (size <= 0) return false;
|
|
||||||
ifs.seekg(0, std::ios::beg);
|
|
||||||
out.resize(static_cast<size_t>(size));
|
|
||||||
if (!ifs.read(reinterpret_cast<char*>(out.data()), size)) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool write_file_all(const std::string& path, const uint8_t* data, size_t len) {
|
|
||||||
std::ofstream ofs(path, std::ios::binary | std::ios::trunc);
|
|
||||||
if (!ofs) return false;
|
|
||||||
ofs.write(reinterpret_cast<const char*>(data), static_cast<std::streamsize>(len));
|
|
||||||
return static_cast<bool>(ofs);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool decrypt_ota_file_impl(const std::string& input_path, const std::string& output_zip_path) {
|
bool decrypt_ota_file_impl(const std::string& input_path, const std::string& output_zip_path) {
|
||||||
std::vector<uint8_t> in;
|
std::ifstream ifs(input_path, std::ios::binary);
|
||||||
if (!netcore::read_file_all(input_path, in)) {
|
if (!ifs) {
|
||||||
netcore::log_error(std::string("decrypt_ota_file: read failed: ") + input_path);
|
netcore::log_error(std::string("decrypt_ota_file: open in failed: ") + input_path);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
ifs.seekg(0, std::ios::end);
|
||||||
const size_t min_len = kOtaMagicLen + kGcmNonceLen + kGcmTagLen + 1;
|
const std::streampos szp = ifs.tellg();
|
||||||
if (in.size() < min_len) {
|
if (szp <= 0) {
|
||||||
|
netcore::log_error("decrypt_ota_file: empty input");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const uint64_t file_size = static_cast<uint64_t>(szp);
|
||||||
|
const size_t min_len = kHeaderLen + kGcmTagLen + 1;
|
||||||
|
if (file_size < min_len) {
|
||||||
netcore::log_error("decrypt_ota_file: too short");
|
netcore::log_error("decrypt_ota_file: too short");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!std::equal(in.begin(), in.begin() + kOtaMagicLen, reinterpret_cast<const uint8_t*>(kOtaMagic))) {
|
const uint64_t ciphertext_len = file_size - kHeaderLen - kGcmTagLen;
|
||||||
|
|
||||||
|
ifs.seekg(0, std::ios::beg);
|
||||||
|
std::array<uint8_t, kHeaderLen> header{};
|
||||||
|
ifs.read(reinterpret_cast<char*>(header.data()), static_cast<std::streamsize>(kHeaderLen));
|
||||||
|
if (ifs.gcount() != static_cast<std::streamsize>(kHeaderLen)) {
|
||||||
|
netcore::log_error("decrypt_ota_file: read header failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!std::equal(header.begin(), header.begin() + kOtaMagicLen,
|
||||||
|
reinterpret_cast<const uint8_t*>(kOtaMagic))) {
|
||||||
netcore::log_error("decrypt_ota_file: bad magic");
|
netcore::log_error("decrypt_ota_file: bad magic");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const uint8_t* nonce = header.data() + kOtaMagicLen;
|
||||||
|
|
||||||
const uint8_t* nonce = in.data() + kOtaMagicLen;
|
std::ofstream ofs(output_zip_path, std::ios::binary | std::ios::trunc);
|
||||||
const uint8_t* ct_and_tag = in.data() + kOtaMagicLen + kGcmNonceLen;
|
if (!ofs) {
|
||||||
const size_t ct_and_tag_len = in.size() - (kOtaMagicLen + kGcmNonceLen);
|
netcore::log_error(std::string("decrypt_ota_file: open out failed: ") + output_zip_path);
|
||||||
if (ct_and_tag_len <= kGcmTagLen) {
|
|
||||||
netcore::log_error("decrypt_ota_file: no ciphertext");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const size_t ciphertext_len = ct_and_tag_len - kGcmTagLen;
|
|
||||||
const uint8_t* ciphertext = ct_and_tag;
|
|
||||||
const uint8_t* tag = ct_and_tag + ciphertext_len;
|
|
||||||
|
|
||||||
std::vector<uint8_t> plain(ciphertext_len);
|
|
||||||
int out_len1 = 0;
|
|
||||||
int out_len2 = 0;
|
|
||||||
|
|
||||||
EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
|
EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
@@ -95,6 +81,8 @@ namespace netcore{
|
|||||||
|
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
auto key = ota_key_bytes();
|
auto key = ota_key_bytes();
|
||||||
|
std::vector<uint8_t> chunk_in(kDecryptChunk);
|
||||||
|
std::vector<uint8_t> chunk_out(kDecryptChunk + EVP_MAX_BLOCK_LENGTH);
|
||||||
|
|
||||||
do {
|
do {
|
||||||
if (1 != EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr)) {
|
if (1 != EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr)) {
|
||||||
@@ -109,27 +97,59 @@ namespace netcore{
|
|||||||
netcore::log_error("decrypt_ota_file: set key/iv failed");
|
netcore::log_error("decrypt_ota_file: set key/iv failed");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (1 != EVP_DecryptUpdate(ctx, plain.data(), &out_len1, ciphertext, static_cast<int>(ciphertext_len))) {
|
|
||||||
netcore::log_error("decrypt_ota_file: update failed");
|
uint64_t remaining = ciphertext_len;
|
||||||
|
while (remaining > 0) {
|
||||||
|
const size_t n = static_cast<size_t>(std::min<uint64_t>(remaining, kDecryptChunk));
|
||||||
|
ifs.read(reinterpret_cast<char*>(chunk_in.data()), static_cast<std::streamsize>(n));
|
||||||
|
if (ifs.gcount() != static_cast<std::streamsize>(n)) {
|
||||||
|
netcore::log_error("decrypt_ota_file: read ciphertext chunk failed");
|
||||||
|
goto cleanup_ctx;
|
||||||
|
}
|
||||||
|
int outl = 0;
|
||||||
|
if (1 != EVP_DecryptUpdate(ctx, chunk_out.data(), &outl,
|
||||||
|
chunk_in.data(), static_cast<int>(n))) {
|
||||||
|
netcore::log_error("decrypt_ota_file: update failed");
|
||||||
|
goto cleanup_ctx;
|
||||||
|
}
|
||||||
|
if (outl > 0) {
|
||||||
|
ofs.write(reinterpret_cast<const char*>(chunk_out.data()), outl);
|
||||||
|
if (!ofs) {
|
||||||
|
netcore::log_error("decrypt_ota_file: write plaintext failed");
|
||||||
|
goto cleanup_ctx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remaining -= n;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::array<uint8_t, kGcmTagLen> tag{};
|
||||||
|
ifs.read(reinterpret_cast<char*>(tag.data()), static_cast<std::streamsize>(kGcmTagLen));
|
||||||
|
if (ifs.gcount() != static_cast<std::streamsize>(kGcmTagLen)) {
|
||||||
|
netcore::log_error("decrypt_ota_file: read tag failed");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (1 != EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, static_cast<int>(kGcmTagLen), const_cast<uint8_t*>(tag))) {
|
if (1 != EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, static_cast<int>(kGcmTagLen), tag.data())) {
|
||||||
netcore::log_error("decrypt_ota_file: set tag failed");
|
netcore::log_error("decrypt_ota_file: set tag failed");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (1 != EVP_DecryptFinal_ex(ctx, plain.data() + out_len1, &out_len2)) {
|
|
||||||
|
int outl2 = 0;
|
||||||
|
if (1 != EVP_DecryptFinal_ex(ctx, chunk_out.data(), &outl2)) {
|
||||||
netcore::log_error("decrypt_ota_file: final failed (auth tag mismatch?)");
|
netcore::log_error("decrypt_ota_file: final failed (auth tag mismatch?)");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const size_t plain_len = static_cast<size_t>(out_len1 + out_len2);
|
if (outl2 > 0) {
|
||||||
if (!netcore::write_file_all(output_zip_path, plain.data(), plain_len)) {
|
ofs.write(reinterpret_cast<const char*>(chunk_out.data()), outl2);
|
||||||
netcore::log_error(std::string("decrypt_ota_file: write failed: ") + output_zip_path);
|
if (!ofs) {
|
||||||
break;
|
netcore::log_error("decrypt_ota_file: write final failed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ok = true;
|
ok = true;
|
||||||
} while (false);
|
} while (false);
|
||||||
|
|
||||||
|
cleanup_ctx:
|
||||||
EVP_CIPHER_CTX_free(ctx);
|
EVP_CIPHER_CTX_free(ctx);
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
}
|
} // namespace netcore
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#include "tcp_ssl_password.hpp"
|
||||||
|
|
||||||
|
#include <openssl/md5.h>
|
||||||
|
#include <sstream>
|
||||||
|
#include <iomanip>
|
||||||
|
|
||||||
|
namespace netcore {
|
||||||
|
|
||||||
|
static std::string md5_hex(const std::string& input) {
|
||||||
|
MD5_CTX ctx;
|
||||||
|
MD5_Init(&ctx);
|
||||||
|
MD5_Update(&ctx, input.data(), input.size());
|
||||||
|
|
||||||
|
unsigned char digest[MD5_DIGEST_LENGTH];
|
||||||
|
MD5_Final(digest, &ctx);
|
||||||
|
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << std::hex << std::setfill('0');
|
||||||
|
for (int i = 0; i < MD5_DIGEST_LENGTH; ++i) {
|
||||||
|
oss << std::setw(2) << static_cast<unsigned int>(digest[i]);
|
||||||
|
}
|
||||||
|
return oss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string calculate_tcp_ssl_password(const std::string& device_id, const std::string& iccid) {
|
||||||
|
std::string md5_device_hex = md5_hex(device_id);
|
||||||
|
if (!iccid.empty()) {
|
||||||
|
md5_device_hex += iccid;
|
||||||
|
}
|
||||||
|
return md5_hex(md5_device_hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace netcore
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace netcore {
|
||||||
|
std::string calculate_tcp_ssl_password(const std::string& device_id, const std::string& iccid);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
1. 系统目标
|
||||||
|
# 检测靶纸四角的等腰直角三角形标记(每个角一个)
|
||||||
|
# 计算激光落点在靶面上的二维偏移(厘米)
|
||||||
|
# 通过PnP算法估算靶面到相机的距离(米)
|
||||||
|
|
||||||
|
2. 核心算法流程
|
||||||
|
2.1 三角形检测 (detect_triangle_markers)
|
||||||
|
采用多策略级联保证鲁棒性:
|
||||||
|
图像输入 → 多阈值策略 → 候选三角形过滤 → 四点匹配
|
||||||
|
检测策略(按优先级):
|
||||||
|
|
||||||
|
1.全局Otsu二值化(最快,~10ms)
|
||||||
|
2.自适应阈值(多种block size,光照不均时)
|
||||||
|
3.ROI局部阈值(候选不足3个时,分象限独立处理)
|
||||||
|
4.Black-Hat形态学增强(仍不足时,突出暗色标记)
|
||||||
|
|
||||||
|
三角形几何验证:
|
||||||
|
# 必须是直角三角形(检查勾股定理,容差20%)
|
||||||
|
# 两直角边长度差<20%
|
||||||
|
# 内部像素足够暗(灰度≤130,暗像素比例≥30%)
|
||||||
|
# 与周围背景对比度≥15灰度级
|
||||||
|
|
||||||
|
四点匹配算法:
|
||||||
|
# 从候选三角形中枚举所有4点组合
|
||||||
|
# 计算四边形评分:(对角比-1)*3 + (水平比-1) + (垂直比-1) + (边长偏差)*2
|
||||||
|
# 选择评分最低的组合作为四角标记
|
||||||
|
|
||||||
|
2.2 单应性落点计算 (homography_calibration)
|
||||||
|
建立图像坐标系 → 靶面坐标系(二维平面)的透视变换
|
||||||
|
|
||||||
|
将激光点像素坐标映射到靶面坐标(厘米)
|
||||||
|
|
||||||
|
使用RANSAC提高鲁棒性(阈值1像素)
|
||||||
|
|
||||||
|
2.3 PnP距离估计 (pnp_distance_meters)
|
||||||
|
已知四个标记点的三维坐标(x,y,z,单位cm)
|
||||||
|
|
||||||
|
通过solvePnP求解相机外参(旋转+平移)
|
||||||
|
|
||||||
|
距离 = ‖平移向量‖ / 100(转换为米)
|
||||||
|
|
||||||
|
3. 关键优化策略
|
||||||
|
3.1 多路径投票
|
||||||
|
同一图像区域被不同二值化方法检测到时,path_votes++
|
||||||
|
|
||||||
|
选择投票数高的候选,提高检测可信度
|
||||||
|
|
||||||
|
3.2 早退机制
|
||||||
|
候选≥3个 且 覆盖3个以上象限 → 停止更多阈值尝试
|
||||||
|
|
||||||
|
大幅降低嵌入式设备计算开销
|
||||||
|
|
||||||
|
3.3 3点补全机制
|
||||||
|
当只检测到3个角时,通过仿射变换估算第4个角位置
|
||||||
|
|
||||||
|
公式:P_missing = M_inv @ [x_target, y_target, 1]
|
||||||
|
|
||||||
|
3.4 图像缩放
|
||||||
|
默认缩放到0.5倍进行检测(由config控制)
|
||||||
|
|
||||||
|
坐标还原时乘以inv_scale,保持与标定矩阵一致
|
||||||
|
|
||||||
|
4. 数据流示例
|
||||||
|
python
|
||||||
|
输入:
|
||||||
|
- img_rgb: H×W×3 图像
|
||||||
|
- laser_xy: (x_px, y_px) 激光点像素坐标
|
||||||
|
- marker_positions: {0:[0,0,0], 1:[0,30,0], 2:[30,30,0], 3:[30,0,0]} # 4角3D坐标(cm)
|
||||||
|
|
||||||
|
输出:
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"dx_cm": 2.5, # 靶面X偏移(cm,向右为正)
|
||||||
|
"dy_cm": -3.2, # 靶面Y偏移(cm,向上为正)
|
||||||
|
"distance_m": 5.43, # 相机到靶面距离(米)
|
||||||
|
"offset_method": "triangle_homography",
|
||||||
|
"distance_method": "pnp_triangle"
|
||||||
|
}
|
||||||
|
5. 鲁棒性设计
|
||||||
|
5.1 参数自适应
|
||||||
|
从config.py动态读取所有阈值(可在线调整)
|
||||||
|
|
||||||
|
三角形边长范围、灰度阈值、对比度要求等均可配置
|
||||||
|
|
||||||
|
5.2 异常处理
|
||||||
|
角点退化检测(距离<3像素判定为重复)
|
||||||
|
|
||||||
|
NaN/Inf校验(单应性矩阵、偏移量、距离)
|
||||||
|
|
||||||
|
距离合理性检查(0.3~20米)
|
||||||
|
|
||||||
|
5.3 降级策略
|
||||||
|
PnP失败 → 只输出偏移,距离置None
|
||||||
|
|
||||||
|
4角检测失败 → 尝试3角补全
|
||||||
|
|
||||||
|
快速路径失败 → CLAHE增强兜底(可选)
|
||||||
|
|
||||||
|
6. 性能特点
|
||||||
|
CPU友好:默认Otsu单次处理,多数场景10-30ms完成检测
|
||||||
|
|
||||||
|
内存可控:最大候选数截断(默认10个),避免组合爆炸
|
||||||
|
|
||||||
|
嵌入式适配:支持图像缩放、早退机制降低计算量
|
||||||
|
|
||||||
|
7. 局限性
|
||||||
|
依赖四个等腰直角三角形(需靶纸特殊设计)
|
||||||
|
|
||||||
|
要求三角形内部足够暗、与背景有对比度
|
||||||
|
|
||||||
|
单应性假设靶面为平面(实际靶纸可能有轻微起伏)
|
||||||
|
|
||||||
|
这套算法在射击训练系统中作为主要定位手段。
|
||||||
|
|
||||||
|
8. 为了加速单应性的计算,引入了yolo模型,一共做了两个模型,一个为靶纸和黑色三角形一体的识别模型,用于做原照片上快速找到靶纸区域。另一个模型是黑色三角形的模型,用于做靶纸区域再找黑色三角形。但是经过对比发现,引入黑色三角形模型反而更慢。入下面的流程A和流程B:
|
||||||
|
yolo靶纸+传统(流程B) yolo靶纸+yolo黑色三角形(流程A)
|
||||||
|
平均值 646.08 916.4457143
|
||||||
|
标准差 94.61300968 57.40401849
|
||||||
|
|
||||||
|
公共前置(两条路都一样)
|
||||||
|
是否用靶环模型裁 Stage1
|
||||||
|
|
||||||
|
TRIANGLE_YOLO_ROI_ENABLE=True 时:跑 靶环 YOLO,得到全图上的 roi_xyxy,后面的三角形都在 img_work = 全图[roi] 上做(必要时再缩成 img_det 给整图传统分支用)。
|
||||||
|
False 时:roi_xyxy=None,三角形在 整幅相机图 上当 img_work。
|
||||||
|
之后都进入 try_triangle_scoring(img_cv, …, roi_xyxy=…, black_yolo_boxes_work=…)
|
||||||
|
|
||||||
|
在里面先做灰度、v_suppress、锐化、det_scale 缩略图等 prep(与是否黑三角模型无关)。
|
||||||
|
差别从 black_yolo_boxes_work 有没有有效子框列表 开始。
|
||||||
|
|
||||||
|
流程 A:用黑色三角形模型(Stage2 黑三角 YOLO)
|
||||||
|
配置要点:TRIANGLE_BLACK_YOLO_ENABLE=True,且 TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE="yolo",并且 已有 Stage1 裁切(roi_xyxy 不能为 None,否则根本不会跑黑三角 YOLO)。
|
||||||
|
|
||||||
|
步骤概要:
|
||||||
|
|
||||||
|
try_black_triangle_boxes_work
|
||||||
|
|
||||||
|
输入:全图 RGB + Stage1 的 ring_roi_xyxy。
|
||||||
|
在 Stage1 裁切图(与训练一致的 slab)上跑 黑三角 YOLO,得到若干个 子框(black_boxes_work,坐标在 裁切图/work 系)。
|
||||||
|
try_triangle_scoring 内
|
||||||
|
|
||||||
|
若 black_yolo_boxes_work 非空:
|
||||||
|
按配置在 Stage1 全分辨率灰度(或缩略灰度,视 det_scale / TRIANGLE_BLACK_YOLO_PATCH_GRAY_SOURCE)上,对每个子框裁 patch,跑 _extract_triangle_from_yolo_patch(子框内:Otsu → 失败再单次 Adaptive + 轮廓 + 形状/颜色)。
|
||||||
|
median_leg 过滤,再 四点分配 ID。
|
||||||
|
若 ≥3 个(通常 4 个)有效:认为 Stage2 成功,跳过 整幅 Stage1 上的 detect_triangle_markers。
|
||||||
|
若 不足 3 个 且未关 fallback:在 缩略后的整幅 work 灰度上再走 detect_triangle_markers(整图 Otsu + 整图 Adaptive×block_sizes + 各类 fallback),与「不用黑三角模型时的传统主路径」同类。
|
||||||
|
后续
|
||||||
|
|
||||||
|
角点从 det 坐标 ×inv_scale 回到 work,再 +roi 原点 回到全图;单应性、补第 4 点、PnP 等与另一条路相同。
|
||||||
|
耗时上多出来的部分:黑三角 YOLO 推理 + 每个子框一遍传统小流水线(成功时通常 不再付整图 detect_triangle_markers)。
|
||||||
|
|
||||||
|
流程 B:不用黑色三角形模型(纯传统定位三角)
|
||||||
|
典型配置(任一即可达到「不用黑三角模型」的效果):
|
||||||
|
|
||||||
|
TRIANGLE_BLACK_YOLO_ENABLE=False,或
|
||||||
|
TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE="traditional"(即使模型开关开着也不跑黑三角 YOLO),或
|
||||||
|
没有 Stage1 ROI(roi_xyxy is None)时,当前逻辑下 也不会跑 Stage2 黑三角 YOLO。
|
||||||
|
此时 black_yolo_boxes_work=None(或不等价于「有子框」)。
|
||||||
|
|
||||||
|
步骤概要:
|
||||||
|
|
||||||
|
try_triangle_scoring 内
|
||||||
|
不跑 子框 _extract_triangle_from_yolo_patch。
|
||||||
|
直接在 img_det(缩略后的 work) 上调用 detect_triangle_markers:
|
||||||
|
全局 Otsu(若 TRIANGLE_SKIP_GLOBAL_OTSU_EXTRACT_ON_YOLO_ROI 在有 ROI 时可能 不算 Otsu 轮廓,但仍会生成 Otsu 图供后续用);
|
||||||
|
可选 象限 ROI(TRIANGLE_ROI_ENABLED);
|
||||||
|
整图 Adaptive(TRIANGLE_ADAPTIVE_BLOCK_SIZES,例如 (11,));
|
||||||
|
不足再走 放宽 approxPolyDP、BlackHat 等。
|
||||||
|
后面同样是过滤、四点组合/象限分配、单应性、PnP 等。
|
||||||
|
特点:没有黑三角 NPU 时间,也 没有「按框重复 4 次子框传统」;但要在 一整张(缩略)ROI 图 上跑一套更重的 整图 pipeline。
|
||||||
|
|
||||||
|
对照一句话
|
||||||
|
用黑三角 YOLO(流程 A) 不用黑三角 YOLO(流程 B)
|
||||||
|
Stage2
|
||||||
|
黑三角模型给子框 → 子框内 Otsu + 至多一次 Adaptive
|
||||||
|
无 Stage2 模型
|
||||||
|
三角角点从哪来
|
||||||
|
优先 子框传统;不够再 整图 detect_triangle_markers
|
||||||
|
只有 整图 detect_triangle_markers
|
||||||
|
和「全图是否只做 Adaptive」
|
||||||
|
子框 不是只做 Adaptive;整图回退时也与全图路径一致(先 Otsu 等)
|
||||||
|
整图路径 也不是只做 Adaptive
|
||||||
|
靶环 YOLO(Stage1 裁切)在 A/B 里都可以开或关,与「黑三角模型」是独立开关。
|
||||||
|
|
||||||
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
|
|
||||||
1. CPP构建命令:
|
1. CPP构建命令:在docker环境下执行以下命令
|
||||||
|
|
||||||
cd /mnt/d/code/archery/cpp_ext
|
cd /data/cpp_ext
|
||||||
rm -rf build && mkdir build && cd build
|
rm -rf build && mkdir build && cd build
|
||||||
|
|
||||||
TOOLCHAIN_BIN=/mnt/d/code/MaixCDK/dl/extracted/toolchains/maixcam/host-tools/gcc/riscv64-linux-musl-x86_64/bin
|
TOOLCHAIN_BIN=/data/MaixCDK-main/dl/extracted/toolchains/maixcam/host-tools/gcc/riscv64-linux-musl-x86_64/bin
|
||||||
PYDEV=/mnt/d/code/shooting/python3_lib_maixcam_musl_3.11.6
|
PYDEV=/data/python3_lib_maixcam_musl_3.11.6
|
||||||
MAIXCDK=/mnt/d/code/MaixCDK
|
MAIXCDK=/data/MaixCDK-main
|
||||||
|
|
||||||
cmake .. -G Ninja \
|
cmake .. -G Ninja \
|
||||||
-DCMAKE_C_COMPILER="${TOOLCHAIN_BIN}/riscv64-unknown-linux-musl-gcc" \
|
-DCMAKE_C_COMPILER="${TOOLCHAIN_BIN}/riscv64-unknown-linux-musl-gcc" \
|
||||||
@@ -36,3 +36,67 @@ printf 'AT+MHTTPDLFILE="http://static.shelingxingqiu.com/shoot/v1/main.py","down
|
|||||||
4. wifi的启动条件,在 /boot 目录下,看看是否有 wifi.sta 和 wifi.ssid, wifi.pass 这些文件。其中 wifi.sta 是开关文件。
|
4. wifi的启动条件,在 /boot 目录下,看看是否有 wifi.sta 和 wifi.ssid, wifi.pass 这些文件。其中 wifi.sta 是开关文件。
|
||||||
如果没有了它就不会启动wifi流程。具体的wifi流程 由 /etc/init.d/S30wifi 控制。它会判断 wifi.sta 是否存在,然后是否启动wifi,还是启动热点。
|
如果没有了它就不会启动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
|
||||||
|
解决办法:
|
||||||
|
pip install maixtool
|
||||||
|
|
||||||
|
遇到问题:
|
||||||
|
./update_img.sh: line 80: mcopy: command not found
|
||||||
|
解决办法:
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install mtools
|
||||||
|
|
||||||
|
6. 相机标定:
|
||||||
|
然后在板子上跑 test 目录下的 test_camera_rtsp.py ,让相机启动了一个服务,然后在电脑上接收这个视频流,并且跑opencv 内置的标定程序:
|
||||||
|
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.81:8000/stream" 2>nul
|
||||||
|
|
||||||
|
|
||||||
|
7. 生成训练图片:在test目录下,执行以下命令。注意,其中 D:\code\shooting\target_photo\write.png 是靶纸的图片。
|
||||||
|
D:\data\test_target_photo 是用来叠加的背景图
|
||||||
|
|
||||||
|
7.1 生成靶纸及黑色三角形的截图的图片,带动动,但1.12的外框
|
||||||
|
bak
|
||||||
|
python .\synth_compose_yolo.py --perspective 0.04 --perspective-prob 0.8 --color-jitter 0.6 --bg-dir D:\data\test_target_photo --fg D:\code\shooting\target_photo\write.png --out ./synth_out --class-name triangle --zip ./maix_dataset.zip --num 60 --triangles-json archery_triangles_default.json --format voc --stage2-crop --stage2-pad-min 0.03 --stage2-pad-max 0.18 --motion-prob 0.9 --motion-kernel-max 8 --blur-max 0 --triangle-bbox-pad-frac 0.12
|
||||||
|
|
||||||
|
bak_2
|
||||||
|
python synth_keypoints_right_angle.py --bg-dir D:\data\test_target_photo --fg D:\code\shooting\target_photo\write.png --triangles-json archery_triangles_default.json --out ./synth_out --num 1000 --offscreen-shift-prob 0.3 --offscreen-shift-frac 0.4 --offscreen-min-visible 1 --stage2-crop --stage2-pad-min 0.03 --stage2-pad-max 0.18 --motion-prob 0.9 --motion-kernel-max 8 --blur-max 0 --perspective-mode planar --yaw-max-deg 10 --pitch-max-deg 8 --roll-max-deg 4 --planar-focal-frac 1.45 --perspective-prob 0.4
|
||||||
|
|
||||||
|
python synth_keypoints_right_angle.py --bg-dir D:\data\test_target_photo --fg D:\code\shooting\target_photo\write.png --triangles-json archery_triangles_default.json --out ./synth_out --num 1000 --offscreen-shift-prob 0.3 --offscreen-shift-frac 0.4 --offscreen-min-visible 1 --stage2-crop --stage2-pad-min 0.03 --stage2-pad-max 0.18 --motion-prob 1.0 --motion-kernel-max 8 --blur-max 0 --perspective-mode planar --yaw-max-deg 10 --pitch-max-deg 8 --roll-max-deg 4 --planar-focal-frac 1.45 --perspective-prob 0.4
|
||||||
|
|
||||||
|
|
||||||
|
python pose_pixel_metrics.py --model D:\code\archery\runs\pose\runs\pose\target_pose_train\weights\best.pt --data D:\code\archery\datasets\dataset_pose.yaml --imgsz 640
|
||||||
+1
-2
@@ -26,8 +26,7 @@
|
|||||||
program exit failed. exit code: 1.
|
program exit failed. exit code: 1.
|
||||||
|
|
||||||
解决方案:
|
解决方案:
|
||||||
从日志看,就是开始发送登录信息之后就崩溃了。出发了底层的read failed。经过排查,是一定要插上电源板的数据连线,以及电源板要插上电池。这个应该是
|
从日志看,就是开始发送登录信息之后就崩溃了。出发了底层的read failed。经过排查,是一定要插上电源板的数据连线,以及电源板要插上电池。这个应该是登录时需要读电源电压数据。后面我们已经优化了日志,而且增加了对ina226的试探,但发现ina226不存在的时候,就直接返回电压和电流为0.0。而且,一定要注意,在新配套的电源板和核心板上面,才能正常读到电流和电压。
|
||||||
登录时需要读电源电压数据,
|
|
||||||
|
|
||||||
3. a)问题描述:202609 批次的拓展版,有一块maixcam的蓝灯常亮,询问maixcam的人,他们觉得应该是卡没有插好。但是拓展版上的激光口挡住了数据卡的出口,
|
3. a)问题描述:202609 批次的拓展版,有一块maixcam的蓝灯常亮,询问maixcam的人,他们觉得应该是卡没有插好。但是拓展版上的激光口挡住了数据卡的出口,
|
||||||
没法拔出检查,
|
没法拔出检查,
|
||||||
|
|||||||
@@ -102,4 +102,175 @@ WiFi 连接成功
|
|||||||
尝试切换到 4G
|
尝试切换到 4G
|
||||||
↓
|
↓
|
||||||
上层检测到连接断开:
|
上层检测到连接断开:
|
||||||
重新 connect_server() → 自动选择 4G
|
重新 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(黄心半径估距)
|
||||||
|
|
||||||
|
12. 4g模块上传文件:
|
||||||
|
|
||||||
|
Upload images from MaixCam to Qiniu cloud via ML307R 4G module's AT commands. The HTTP body requires multipart/form-data with real CR/LF bytes (0x0D 0x0A) in boundaries.
|
||||||
|
Methods Tried
|
||||||
|
# Method AT Commands Result Root Cause
|
||||||
|
1 Raw binary, no encoding MHTTPCONTENT with raw bytes + length param ERROR at first chunk CR/LF in binary data terminates AT command parser
|
||||||
|
2 Encoding mode 2 (escape) MHTTPCFG="encoding",0,2 + \r\n escapes Server 400 Bad Request Module sends literal text \r\n to server, NOT actual 0x0D 0x0A bytes. Multipart body is garbled
|
||||||
|
3 Encoding mode 1 (hex) MHTTPCFG="encoding",0,1 + hex-encoded data CME ERROR: 650/50 Firmware doesn't properly support hex mode for MHTTPCONTENT
|
||||||
|
4 No chunked mode Skip MHTTPCFG="chunked" CME ERROR: 65 Module requires chunked mode to accept MHTTPCONTENT at all
|
||||||
|
5 Single large MHTTPCONTENT All data in one command (2793 bytes) +MHTTPURC: "err",0,5 (timeout) Possible buffer limit; module hangs then times out
|
||||||
|
6 Per-chunk HTTP instance (OTA style) CREATE→POST→DELETE per chunk Not feasible Each instance = separate HTTP request; Qiniu needs complete body in single POST
|
||||||
|
Conclusion: AT HTTP layer (MHTTPCONTENT) is fundamentally broken for binary uploads.
|
||||||
|
The Solution: Raw TCP Socket (MIPOPEN + MIPSEND)
|
||||||
|
Bypass the AT HTTP layer entirely. Open a raw TCP connection and send a hand-crafted HTTP POST:
|
||||||
|
plaintext
|
||||||
|
AT+MIPCLOSE=3 // Clean up old socket
|
||||||
|
AT+MIPOPEN=3,"TCP","upload.qiniup.com",80 // Raw TCP connection
|
||||||
|
AT+MIPSEND=3,1024 → ">" → [raw bytes] → OK // Binary-safe!
|
||||||
|
AT+MIPSEND=3,1024 → ">" → [raw bytes] → OK
|
||||||
|
AT+MIPSEND=3,766 → ">" → [raw bytes] → OK
|
||||||
|
// Response: +MIPURC: "rtcp",3,<len>,HTTP/1.1 200 OK...
|
||||||
|
AT+MIPCLOSE=3
|
||||||
|
Why it works:
|
||||||
|
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
|
||||||
|
|
||||||
|
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. 黄色怎么判、范围是什么?
|
||||||
|
图像先转 HSV(cv2.COLOR_RGB2HSV,注意输入是 RGB)。
|
||||||
|
饱和度 S 整体乘 1.1 并限制在 0–255(让黄色更「显」一点)。
|
||||||
|
黄色 inRange(OpenCV HSV,H 多为 0–179):
|
||||||
|
通道 下限 上限
|
||||||
|
H 7 32
|
||||||
|
S 80 255
|
||||||
|
V 0 255
|
||||||
|
在黄掩码上找轮廓后,还要满足:面积 > 50,圆度 > 0.7(circularity = 4π·面积/周长²),且点数 ≥5 才 fitEllipse 当黄心椭圆。
|
||||||
|
|
||||||
|
2. 红色怎么判、范围是什么?
|
||||||
|
红色在 HSV 里跨 0°,所以用 两段 H 做并集:
|
||||||
|
两段分别是:
|
||||||
|
H 0–10,S 80–255,V 0–255
|
||||||
|
H 170–180,S 80–255,V 0–255
|
||||||
|
红轮廓候选:面积 > 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]、S≥80(且 S 放大 1.1)+ 形态学闭运算 + 面积/圆度;红色 = 两段 H(0–10 与 170–180)、S≥80 + 闭运算 + 面积/圆度;配对用 同心/包含 的距离与半径比例阈值。若你还关心 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)。
|
||||||
|
逆变换 H−1:已知照片上的像素 (u,v)(如镜头中心点),查字典反推它在靶纸上的真实位置 (x,y)。
|
||||||
|
这个“虚拟平面”就是你的靶纸平面(Z=0 的世界坐标系)。算法没有在物理上移动任何点,只是在做坐标系的换算。
|
||||||
|
|
||||||
|
二、详细步骤:镜头中心点如何“落地”
|
||||||
|
|
||||||
|
相机分辨率是 640x480,镜头中心点(光轴与图像的交点)通常是 (u0,v0)=(320,240)。
|
||||||
|
1. 输入:镜头中心点(像素)
|
||||||
|
2. 核心运算:乘以逆矩阵
|
||||||
|
通过 4 个黑色三角形的角点(已知真实坐标)计算出了单应性矩阵 H。现在使用它的逆矩阵 H−1
|
||||||
|
3. 输出:虚拟平面上的落点(物理坐标)
|
||||||
|
计算后,你会得到:(xhit,yhit)
|
||||||
|
这就是镜头中心点对应的靶纸上的真实位置(单位:毫米)。
|
||||||
|
4. 计算环数
|
||||||
|
由于虚拟平面原点 (0,0)就是靶纸圆心,直接计算欧氏距离。
|
||||||
|
这个 d就是箭着点偏离圆心的真实物理距离,直接用于环数判定。
|
||||||
+8
-24
@@ -1,17 +1,6 @@
|
|||||||
你现在要防的是“别人拿到设备/拿到代码包后,能伪造请求、刷接口、下发恶意 OTA、甚至劫持通信”。单靠隐藏 Python 源码只能提高门槛,真正的安全要靠协议和密钥设计。结合你仓库里实际内容,建议你重点隐藏/整改这些点(按风险排序)。
|
你现在要防的是“别人拿到设备/拿到代码包后,能伪造请求、刷接口、下发恶意 OTA、甚至劫持通信”。单靠隐藏 Python 源码只能提高门槛,真正的安全要靠协议和密钥设计。结合你仓库里实际内容,建议你重点隐藏/整改这些点(按风险排序)。
|
||||||
1. 必须隐藏/必须整改(高风险)
|
|
||||||
1.1 登录口令规则太弱(几乎等于明文)
|
|
||||||
你现在的登录是 password = device_id + "."(见 network.py 读取设备 ID 后直接拼出来),这意味着只要攻击者知道/猜到 device_id,就能直接登录伪装设备。
|
|
||||||
相关位置:
|
|
||||||
with open("/device_key", "r") as f: device_id = f.read().strip() ... self._device_id = device_id self._password = device_id + "."
|
|
||||||
1.2 HTTP 鉴权 token 的盐值是硬编码常量(泄露后可离线伪造)
|
|
||||||
你 token 是 HMAC-SHA256((SALT+device_id), SALT2),而 SALT/SALT2 是固定字符串:"shootMessageFire" / "shoot"。这类“硬编码盐值 + 可猜/可读的 device_id”意味着:攻击者只要拿到代码包/逆向 .so,就能在自己电脑上批量算 token,伪造 HTTP 请求。
|
|
||||||
相关位置:
|
|
||||||
SALT = "shootMessageFire"SALT2 = "shoot"return "Arrow_" + hmac.new((SALT + device_id).encode(), SALT2.encode(), hashlib.sha256).hexdigest()
|
|
||||||
1.3 TLS 配置目前看起来没有做证书校验(容易被中间人攻击)
|
|
||||||
config.py 虽然 USE_TCP_SSL=True,但你在 network.py 里实际把 MSSLCFG="auth" 固定成 0(不验),且写证书分支被 if False 禁用了。这样“看起来是 TLS”,但仍可能被抓包/篡改/假服务器接入。
|
|
||||||
相关位置:
|
|
||||||
r = hardware_manager.at_client.send(f'AT+MSSLCFG="auth",{ssl_id},0', "OK", 3000)...if False: # 写证书/校验被禁用 ...r = hardware_manager.at_client.send(f'AT+MIPCFG="ssl",{link_id},{ssl_id},1', "OK", 3000)
|
|
||||||
1.4 OTA 下发“url”如果缺少强校验,就是远程代码执行入口
|
1.4 OTA 下发“url”如果缺少强校验,就是远程代码执行入口
|
||||||
你 OTA 逻辑里从服务器指令拿到 url 就去下载并替换文件/重启(这是正常 OTA),但安全性取决于:
|
你 OTA 逻辑里从服务器指令拿到 url 就去下载并替换文件/重启(这是正常 OTA),但安全性取决于:
|
||||||
是否只允许白名单域名/路径
|
是否只允许白名单域名/路径
|
||||||
@@ -24,18 +13,13 @@ r = hardware_manager.at_client.send(f'AT+MSSLCFG="auth",{ssl_id},0', "OK", 3000)
|
|||||||
这些在 config.py 是明文:
|
这些在 config.py 是明文:
|
||||||
SERVER_IP = "stcp.shelingxingqiu.com"SERVER_PORT = 50005HTTP_URL = "http://ws.shelingxingqiu.com"HTTP_API_PATH = "/home/shoot/device_fire/arrow/fire"
|
SERVER_IP = "stcp.shelingxingqiu.com"SERVER_PORT = 50005HTTP_URL = "http://ws.shelingxingqiu.com"HTTP_API_PATH = "/home/shoot/device_fire/arrow/fire"
|
||||||
注意:即使你把它们藏进 .so,攻击者仍可能通过抓包/观察 DNS/SNI/流量拿到域名或 IP,所以“隐藏”只能降低静态分析风险,不能替代鉴权/签名。
|
注意:即使你把它们藏进 .so,攻击者仍可能通过抓包/观察 DNS/SNI/流量拿到域名或 IP,所以“隐藏”只能降低静态分析风险,不能替代鉴权/签名。
|
||||||
2.2 WiFi 凭证落盘位置
|
|
||||||
你会把 SSID/密码写到 /boot/wifi.ssid 和 /boot/wifi.pass(network.py/wifi.py 都有)。拿到设备存储就能读到明文密码,这属于设备侧安全问题。
|
|
||||||
2.3 日志/调试信息泄露
|
|
||||||
你仓库里 .cursor/debug.log* 已经记录了完整的 AT+MIPOPEN ... host:port 等信息;如果这些被打进包或被用户导出,也会泄露通信细节。
|
|
||||||
你发布包里“真正会被带走的敏感内容”
|
|
||||||
你现在 app.yaml 的打包清单只包含核心运行文件(不包含 test/、backup_code/、.cursor/ 等),这一点很好:
|
|
||||||
files: - app.yaml - at_client.py - camera_manager.py - config.py - hardware.py - laser_manager.py - logger_manager.py - main.py - network.py - ota_manager.py - power.py - shot_id_generator.py - time_sync.py - version.py - vision.py
|
|
||||||
但要注意:你当前最敏感的“盐值/口令规则/HTTP header 逻辑/OTA 策略”都在这些会打包的文件里(尤其是 network.py、ota_manager.py、config.py)。
|
|
||||||
|
|
||||||
3. 最有效的“防黑”措施(比隐藏更关键)
|
3. 最有效的“防黑”措施(比隐藏更关键)
|
||||||
把“password=device_id+.” 改为真正的 per-device secret(设备出厂烧录,服务端保存;或服务端下发公钥,设备用私钥签名)
|
|
||||||
所有上报/控制命令加签名 + nonce/timestamp + 服务端防重放(别人抓到一次包也不能复用)
|
所有上报/控制命令加签名 + nonce/timestamp + 服务端防重放(别人抓到一次包也不能复用)
|
||||||
OTA 包必须做签名校验(设备端内置公钥,下载后验签通过才应用)
|
OTA 包必须做签名校验(设备端内置公钥,下载后验签通过才应用)
|
||||||
TLS 必须做证书校验/最好做 pinning(至少别用 auth=0)
|
|
||||||
如果你告诉我:你们服务端目前能不能改协议(例如新增签名字段、下发 challenge、做 OTA 签名),我可以按“最小改动但提升最大安全”的顺序,帮你规划一套从现状平滑升级的方案。
|
如果你告诉我:你们服务端目前能不能改协议(例如新增签名字段、下发 challenge、做 OTA 签名),我可以按“最小改动但提升最大安全”的顺序,帮你规划一套从现状平滑升级的方案。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
from maix import image, time
|
||||||
|
from logger_manager import logger_manager
|
||||||
|
from camera_manager import camera_manager
|
||||||
|
|
||||||
|
_USE_CV = False
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
_USE_CV = True
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
WIDTH = 640
|
||||||
|
HEIGHT = 480
|
||||||
|
THRESHOLD = 100
|
||||||
|
RED_RATIO = 1.5
|
||||||
|
SEARCH_RADIUS = 80
|
||||||
|
TRACK_RADIUS = 30
|
||||||
|
MIN_PIXELS = 3
|
||||||
|
COARSE_STEP = 2
|
||||||
|
STABLE_COUNT = 2
|
||||||
|
MAX_SKIP_FRAMES = 5
|
||||||
|
|
||||||
|
# Temporal smoothing
|
||||||
|
_EMA_ALPHA = 0.35
|
||||||
|
_GATE_PX = 10
|
||||||
|
_FRAME_INTERVAL_MS = 50
|
||||||
|
|
||||||
|
_prev_smoothed = None
|
||||||
|
|
||||||
|
|
||||||
|
def _red_weighted_centroid(r_ch, g_ch, b_ch, mask, x0, y0):
|
||||||
|
y_ids, x_ids = np.where(mask)
|
||||||
|
if len(y_ids) == 0:
|
||||||
|
return None
|
||||||
|
r_vals = r_ch[y_ids, x_ids].astype(np.float64)
|
||||||
|
g_vals = g_ch[y_ids, x_ids].astype(np.float64)
|
||||||
|
b_vals = b_ch[y_ids, x_ids].astype(np.float64)
|
||||||
|
w = r_vals - np.maximum(g_vals, b_vals)
|
||||||
|
w = np.clip(w, 0, None)
|
||||||
|
w = w * w
|
||||||
|
total_w = w.sum()
|
||||||
|
if total_w < 1e-6:
|
||||||
|
return None
|
||||||
|
cx = (x_ids.astype(np.float64) * w).sum() / total_w + x0
|
||||||
|
cy = (y_ids.astype(np.float64) * w).sum() / total_w + y0
|
||||||
|
return (float(cx), float(cy))
|
||||||
|
|
||||||
|
|
||||||
|
def find_ellipse(img_cv, cx, cy, roi_r, th, ratio):
|
||||||
|
x1 = max(0, cx - roi_r)
|
||||||
|
x2 = min(WIDTH, cx + roi_r)
|
||||||
|
y1 = max(0, cy - roi_r)
|
||||||
|
y2 = min(HEIGHT, cy + roi_r)
|
||||||
|
roi = img_cv[y1:y2, x1:x2]
|
||||||
|
if roi.size == 0:
|
||||||
|
return None
|
||||||
|
r = roi[:, :, 0].astype(np.int32)
|
||||||
|
g = roi[:, :, 1].astype(np.int32)
|
||||||
|
b = roi[:, :, 2].astype(np.int32)
|
||||||
|
mask = (r > th) & (r > g * ratio) & (r > b * ratio)
|
||||||
|
oe = (r > 200) & (g > 200) & (b > 200) & (r >= g) & (r >= b) & ((r - g) > 10) & ((r - b) > 10)
|
||||||
|
combined = (mask | oe).astype(np.uint8) * 255
|
||||||
|
contours, _ = cv2.findContours(combined, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
if not contours:
|
||||||
|
return None
|
||||||
|
largest = max(contours, key=cv2.contourArea)
|
||||||
|
if cv2.contourArea(largest) < 5:
|
||||||
|
return None
|
||||||
|
cnt = largest.copy()
|
||||||
|
for pt in cnt:
|
||||||
|
pt[0][0] += x1
|
||||||
|
pt[0][1] += y1
|
||||||
|
ellipse_valid = len(cnt) >= 5
|
||||||
|
if ellipse_valid:
|
||||||
|
(ex, ey), (ew, eh), ang = cv2.fitEllipse(cnt)
|
||||||
|
mask_ellipse = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
|
||||||
|
cv2.ellipse(mask_ellipse, (int(ex), int(ey)), (int(ew / 2), int(eh / 2)), ang, 0, 360, 255, -1)
|
||||||
|
return _red_weighted_centroid(
|
||||||
|
img_cv[:, :, 0], img_cv[:, :, 1], img_cv[:, :, 2],
|
||||||
|
mask_ellipse > 0, 0, 0
|
||||||
|
)
|
||||||
|
M = cv2.moments(cnt)
|
||||||
|
if M["m00"] > 0:
|
||||||
|
return (float(M["m10"] / M["m00"]), float(M["m01"] / M["m00"]))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_red(r, g, b, th, ratio):
|
||||||
|
if r > th and r > g * ratio and r > b * ratio:
|
||||||
|
return True
|
||||||
|
if (r > 200 and g > 200 and b > 200 and r >= g and r >= b
|
||||||
|
and (r - g) > 10 and (r - b) > 10):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def find_brightest_bytes(frame, cx, cy, roi_r, th, ratio):
|
||||||
|
x1 = max(0, cx - roi_r)
|
||||||
|
x2 = min(WIDTH, cx + roi_r)
|
||||||
|
y1 = max(0, cy - roi_r)
|
||||||
|
y2 = min(HEIGHT, cy + roi_r)
|
||||||
|
data = frame.to_bytes()
|
||||||
|
|
||||||
|
best_score = 0
|
||||||
|
best_x = (x1 + x2) // 2
|
||||||
|
best_y = (y1 + y2) // 2
|
||||||
|
found_any = False
|
||||||
|
for y in range(y1, y2, COARSE_STEP):
|
||||||
|
for x in range(x1, x2, COARSE_STEP):
|
||||||
|
idx = (y * WIDTH + x) * 3
|
||||||
|
r = data[idx]
|
||||||
|
g = data[idx + 1]
|
||||||
|
b = data[idx + 2]
|
||||||
|
if is_red(r, g, b, th, ratio):
|
||||||
|
score = r + g + b
|
||||||
|
dx = x - cx
|
||||||
|
dy = y - cy
|
||||||
|
dist_decay = max(0.5, 1.0 - ((dx * dx + dy * dy) ** 0.5 / roi_r) * 0.5)
|
||||||
|
score *= dist_decay
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_x = x
|
||||||
|
best_y = y
|
||||||
|
found_any = True
|
||||||
|
|
||||||
|
if not found_any:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sf = 4
|
||||||
|
fx1 = max(x1, best_x - sf)
|
||||||
|
fx2 = min(x2, best_x + sf + 1)
|
||||||
|
fy1 = max(y1, best_y - sf)
|
||||||
|
fy2 = min(y2, best_y + sf + 1)
|
||||||
|
|
||||||
|
sum_x = 0.0
|
||||||
|
sum_y = 0.0
|
||||||
|
total_w = 0.0
|
||||||
|
count = 0
|
||||||
|
for y in range(fy1, fy2):
|
||||||
|
for x in range(fx1, fx2):
|
||||||
|
idx = (y * WIDTH + x) * 3
|
||||||
|
r = data[idx]
|
||||||
|
g = data[idx + 1]
|
||||||
|
b = data[idx + 2]
|
||||||
|
if is_red(r, g, b, th, ratio):
|
||||||
|
w = r + g + b
|
||||||
|
sum_x += x * w
|
||||||
|
sum_y += y * w
|
||||||
|
total_w += w
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if count < MIN_PIXELS:
|
||||||
|
return (float(best_x), float(best_y))
|
||||||
|
|
||||||
|
return (float(sum_x / total_w), float(sum_y / total_w))
|
||||||
|
|
||||||
|
|
||||||
|
def _ema_filter(pos, alpha=_EMA_ALPHA):
|
||||||
|
global _prev_smoothed
|
||||||
|
if _prev_smoothed is None:
|
||||||
|
_prev_smoothed = pos
|
||||||
|
return pos
|
||||||
|
sx = alpha * pos[0] + (1 - alpha) * _prev_smoothed[0]
|
||||||
|
sy = alpha * pos[1] + (1 - alpha) * _prev_smoothed[1]
|
||||||
|
_prev_smoothed = (sx, sy)
|
||||||
|
return _prev_smoothed
|
||||||
|
|
||||||
|
|
||||||
|
def _gated(pos, gate_px=_GATE_PX):
|
||||||
|
global _prev_smoothed
|
||||||
|
if _prev_smoothed is None:
|
||||||
|
return True
|
||||||
|
dx = pos[0] - _prev_smoothed[0]
|
||||||
|
dy = pos[1] - _prev_smoothed[1]
|
||||||
|
return (dx * dx + dy * dy) <= gate_px * gate_px
|
||||||
|
|
||||||
|
|
||||||
|
def get_stable_laser_point(timeout_ms=15000, stable_count=STABLE_COUNT):
|
||||||
|
global _prev_smoothed
|
||||||
|
_prev_smoothed = None
|
||||||
|
try:
|
||||||
|
last_raw = None
|
||||||
|
stable = 0
|
||||||
|
start = time.ticks_ms()
|
||||||
|
cx, cy = WIDTH // 2, HEIGHT // 2
|
||||||
|
track_count = 0
|
||||||
|
skip_count = 0
|
||||||
|
while True:
|
||||||
|
if abs(time.ticks_diff(time.ticks_ms(), start)) > timeout_ms:
|
||||||
|
_prev_smoothed = None
|
||||||
|
return None
|
||||||
|
frame = camera_manager.read_frame()
|
||||||
|
if frame is None:
|
||||||
|
time.sleep_ms(10)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if track_count > 0 and _prev_smoothed is not None:
|
||||||
|
search_cx = int(_prev_smoothed[0])
|
||||||
|
search_cy = int(_prev_smoothed[1])
|
||||||
|
search_r = TRACK_RADIUS
|
||||||
|
else:
|
||||||
|
search_cx = cx
|
||||||
|
search_cy = cy
|
||||||
|
search_r = SEARCH_RADIUS
|
||||||
|
|
||||||
|
pos_bright = find_brightest_bytes(frame, search_cx, search_cy, search_r, THRESHOLD, RED_RATIO)
|
||||||
|
pos = pos_bright
|
||||||
|
if _USE_CV:
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
pos_ellipse = find_ellipse(img_cv, search_cx, search_cy, search_r, THRESHOLD, RED_RATIO)
|
||||||
|
if pos_ellipse is not None:
|
||||||
|
pos = pos_ellipse
|
||||||
|
|
||||||
|
if pos is not None:
|
||||||
|
skip_count = 0
|
||||||
|
track_count += 1
|
||||||
|
filtered = _ema_filter(pos)
|
||||||
|
if last_raw is not None:
|
||||||
|
dx = abs(filtered[0] - last_raw[0])
|
||||||
|
dy = abs(filtered[1] - last_raw[1])
|
||||||
|
if dx <= 2 and dy <= 2:
|
||||||
|
stable += 1
|
||||||
|
else:
|
||||||
|
stable = 1
|
||||||
|
else:
|
||||||
|
stable = 1
|
||||||
|
last_raw = filtered
|
||||||
|
if logger_manager.logger:
|
||||||
|
logger_manager.logger.info(f"pos:{pos},filtered:{filtered},stable:{stable}")
|
||||||
|
if stable >= stable_count:
|
||||||
|
result = (int(filtered[0]), int(filtered[1]))
|
||||||
|
_prev_smoothed = None
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
skip_count += 1
|
||||||
|
if logger_manager.logger:
|
||||||
|
logger_manager.logger.info(f"find_brightest_bytes None, skip={skip_count}, track={track_count}, search_center=({search_cx},{search_cy}), search_r={search_r}")
|
||||||
|
if skip_count > MAX_SKIP_FRAMES:
|
||||||
|
_prev_smoothed = None
|
||||||
|
track_count = 0
|
||||||
|
stable = 0
|
||||||
|
last_raw = None
|
||||||
|
|
||||||
|
time.sleep_ms(_FRAME_INTERVAL_MS)
|
||||||
|
finally:
|
||||||
|
_prev_smoothed = None
|
||||||
+39
-20
@@ -54,8 +54,8 @@ class LaserManager:
|
|||||||
@property
|
@property
|
||||||
def laser_point(self):
|
def laser_point(self):
|
||||||
"""当前激光点(如果启用硬编码,则返回硬编码值)"""
|
"""当前激光点(如果启用硬编码,则返回硬编码值)"""
|
||||||
if config.HARDCODE_LASER_POINT:
|
# if config.HARDCODE_LASER_POINT:
|
||||||
return config.HARDCODE_LASER_POINT_VALUE
|
# return config.HARDCODE_LASER_POINT_VALUE
|
||||||
return self._laser_point
|
return self._laser_point
|
||||||
|
|
||||||
def get_last_frame_with_ellipse(self):
|
def get_last_frame_with_ellipse(self):
|
||||||
@@ -102,31 +102,28 @@ class LaserManager:
|
|||||||
# ==================== 业务方法 ====================
|
# ==================== 业务方法 ====================
|
||||||
|
|
||||||
def load_laser_point(self):
|
def load_laser_point(self):
|
||||||
"""从配置文件加载激光中心点,失败则使用默认值
|
"""加载激光中心点:优先使用本地保存的坐标,其次硬编码值,最后默认值"""
|
||||||
如果启用硬编码模式,则直接使用硬编码值
|
# 优先:从本地持久化文件加载(由 cmd 201 保存)
|
||||||
"""
|
|
||||||
if config.HARDCODE_LASER_POINT:
|
|
||||||
# 硬编码模式:直接使用硬编码值
|
|
||||||
self._laser_point = config.HARDCODE_LASER_POINT_VALUE
|
|
||||||
self.logger.info(f"[LASER] 使用硬编码激光点: {self._laser_point}")
|
|
||||||
return self._laser_point
|
|
||||||
|
|
||||||
# 正常模式:从配置文件加载
|
|
||||||
try:
|
try:
|
||||||
if "laser_config.json" in os.listdir("/root"):
|
if "laser_config.json" in os.listdir("/root"):
|
||||||
with open(config.CONFIG_FILE, "r") as f:
|
with open(config.CONFIG_FILE, "r") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
if isinstance(data, list) and len(data) == 2:
|
if isinstance(data, list) and len(data) == 2:
|
||||||
self._laser_point = (int(data[0]), int(data[1]))
|
self._laser_point = (int(data[0]), int(data[1]))
|
||||||
self.logger.debug(f"[INFO] 加载激光点: {self._laser_point}")
|
self.logger.info(f"[LASER] 从本地加载激光点: {self._laser_point}")
|
||||||
return self._laser_point
|
return self._laser_point
|
||||||
else:
|
except Exception:
|
||||||
raise ValueError
|
pass
|
||||||
else:
|
|
||||||
self._laser_point = config.DEFAULT_LASER_POINT
|
# 其次:硬编码值
|
||||||
except:
|
if config.HARDCODE_LASER_POINT:
|
||||||
self._laser_point = config.DEFAULT_LASER_POINT
|
self._laser_point = config.HARDCODE_LASER_POINT_VALUE
|
||||||
|
self.logger.info(f"[LASER] 使用硬编码激光点: {self._laser_point}")
|
||||||
|
return self._laser_point
|
||||||
|
|
||||||
|
# 最后:默认值
|
||||||
|
self._laser_point = config.DEFAULT_LASER_POINT
|
||||||
|
self.logger.info(f"[LASER] 使用默认激光点: {self._laser_point}")
|
||||||
return self._laser_point
|
return self._laser_point
|
||||||
|
|
||||||
def save_laser_point(self, point):
|
def save_laser_point(self, point):
|
||||||
@@ -1264,6 +1261,28 @@ class LaserManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[LASER] 关闭激光失败: {e}")
|
self.logger.error(f"[LASER] 关闭激光失败: {e}")
|
||||||
|
|
||||||
|
def set_hardcoded_laser_point(self, raw_x, raw_y):
|
||||||
|
"""
|
||||||
|
设置服务下发的硬编码激光点坐标,并保存到本地持久化文件。
|
||||||
|
下次启动时 load_laser_point() 会优先使用此保存的值。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_x: 服务下发的 x 坐标
|
||||||
|
raw_y: 服务下发的 y 坐标
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(int_x, int_y) 元组
|
||||||
|
"""
|
||||||
|
ix = int(raw_x)
|
||||||
|
iy = int(raw_y)
|
||||||
|
self._laser_point = (ix, iy)
|
||||||
|
try:
|
||||||
|
with open(config.CONFIG_FILE, "w") as f:
|
||||||
|
json.dump([ix, iy], f)
|
||||||
|
self.logger.info(f"[LASER] 设置并持久化激光点: ({ix}, {iy})")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"[LASER] 持久化激光点失败: {e}")
|
||||||
|
return ix, iy
|
||||||
|
|
||||||
# 创建全局单例实例
|
# 创建全局单例实例
|
||||||
laser_manager = LaserManager()
|
laser_manager = LaserManager()
|
||||||
|
|||||||
+2
-2
@@ -65,8 +65,8 @@ class LoggerManager:
|
|||||||
backup_count = config.LOG_BACKUP_COUNT
|
backup_count = config.LOG_BACKUP_COUNT
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 创建日志队列(无界队列)
|
# 创建日志队列(有界队列,防止内存泄漏;满时自动丢弃旧日志)
|
||||||
self._log_queue = queue.Queue(-1)
|
self._log_queue = queue.Queue(maxsize=config.LOG_QUEUE_MAXSIZE)
|
||||||
|
|
||||||
# 确保日志文件所在的目录存在
|
# 确保日志文件所在的目录存在
|
||||||
log_dir = os.path.dirname(log_file)
|
log_dir = os.path.dirname(log_file)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from maix.peripheral import adc
|
|||||||
import _thread
|
import _thread
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import time as wall_time
|
||||||
|
|
||||||
# 导入新模块
|
# 导入新模块
|
||||||
import config
|
import config
|
||||||
@@ -21,24 +22,26 @@ from version import VERSION
|
|||||||
# from logger import init_logging, get_logger, stop_logging
|
# from logger import init_logging, get_logger, stop_logging
|
||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
from time_sync import sync_system_time_from_4g
|
from time_sync import sync_system_time_from_4g
|
||||||
from power import init_ina226, get_bus_voltage, voltage_to_percent
|
from power import charging_shutdown_monitor, init_ina226
|
||||||
from laser_manager import laser_manager
|
from laser_manager import laser_manager
|
||||||
from vision import detect_circle_v3, estimate_distance, enqueue_save_shot, start_save_shot_worker
|
from vision import start_save_shot_worker
|
||||||
from network import network_manager
|
from network import network_manager
|
||||||
from ota_manager import ota_manager
|
from ota_manager import ota_manager
|
||||||
from hardware import hardware_manager
|
from hardware import hardware_manager
|
||||||
from camera_manager import camera_manager
|
from camera_manager import camera_manager
|
||||||
|
from shoot_manager import process_shot, preload_triangle_calib
|
||||||
|
from target_roi_yolo import preload_yolo_detector
|
||||||
|
|
||||||
|
|
||||||
def laser_calibration_worker():
|
def laser_calibration_worker():
|
||||||
"""后台线程:持续检测是否需要执行激光校准"""
|
"""后台线程:持续检测是否需要执行激光校准"""
|
||||||
from laser_manager import laser_manager
|
from laser_manager import laser_manager
|
||||||
from ota_manager import ota_manager
|
from ota_manager import ota_manager
|
||||||
|
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
if logger:
|
if logger:
|
||||||
logger.info("[LASER] 激光校准线程启动")
|
logger.info("[LASER] 激光校准线程启动")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
@@ -55,7 +58,7 @@ def laser_calibration_worker():
|
|||||||
if laser_manager.calibration_active:
|
if laser_manager.calibration_active:
|
||||||
# 调用校准方法,所有逻辑都在 LaserManager 中
|
# 调用校准方法,所有逻辑都在 LaserManager 中
|
||||||
result = laser_manager.calibrate_laser_position(timeout_ms=8000, check_sharpness=True)
|
result = laser_manager.calibrate_laser_position(timeout_ms=8000, check_sharpness=True)
|
||||||
|
|
||||||
# 如果超时仍未成功,稍微休息一下
|
# 如果超时仍未成功,稍微休息一下
|
||||||
if laser_manager.calibration_active:
|
if laser_manager.calibration_active:
|
||||||
time.sleep_ms(300)
|
time.sleep_ms(300)
|
||||||
@@ -78,37 +81,57 @@ def cmd_str():
|
|||||||
"""主程序入口"""
|
"""主程序入口"""
|
||||||
# ==================== 第一阶段:硬件初始化 ====================
|
# ==================== 第一阶段:硬件初始化 ====================
|
||||||
# 按照 main104.py 的顺序,先完成所有硬件初始化
|
# 按照 main104.py 的顺序,先完成所有硬件初始化
|
||||||
|
|
||||||
# 1. 引脚功能映射
|
# 1. 引脚功能映射
|
||||||
for pin, func in config.PIN_MAPPINGS.items():
|
for pin, func in config.PIN_MAPPINGS.items():
|
||||||
try:
|
try:
|
||||||
pinmap.set_pin_function(pin, func)
|
pinmap.set_pin_function(pin, func)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 2. 初始化硬件对象(UART、I2C、ADC)
|
# 2. 初始化硬件对象(UART、I2C、ADC)
|
||||||
hardware_manager.init_uart4g()
|
hardware_manager.init_uart4g()
|
||||||
hardware_manager.init_bus()
|
hardware_manager.init_bus()
|
||||||
hardware_manager.init_adc()
|
hardware_manager.init_adc()
|
||||||
hardware_manager.init_at_client()
|
hardware_manager.init_at_client()
|
||||||
|
|
||||||
# 3. 初始化激光模块(串口 + 开机关闭激光防误触发)
|
# 3. 初始化激光模块(串口 + 开机关闭激光防误触发)
|
||||||
laser_manager.init()
|
laser_manager.init()
|
||||||
|
|
||||||
# 3. 初始化 INA226 电量监测芯片
|
# 3. 初始化 INA226 电量监测芯片(与后续相机启动之间的耗时,便于定位启动卡顿)
|
||||||
|
_w_boot = wall_time.time()
|
||||||
|
print(f"[BOOT] init_ina226 开始 wall_s={_w_boot:.3f}")
|
||||||
init_ina226()
|
init_ina226()
|
||||||
|
print(f"[BOOT] init_ina226 结束 wall +{int(round((wall_time.time() - _w_boot) * 1000))} ms")
|
||||||
|
|
||||||
# 4. 初始化显示和相机
|
# 4. 初始化显示和相机
|
||||||
camera_manager.init_camera(640, 480)
|
_w_boot = wall_time.time()
|
||||||
|
print(
|
||||||
|
f"[BOOT] init_camera({getattr(config, 'CAMERA_WIDTH', 640)}x{getattr(config, 'CAMERA_HEIGHT', 480)}) "
|
||||||
|
f"开始 wall_s={_w_boot:.3f}"
|
||||||
|
)
|
||||||
|
camera_manager.init_camera(getattr(config, "CAMERA_WIDTH", 640), getattr(config, "CAMERA_HEIGHT", 480))
|
||||||
|
print(f"[BOOT] init_camera 结束 wall +{int(round((wall_time.time() - _w_boot) * 1000))} ms")
|
||||||
|
|
||||||
|
_w_boot = wall_time.time()
|
||||||
|
print(f"[BOOT] init_display 开始 wall_s={_w_boot:.3f}")
|
||||||
camera_manager.init_display()
|
camera_manager.init_display()
|
||||||
|
print(f"[BOOT] init_display 结束 wall +{int(round((wall_time.time() - _w_boot) * 1000))} ms")
|
||||||
|
|
||||||
# ==================== 第二阶段:软件初始化 ====================
|
# ==================== 第二阶段:软件初始化 ====================
|
||||||
|
|
||||||
# 1. 初始化日志系统
|
# 1. 初始化日志系统
|
||||||
import logging
|
import logging
|
||||||
logger_manager.init_logging(log_level=logging.DEBUG)
|
logger_manager.init_logging(log_level=logging.WARNING)
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
|
||||||
|
# 充电关机独立读取 INA226,不依赖 TCP 连接或心跳流程。
|
||||||
|
try:
|
||||||
|
_thread.start_new_thread(charging_shutdown_monitor, ())
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[CHARGE] 启动独立监测线程失败: {e}")
|
||||||
|
|
||||||
# 补充:因为初始化的时候,激光会亮,先关了它
|
# 补充:因为初始化的时候,激光会亮,先关了它
|
||||||
# laser_manager.turn_off_laser()
|
# laser_manager.turn_off_laser()
|
||||||
|
|
||||||
@@ -126,25 +149,51 @@ def cmd_str():
|
|||||||
|
|
||||||
# 2.5. 启动存图 worker 线程(队列 + worker,避免主循环阻塞)
|
# 2.5. 启动存图 worker 线程(队列 + worker,避免主循环阻塞)
|
||||||
start_save_shot_worker()
|
start_save_shot_worker()
|
||||||
|
|
||||||
|
# 2.6 预加载三角形标定/坐标文件(避免首次射箭卡顿)
|
||||||
|
try:
|
||||||
|
preload_triangle_calib()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2.7 预加载 YOLO(靶环 ROI + 黑三角);dual_buff=False 时无需 warmup 消除一帧延迟
|
||||||
|
try:
|
||||||
|
_preload_yolo = bool(getattr(config, "TRIANGLE_YOLO_PRELOAD_ON_BOOT", True))
|
||||||
|
_loc_black = str(
|
||||||
|
getattr(config, "TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE", "yolo")
|
||||||
|
).lower().strip()
|
||||||
|
if _loc_black not in ("yolo", "traditional"):
|
||||||
|
_loc_black = "yolo"
|
||||||
|
_need_black_preload = (
|
||||||
|
bool(getattr(config, "TRIANGLE_BLACK_YOLO_ENABLE", False))
|
||||||
|
and _loc_black == "yolo"
|
||||||
|
and bool(getattr(config, "TRIANGLE_BLACK_YOLO_PRELOAD_ON_BOOT", True))
|
||||||
|
)
|
||||||
|
_preload_yolo = _preload_yolo or _need_black_preload
|
||||||
|
if _preload_yolo:
|
||||||
|
preload_yolo_detector(logger)
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-ROI] 启动预加载异常(不影响后续射箭): {e}")
|
||||||
|
|
||||||
# 3. 启动时检查:是否需要恢复备份
|
# 3. 启动时检查:是否需要恢复备份
|
||||||
pending_path = f"{config.APP_DIR}/ota_pending.json"
|
pending_path = f"{config.APP_DIR}/ota_pending.json"
|
||||||
if os.path.exists(pending_path):
|
if os.path.exists(pending_path):
|
||||||
try:
|
try:
|
||||||
with open(pending_path, 'r', encoding='utf-8') as f:
|
with open(pending_path, 'r', encoding='utf-8') as f:
|
||||||
pending_obj = json.load(f)
|
pending_obj = json.load(f)
|
||||||
|
|
||||||
restart_count = pending_obj.get('restart_count', 0)
|
restart_count = pending_obj.get('restart_count', 0)
|
||||||
max_restarts = pending_obj.get('max_restarts', 3)
|
max_restarts = pending_obj.get('max_restarts', 3)
|
||||||
backup_dir = pending_obj.get('backup_dir')
|
backup_dir = pending_obj.get('backup_dir')
|
||||||
|
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f"检测到 ota_pending.json,重启计数: {restart_count}/{max_restarts}")
|
logger.info(f"检测到 ota_pending.json,重启计数: {restart_count}/{max_restarts}")
|
||||||
|
|
||||||
if restart_count >= max_restarts:
|
if restart_count >= max_restarts:
|
||||||
if logger:
|
if logger:
|
||||||
logger.error(f"[STARTUP] 重启次数 ({restart_count}) 超过阈值 ({max_restarts}),执行恢复...")
|
logger.error(f"[STARTUP] 重启次数 ({restart_count}) 超过阈值 ({max_restarts}),执行恢复...")
|
||||||
|
|
||||||
if backup_dir and os.path.exists(backup_dir):
|
if backup_dir and os.path.exists(backup_dir):
|
||||||
if ota_manager.restore_from_backup(backup_dir):
|
if ota_manager.restore_from_backup(backup_dir):
|
||||||
if logger:
|
if logger:
|
||||||
@@ -160,7 +209,7 @@ def cmd_str():
|
|||||||
else:
|
else:
|
||||||
if logger:
|
if logger:
|
||||||
logger.error(f"[STARTUP] 恢复备份失败")
|
logger.error(f"[STARTUP] 恢复备份失败")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
os.remove(pending_path)
|
os.remove(pending_path)
|
||||||
if logger:
|
if logger:
|
||||||
@@ -168,7 +217,7 @@ def cmd_str():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
if logger:
|
if logger:
|
||||||
logger.error(f"[STARTUP] 删除 pending 文件失败: {e}")
|
logger.error(f"[STARTUP] 删除 pending 文件失败: {e}")
|
||||||
|
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f"[STARTUP] 恢复完成,准备重启系统...")
|
logger.info(f"[STARTUP] 恢复完成,准备重启系统...")
|
||||||
time.sleep_ms(2000)
|
time.sleep_ms(2000)
|
||||||
@@ -199,10 +248,10 @@ def cmd_str():
|
|||||||
return
|
return
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 4. 初始化设备ID(network_manager 内部会自动设置 device_id 和 password)
|
# 4. 初始化设备ID(network_manager 内部会自动设置 device_id 和 password)
|
||||||
network_manager.read_device_id()
|
network_manager.read_device_id()
|
||||||
|
|
||||||
# 5. 创建照片存储目录(如果启用图像保存)
|
# 5. 创建照片存储目录(如果启用图像保存)
|
||||||
if config.SAVE_IMAGE_ENABLED:
|
if config.SAVE_IMAGE_ENABLED:
|
||||||
photo_dir = config.PHOTO_DIR
|
photo_dir = config.PHOTO_DIR
|
||||||
@@ -241,47 +290,44 @@ def cmd_str():
|
|||||||
|
|
||||||
pressure_buf = []
|
pressure_buf = []
|
||||||
pressure_sum = 0
|
pressure_sum = 0
|
||||||
pressure_abs_sum = 0
|
|
||||||
pressure_min = 4095
|
pressure_min = 4095
|
||||||
pressure_max = 0
|
pressure_max = 0
|
||||||
pressure_t0_ms = None
|
pressure_t0_ms = None
|
||||||
last_avg_abs = 0
|
|
||||||
|
# 压力突变检测(上升沿触发)
|
||||||
|
prev_adc_val = 0 # 上一次ADC值
|
||||||
|
ADC_JUMP_THRESHOLD = 250 # 突变阈值:相邻两次采样差值超过此值视为突变
|
||||||
|
|
||||||
def _flush_pressure_buf(reason: str):
|
def _flush_pressure_buf(reason: str):
|
||||||
if not config.AIR_PRESSURE_lOG:
|
nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger
|
||||||
return
|
|
||||||
nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger, pressure_abs_sum, last_avg_abs
|
|
||||||
if not pressure_buf:
|
if not pressure_buf:
|
||||||
return
|
return
|
||||||
t1_ms = time.ticks_ms()
|
if config.AIR_PRESSURE_lOG:
|
||||||
n = len(pressure_buf)
|
t1_ms = time.ticks_ms()
|
||||||
avg = (pressure_sum / n) if n else 0
|
n = len(pressure_buf)
|
||||||
avg_abs = (pressure_abs_sum / n) if n else 0
|
avg = (pressure_sum / n) if n else 0
|
||||||
# 一行输出:方便后处理画曲线;同时带上统计信息便于快速看波峰
|
line = (
|
||||||
line = (
|
f"[气压批量] reason={reason} "
|
||||||
f"[气压批量] reason={reason} "
|
f"t0={pressure_t0_ms} t1={t1_ms} n={n} "
|
||||||
f"t0={pressure_t0_ms} t1={t1_ms} n={n} "
|
f"min={pressure_min} max={pressure_max} avg={avg:.1f} "
|
||||||
f"min={pressure_min} max={pressure_max} avg={avg:.1f} avg_abs={avg_abs:.3f} "
|
f"values={','.join(map(str, pressure_buf))}"
|
||||||
f"values={','.join(map(str, pressure_buf))}"
|
)
|
||||||
f" convert value (kpa): {(max(pressure_buf, key=lambda x: x[1])[1] - last_avg_abs) / (5 - 2.5) * config.AIR_PRESSURE_HARDWARE_MAX:.1f}"
|
if logger:
|
||||||
)
|
logger.debug(line)
|
||||||
if logger:
|
else:
|
||||||
logger.debug(line)
|
print(line)
|
||||||
else:
|
# 无论是否记录日志,都必须清空 buffer,否则内存泄漏
|
||||||
print(line)
|
|
||||||
pressure_buf = []
|
pressure_buf = []
|
||||||
pressure_sum = 0
|
pressure_sum = 0
|
||||||
pressure_abs_sum = 0
|
|
||||||
pressure_min = 4095
|
pressure_min = 4095
|
||||||
pressure_max = 0
|
pressure_max = 0
|
||||||
pressure_t0_ms = None
|
pressure_t0_ms = None
|
||||||
last_avg_abs = avg_abs
|
|
||||||
|
|
||||||
# 主循环:检测扳机触发 → 拍照 → 分析 → 上报
|
# 主循环:检测扳机触发 → 拍照 → 分析 → 上报
|
||||||
while not app.need_exit():
|
while not app.need_exit():
|
||||||
try:
|
try:
|
||||||
current_time = time.ticks_ms()
|
current_time = time.ticks_ms()
|
||||||
|
|
||||||
# OTA 期间暂停相机预览
|
# OTA 期间暂停相机预览
|
||||||
try:
|
try:
|
||||||
if ota_manager.ota_in_progress:
|
if ota_manager.ota_in_progress:
|
||||||
@@ -294,6 +340,7 @@ def cmd_str():
|
|||||||
time.sleep_ms(250)
|
time.sleep_ms(250)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# todo 去除或者不在这里检测
|
||||||
# 不在 OTA 状态下,检测是否空闲足够长,自动关机
|
# 不在 OTA 状态下,检测是否空闲足够长,自动关机
|
||||||
# print(f"[MAIN] 空闲时间: {hardware_manager.get_idle_time_in_sec() }秒")
|
# print(f"[MAIN] 空闲时间: {hardware_manager.get_idle_time_in_sec() }秒")
|
||||||
# print(f"配置关机时间:{config.AUTO_POWER_OFF_IN_SECONDS} 秒")
|
# print(f"配置关机时间:{config.AUTO_POWER_OFF_IN_SECONDS} 秒")
|
||||||
@@ -310,12 +357,10 @@ def cmd_str():
|
|||||||
if network_manager.manual_trigger_flag:
|
if network_manager.manual_trigger_flag:
|
||||||
network_manager.clear_manual_trigger()
|
network_manager.clear_manual_trigger()
|
||||||
adc_val = config.ADC_TRIGGER_THRESHOLD + 1
|
adc_val = config.ADC_TRIGGER_THRESHOLD + 1
|
||||||
adc_abs_val = 10
|
|
||||||
if logger:
|
if logger:
|
||||||
logger.info("[TEST] TCP命令触发射箭")
|
logger.info("[TEST] TCP命令触发射箭")
|
||||||
else:
|
else:
|
||||||
adc_val = hardware_manager.adc_obj.read()
|
adc_val = hardware_manager.adc_obj.read()
|
||||||
adc_abs_val = hardware_manager.adc_obj.read_vol()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
if logger:
|
if logger:
|
||||||
@@ -326,183 +371,56 @@ def cmd_str():
|
|||||||
# ====== 气压采样缓存(每次循环都记录,批量输出日志)======
|
# ====== 气压采样缓存(每次循环都记录,批量输出日志)======
|
||||||
if pressure_t0_ms is None:
|
if pressure_t0_ms is None:
|
||||||
pressure_t0_ms = current_time
|
pressure_t0_ms = current_time
|
||||||
pressure_buf.append((adc_val, adc_abs_val))
|
pressure_buf.append(adc_val)
|
||||||
pressure_sum += adc_val
|
pressure_sum += adc_val
|
||||||
pressure_abs_sum += adc_abs_val
|
|
||||||
if adc_val < pressure_min:
|
if adc_val < pressure_min:
|
||||||
pressure_min = adc_val
|
pressure_min = adc_val
|
||||||
if adc_val > pressure_max:
|
if adc_val > pressure_max:
|
||||||
pressure_max = adc_val
|
pressure_max = adc_val
|
||||||
if len(pressure_buf) >= PRESSURE_BATCH_SIZE:
|
if len(pressure_buf) >= PRESSURE_BATCH_SIZE:
|
||||||
_flush_pressure_buf("batch")
|
_flush_pressure_buf("batch")
|
||||||
# if adc_val >= 2000:
|
|
||||||
# print(f"adc :{adc_val}")
|
# 压力突变检测:当前值与前一次值的差值超过阈值时触发
|
||||||
if adc_val >= config.ADC_TRIGGER_THRESHOLD:
|
adc_diff = adc_val - prev_adc_val
|
||||||
|
if adc_diff >= ADC_JUMP_THRESHOLD and prev_adc_val > 0 :
|
||||||
hardware_manager.start_idle_timer() # 重新计时
|
hardware_manager.start_idle_timer() # 重新计时
|
||||||
diff_ms = current_time - last_adc_trigger
|
diff_ms = current_time - last_adc_trigger
|
||||||
if diff_ms < 3000:
|
if diff_ms < 3000:
|
||||||
logger.info(f"[MAIN] 扳机触发过于频繁, {diff_ms}ms")
|
logger.info(f"[MAIN] 扳机触发过于频繁, {diff_ms}ms")
|
||||||
|
prev_adc_val = adc_val
|
||||||
continue
|
continue
|
||||||
last_adc_trigger = current_time
|
last_adc_trigger = current_time
|
||||||
# 触发前先把缓存刷出来,避免波形被长耗时处理截断
|
# 触发前先把缓存刷出来
|
||||||
_flush_pressure_buf("before_trigger")
|
_flush_pressure_buf("before_trigger")
|
||||||
|
|
||||||
try:
|
|
||||||
frame = camera_manager.read_frame()
|
|
||||||
|
|
||||||
laser_point_method = None # 记录激光点选择方法
|
|
||||||
if config.HARDCODE_LASER_POINT:
|
|
||||||
# 硬编码模式:使用硬编码值
|
|
||||||
laser_point = laser_manager.laser_point
|
|
||||||
laser_point_method = "hardcode"
|
|
||||||
elif laser_manager.has_calibrated_point():
|
|
||||||
# 假如校准过,并且有保存值,使用校准值
|
|
||||||
laser_point = laser_manager.laser_point
|
|
||||||
laser_point_method = "calibrated"
|
|
||||||
logger_manager.logger.info(f"[算法] 使用校准值: {laser_manager.laser_point}")
|
|
||||||
elif distance_m and distance_m > 0:
|
|
||||||
# 动态计算模式:根据距离计算激光点
|
|
||||||
# 先检测靶心以获取距离(用于计算激光点)
|
|
||||||
# 第一次检测不使用激光点,仅用于获取距离
|
|
||||||
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
|
|
||||||
laser_point = laser_manager.calculate_laser_point_from_distance(distance_m)
|
|
||||||
laser_point_method = "dynamic"
|
|
||||||
if laser_point is None:
|
|
||||||
logger = logger_manager.logger
|
|
||||||
if logger:
|
|
||||||
logger.warning("[MAIN] 激光点未初始化,跳过本次检测")
|
|
||||||
time.sleep_ms(100)
|
|
||||||
continue
|
|
||||||
|
|
||||||
x, y = laser_point
|
|
||||||
|
|
||||||
# 检测靶心
|
if logger:
|
||||||
result_img, center, radius, method, best_radius1, ellipse_params = detect_circle_v3(frame, laser_point)
|
logger.info(
|
||||||
|
f"[TRIGGER] ▲ 压力突变触发! "
|
||||||
if config.SHOW_CAMERA_PHOTO_WHILE_SHOOTING:
|
f"adc={adc_val}, prev={prev_adc_val}, "
|
||||||
camera_manager.show(result_img)
|
f"diff=+{adc_diff}, t={current_time}ms"
|
||||||
|
|
||||||
# 计算偏移与距离(如果检测到靶心)
|
|
||||||
if center and radius:
|
|
||||||
dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method)
|
|
||||||
distance_m = estimate_distance(best_radius1)
|
|
||||||
else:
|
|
||||||
# 未检测到靶心
|
|
||||||
dx, dy = None, None
|
|
||||||
distance_m = None
|
|
||||||
if logger:
|
|
||||||
logger.warning("[MAIN] 未检测到靶心,但会保存图像")
|
|
||||||
|
|
||||||
# 快速激光测距(激光一闪而过,约500-600ms)
|
|
||||||
laser_distance_m = None
|
|
||||||
laser_signal_quality = 0
|
|
||||||
# try:
|
|
||||||
# result = laser_manager.quick_measure_distance()
|
|
||||||
# if isinstance(result, tuple) and len(result) == 2:
|
|
||||||
# laser_distance_m, laser_signal_quality = result
|
|
||||||
# else:
|
|
||||||
# # 向后兼容:如果返回的是单个值
|
|
||||||
# laser_distance_m = result if isinstance(result, (int, float)) else 0.0
|
|
||||||
# laser_signal_quality = 0
|
|
||||||
# if logger:
|
|
||||||
# if laser_distance_m > 0:
|
|
||||||
# logger.info(f"[MAIN] 激光测距成功: {laser_distance_m:.3f} m, 信号质量: {laser_signal_quality}")
|
|
||||||
# else:
|
|
||||||
# logger.warning("[MAIN] 激光测距失败或返回0")
|
|
||||||
# except Exception as e:
|
|
||||||
# if logger:
|
|
||||||
# logger.error(f"[MAIN] 激光测距异常: {e}")
|
|
||||||
|
|
||||||
# 读取电量
|
|
||||||
voltage = get_bus_voltage()
|
|
||||||
battery_percent = voltage_to_percent(voltage)
|
|
||||||
|
|
||||||
# 生成射箭ID
|
|
||||||
from shot_id_generator import shot_id_generator
|
|
||||||
shot_id = shot_id_generator.generate_id() # 不需要使用device_id
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 构造上报数据
|
|
||||||
inner_data = {
|
|
||||||
"shot_id": shot_id, # 射箭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,
|
|
||||||
"d": round((distance_m or 0.0) * 100), # 视觉测距值(厘米)
|
|
||||||
"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, # 激光点选择方法:hardcode/calibrated/dynamic/default
|
|
||||||
# 激光点坐标(像素)
|
|
||||||
"target_x": float(x), # 激光点 X 坐标(像素)
|
|
||||||
"target_y": float(y), # 激光点 Y 坐标(像素)
|
|
||||||
}
|
|
||||||
|
|
||||||
# 添加椭圆参数(如果存在)
|
|
||||||
if ellipse_params:
|
|
||||||
(ell_center, (width, height), angle) = ellipse_params
|
|
||||||
inner_data["ellipse_major_axis"] = float(max(width, height)) # 长轴(像素)
|
|
||||||
inner_data["ellipse_minor_axis"] = float(min(width, height)) # 短轴(像素)
|
|
||||||
inner_data["ellipse_angle"] = float(angle) # 椭圆角度(度)
|
|
||||||
inner_data["ellipse_center_x"] = float(ell_center[0]) # 椭圆中心 X 坐标(像素)
|
|
||||||
inner_data["ellipse_center_y"] = float(ell_center[1]) # 椭圆中心 Y 坐标(像素)
|
|
||||||
else:
|
|
||||||
inner_data["ellipse_major_axis"] = None
|
|
||||||
inner_data["ellipse_minor_axis"] = None
|
|
||||||
inner_data["ellipse_angle"] = None
|
|
||||||
inner_data["ellipse_center_x"] = None
|
|
||||||
inner_data["ellipse_center_y"] = None
|
|
||||||
|
|
||||||
report_data = {"cmd": 1, "data": inner_data}
|
|
||||||
network_manager.safe_enqueue(report_data, msg_type=2, high=True)
|
|
||||||
# 闪一下激光(射箭反馈)
|
|
||||||
if config.FLASH_LASER_WHILE_SHOOTING:
|
|
||||||
laser_manager.flash_laser(config.FLASH_LASER_DURATION_MS)
|
|
||||||
|
|
||||||
# 保存图像(无论是否检测到靶心都保存):放入队列由 worker 异步保存,不阻塞主循环
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
if center and radius:
|
process_shot(adc_val)
|
||||||
logger.info(f"射箭事件已加入发送队列(已检测到靶心),ID: {shot_id}")
|
|
||||||
else:
|
|
||||||
logger.info(f"射箭事件已加入发送队列(未检测到靶心,已保存图像),ID: {shot_id}")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
time.sleep_ms(100)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
if logger:
|
if logger:
|
||||||
logger.error(f"[MAIN] 图像处理异常: {e}")
|
logger.error(f"[MAIN] 图像处理异常: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
time.sleep_ms(100)
|
|
||||||
continue
|
prev_adc_val = adc_val
|
||||||
else:
|
|
||||||
if config.SHOW_CAMERA_PHOTO_WHILE_SHOOTING:
|
# 未触发时的显示逻辑
|
||||||
try:
|
# 未触发时的显示逻辑
|
||||||
camera_manager.show(camera_manager.read_frame())
|
if config.SHOW_CAMERA_PHOTO_WHILE_SHOOTING:
|
||||||
except Exception as e:
|
try:
|
||||||
logger = logger_manager.logger
|
camera_manager.show(camera_manager.read_frame())
|
||||||
if logger:
|
except Exception as e:
|
||||||
logger.error(f"[MAIN] 显示异常: {e}")
|
logger = logger_manager.logger
|
||||||
time.sleep_ms(5)
|
if logger:
|
||||||
|
logger.error(f"[MAIN] 显示异常: {e}")
|
||||||
|
time.sleep_ms(1)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 主循环的顶层异常捕获,防止程序静默退出
|
# 主循环的顶层异常捕获,防止程序静默退出
|
||||||
@@ -537,13 +455,13 @@ if __name__ == "__main__":
|
|||||||
# 用于测试图片清晰度
|
# 用于测试图片清晰度
|
||||||
# 方式1: 测试单张图片
|
# 方式1: 测试单张图片
|
||||||
# test_sharpness("/root/phot/image.bmp")
|
# test_sharpness("/root/phot/image.bmp")
|
||||||
|
|
||||||
# 方式2: 测试目录下所有图片
|
# 方式2: 测试目录下所有图片
|
||||||
# test_sharpness("/root/phot")
|
# test_sharpness("/root/phot")
|
||||||
|
|
||||||
# 方式3: 使用默认路径(config.PHOTO_DIR)
|
# 方式3: 使用默认路径(config.PHOTO_DIR)
|
||||||
# test_sharpness("/root/phot/")
|
# test_sharpness("/root/phot/")
|
||||||
|
|
||||||
# 用于测试激光点清晰度
|
# 用于测试激光点清晰度
|
||||||
# 方式1: 测试单张图片
|
# 方式1: 测试单张图片
|
||||||
# test_laser_point_sharpness("/root/phot/image.bmp")
|
# test_laser_point_sharpness("/root/phot/image.bmp")
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
[basic]
|
||||||
|
type = cvimodel
|
||||||
|
model = model_270139.cvimodel
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
model_type = yolov5
|
||||||
|
input_type = rgb
|
||||||
|
mean = 0, 0, 0
|
||||||
|
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
||||||
|
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
||||||
|
labels = 黑三角和圆环
|
||||||
|
|
||||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
[basic]
|
||||||
|
type = cvimodel
|
||||||
|
model = model_270820.cvimodel
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
model_type = yolov5
|
||||||
|
input_type = rgb
|
||||||
|
mean = 0, 0, 0
|
||||||
|
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
||||||
|
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
||||||
|
labels = triangle
|
||||||
|
|
||||||
+1089
-396
File diff suppressed because it is too large
Load Diff
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# OTA 更新脚本 - 使用 curl 断点下载
|
||||||
|
# 用法: sh ota_curl.sh <下载URL>
|
||||||
|
# 示例: sh ota_curl.sh http://example.com/maix-t11-v2.15.1.zip
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_DIR="/maixapp/apps/t11"
|
||||||
|
BACKUP_BASE="$APP_DIR/backups"
|
||||||
|
TMP_DIR="/tmp/ota_curl"
|
||||||
|
PENDING_FILE="$APP_DIR/ota_pending.json"
|
||||||
|
|
||||||
|
if [ $# -lt 1 ]; then
|
||||||
|
echo "用法: $0 <下载URL>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
OTA_URL="$1"
|
||||||
|
FILENAME=$(basename "$OTA_URL" | sed 's/?.*//')
|
||||||
|
[ -z "$FILENAME" ] && FILENAME="update.zip"
|
||||||
|
|
||||||
|
mkdir -p "$TMP_DIR" "$BACKUP_BASE"
|
||||||
|
|
||||||
|
# 1. 断点下载
|
||||||
|
echo "[OTA] 开始下载: $OTA_URL"
|
||||||
|
echo "[OTA] 保存到: $TMP_DIR/$FILENAME"
|
||||||
|
curl -C - -L --retry 3 --retry-delay 5 -o "$TMP_DIR/$FILENAME" "$OTA_URL"
|
||||||
|
echo "[OTA] 下载完成"
|
||||||
|
|
||||||
|
# 2. 备份当前目录
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S 2>/dev/null || echo "00000000_000000")
|
||||||
|
BACKUP_DIR="$BACKUP_BASE/backup_$TIMESTAMP"
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
echo "[OTA] 备份到: $BACKUP_DIR"
|
||||||
|
for f in "$APP_DIR"/*.py "$APP_DIR"/*.json "$APP_DIR"/*.xml "$APP_DIR"/*.yaml "$APP_DIR"/*.pem "$APP_DIR"/*.mud "$APP_DIR"/*.so "$APP_DIR"/S99archery; do
|
||||||
|
[ -f "$f" ] && cp "$f" "$BACKUP_DIR/"
|
||||||
|
done
|
||||||
|
echo "[OTA] 备份完成"
|
||||||
|
|
||||||
|
# 3. 解压并替换文件
|
||||||
|
echo "[OTA] 开始更新..."
|
||||||
|
if echo "$FILENAME" | grep -qi '\.zip$'; then
|
||||||
|
unzip -q -o "$TMP_DIR/$FILENAME" -d "$APP_DIR/"
|
||||||
|
else
|
||||||
|
cp "$TMP_DIR/$FILENAME" "$APP_DIR/"
|
||||||
|
fi
|
||||||
|
sync
|
||||||
|
|
||||||
|
# 4. 写入 pending 文件(用于崩溃恢复)
|
||||||
|
echo '{"ts":0,"url":"'"$OTA_URL"'","backup_dir":"'"$BACKUP_DIR"'","restart_count":0,"max_restarts":3}' > "$PENDING_FILE"
|
||||||
|
sync
|
||||||
|
|
||||||
|
echo "[OTA] 更新完成,准备重启..."
|
||||||
|
|
||||||
|
# 5. 重启
|
||||||
|
sleep 1
|
||||||
|
reboot
|
||||||
+10
-5
@@ -758,19 +758,24 @@ class OTAManager:
|
|||||||
|
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
host = parsed.hostname
|
host = parsed.hostname
|
||||||
|
# MHTTPREQUEST 的路径必须包含 query(七牛/ OSS 签名、token 多在 ? 后),否则易 403/HTML,header 无 CL → no_header_or_total
|
||||||
path = parsed.path or "/"
|
path = parsed.path or "/"
|
||||||
|
if parsed.query:
|
||||||
|
path = f"{path}?{parsed.query}"
|
||||||
|
if parsed.fragment:
|
||||||
|
path = f"{path}#{parsed.fragment}"
|
||||||
if not host:
|
if not host:
|
||||||
return False, "bad_url (no host)"
|
return False, "bad_url (no host)"
|
||||||
|
|
||||||
# 很多 ML307R 的 MHTTP 对 https 不稳定;对已知域名做降级
|
# 很多 ML307R 的 MHTTP 对 https 不稳定;对已知域名做降级
|
||||||
|
|
||||||
if isinstance(url, str) and url.startswith("https://static.shelingxingqiu.com/"):
|
if isinstance(url, str) and url.startswith("https://static.shelingxingqiu.com/"):
|
||||||
base_url = "https://static.shelingxingqiu.com"
|
base_url = "http://static.shelingxingqiu.com"
|
||||||
# TODO:使用https,看看是否能成功
|
self._is_https = False
|
||||||
self._is_https = True
|
|
||||||
else:
|
else:
|
||||||
base_url = f"http://{host}"
|
base_url = f"http://{host}"
|
||||||
self._is_https = False
|
self._is_https = False
|
||||||
|
self.logger.info(f"base_url: {base_url}, self._is_https: {self._is_https}")
|
||||||
# logger removed - use self.logger instead
|
# logger removed - use self.logger instead
|
||||||
|
|
||||||
def _log(*a):
|
def _log(*a):
|
||||||
@@ -1155,8 +1160,8 @@ class OTAManager:
|
|||||||
self.logger.error(f"[OTA-4G][PWR] before_urc read_failed: {e}")
|
self.logger.error(f"[OTA-4G][PWR] before_urc read_failed: {e}")
|
||||||
|
|
||||||
t_dl0 = time.ticks_ms()
|
t_dl0 = time.ticks_ms()
|
||||||
success, msg = self.download_file_via_4g(ota_url, downloaded_filename, debug=False)
|
success, msg = self.download_file_via_4g(ota_url, downloaded_filename, debug=True)
|
||||||
t_dl_cost = time.ticks_diff(t_dl0, time.ticks_ms())
|
t_dl_cost = time.ticks_diff(time.ticks_ms(), t_dl0)
|
||||||
self.logger.info(f"[OTA-4G] {msg}")
|
self.logger.info(f"[OTA-4G] {msg}")
|
||||||
self.logger.info(f"[OTA-4G] download_cost_ms={t_dl_cost}")
|
self.logger.info(f"[OTA-4G] download_cost_ms={t_dl_cost}")
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,39 @@
|
|||||||
提供电压、电流监测和充电状态检测
|
提供电压、电流监测和充电状态检测
|
||||||
"""
|
"""
|
||||||
import config
|
import config
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
|
from maix import time as maix_time
|
||||||
|
|
||||||
|
|
||||||
|
_INA226_PRESENT = None
|
||||||
|
|
||||||
|
|
||||||
|
def _ina226_ready() -> bool:
|
||||||
|
"""
|
||||||
|
是否允许访问 INA226。
|
||||||
|
|
||||||
|
重要:
|
||||||
|
- 这里刻意不做任何 I2C 探测/读写。
|
||||||
|
- 经验上,在 INA226 未供电/未响应时,I2C 的 readfrom_mem 可能直接触发底层崩溃(SIGSEGV),try/except 无法拦截。
|
||||||
|
- 因此只在开机 init_ina226() 成功后才允许后续读电压/电流。
|
||||||
|
"""
|
||||||
|
return bool(getattr(config, "INA226_ENABLE", True)) and (_INA226_PRESENT is True)
|
||||||
|
|
||||||
|
|
||||||
def write_register(reg, value):
|
def write_register(reg, value):
|
||||||
"""写入INA226寄存器"""
|
"""写入INA226寄存器"""
|
||||||
from hardware import hardware_manager
|
from hardware import hardware_manager
|
||||||
|
logger = logger_manager.logger
|
||||||
data = [(value >> 8) & 0xFF, value & 0xFF]
|
data = [(value >> 8) & 0xFF, value & 0xFF]
|
||||||
hardware_manager.bus.writeto_mem(config.INA226_ADDR, reg, bytes(data))
|
# 某些底层驱动在失败时只打印 “write failed” 并返回 -1,而不是抛异常;
|
||||||
|
# 为避免误判“初始化成功”导致后续 readfrom_mem SIGSEGV,这里把失败显式转成异常。
|
||||||
|
ret = hardware_manager.bus.writeto_mem(config.INA226_ADDR, reg, bytes(data))
|
||||||
|
if isinstance(ret, int) and ret < 0:
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[INA226] writeto_mem 失败: addr=0x{config.INA226_ADDR:02X} reg=0x{reg:02X} ret={ret}")
|
||||||
|
raise OSError(ret)
|
||||||
|
|
||||||
|
|
||||||
def read_register(reg):
|
def read_register(reg):
|
||||||
@@ -24,35 +49,62 @@ def read_register(reg):
|
|||||||
|
|
||||||
def init_ina226():
|
def init_ina226():
|
||||||
"""初始化 INA226 芯片:配置模式 + 校准值"""
|
"""初始化 INA226 芯片:配置模式 + 校准值"""
|
||||||
write_register(config.REG_CONFIGURATION, 0x4527)
|
global _INA226_PRESENT
|
||||||
write_register(config.REG_CALIBRATION, config.CALIBRATION_VALUE)
|
logger = logger_manager.logger
|
||||||
|
if not getattr(config, "INA226_ENABLE", True):
|
||||||
|
if logger:
|
||||||
|
logger.info("[INA226] INA226_ENABLE=False,跳过初始化与 I2C 探测")
|
||||||
|
# 显式标记不可用,避免后续误读
|
||||||
|
_INA226_PRESENT = False
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
# 仅通过“写寄存器成功”来判定可用,避免额外的读操作触发底层崩溃
|
||||||
|
write_register(config.REG_CONFIGURATION, 0x4527)
|
||||||
|
write_register(config.REG_CALIBRATION, config.CALIBRATION_VALUE)
|
||||||
|
_INA226_PRESENT = True
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
_INA226_PRESENT = False
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[INA226] 初始化失败:{e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_bus_voltage():
|
def get_bus_voltage():
|
||||||
"""读取总线电压(单位:V)"""
|
"""读取总线电压(单位:V)。未探测到 INA226 或读失败时返回 0.0(上报用,避免 null)。"""
|
||||||
raw = read_register(config.REG_BUS_VOLTAGE)
|
logger = logger_manager.logger
|
||||||
return raw * 1.25 / 1000
|
if not _ina226_ready():
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
raw = read_register(config.REG_BUS_VOLTAGE)
|
||||||
|
return raw * 1.25 / 1000
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[INA226] 读取电压失败:{e}")
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
def get_current():
|
def get_current():
|
||||||
"""
|
"""
|
||||||
读取电流(单位:mA)
|
读取电流(单位:mA)
|
||||||
正数表示充电,负数表示放电
|
当前电源板实测:正数表示放电,负数表示充电。
|
||||||
|
|
||||||
INA226 电流计算公式:
|
INA226 电流计算公式:
|
||||||
Current = (Current Register Value) × Current_LSB
|
Current = (Current Register Value) × Current_LSB
|
||||||
Current_LSB = 0.001 × CALIBRATION_VALUE / 4096
|
Current_LSB = 0.001 × CALIBRATION_VALUE / 4096
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
if not _ina226_ready():
|
||||||
|
return 0.0
|
||||||
raw = read_register(config.REG_CURRENT)
|
raw = read_register(config.REG_CURRENT)
|
||||||
# INA226 电流寄存器是16位有符号整数
|
# INA226 电流寄存器是16位有符号整数
|
||||||
# 最高位是符号位:0=正(充电),1=负(放电)
|
# 最高位是符号位;电流方向含义取决于电源板的采样电阻接线方向。
|
||||||
# 计算 Current_LSB(根据 CALIBRATION_VALUE)
|
# 计算 Current_LSB(根据 CALIBRATION_VALUE)
|
||||||
current_lsb = 0.001 * config.CALIBRATION_VALUE / 4096 # 单位:A
|
current_lsb = 0.001 * config.CALIBRATION_VALUE / 4096 # 单位:A
|
||||||
# 处理有符号数:如果最高位为1,转换为负数
|
# 处理有符号数:如果最高位为1,转换为负数
|
||||||
if raw & 0x8000: # 最高位为1,表示负数(放电)
|
if raw & 0x8000:
|
||||||
signed_raw = raw - 0x10000 # 转换为有符号整数
|
signed_raw = raw - 0x10000 # 转换为有符号整数
|
||||||
else: # 最高位为0,表示正数(充电)
|
else:
|
||||||
signed_raw = raw
|
signed_raw = raw
|
||||||
# 转换为毫安
|
# 转换为毫安
|
||||||
current_ma = signed_raw * current_lsb * 1000
|
current_ma = signed_raw * current_lsb * 1000
|
||||||
@@ -79,7 +131,7 @@ def is_charging(threshold_ma=10.0):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
current = get_current()
|
current = get_current()
|
||||||
is_charge = current > threshold_ma
|
is_charge = current < -abs(float(threshold_ma))
|
||||||
return is_charge
|
return is_charge
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
@@ -90,23 +142,224 @@ def is_charging(threshold_ma=10.0):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def charging_shutdown_monitor():
|
||||||
|
"""独立监测 INA226;连续确认充电后通知服务器并退出应用。"""
|
||||||
|
logger = logger_manager.logger
|
||||||
|
shutdown_enabled = bool(getattr(config, "CHARGING_SHUTDOWN_ENABLED", False))
|
||||||
|
diagnostic_enabled = bool(getattr(config, "CHARGING_DIAGNOSTIC_LOG_ENABLED", False))
|
||||||
|
if not shutdown_enabled and not diagnostic_enabled:
|
||||||
|
if logger:
|
||||||
|
logger.info("[CHARGE] 充电退出监测已禁用")
|
||||||
|
return
|
||||||
|
|
||||||
|
interval_ms = max(100, int(getattr(config, "CHARGING_CHECK_INTERVAL_MS", 5000)))
|
||||||
|
threshold_ma = float(getattr(config, "CHARGING_CURRENT_THRESHOLD_MA", 10.0))
|
||||||
|
confirm_required = max(1, int(getattr(config, "CHARGING_CONFIRM_COUNT", 2)))
|
||||||
|
confirm_count = 0
|
||||||
|
|
||||||
|
if logger:
|
||||||
|
logger.info(
|
||||||
|
f"[CHARGE] 独立监测线程启动: interval={interval_ms}ms, "
|
||||||
|
f"threshold={threshold_ma:.1f}mA, confirm={confirm_required}, "
|
||||||
|
f"shutdown={'on' if shutdown_enabled else 'off'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
current_ma = get_current()
|
||||||
|
if diagnostic_enabled and logger:
|
||||||
|
voltage = get_bus_voltage()
|
||||||
|
logger.info(
|
||||||
|
f"[CHARGE-DIAG] INA226 voltage={voltage:.3f}V, "
|
||||||
|
f"current={current_ma:.1f}mA"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not shutdown_enabled:
|
||||||
|
maix_time.sleep_ms(interval_ms)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if current_ma < -abs(threshold_ma):
|
||||||
|
confirm_count += 1
|
||||||
|
if logger:
|
||||||
|
logger.info(
|
||||||
|
f"[CHARGE] INA226 充电电流 {current_ma:.1f}mA "
|
||||||
|
f"({confirm_count}/{confirm_required})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
confirm_count = 0
|
||||||
|
|
||||||
|
if confirm_count >= confirm_required:
|
||||||
|
script_path = getattr(
|
||||||
|
config,
|
||||||
|
"CHARGING_EXIT_SCRIPT",
|
||||||
|
config.APP_DIR + "/charging_exit.sh",
|
||||||
|
)
|
||||||
|
if not os.path.isfile(script_path):
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[CHARGE] 退出脚本不存在: {script_path}")
|
||||||
|
confirm_count = 0
|
||||||
|
else:
|
||||||
|
if logger:
|
||||||
|
logger.warning(
|
||||||
|
f"[CHARGE] 已连续确认充电,通知服务器后退出应用: current={current_ma:.1f}mA"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from network import network_manager
|
||||||
|
|
||||||
|
notify_timeout_ms = max(
|
||||||
|
0,
|
||||||
|
int(getattr(config, "CHARGING_NOTIFY_TIMEOUT_MS", 30000)),
|
||||||
|
)
|
||||||
|
notification_sent = network_manager.safe_enqueue_and_wait(
|
||||||
|
{"poweroff": "充电中"},
|
||||||
|
2,
|
||||||
|
high=True,
|
||||||
|
timeout_ms=notify_timeout_ms,
|
||||||
|
)
|
||||||
|
if notification_sent:
|
||||||
|
if logger:
|
||||||
|
logger.info("[CHARGE] 充电状态已发送到服务器")
|
||||||
|
elif logger:
|
||||||
|
logger.warning(
|
||||||
|
f"[CHARGE] 等待服务器发送超时({notify_timeout_ms}ms),继续执行退出"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[CHARGE] 充电状态上报失败,继续执行退出: {e}")
|
||||||
|
try:
|
||||||
|
from laser_manager import laser_manager
|
||||||
|
|
||||||
|
laser_manager.turn_off_laser()
|
||||||
|
if logger:
|
||||||
|
logger.info("[CHARGE] 激光关闭命令已发送")
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[CHARGE] Python 关闭激光失败,交由退出脚本兜底: {e}")
|
||||||
|
try:
|
||||||
|
subprocess.Popen([
|
||||||
|
"/bin/sh",
|
||||||
|
script_path,
|
||||||
|
str(os.getpid()),
|
||||||
|
str(getattr(config, "DISTANCE_SERIAL_DEVICE", "/dev/ttyS1")),
|
||||||
|
str(getattr(config, "DISTANCE_SERIAL_BAUDRATE", 9600)),
|
||||||
|
])
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[CHARGE] 调用退出脚本失败: {e}")
|
||||||
|
confirm_count = 0
|
||||||
|
|
||||||
|
maix_time.sleep_ms(interval_ms)
|
||||||
|
|
||||||
|
|
||||||
def voltage_to_percent(voltage):
|
def voltage_to_percent(voltage):
|
||||||
"""根据电压估算电池百分比(查表插值)"""
|
"""
|
||||||
points = [
|
根据电压估算电池百分比(高密度查表插值 + 滤波)。
|
||||||
(4.20, 100), (4.10, 95), (4.05, 85), (4.00, 75), (3.95, 65),
|
|
||||||
(3.90, 55), (3.85, 45), (3.80, 35), (3.75, 25), (3.70, 15),
|
- 电压先做 5 点移动平均(抑制瞬时抖动)
|
||||||
(3.65, 5), (3.60, 0)
|
- SOC 再做一阶低通(抑制“跳电量”)
|
||||||
]
|
|
||||||
if voltage >= points[0][0]:
|
注意:
|
||||||
return 100
|
- 该方法仍是“开路电压→SOC”的近似;负载较大/瞬时大电流时电压会下沉,SOC 会偏低。
|
||||||
if voltage <= points[-1][0]:
|
- 滤波会带来滞后:电量变化会更平滑,但更新更慢。
|
||||||
|
"""
|
||||||
|
if voltage is None:
|
||||||
return 0
|
return 0
|
||||||
for i in range(len(points) - 1):
|
try:
|
||||||
v1, p1 = points[i]
|
v = float(voltage)
|
||||||
v2, p2 = points[i + 1]
|
except Exception:
|
||||||
if voltage >= v2:
|
return 0
|
||||||
ratio = (voltage - v1) / (v2 - v1)
|
if v <= 0:
|
||||||
percent = p1 + (p2 - p1) * ratio
|
return 0
|
||||||
return max(0, min(100, int(round(percent))))
|
return int(int(_BATTERY_MONITOR.get_soc(v) * 10) / 10) # 截断而不是四舍五入
|
||||||
return 0
|
|
||||||
|
|
||||||
|
class BatteryMonitor:
|
||||||
|
"""
|
||||||
|
电压→SOC 估算器(查表 + 线性插值 + 双重滤波)。
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 表为单节锂电“静态电压”近似曲线;不同电池/温度/老化会有偏差。
|
||||||
|
- 这里不区分充电/放电曲线(滞后),主要用于“显示电量/粗略判断”。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, avg_window: int = 5, alpha: float = 0.2):
|
||||||
|
# 电压-SOC对照表(电压从高到低)
|
||||||
|
self.voltages = [
|
||||||
|
4.20, 4.15, 4.10, 4.05, 4.00,
|
||||||
|
3.95, 3.90, 3.88, 3.85, 3.82,
|
||||||
|
3.80, 3.78, 3.75, 3.72, 3.70,
|
||||||
|
3.65, 3.60, 3.55, 3.50, 3.45,
|
||||||
|
3.40, 3.35, 3.30, 3.20, 2.50,
|
||||||
|
]
|
||||||
|
self.socs = [
|
||||||
|
100, 98, 95, 90, 85,
|
||||||
|
80, 75, 72, 68, 64,
|
||||||
|
60, 56, 52, 48, 44,
|
||||||
|
38, 32, 26, 20, 14,
|
||||||
|
10, 6, 3, 1, 0,
|
||||||
|
]
|
||||||
|
|
||||||
|
self.avg_window = max(1, int(avg_window))
|
||||||
|
self.alpha = float(alpha) if alpha is not None else 0.2
|
||||||
|
if not (0.0 < self.alpha <= 1.0):
|
||||||
|
self.alpha = 0.2
|
||||||
|
|
||||||
|
self.voltage_history = []
|
||||||
|
self.last_soc = 50.0
|
||||||
|
|
||||||
|
def _voltage_to_soc_raw(self, voltage: float) -> float:
|
||||||
|
# 越界
|
||||||
|
if voltage >= self.voltages[0]:
|
||||||
|
return 100.0
|
||||||
|
if voltage <= self.voltages[-1]:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# 表是降序,二分查找
|
||||||
|
left, right = 0, len(self.voltages) - 1
|
||||||
|
while left <= right:
|
||||||
|
mid = (left + right) // 2
|
||||||
|
vm = self.voltages[mid]
|
||||||
|
if vm == voltage:
|
||||||
|
return float(self.socs[mid])
|
||||||
|
elif vm < voltage:
|
||||||
|
right = mid - 1
|
||||||
|
else:
|
||||||
|
left = mid + 1
|
||||||
|
|
||||||
|
# 线性插值:right 在高电压侧,left 在低电压侧(降序表)
|
||||||
|
# 例:voltages = [4.2,4.15,...],则 v_high=voltages[right] >= voltage >= voltages[left]=v_low
|
||||||
|
v_high, v_low = float(self.voltages[right]), float(self.voltages[left])
|
||||||
|
soc_high, soc_low = float(self.socs[right]), float(self.socs[left])
|
||||||
|
if abs(v_high - v_low) < 1e-9:
|
||||||
|
return soc_low
|
||||||
|
soc = soc_low + (voltage - v_low) * (soc_high - soc_low) / (v_high - v_low)
|
||||||
|
return soc
|
||||||
|
|
||||||
|
def get_soc(self, raw_voltage: float) -> float:
|
||||||
|
# 1) 电压滤波(移动平均)
|
||||||
|
self.voltage_history.append(float(raw_voltage))
|
||||||
|
if len(self.voltage_history) > self.avg_window:
|
||||||
|
self.voltage_history.pop(0)
|
||||||
|
voltage = sum(self.voltage_history) / float(len(self.voltage_history))
|
||||||
|
|
||||||
|
# 2) 查表插值
|
||||||
|
raw_soc = self._voltage_to_soc_raw(voltage)
|
||||||
|
|
||||||
|
# 3) SOC 低通滤波
|
||||||
|
a = self.alpha
|
||||||
|
self.last_soc = a * raw_soc + (1.0 - a) * float(self.last_soc)
|
||||||
|
|
||||||
|
# clip
|
||||||
|
if self.last_soc < 0.0:
|
||||||
|
self.last_soc = 0.0
|
||||||
|
if self.last_soc > 100.0:
|
||||||
|
self.last_soc = 100.0
|
||||||
|
return float(self.last_soc)
|
||||||
|
|
||||||
|
|
||||||
|
# 模块级单例:保留历史,实现平滑(进程重启会重置)
|
||||||
|
_BATTERY_MONITOR = BatteryMonitor(
|
||||||
|
avg_window=int(getattr(config, "BATTERY_SOC_AVG_WINDOW", 5)),
|
||||||
|
alpha=float(getattr(config, "BATTERY_SOC_LPF_ALPHA", 0.2)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
+33
@@ -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-----
|
||||||
+427
-89
@@ -1,11 +1,45 @@
|
|||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time as time_std
|
||||||
|
|
||||||
import config
|
import config
|
||||||
from camera_manager import camera_manager
|
from camera_manager import camera_manager
|
||||||
from laser_manager import laser_manager
|
from laser_manager import laser_manager
|
||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
from network import network_manager
|
from network import network_manager
|
||||||
from power import get_bus_voltage, voltage_to_percent
|
from triangle_target import load_camera_from_xml, load_triangle_positions, try_triangle_scoring
|
||||||
from vision import estimate_distance, detect_circle_v3, save_shot_image
|
from vision import estimate_distance, detect_circle_v3, enqueue_save_shot
|
||||||
from maix import camera, display, image, app, time, uart, pinmap, i2c
|
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):
|
def analyze_shot(frame, laser_point=None):
|
||||||
"""
|
"""
|
||||||
@@ -13,18 +47,18 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
:param frame: 图像帧
|
:param frame: 图像帧
|
||||||
:param laser_point: 激光点坐标 (x, y)
|
:param laser_point: 激光点坐标 (x, y)
|
||||||
:return: 包含分析结果的字典
|
:return: 包含分析结果的字典
|
||||||
|
|
||||||
|
优先级:
|
||||||
|
1. 三角形单应性(USE_TRIANGLE_OFFSET=True 时)— 成功则直接返回,跳过圆形检测
|
||||||
|
2. 圆形检测(三角形不可用或识别失败时兜底)
|
||||||
"""
|
"""
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
# 先检测靶心以获取距离(用于计算激光点)
|
# ── Step 1: 确定激光点 ────────────────────────────────────────────────────
|
||||||
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
|
|
||||||
|
|
||||||
# 根据距离动态计算激光点坐标
|
|
||||||
laser_point_method = None
|
laser_point_method = None
|
||||||
|
distance_m_first = None
|
||||||
|
|
||||||
if config.HARDCODE_LASER_POINT:
|
if config.HARDCODE_LASER_POINT:
|
||||||
laser_point = laser_manager.laser_point
|
laser_point = laser_manager.laser_point
|
||||||
laser_point_method = "hardcode"
|
laser_point_method = "hardcode"
|
||||||
@@ -33,65 +67,248 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
laser_point_method = "calibrated"
|
laser_point_method = "calibrated"
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f"[算法] 使用校准值: {laser_manager.laser_point}")
|
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:
|
else:
|
||||||
laser_point = laser_manager.laser_point
|
# 动态模式:先做一次无激光点检测以估算距离,再推算激光点
|
||||||
laser_point_method = "default"
|
_, _, _, _, best_radius1_temp, _ = detect_circle_v3(frame, None)
|
||||||
if logger:
|
distance_m_first = estimate_distance(best_radius1_temp) if best_radius1_temp else None
|
||||||
logger.info(f"[算法] 使用默认值: {laser_point}")
|
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:
|
if laser_point is None:
|
||||||
return {
|
return {"success": False, "reason": "laser_point_not_initialized"}
|
||||||
"success": False,
|
|
||||||
"reason": "laser_point_not_initialized"
|
|
||||||
}
|
|
||||||
|
|
||||||
x, y = laser_point
|
x, y = laser_point
|
||||||
|
|
||||||
# 绘制激光十字线
|
# ── Step 2: 提前转换一次图像,两个检测线程共享(只读)────────────────────────
|
||||||
color = image.Color(config.LASER_COLOR[0], config.LASER_COLOR[1], config.LASER_COLOR[2])
|
img_cv = image.image2cv(frame, False, False)
|
||||||
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 3: 检查三角形是否可用 ────────────────────────────────────────────────
|
||||||
result_img, center, radius, method, best_radius1, ellipse_params = detect_circle_v3(frame, laser_point)
|
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
|
||||||
|
|
||||||
# 计算偏移与距离
|
def _build_circle_result(cdata, yolo_roi_xyxy=None):
|
||||||
if center and radius:
|
"""从圆形检测结果构建 analyze_shot 返回值。"""
|
||||||
dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method)
|
r_img, center, radius, method, best_radius1, ellipse_params = cdata
|
||||||
distance_m = estimate_distance(best_radius1)
|
|
||||||
else:
|
|
||||||
dx, dy = None, None
|
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
|
||||||
|
out = {
|
||||||
|
"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",
|
||||||
|
}
|
||||||
|
if yolo_roi_xyxy is not None:
|
||||||
|
out["yolo_roi_xyxy"] = yolo_roi_xyxy
|
||||||
|
return out
|
||||||
|
|
||||||
# 返回分析结果
|
if not use_tri:
|
||||||
return {
|
# 三角形未配置,直接跑圆形检测
|
||||||
"success": True,
|
return _build_circle_result(
|
||||||
"result_img": result_img,
|
detect_circle_v3(frame, laser_point, img_cv=img_cv)
|
||||||
"center": center,
|
)
|
||||||
"radius": radius,
|
|
||||||
"method": method,
|
# ── Step 4: 先独占跑三角形,超时或失败后再跑圆形(不与圆心并行,避免抢 CPU)──
|
||||||
"best_radius1": best_radius1,
|
roi_xyxy = None
|
||||||
"ellipse_params": ellipse_params,
|
yolo_ring_ms = 0.0
|
||||||
"dx": dx,
|
yolo_black_ms = 0.0
|
||||||
"dy": dy,
|
if getattr(config, "TRIANGLE_YOLO_ROI_ENABLE", False):
|
||||||
"distance_m": distance_m,
|
_t_yolo_ring = time_std.perf_counter()
|
||||||
"laser_point": laser_point,
|
try:
|
||||||
"laser_point_method": laser_point_method
|
from target_roi_yolo import try_get_triangle_roi_from_yolo
|
||||||
}
|
roi_xyxy = try_get_triangle_roi_from_yolo(
|
||||||
|
frame, img_cv.shape[1], img_cv.shape[0], logger
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-ROI] {e}")
|
||||||
|
finally:
|
||||||
|
yolo_ring_ms = (time_std.perf_counter() - _t_yolo_ring) * 1000.0
|
||||||
|
|
||||||
|
_loc_mode = str(
|
||||||
|
getattr(config, "TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE", "yolo")
|
||||||
|
).lower().strip()
|
||||||
|
if _loc_mode not in ("yolo", "traditional"):
|
||||||
|
_loc_mode = "yolo"
|
||||||
|
|
||||||
|
black_boxes_work = None
|
||||||
|
_run_stage2_black_yolo = (
|
||||||
|
_loc_mode == "yolo"
|
||||||
|
and getattr(config, "TRIANGLE_BLACK_YOLO_ENABLE", False)
|
||||||
|
and roi_xyxy is not None
|
||||||
|
)
|
||||||
|
if _run_stage2_black_yolo:
|
||||||
|
_t_yolo_black = time_std.perf_counter()
|
||||||
|
try:
|
||||||
|
from target_roi_yolo import try_black_triangle_boxes_work
|
||||||
|
|
||||||
|
black_boxes_work = try_black_triangle_boxes_work(
|
||||||
|
img_cv, roi_xyxy, logger
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-BLACK] {e}")
|
||||||
|
finally:
|
||||||
|
yolo_black_ms = (time_std.perf_counter() - _t_yolo_black) * 1000.0
|
||||||
|
elif (
|
||||||
|
logger
|
||||||
|
and _loc_mode == "traditional"
|
||||||
|
and roi_xyxy is not None
|
||||||
|
and getattr(config, "TRIANGLE_BLACK_YOLO_ENABLE", False)
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"[TRI] TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE=traditional:跳过 Stage2 黑三角 YOLO,"
|
||||||
|
"仅在 Stage1 裁切内跑整幅传统三角检测"
|
||||||
|
)
|
||||||
|
|
||||||
|
tri_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()}")
|
||||||
|
_t_wall_try = time_std.perf_counter()
|
||||||
|
tri = try_triangle_scoring(
|
||||||
|
img_cv, (x, y), pos, K, dist_coef,
|
||||||
|
size_range=getattr(config, "TRIANGLE_SIZE_RANGE", (8, 500)),
|
||||||
|
roi_xyxy=roi_xyxy,
|
||||||
|
black_yolo_boxes_work=black_boxes_work,
|
||||||
|
yolo_ring_ms=yolo_ring_ms,
|
||||||
|
yolo_black_ms=yolo_black_ms,
|
||||||
|
)
|
||||||
|
_wall_try_ms = (time_std.perf_counter() - _t_wall_try) * 1000.0
|
||||||
|
if logger and bool(getattr(config, "TRIANGLE_LOG_E2E_TIMING", True)):
|
||||||
|
_e2e = float(yolo_ring_ms) + float(yolo_black_ms) + float(_wall_try_ms)
|
||||||
|
logger.info(
|
||||||
|
f"[TRI] timing_e2e_triangle_ms={_e2e:.1f} "
|
||||||
|
f"(yolo_ring={float(yolo_ring_ms):.1f} yolo_black={float(yolo_black_ms):.1f} "
|
||||||
|
f"try_triangle_wall={_wall_try_ms:.1f} locate_mode={_loc_mode})"
|
||||||
|
)
|
||||||
|
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}
|
||||||
|
|
||||||
|
t_tri = threading.Thread(target=_run_triangle, daemon=True)
|
||||||
|
t_tri.start()
|
||||||
|
|
||||||
|
tri_timeout_s = float(getattr(config, "TRIANGLE_TIMEOUT_MS", 2000)) / 1000.0
|
||||||
|
|
||||||
|
t_tri.join(timeout=tri_timeout_s)
|
||||||
|
|
||||||
|
def _tri_ok_validated(tri):
|
||||||
|
try:
|
||||||
|
import numpy as _np
|
||||||
|
ok = bool(tri.get('ok'))
|
||||||
|
if not ok:
|
||||||
|
return False
|
||||||
|
|
||||||
|
dxv = tri.get("dx_cm")
|
||||||
|
dyv = tri.get("dy_cm")
|
||||||
|
H = tri.get("homography")
|
||||||
|
if not _np.isfinite(dxv) or not _np.isfinite(dyv):
|
||||||
|
logger.warning("[TRI] dx/dy 非有限值,判定为误检")
|
||||||
|
return False
|
||||||
|
if H is not None and not _np.all(_np.isfinite(H)):
|
||||||
|
logger.warning("[TRI] 单应矩阵含非有限值,判定为误检")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── 检查1:单应矩阵 x/y 缩放比(靶标是正方形,H[0,0]≈H[1,1])──
|
||||||
|
if H is not None:
|
||||||
|
sx = abs(float(H[0, 0]))
|
||||||
|
sy = abs(float(H[1, 1]))
|
||||||
|
if sy > 1e-6:
|
||||||
|
hxy_ratio = sx / sy
|
||||||
|
# 正常拍摄比值在 0.6~1.7 之间;超出则四点严重变形,说明有误检
|
||||||
|
if not (0.6 <= hxy_ratio <= 1.7):
|
||||||
|
logger.warning(
|
||||||
|
f"[TRI] 单应矩阵 sx/sy={hxy_ratio:.2f} 偏差过大,判定为误检,回退圆心"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── 检查2:可选配置距离上下限(写 0 表示不启用)──────────────────
|
||||||
|
dist_m = tri.get("distance_m")
|
||||||
|
if dist_m is not None:
|
||||||
|
try:
|
||||||
|
import config as _vc
|
||||||
|
d_min = float(getattr(_vc, "TRIANGLE_DISTANCE_MIN_M", 0.0))
|
||||||
|
d_max = float(getattr(_vc, "TRIANGLE_DISTANCE_MAX_M", 0.0))
|
||||||
|
except Exception:
|
||||||
|
d_min, d_max = 0.0, 0.0
|
||||||
|
if d_min > 0 and d_max > d_min:
|
||||||
|
if not (d_min <= dist_m <= d_max):
|
||||||
|
logger.warning(
|
||||||
|
f"[TRI] 距离 {dist_m:.2f}m 超出配置范围 [{d_min},{d_max}],判定为误检,回退圆心"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return bool(tri.get('ok'))
|
||||||
|
|
||||||
|
def _build_tri_result(tri, yolo_roi_xyxy=None):
|
||||||
|
out = {
|
||||||
|
"success": True,
|
||||||
|
"result_img": frame,
|
||||||
|
"center": None, "radius": None,
|
||||||
|
"method": "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_markers_completed": tri.get("markers_completed", []),
|
||||||
|
"tri_homography": tri.get("homography"),
|
||||||
|
}
|
||||||
|
if yolo_roi_xyxy is not None:
|
||||||
|
out["yolo_roi_xyxy"] = yolo_roi_xyxy
|
||||||
|
return out
|
||||||
|
|
||||||
|
# 三角形在超时内完成
|
||||||
|
if not t_tri.is_alive():
|
||||||
|
tri = tri_result.get('data', {})
|
||||||
|
if _tri_ok_validated(tri):
|
||||||
|
logger.info(f"[TRI] end {datetime.now()} — 使用三角形结果(dx={tri['dx_cm']:.2f},dy={tri['dy_cm']:.2f}cm)")
|
||||||
|
return _build_tri_result(tri, roi_xyxy)
|
||||||
|
logger.info(f"[TRI] end(tri_failed, fallback circle) {datetime.now()}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[TRI] 超时 {tri_timeout_s:.2f}s 仍未结束,启动圆心算法(三角形仍在后台)")
|
||||||
|
|
||||||
|
# 三角形超时或失败 → 跑圆心;圆心跑完后再检查三角形是否已结束
|
||||||
|
try:
|
||||||
|
cdata = detect_circle_v3(frame, laser_point, img_cv=img_cv)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[CIRCLE] 圆形检测异常: {e}")
|
||||||
|
cdata = (frame, None, None, None, None, None)
|
||||||
|
|
||||||
|
# 圆心跑完后,若三角形恰好已经结束且结果有效,优先用三角形
|
||||||
|
if not t_tri.is_alive():
|
||||||
|
tri = tri_result.get('data', {})
|
||||||
|
if _tri_ok_validated(tri):
|
||||||
|
logger.info(f"[TRI] 圆心跑完后三角形已就绪 — 优先使用三角形结果(dx={tri['dx_cm']:.2f},dy={tri['dy_cm']:.2f}cm)")
|
||||||
|
return _build_tri_result(tri, roi_xyxy)
|
||||||
|
|
||||||
|
return _build_circle_result(cdata, roi_xyxy)
|
||||||
|
|
||||||
|
|
||||||
def process_shot(adc_val):
|
def process_shot(adc_val):
|
||||||
@@ -104,6 +321,7 @@ def process_shot(adc_val):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
frame = camera_manager.read_frame()
|
frame = camera_manager.read_frame()
|
||||||
|
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
|
||||||
|
|
||||||
# 调用算法分析
|
# 调用算法分析
|
||||||
analysis_result = analyze_shot(frame)
|
analysis_result = analyze_shot(frame)
|
||||||
@@ -126,16 +344,27 @@ def process_shot(adc_val):
|
|||||||
distance_m = analysis_result["distance_m"]
|
distance_m = analysis_result["distance_m"]
|
||||||
laser_point = analysis_result["laser_point"]
|
laser_point = analysis_result["laser_point"]
|
||||||
laser_point_method = analysis_result["laser_point_method"]
|
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_markers_completed = analysis_result.get("tri_markers_completed", [])
|
||||||
|
tri_homography = analysis_result.get("tri_homography")
|
||||||
|
yolo_roi_xyxy = analysis_result.get("yolo_roi_xyxy")
|
||||||
|
draw_yolo_roi = (
|
||||||
|
yolo_roi_xyxy is not None
|
||||||
|
and getattr(config, "TRIANGLE_YOLO_DRAW_ROI_ON_SHOT", True)
|
||||||
|
)
|
||||||
x, y = laser_point
|
x, y = laser_point
|
||||||
|
|
||||||
camera_manager.show(result_img)
|
# 三角形路径成功时 center/radius 为空是正常的;此时用 triangle 方法名用于保存文件名与上报字段 m
|
||||||
|
if (not method) and tri_markers:
|
||||||
|
method = "triangle_homography"
|
||||||
|
|
||||||
if not (center and radius) and logger:
|
if config.SHOW_CAMERA_PHOTO_WHILE_SHOOTING:
|
||||||
logger.warning("[MAIN] 未检测到靶心,但会保存图像")
|
camera_manager.show(result_img)
|
||||||
|
|
||||||
# 读取电量
|
if dx is None and dy is None and logger:
|
||||||
voltage = get_bus_voltage()
|
logger.warning("[MAIN] 未检测到偏移量(三角形与圆形均失败),但会保存图像")
|
||||||
battery_percent = voltage_to_percent(voltage)
|
|
||||||
|
|
||||||
# 生成射箭ID
|
# 生成射箭ID
|
||||||
from shot_id_generator import shot_id_generator
|
from shot_id_generator import shot_id_generator
|
||||||
@@ -144,33 +373,30 @@ def process_shot(adc_val):
|
|||||||
if logger:
|
if logger:
|
||||||
logger.info(f"[MAIN] 射箭ID: {shot_id}")
|
logger.info(f"[MAIN] 射箭ID: {shot_id}")
|
||||||
|
|
||||||
# 保存图像
|
laser_distance_m = None
|
||||||
save_shot_image(
|
laser_signal_quality = 0
|
||||||
result_img,
|
|
||||||
center,
|
# x,y 单位:物理厘米(compute_laser_position 与三角形单应性均输出物理 cm)
|
||||||
radius,
|
# 未检测到靶心时 x/y 用 200.0(脱靶标志)
|
||||||
method,
|
srv_x = round(float(dx), 4) if dx is not None else 200.0
|
||||||
ellipse_params,
|
srv_y = round(float(dy), 4) if dy is not None else 200.0
|
||||||
(x, y),
|
|
||||||
distance_m,
|
|
||||||
shot_id=shot_id,
|
|
||||||
photo_dir=config.PHOTO_DIR if config.SAVE_IMAGE_ENABLED else None
|
|
||||||
)
|
|
||||||
|
|
||||||
# 构造上报数据
|
# 构造上报数据
|
||||||
inner_data = {
|
inner_data = {
|
||||||
"shot_id": shot_id,
|
"shot_id": shot_id,
|
||||||
"x": float(dx) if dx is not None else 200.0,
|
"x": srv_x,
|
||||||
"y": float(dy) if dy is not None else 200.0,
|
"y": srv_y,
|
||||||
"r": 90.0,
|
"r": 20.0, # 保留字段(服务端当前忽略,物理外环半径 cm)
|
||||||
"d": round((distance_m or 0.0) * 100),
|
"d": round((distance_m or 0.0) * 100),
|
||||||
"d_laser": 0.0,
|
"d_laser": round((laser_distance_m or 0.0) * 100),
|
||||||
"d_laser_quality": 0,
|
"d_laser_quality": laser_signal_quality,
|
||||||
"m": method if method else "no_target",
|
"m": method if method else "no_target",
|
||||||
"adc": adc_val,
|
"adc": adc_val,
|
||||||
"laser_method": laser_point_method,
|
"laser_method": laser_point_method,
|
||||||
"target_x": float(x),
|
"target_x": float(x),
|
||||||
"target_y": float(y),
|
"target_y": float(y),
|
||||||
|
"offset_method": offset_method,
|
||||||
|
"distance_method": distance_method,
|
||||||
}
|
}
|
||||||
|
|
||||||
if ellipse_params:
|
if ellipse_params:
|
||||||
@@ -190,14 +416,126 @@ def process_shot(adc_val):
|
|||||||
report_data = {"cmd": 1, "data": inner_data}
|
report_data = {"cmd": 1, "data": inner_data}
|
||||||
network_manager.safe_enqueue(report_data, msg_type=2, high=True)
|
network_manager.safe_enqueue(report_data, msg_type=2, high=True)
|
||||||
|
|
||||||
if logger:
|
# 数据上报后再画标注,不干扰检测阶段的原始画面
|
||||||
if center and radius:
|
if result_img is not None:
|
||||||
logger.info(f"射箭事件已加入发送队列(已检测到靶心),ID: {shot_id}")
|
# 1. 若有三角形标记,先用 cv2 画轮廓 / 顶点 / ID,再反推靶心位置
|
||||||
else:
|
if tri_markers:
|
||||||
logger.info(f"射箭事件已加入发送队列(未检测到靶心,已保存图像),ID: {shot_id}")
|
import cv2 as _cv2
|
||||||
|
import numpy as _np
|
||||||
|
_img_cv = image.image2cv(result_img, False, False)
|
||||||
|
|
||||||
|
# YOLO 靶环框在 vision.enqueue_save_shot 的 worker 里绘制,避免阻塞主流程
|
||||||
|
|
||||||
|
# 三角形轮廓 + 直角顶点 + 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)
|
||||||
|
|
||||||
|
# 3点补全的虚拟角点:只画中心点 + 文本,避免误认为真实检测到的三角形
|
||||||
|
try:
|
||||||
|
if tri_markers_completed:
|
||||||
|
for _m in tri_markers_completed:
|
||||||
|
if not _m.get("is_virtual"):
|
||||||
|
continue
|
||||||
|
_cx, _cy = int(_m["center"][0]), int(_m["center"][1])
|
||||||
|
_cv2.circle(_img_cv, (_cx, _cy), 6, (255, 0, 255), 2) # 紫色空心圈
|
||||||
|
_cv2.putText(
|
||||||
|
_img_cv,
|
||||||
|
f"VT{_m['id']}",
|
||||||
|
(_cx - 22, _cy - 12),
|
||||||
|
_cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.55,
|
||||||
|
(255, 0, 255),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 靶心(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)
|
||||||
|
|
||||||
|
elif draw_yolo_roi:
|
||||||
|
# 仅 YOLO 标注时也不在主线程画框,交给存图 worker
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
yolo_roi_xyxy=yolo_roi_xyxy if draw_yolo_roi 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)
|
time.sleep_ms(100)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,668 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
MaixCAM NPU YOLOv5:先检靶环/整靶区域并裁切 ROI;黑三角 Stage2 在裁切图上推理(与训练一致),
|
||||||
|
再在各子框上跑传统直角点算法。
|
||||||
|
|
||||||
|
- 相机全分辨率(如 640×480)与模型输入(如 320×320)不一致时,需把检测框从
|
||||||
|
「网络输入坐标系」映回全图,或直接使用 Maix 已映射到源图坐标的模式(见 config)。
|
||||||
|
|
||||||
|
依赖:maix.nn.YOLOv5;靶环模型 config.TRIANGLE_YOLO_MODEL_PATH;黑三角模型
|
||||||
|
config.TRIANGLE_BLACK_YOLO_MODEL_PATH(可多实例缓存,按路径区分)。
|
||||||
|
|
||||||
|
224×224、320×320 等「网络输入尺寸」由导出的 .mud 决定,运行时打印为 net_in=,无需在业务 config 里写死。
|
||||||
|
|
||||||
|
返回 (x0, y0, x1, y1) 为整幅 img_cv 上的轴对齐矩形,半开区间按三角形裁剪习惯:
|
||||||
|
实际裁剪为 img[y0:y1, x0:x1]。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def _stage2_roi_crop_save_worker(
|
||||||
|
slab_rgb,
|
||||||
|
out_local_boxes,
|
||||||
|
rx0,
|
||||||
|
ry0,
|
||||||
|
rw,
|
||||||
|
rh,
|
||||||
|
base_dir,
|
||||||
|
draw_boxes,
|
||||||
|
jpeg_quality,
|
||||||
|
roi_max_images,
|
||||||
|
logger_ref,
|
||||||
|
):
|
||||||
|
"""后台写 Stage2 裁切 JPEG,避免阻塞 NPU 后续流程。"""
|
||||||
|
try:
|
||||||
|
import time
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
os.makedirs(base_dir, exist_ok=True)
|
||||||
|
fn = os.path.join(
|
||||||
|
base_dir,
|
||||||
|
f"stage2_roi_{rx0}_{ry0}_{rw}x{rh}_{int(time.time() * 1000)}.jpg",
|
||||||
|
)
|
||||||
|
bgr = cv2.cvtColor(slab_rgb, cv2.COLOR_RGB2BGR)
|
||||||
|
if draw_boxes and out_local_boxes:
|
||||||
|
for i, (bx0, by0, bx1, by1) in enumerate(out_local_boxes):
|
||||||
|
x0, y0 = int(bx0), int(by0)
|
||||||
|
x1, y1 = int(bx1) - 1, int(by1) - 1
|
||||||
|
x1 = max(x0, min(x1, rw - 1))
|
||||||
|
y1 = max(y0, min(y1, rh - 1))
|
||||||
|
cv2.rectangle(bgr, (x0, y0), (x1, y1), (0, 255, 0), 2)
|
||||||
|
cv2.putText(
|
||||||
|
bgr,
|
||||||
|
f"s2_{i}",
|
||||||
|
(x0, max(0, y0 - 4)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.5,
|
||||||
|
(0, 255, 0),
|
||||||
|
1,
|
||||||
|
cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
cv2.imwrite(fn, bgr, [int(cv2.IMWRITE_JPEG_QUALITY), int(jpeg_quality)])
|
||||||
|
try:
|
||||||
|
from vision import prune_old_images_in_dir
|
||||||
|
|
||||||
|
prune_old_images_in_dir(
|
||||||
|
base_dir, roi_max_images, logger_ref, "[YOLO-BLACK]"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if logger_ref:
|
||||||
|
extra = (
|
||||||
|
f",已绘 Stage2 框×{len(out_local_boxes)}"
|
||||||
|
if (draw_boxes and out_local_boxes)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
logger_ref.info(f"[YOLO-BLACK] 已保存 Stage1 裁切图(异步): {fn}{extra}")
|
||||||
|
except Exception as e:
|
||||||
|
if logger_ref:
|
||||||
|
logger_ref.warning(f"[YOLO-BLACK] 异步保存裁切图失败: {e}")
|
||||||
|
|
||||||
|
_detector_by_path = {}
|
||||||
|
|
||||||
|
|
||||||
|
def reset_yolo_detector_cache():
|
||||||
|
"""切换模型路径时可调用(通常不必)。"""
|
||||||
|
global _detector_by_path
|
||||||
|
_detector_by_path.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_detector(model_path: str):
|
||||||
|
global _detector_by_path
|
||||||
|
if not model_path or not os.path.isfile(model_path):
|
||||||
|
return None
|
||||||
|
if model_path in _detector_by_path:
|
||||||
|
return _detector_by_path[model_path]
|
||||||
|
try:
|
||||||
|
from maix import nn
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
_detector_by_path[model_path] = nn.YOLOv5(model=model_path, dual_buff=False)
|
||||||
|
return _detector_by_path[model_path]
|
||||||
|
|
||||||
|
|
||||||
|
def preload_yolo_detector(logger=None):
|
||||||
|
"""
|
||||||
|
启动阶段预加载 YOLO detector,避免第一次真实射箭承担模型加载开销。
|
||||||
|
detect 使用 dual_buff=False,不再需要用首帧 warmup 抵消双缓冲的一帧延迟。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import config as cfg
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-ROI] 预加载失败:无法读取 config: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
if bool(getattr(cfg, "TRIANGLE_YOLO_ROI_ENABLE", False)):
|
||||||
|
model_path = getattr(cfg, "TRIANGLE_YOLO_MODEL_PATH", "") or ""
|
||||||
|
det = _get_detector(model_path)
|
||||||
|
if det is None:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-ROI] 预加载失败:无法加载模型 {model_path}")
|
||||||
|
else:
|
||||||
|
ok = True
|
||||||
|
try:
|
||||||
|
net_w = int(det.input_width())
|
||||||
|
net_h = int(det.input_height())
|
||||||
|
except Exception:
|
||||||
|
net_w = net_h = -1
|
||||||
|
if logger:
|
||||||
|
logger.info(
|
||||||
|
f"[YOLO-ROI] 靶环模型已预加载: {model_path}, net_in={net_w}×{net_h}"
|
||||||
|
)
|
||||||
|
|
||||||
|
_loc_black = str(
|
||||||
|
getattr(cfg, "TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE", "yolo")
|
||||||
|
).lower().strip()
|
||||||
|
if _loc_black not in ("yolo", "traditional"):
|
||||||
|
_loc_black = "yolo"
|
||||||
|
_preload_black = (
|
||||||
|
bool(getattr(cfg, "TRIANGLE_BLACK_YOLO_ENABLE", False))
|
||||||
|
and _loc_black == "yolo"
|
||||||
|
and bool(getattr(cfg, "TRIANGLE_BLACK_YOLO_PRELOAD_ON_BOOT", True))
|
||||||
|
)
|
||||||
|
if _preload_black:
|
||||||
|
bp = getattr(cfg, "TRIANGLE_BLACK_YOLO_MODEL_PATH", "") or ""
|
||||||
|
d2 = _get_detector(bp)
|
||||||
|
if d2 is None:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-BLACK] 预加载失败:无法加载模型 {bp}")
|
||||||
|
else:
|
||||||
|
ok = True
|
||||||
|
try:
|
||||||
|
nw2 = int(d2.input_width())
|
||||||
|
nh2 = int(d2.input_height())
|
||||||
|
except Exception:
|
||||||
|
nw2 = nh2 = -1
|
||||||
|
if logger:
|
||||||
|
logger.info(
|
||||||
|
f"[YOLO-BLACK] 黑三角模型已预加载: {bp}, net_in={nw2}×{nh2}"
|
||||||
|
)
|
||||||
|
elif logger and bool(getattr(cfg, "TRIANGLE_BLACK_YOLO_ENABLE", False)):
|
||||||
|
if _loc_black != "yolo":
|
||||||
|
logger.info(
|
||||||
|
"[YOLO-BLACK] TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE=%s:跳过黑三角模型预加载"
|
||||||
|
% (_loc_black,)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def _letterbox_net_to_src_xyxy(
|
||||||
|
x: float, y: float, w: float, h: float,
|
||||||
|
src_w: int, src_h: int, net_w: int, net_h: int,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
检测框在网络输入图上(含 letterbox 填充),映回到 src_w×src_h 原图。
|
||||||
|
x,y,w,h 为网络坐标系下的左上角与宽高。
|
||||||
|
"""
|
||||||
|
scale = min(net_w / float(src_w), net_h / float(src_h))
|
||||||
|
nw = src_w * scale
|
||||||
|
nh = src_h * scale
|
||||||
|
pad_x = (net_w - nw) * 0.5
|
||||||
|
pad_y = (net_h - nh) * 0.5
|
||||||
|
x0 = (x - pad_x) / scale
|
||||||
|
y0 = (y - pad_y) / scale
|
||||||
|
x1 = (x + w - pad_x) / scale
|
||||||
|
y1 = (y + h - pad_y) / scale
|
||||||
|
return x0, y0, x1, y1
|
||||||
|
|
||||||
|
|
||||||
|
def _det_obj_class_id(o):
|
||||||
|
"""Maix / 不同版本可能用 class_id、cls、label 等字段。"""
|
||||||
|
for key in ("class_id", "cls", "label", "category", "cat_id", "id"):
|
||||||
|
if hasattr(o, key):
|
||||||
|
v = getattr(o, key)
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return int(float(v))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _det_obj_from_seq(t):
|
||||||
|
"""若 detect 返回 list/tuple:[x,y,w,h,score,cls](Maix 常用 xywh),包装成属性对象。"""
|
||||||
|
if not isinstance(t, (list, tuple)) or len(t) < 6:
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _Box:
|
||||||
|
__slots__ = ("x", "y", "w", "h", "score", "class_id")
|
||||||
|
|
||||||
|
b = _Box()
|
||||||
|
b.x = float(t[0])
|
||||||
|
b.y = float(t[1])
|
||||||
|
b.w = float(t[2])
|
||||||
|
b.h = float(t[3])
|
||||||
|
b.score = float(t[4])
|
||||||
|
b.class_id = int(float(t[5]))
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_objs(objs):
|
||||||
|
out = []
|
||||||
|
for o in objs or []:
|
||||||
|
if isinstance(o, (list, tuple)):
|
||||||
|
m = _det_obj_from_seq(o)
|
||||||
|
if m is not None:
|
||||||
|
out.append(m)
|
||||||
|
else:
|
||||||
|
out.append(o)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _det_to_src_xyxy(o, coord_mode: str, src_w: int, src_h: int, net_w: int, net_h: int):
|
||||||
|
"""把单个检测框转为全图坐标系下的 xyxy(半开区间语义与后续 clip 一致)。"""
|
||||||
|
x, y, w, h = float(o.x), float(o.y), float(o.w), float(o.h)
|
||||||
|
if coord_mode in ("native", "source", "camera", "full"):
|
||||||
|
return x, y, x + w, y + h
|
||||||
|
return _letterbox_net_to_src_xyxy(x, y, w, h, src_w, src_h, net_w, net_h)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_roi_xyxy(xy_list, merge_mode: str):
|
||||||
|
"""
|
||||||
|
merge_mode:
|
||||||
|
union — 所有框的外接矩形(适合「整靶+多角标」同属一类、多框场景)
|
||||||
|
largest — 取面积最大的单个框(适合只有一个大框代表整靶)
|
||||||
|
"""
|
||||||
|
if not xy_list:
|
||||||
|
return None
|
||||||
|
if merge_mode in ("union", "merge", "all"):
|
||||||
|
x0 = min(a[0] for a in xy_list)
|
||||||
|
y0 = min(a[1] for a in xy_list)
|
||||||
|
x1 = max(a[2] for a in xy_list)
|
||||||
|
y1 = max(a[3] for a in xy_list)
|
||||||
|
return x0, y0, x1, y1
|
||||||
|
# largest
|
||||||
|
def _area(t):
|
||||||
|
return max(0.0, t[2] - t[0]) * max(0.0, t[3] - t[1])
|
||||||
|
|
||||||
|
best = max(xy_list, key=_area)
|
||||||
|
return best[0], best[1], best[2], best[3]
|
||||||
|
|
||||||
|
|
||||||
|
def _roi_aspect_sane(x0, y0, x1, y1, src_w: int, src_h: int) -> bool:
|
||||||
|
"""过滤 letterbox 重复映射等导致的扁条/细条 ROI。"""
|
||||||
|
bw = x1 - x0
|
||||||
|
bh = y1 - y0
|
||||||
|
if bw < 8 or bh < 8:
|
||||||
|
return False
|
||||||
|
area_frac = (bw * bh) / float(max(1, src_w * src_h))
|
||||||
|
if area_frac < 0.015: # 小于全图约 1.5% 认为不可信
|
||||||
|
return False
|
||||||
|
ar = bw / max(bh, 1e-6)
|
||||||
|
if ar > 5.5 or ar < 1.0 / 5.5:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_xyxy(x0, y0, x1, y1, src_w, src_h, margin_frac: float):
|
||||||
|
bw = max(x1 - x0, 1e-6)
|
||||||
|
bh = max(y1 - y0, 1e-6)
|
||||||
|
mx = bw * margin_frac
|
||||||
|
my = bh * margin_frac
|
||||||
|
x0 -= mx
|
||||||
|
y0 -= my
|
||||||
|
x1 += mx
|
||||||
|
y1 += my
|
||||||
|
x0 = max(0, min(int(round(x0)), src_w - 1))
|
||||||
|
y0 = max(0, min(int(round(y0)), src_h - 1))
|
||||||
|
x1 = max(x0 + 1, min(int(round(x1)), src_w))
|
||||||
|
y1 = max(y0 + 1, min(int(round(y1)), src_h))
|
||||||
|
return x0, y0, x1, y1
|
||||||
|
|
||||||
|
|
||||||
|
def try_get_triangle_roi_from_yolo(maix_frame, src_w: int, src_h: int, logger=None):
|
||||||
|
"""
|
||||||
|
用 YOLO 在 maix_frame 上检测靶环类,返回整图上的裁剪框 (x0,y0,x1,y1);失败返回 None。
|
||||||
|
|
||||||
|
:param maix_frame: camera.read() 返回的 Maix 图像(与 nn.YOLOv5.detect 一致)
|
||||||
|
:param src_w, src_h: 与 img_cv / 标定一致的分辨率(通常与 camera 一致)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import config as cfg
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not bool(getattr(cfg, "TRIANGLE_YOLO_ROI_ENABLE", False)):
|
||||||
|
return None
|
||||||
|
|
||||||
|
model_path = getattr(cfg, "TRIANGLE_YOLO_MODEL_PATH", "") or ""
|
||||||
|
if not os.path.isfile(model_path):
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-ROI] 模型文件不存在: {model_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
det = _get_detector(model_path)
|
||||||
|
if det is None:
|
||||||
|
if logger:
|
||||||
|
logger.warning("[YOLO-ROI] 无法加载 nn.YOLOv5(非 Maix 环境或导入失败)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
conf_th = float(getattr(cfg, "TRIANGLE_YOLO_CONF_TH", 0.5))
|
||||||
|
iou_th = float(getattr(cfg, "TRIANGLE_YOLO_IOU_TH", 0.45))
|
||||||
|
class_ids = getattr(cfg, "TRIANGLE_YOLO_RING_CLASS_IDS", (0,))
|
||||||
|
if isinstance(class_ids, int):
|
||||||
|
class_ids = (class_ids,)
|
||||||
|
margin_frac = float(getattr(cfg, "TRIANGLE_YOLO_ROI_MARGIN_FRAC", 0.12))
|
||||||
|
coord_mode = str(getattr(cfg, "TRIANGLE_YOLO_COORD_MODE", "native")).lower()
|
||||||
|
merge_mode = str(getattr(cfg, "TRIANGLE_YOLO_ROI_MERGE_MODE", "union")).lower()
|
||||||
|
reject_bad = bool(getattr(cfg, "TRIANGLE_YOLO_REJECT_BAD_ROI", True))
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = det.detect(maix_frame, conf_th=conf_th, iou_th=iou_th)
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-ROI] detect 异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
objs = _normalize_objs(raw if raw is not None else [])
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
for o in objs:
|
||||||
|
cid = _det_obj_class_id(o)
|
||||||
|
if cid is not None and cid in class_ids:
|
||||||
|
candidates.append(o)
|
||||||
|
|
||||||
|
if not candidates and bool(getattr(cfg, "TRIANGLE_YOLO_RETRY_ON_EMPTY", False)):
|
||||||
|
retry_conf = float(getattr(cfg, "TRIANGLE_YOLO_RETRY_CONF_TH", conf_th))
|
||||||
|
if retry_conf > 0 and retry_conf < conf_th:
|
||||||
|
try:
|
||||||
|
raw_retry = det.detect(maix_frame, conf_th=retry_conf, iou_th=iou_th)
|
||||||
|
objs_retry = _normalize_objs(raw_retry if raw_retry is not None else [])
|
||||||
|
candidates_retry = []
|
||||||
|
for o in objs_retry:
|
||||||
|
cid = _det_obj_class_id(o)
|
||||||
|
if cid is not None and cid in class_ids:
|
||||||
|
candidates_retry.append(o)
|
||||||
|
if candidates_retry:
|
||||||
|
if logger:
|
||||||
|
logger.info(
|
||||||
|
f"[YOLO-ROI] conf={conf_th} 下 0 候选,"
|
||||||
|
f"用 retry_conf={retry_conf} 重试得到 {len(candidates_retry)} 个候选"
|
||||||
|
)
|
||||||
|
objs = objs_retry
|
||||||
|
candidates = candidates_retry
|
||||||
|
conf_th = retry_conf
|
||||||
|
elif logger:
|
||||||
|
logger.info(
|
||||||
|
f"[YOLO-ROI] conf={conf_th} 下 0 候选;"
|
||||||
|
f"retry_conf={retry_conf} 仍为 0 候选"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-ROI] 低阈值重试异常: {e}")
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
if logger:
|
||||||
|
n = len(objs)
|
||||||
|
if n == 0:
|
||||||
|
logger.info(
|
||||||
|
f"[YOLO-ROI] detect 返回 0 个框(conf≥{conf_th})。"
|
||||||
|
f"可尝试 config 里降低 TRIANGLE_YOLO_CONF_TH(如 0.25~0.35),"
|
||||||
|
f"或确认射箭帧与训练图光照/构图接近。"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
seen = []
|
||||||
|
for o in objs[:8]:
|
||||||
|
cid = _det_obj_class_id(o)
|
||||||
|
sc = getattr(o, "score", None)
|
||||||
|
try:
|
||||||
|
sc_f = float(sc) if sc is not None else None
|
||||||
|
except Exception:
|
||||||
|
sc_f = None
|
||||||
|
seen.append(f"cls={cid},score={sc_f}")
|
||||||
|
logger.info(
|
||||||
|
f"[YOLO-ROI] 有 {n} 个框但类别不在 {class_ids} 内;"
|
||||||
|
f"前几条: {seen}。请核对 TRIANGLE_YOLO_RING_CLASS_IDS,"
|
||||||
|
f"或查看 Maix 文档中检测结果的类别字段名。"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
net_w = int(det.input_width())
|
||||||
|
net_h = int(det.input_height())
|
||||||
|
|
||||||
|
min_side = float(getattr(cfg, "TRIANGLE_YOLO_MIN_BOX_SIDE_PX", 8.0))
|
||||||
|
xy_list = []
|
||||||
|
for o in candidates:
|
||||||
|
x0n, y0n, x1n, y1n = _det_to_src_xyxy(o, coord_mode, src_w, src_h, net_w, net_h)
|
||||||
|
bw, bh = x1n - x0n, y1n - y0n
|
||||||
|
if bw >= min_side and bh >= min_side:
|
||||||
|
xy_list.append((x0n, y0n, x1n, y1n))
|
||||||
|
|
||||||
|
if not xy_list:
|
||||||
|
if logger:
|
||||||
|
logger.info(
|
||||||
|
f"[YOLO-ROI] {len(candidates)} 个候选经 min_side={min_side} 过滤后为空,放弃 ROI"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
merged = _merge_roi_xyxy(xy_list, merge_mode)
|
||||||
|
if merged is None:
|
||||||
|
return None
|
||||||
|
x0, y0, x1, y1 = merged
|
||||||
|
|
||||||
|
# clip 到画布(合并前框可能略越界)
|
||||||
|
x0 = max(0, min(x0, src_w - 1))
|
||||||
|
y0 = max(0, min(y0, src_h - 1))
|
||||||
|
x1 = max(x0 + 1, min(x1, src_w))
|
||||||
|
y1 = max(y0 + 1, min(y1, src_h))
|
||||||
|
|
||||||
|
x0, y0, x1, y1 = _expand_xyxy(x0, y0, x1, y1, src_w, src_h, margin_frac)
|
||||||
|
|
||||||
|
if reject_bad and not _roi_aspect_sane(x0, y0, x1, y1, src_w, src_h):
|
||||||
|
if logger:
|
||||||
|
logger.warning(
|
||||||
|
f"[YOLO-ROI] 裁剪框异常(过小或过扁)mode={coord_mode} merge={merge_mode} "
|
||||||
|
f"→ [{x0},{y0},{x1},{y1}],放弃 ROI、三角形改用整图。"
|
||||||
|
f"若持续出现可尝试 coord_mode=letterbox/native 切换。"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if logger:
|
||||||
|
nbox = len(candidates)
|
||||||
|
logger.info(
|
||||||
|
f"[YOLO-ROI] boxes={nbox} merge={merge_mode} coord={coord_mode} "
|
||||||
|
f"net_in={net_w}×{net_h}(来自模型) → crop=[{x0},{y0},{x1},{y1}] "
|
||||||
|
f"({x1-x0}×{y1-y0}px)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return (x0, y0, x1, y1)
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_xyxy_local(x0, y0, x1, y1, w_lim, h_lim, margin_frac: float):
|
||||||
|
"""在宽 w_lim、高 h_lim 的局部坐标系内扩展框。"""
|
||||||
|
bw = max(x1 - x0, 1e-6)
|
||||||
|
bh = max(y1 - y0, 1e-6)
|
||||||
|
mx = bw * margin_frac
|
||||||
|
my = bh * margin_frac
|
||||||
|
x0 -= mx
|
||||||
|
y0 -= my
|
||||||
|
x1 += mx
|
||||||
|
y1 += my
|
||||||
|
x0 = max(0, min(int(round(x0)), w_lim - 1))
|
||||||
|
y0 = max(0, min(int(round(y0)), h_lim - 1))
|
||||||
|
x1 = max(x0 + 1, min(int(round(x1)), w_lim))
|
||||||
|
y1 = max(y0 + 1, min(int(round(y1)), h_lim))
|
||||||
|
return x0, y0, x1, y1
|
||||||
|
|
||||||
|
|
||||||
|
def try_black_triangle_boxes_work(img_rgb, ring_roi_xyxy, logger=None):
|
||||||
|
"""
|
||||||
|
Stage2:在 **Stage1 靶环 ROI 裁切图** 上跑黑三角 YOLO(与训练时 stage2 构图一致),
|
||||||
|
检测框坐标已落在 **靶环裁切图**(与 try_triangle_scoring 中 img_work)同一坐标系,
|
||||||
|
返回 (x0,y0,x1,y1) 整数元组列表。
|
||||||
|
|
||||||
|
img_rgb: 与 try_triangle_scoring 相同的全图 RGB(numpy,H×W×3)。
|
||||||
|
ring_roi_xyxy: 全图上的 (rx0, ry0, rx1, ry1),与 try_get_triangle_roi_from_yolo 一致。
|
||||||
|
"""
|
||||||
|
if ring_roi_xyxy is None:
|
||||||
|
return []
|
||||||
|
if img_rgb is None or getattr(img_rgb, "size", 0) == 0:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
import config as cfg
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not bool(getattr(cfg, "TRIANGLE_BLACK_YOLO_ENABLE", False)):
|
||||||
|
return []
|
||||||
|
|
||||||
|
model_path = getattr(cfg, "TRIANGLE_BLACK_YOLO_MODEL_PATH", "") or ""
|
||||||
|
if not os.path.isfile(model_path):
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-BLACK] 模型文件不存在: {model_path}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
det = _get_detector(model_path)
|
||||||
|
if det is None:
|
||||||
|
if logger:
|
||||||
|
logger.warning("[YOLO-BLACK] 无法加载 nn.YOLOv5")
|
||||||
|
return []
|
||||||
|
|
||||||
|
conf_th = float(getattr(cfg, "TRIANGLE_BLACK_YOLO_CONF_TH", 0.5))
|
||||||
|
iou_th = float(getattr(cfg, "TRIANGLE_BLACK_YOLO_IOU_TH", 0.45))
|
||||||
|
class_ids = getattr(cfg, "TRIANGLE_BLACK_YOLO_CLASS_IDS", (0,))
|
||||||
|
if isinstance(class_ids, int):
|
||||||
|
class_ids = (class_ids,)
|
||||||
|
coord_mode = str(getattr(cfg, "TRIANGLE_BLACK_YOLO_COORD_MODE", "native")).lower()
|
||||||
|
margin_frac = float(getattr(cfg, "TRIANGLE_BLACK_YOLO_BOX_MARGIN_FRAC", 0.08))
|
||||||
|
min_side = float(getattr(cfg, "TRIANGLE_BLACK_YOLO_MIN_BOX_SIDE_PX", 6.0))
|
||||||
|
crop_min = int(getattr(cfg, "TRIANGLE_CROP_ROI_MIN_SIDE_PX", 64))
|
||||||
|
|
||||||
|
h_full, w_full = int(img_rgb.shape[0]), int(img_rgb.shape[1])
|
||||||
|
rx0, ry0, rx1, ry1 = [int(round(float(v))) for v in ring_roi_xyxy]
|
||||||
|
rx0 = max(0, min(rx0, w_full - 1))
|
||||||
|
ry0 = max(0, min(ry0, h_full - 1))
|
||||||
|
rx1 = max(rx0 + 1, min(rx1, w_full))
|
||||||
|
ry1 = max(ry0 + 1, min(ry1, h_full))
|
||||||
|
rw, rh = rx1 - rx0, ry1 - ry0
|
||||||
|
|
||||||
|
if rw < crop_min or rh < crop_min:
|
||||||
|
if logger:
|
||||||
|
logger.warning(
|
||||||
|
f"[YOLO-BLACK] Stage1 ROI 过小 {rw}×{rh} < {crop_min},跳过黑三角检测"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 必须与相机帧缓冲区脱钩:切片常为非连续视图,直接喂 cv2image/NPU 易 SIGSEGV
|
||||||
|
slab = np.ascontiguousarray(
|
||||||
|
img_rgb[ry0:ry1, rx0:rx1], dtype=np.uint8
|
||||||
|
).copy()
|
||||||
|
if slab.size == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
_save_roi = bool(getattr(cfg, "TRIANGLE_BLACK_YOLO_SAVE_ROI_CROP", False))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from maix import image as maix_image
|
||||||
|
|
||||||
|
# copy=True:零拷贝时 detect 内 OpenCV 可能对底层 Mat release 触发 !fixedSize() 断言。
|
||||||
|
roi_maix = maix_image.cv2image(slab, False, True)
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-BLACK] 裁切图转 Maix image 失败: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = det.detect(roi_maix, conf_th=conf_th, iou_th=iou_th)
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-BLACK] detect 异常: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
objs = _normalize_objs(raw if raw is not None else [])
|
||||||
|
net_w = int(det.input_width())
|
||||||
|
net_h = int(det.input_height())
|
||||||
|
|
||||||
|
n_raw = len(objs)
|
||||||
|
n_cls_ok = 0
|
||||||
|
n_too_small = 0
|
||||||
|
|
||||||
|
out_local = []
|
||||||
|
for o in objs:
|
||||||
|
cid = _det_obj_class_id(o)
|
||||||
|
if cid is None or cid not in class_ids:
|
||||||
|
continue
|
||||||
|
n_cls_ok += 1
|
||||||
|
x0f, y0f, x1f, y1f = _det_to_src_xyxy(o, coord_mode, rw, rh, net_w, net_h)
|
||||||
|
lx0 = max(0, min(float(x0f), rw - 1))
|
||||||
|
ly0 = max(0, min(float(y0f), rh - 1))
|
||||||
|
lx1 = max(lx0 + 1, min(float(x1f), rw))
|
||||||
|
ly1 = max(ly0 + 1, min(float(y1f), rh))
|
||||||
|
lx0, ly0, lx1, ly1 = int(round(lx0)), int(round(ly0)), int(round(lx1)), int(round(ly1))
|
||||||
|
if (lx1 - lx0) < min_side or (ly1 - ly0) < min_side:
|
||||||
|
n_too_small += 1
|
||||||
|
continue
|
||||||
|
lx0, ly0, lx1, ly1 = _expand_xyxy_local(
|
||||||
|
lx0, ly0, lx1, ly1, rw, rh, margin_frac
|
||||||
|
)
|
||||||
|
out_local.append((lx0, ly0, lx1, ly1))
|
||||||
|
|
||||||
|
out_local.sort(key=lambda t: ((t[1] + t[3]) * 0.5, (t[0] + t[2]) * 0.5))
|
||||||
|
|
||||||
|
if logger and bool(
|
||||||
|
getattr(cfg, "TRIANGLE_BLACK_YOLO_LOG_EACH_SHOT", True)
|
||||||
|
):
|
||||||
|
msg = (
|
||||||
|
f"[YOLO-BLACK] Stage1裁切{rw}×{rh}上推理: raw={n_raw} 类∈{class_ids}→{n_cls_ok} "
|
||||||
|
f"过小丢弃→{n_too_small} 最终子框={len(out_local)} "
|
||||||
|
f"(conf={conf_th}, coord={coord_mode}, net={net_w}×{net_h}, "
|
||||||
|
f"ring全图=[{rx0},{ry0},{rx1},{ry1}])"
|
||||||
|
)
|
||||||
|
logger.info(msg)
|
||||||
|
if n_raw > 0 and n_cls_ok == 0:
|
||||||
|
seen = []
|
||||||
|
for o in objs[:8]:
|
||||||
|
cid = _det_obj_class_id(o)
|
||||||
|
sc = getattr(o, "score", None)
|
||||||
|
try:
|
||||||
|
sc_f = float(sc) if sc is not None else None
|
||||||
|
except Exception:
|
||||||
|
sc_f = None
|
||||||
|
seen.append(f"cls={cid},score={sc_f}")
|
||||||
|
logger.info(
|
||||||
|
f"[YOLO-BLACK] 有框但类别不在 {class_ids} 内;前几条: {seen}。"
|
||||||
|
f"请核对 TRIANGLE_BLACK_YOLO_CLASS_IDS。"
|
||||||
|
)
|
||||||
|
elif n_cls_ok > 0 and len(out_local) == 0:
|
||||||
|
logger.info(
|
||||||
|
f"[YOLO-BLACK] {n_cls_ok} 个目标类框但边长均 < min_side={min_side},已全部丢弃。"
|
||||||
|
)
|
||||||
|
|
||||||
|
if _save_roi:
|
||||||
|
try:
|
||||||
|
base = (getattr(cfg, "TRIANGLE_BLACK_YOLO_ROI_CROP_DIR", "") or "").strip()
|
||||||
|
if not base:
|
||||||
|
base = os.path.join(
|
||||||
|
getattr(cfg, "PHOTO_DIR", "/tmp") or "/tmp", "stage2_roi"
|
||||||
|
)
|
||||||
|
_draw = bool(
|
||||||
|
getattr(cfg, "TRIANGLE_BLACK_YOLO_SAVE_ROI_DRAW_BOXES", True)
|
||||||
|
)
|
||||||
|
_roi_max_raw = getattr(
|
||||||
|
cfg, "TRIANGLE_BLACK_YOLO_STAGE2_ROI_MAX_IMAGES", None
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_roi_max = (
|
||||||
|
int(_roi_max_raw)
|
||||||
|
if _roi_max_raw is not None
|
||||||
|
else int(getattr(cfg, "MAX_IMAGES", 1000))
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
_roi_max = int(getattr(cfg, "MAX_IMAGES", 1000))
|
||||||
|
slab_copy = np.ascontiguousarray(slab, dtype=np.uint8).copy()
|
||||||
|
boxes_copy = [tuple(t) for t in out_local]
|
||||||
|
threading.Thread(
|
||||||
|
target=_stage2_roi_crop_save_worker,
|
||||||
|
args=(
|
||||||
|
slab_copy,
|
||||||
|
boxes_copy,
|
||||||
|
rx0,
|
||||||
|
ry0,
|
||||||
|
rw,
|
||||||
|
rh,
|
||||||
|
base,
|
||||||
|
_draw,
|
||||||
|
92,
|
||||||
|
_roi_max,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-BLACK] 提交异步保存裁切图失败: {e}")
|
||||||
|
|
||||||
|
return out_local
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,50 @@
|
|||||||
|
# test_audio.pyx
|
||||||
|
from maix import audio, time, app, gpio
|
||||||
|
|
||||||
|
def run_player_loop():
|
||||||
|
"""
|
||||||
|
播放控制主循环函数
|
||||||
|
"""
|
||||||
|
# 初始化音频播放器
|
||||||
|
p = audio.Player("/root/gun.wav")
|
||||||
|
p.volume(40)
|
||||||
|
|
||||||
|
# 初始化 GPIO 引脚为输出
|
||||||
|
led = gpio.GPIO("A25", gpio.Mode.OUT)
|
||||||
|
# 设置低电平
|
||||||
|
led.value(0)
|
||||||
|
|
||||||
|
# 主循环
|
||||||
|
while not app.need_exit():
|
||||||
|
led.value(1) # 点亮 LED
|
||||||
|
time.sleep_ms(200) # 保持 200ms
|
||||||
|
led.value(0) # 熄灭 LED
|
||||||
|
p.play() # 播放音频
|
||||||
|
time.sleep_ms(1000) # 等待 1 秒
|
||||||
|
|
||||||
|
print("play finish!")
|
||||||
|
|
||||||
|
|
||||||
|
# 可选:添加一个简单的测试函数
|
||||||
|
def hello():
|
||||||
|
return "Hello from test_audio!"
|
||||||
|
|
||||||
|
|
||||||
|
# 可选:添加一个初始化函数
|
||||||
|
def init_led():
|
||||||
|
"""单独测试 GPIO"""
|
||||||
|
led = gpio.GPIO("A25", gpio.Mode.OUT)
|
||||||
|
led.value(0)
|
||||||
|
return "LED initialized"
|
||||||
|
|
||||||
|
|
||||||
|
# 可选:添加一个播放测试函数
|
||||||
|
def test_play():
|
||||||
|
"""单独测试音频播放"""
|
||||||
|
p = audio.Player("/root/gun.wav")
|
||||||
|
p.volume(50)
|
||||||
|
p.play()
|
||||||
|
return "Playing..."
|
||||||
|
|
||||||
|
|
||||||
|
run_player_loop()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from maix import audio, time, app,gpio
|
||||||
|
|
||||||
|
|
||||||
|
# button1 = gpio.GPIO("ADC", gpio.Mode.IN)
|
||||||
|
button3 = gpio.GPIO("A26", gpio.Mode.IN) # 可用
|
||||||
|
button2 = gpio.GPIO("A16", gpio.Mode.IN)
|
||||||
|
#设置低电平
|
||||||
|
from maix.peripheral import adc
|
||||||
|
channel = 0
|
||||||
|
res_bit = adc.RES_BIT_12
|
||||||
|
_adc_obj = adc.ADC(channel, res_bit)
|
||||||
|
|
||||||
|
|
||||||
|
while not app.need_exit():
|
||||||
|
# print(f"b1: {button1.value()}")
|
||||||
|
|
||||||
|
print(f"b2: {button2.value()}")
|
||||||
|
|
||||||
|
# print(_adc_obj.read_vol())
|
||||||
|
print(f"b3: {button3.value()}")
|
||||||
|
time.sleep_ms(50)
|
||||||
|
|
||||||
|
# time.sleep_ms(1000)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# from maix import time, rtsp, camera, image
|
||||||
|
|
||||||
|
# # 1. 初始化摄像头(注意:RTSP需要NV21格式)
|
||||||
|
# # 分辨率可以根据需要调整,如 640x480 或 1280x720
|
||||||
|
# cam = camera.Camera(640, 480, image.Format.FMT_YVU420SP)
|
||||||
|
|
||||||
|
# # 2. 创建并启动RTSP服务器
|
||||||
|
# server = rtsp.Rtsp()
|
||||||
|
# server.bind_camera(cam)
|
||||||
|
# server.start()
|
||||||
|
|
||||||
|
# # 3. 打印出访问地址,例如: rtsp://192.168.xxx.xxx:8554/live
|
||||||
|
# print("RTSP 流地址:", server.get_url())
|
||||||
|
|
||||||
|
# # 4. 保持服务运行
|
||||||
|
# while True:
|
||||||
|
# time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from maix import camera, time, app, http, image
|
||||||
|
|
||||||
|
# 初始化相机,注意格式要用 FMT_RGB888(JPEG 编码需要 RGB 输入)
|
||||||
|
cam = camera.Camera(640, 480, image.Format.FMT_RGB888)
|
||||||
|
|
||||||
|
# 创建 JPEG 流服务器
|
||||||
|
stream = http.JpegStreamer()
|
||||||
|
stream.start()
|
||||||
|
|
||||||
|
print("RTSP 替代方案 - HTTP JPEG 流地址: http://{}:{}".format(stream.host(), stream.port()))
|
||||||
|
print("请在浏览器或 OpenCV 中访问: http://<MaixCAM_IP>:8000/stream")
|
||||||
|
|
||||||
|
while not app.need_exit():
|
||||||
|
img = cam.read()
|
||||||
|
jpg = img.to_jpeg() # 将 RGB 图像编码为 JPEG
|
||||||
|
stream.write(jpg) # 推送到 HTTP 客户端
|
||||||
@@ -3,7 +3,10 @@ from maix import camera, display, time
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
print("Initializing camera...")
|
print("Initializing camera...")
|
||||||
cam = camera.Camera(640, 480)
|
cam = camera.Camera(640,480)
|
||||||
|
# cam = camera.Camera(1280,720)
|
||||||
|
# cam.get_exposure_us()
|
||||||
|
# print("Camera exposure: ", cam.get_exposure_us())
|
||||||
print("Camera initialized successfully!")
|
print("Camera initialized successfully!")
|
||||||
|
|
||||||
disp = display.Display()
|
disp = display.Display()
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
class _StopMonitor(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeTime:
|
||||||
|
now_ms = 0
|
||||||
|
stop_at_ms = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def reset(cls, stop_at_ms=None):
|
||||||
|
cls.now_ms = 0
|
||||||
|
cls.stop_at_ms = stop_at_ms
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def ticks_ms(cls):
|
||||||
|
return cls.now_ms
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def sleep_ms(cls, milliseconds):
|
||||||
|
cls.now_ms += milliseconds
|
||||||
|
if cls.stop_at_ms is not None and cls.now_ms >= cls.stop_at_ms:
|
||||||
|
raise _StopMonitor()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_power_module():
|
||||||
|
module_path = Path(__file__).resolve().parents[1] / "power.py"
|
||||||
|
module_name = "power_charging_shutdown_test"
|
||||||
|
maix_module = types.ModuleType("maix")
|
||||||
|
maix_module.time = _FakeTime
|
||||||
|
|
||||||
|
previous_maix = sys.modules.get("maix")
|
||||||
|
sys.modules["maix"] = maix_module
|
||||||
|
try:
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
finally:
|
||||||
|
if previous_maix is None:
|
||||||
|
sys.modules.pop("maix", None)
|
||||||
|
else:
|
||||||
|
sys.modules["maix"] = previous_maix
|
||||||
|
|
||||||
|
|
||||||
|
power = _load_power_module()
|
||||||
|
|
||||||
|
|
||||||
|
class ChargingShutdownTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.config_patch = mock.patch.multiple(
|
||||||
|
power.config,
|
||||||
|
CHARGING_SHUTDOWN_ENABLED=True,
|
||||||
|
CHARGING_DIAGNOSTIC_LOG_ENABLED=False,
|
||||||
|
CHARGING_CHECK_INTERVAL_MS=5000,
|
||||||
|
CHARGING_CURRENT_THRESHOLD_MA=100.0,
|
||||||
|
CHARGING_CONFIRM_COUNT=2,
|
||||||
|
CHARGING_NOTIFY_TIMEOUT_MS=30000,
|
||||||
|
CHARGING_EXIT_SCRIPT="/tmp/charging_exit.sh",
|
||||||
|
)
|
||||||
|
self.config_patch.start()
|
||||||
|
self.network_manager = mock.Mock()
|
||||||
|
self.network_manager.safe_enqueue_and_wait.return_value = True
|
||||||
|
network_module = types.ModuleType("network")
|
||||||
|
network_module.network_manager = self.network_manager
|
||||||
|
self.network_module_patch = mock.patch.dict(
|
||||||
|
sys.modules,
|
||||||
|
{"network": network_module},
|
||||||
|
)
|
||||||
|
self.network_module_patch.start()
|
||||||
|
_FakeTime.reset()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.network_module_patch.stop()
|
||||||
|
self.config_patch.stop()
|
||||||
|
|
||||||
|
def test_two_charging_samples_notify_server_and_exit(self):
|
||||||
|
popen_calls = []
|
||||||
|
with (
|
||||||
|
mock.patch.object(power, "get_current", return_value=-200.0),
|
||||||
|
mock.patch.object(power.os.path, "isfile", return_value=True),
|
||||||
|
mock.patch.object(
|
||||||
|
power.subprocess,
|
||||||
|
"Popen",
|
||||||
|
side_effect=lambda args: popen_calls.append(args),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
power.charging_shutdown_monitor()
|
||||||
|
|
||||||
|
self.assertEqual(_FakeTime.now_ms, 5000)
|
||||||
|
self.assertEqual(len(popen_calls), 1)
|
||||||
|
self.network_manager.safe_enqueue_and_wait.assert_called_once_with(
|
||||||
|
{"poweroff": "充电中"}, 2, high=True, timeout_ms=30000
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_discharging_does_not_notify_or_exit(self):
|
||||||
|
_FakeTime.reset(stop_at_ms=10000)
|
||||||
|
popen_calls = []
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(power, "get_current", return_value=200.0),
|
||||||
|
mock.patch.object(power.os.path, "isfile", return_value=True),
|
||||||
|
mock.patch.object(
|
||||||
|
power.subprocess,
|
||||||
|
"Popen",
|
||||||
|
side_effect=lambda args: popen_calls.append(args),
|
||||||
|
),
|
||||||
|
self.assertRaises(_StopMonitor),
|
||||||
|
):
|
||||||
|
power.charging_shutdown_monitor()
|
||||||
|
|
||||||
|
self.assertEqual(popen_calls, [])
|
||||||
|
self.network_manager.safe_enqueue_and_wait.assert_not_called()
|
||||||
|
|
||||||
|
def test_failed_sample_resets_confirmation_count(self):
|
||||||
|
popen_calls = []
|
||||||
|
currents = iter((-200.0, 0.0, -200.0, -200.0))
|
||||||
|
with (
|
||||||
|
mock.patch.object(power, "get_current", side_effect=lambda: next(currents)),
|
||||||
|
mock.patch.object(power.os.path, "isfile", return_value=True),
|
||||||
|
mock.patch.object(
|
||||||
|
power.subprocess,
|
||||||
|
"Popen",
|
||||||
|
side_effect=lambda args: popen_calls.append(args),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
power.charging_shutdown_monitor()
|
||||||
|
|
||||||
|
self.assertEqual(_FakeTime.now_ms, 15000)
|
||||||
|
self.assertEqual(len(popen_calls), 1)
|
||||||
|
self.network_manager.safe_enqueue_and_wait.assert_called_once_with(
|
||||||
|
{"poweroff": "充电中"}, 2, high=True, timeout_ms=30000
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
离线测试脚本:直接复用 detect_circle 逻辑进行测试
|
||||||
|
运行环境:MaixPy (Sipeed MAIX)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
# import time
|
||||||
|
from maix import image, time
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import math
|
||||||
|
|
||||||
|
# ==================== 全局配置 (与 test_main.py 保持一致) ====================
|
||||||
|
REAL_RADIUS_CM = 20 # 靶心实际半径(厘米)
|
||||||
|
|
||||||
|
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)
|
||||||
|
from datetime import datetime
|
||||||
|
print(f"[detect_circle_v3] begin {datetime.now()}")
|
||||||
|
# -- 1. 缩图加速(与三角形路径保持一致)
|
||||||
|
h_orig, w_orig = img_cv.shape[:2]
|
||||||
|
MAX_DET_DIM = 480
|
||||||
|
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
|
||||||
|
|
||||||
|
print(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)
|
||||||
|
|
||||||
|
print(f"[detect_circle_v3] step 2 fin {datetime.now()}")
|
||||||
|
|
||||||
|
# -- 3. 红色掩码:在循环外只算一次
|
||||||
|
mask_red = cv2.bitwise_or(
|
||||||
|
cv2.inRange(hsv, np.array([0, 50, 40]), np.array([10, 255, 255])),
|
||||||
|
cv2.inRange(hsv, np.array([170, 50, 40]), 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 <= 10:
|
||||||
|
continue
|
||||||
|
pr = cv2.arcLength(cnt_r, True)
|
||||||
|
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.3:
|
||||||
|
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)})
|
||||||
|
|
||||||
|
print(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 <= 15:
|
||||||
|
continue
|
||||||
|
perimeter = cv2.arcLength(cnt_yellow, True)
|
||||||
|
if perimeter <= 0:
|
||||||
|
continue
|
||||||
|
circularity = (4 * np.pi * area) / (perimeter * perimeter)
|
||||||
|
if circularity <= 0.5:
|
||||||
|
continue
|
||||||
|
print(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.7:
|
||||||
|
print(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 :
|
||||||
|
print("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
||||||
|
|
||||||
|
print(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)
|
||||||
|
print(f"[detect_circle_v3] step 5 fin {datetime.now()}")
|
||||||
|
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
||||||
|
|
||||||
|
|
||||||
|
def run_offline_test(image_path):
|
||||||
|
"""读取图片,检测圆,绘制结果,保存图片"""
|
||||||
|
|
||||||
|
# 1. 检查文件是否存在
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
print(f"[ERROR] 找不到图片文件: {image_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. 使用 maix.image 读取图片 (适配 MaixPy v4)
|
||||||
|
try:
|
||||||
|
# 使用 image.load 读取文件,返回 Image 对象
|
||||||
|
img = image.load(image_path)
|
||||||
|
print(f"[INFO] 成功读取图片: {image_path} (尺寸: {img.width()}x{img.height()})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 读取图片失败: {e}")
|
||||||
|
print("提示:请确认 MaixPy 版本是否为 v4,且图片路径正确。")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. 调用 detect_circle_v3 函数
|
||||||
|
print("[INFO] 正在调用 detect_circle_v3 进行检测...")
|
||||||
|
start_time = time.ticks_ms()
|
||||||
|
|
||||||
|
result_img, center, radius, method, radius1, ellipse_params = detect_circle_v3(img)
|
||||||
|
|
||||||
|
cost_time = time.ticks_ms() - start_time
|
||||||
|
print(f"[INFO] 检测完成,耗时: {cost_time}ms")
|
||||||
|
print(f" 结果 -> 圆心: {center}, 半径: {radius}, 方法: {method}")
|
||||||
|
if ellipse_params:
|
||||||
|
(ell_center, (width, height), angle) = ellipse_params
|
||||||
|
print(
|
||||||
|
f" 椭圆 -> 中心: ({ell_center[0]:.1f}, {ell_center[1]:.1f}), 长轴: {max(width, height):.1f}, 短轴: {min(width, height):.1f}, 角度: {angle:.1f}°")
|
||||||
|
|
||||||
|
# 4. 绘制辅助线(可选,用于调试)
|
||||||
|
if center and radius:
|
||||||
|
# 为了绘制椭圆,需要转换回 cv2 图像
|
||||||
|
img_cv = image.image2cv(result_img, False, False)
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
# 确定长轴和短轴
|
||||||
|
if width >= height:
|
||||||
|
# width 是长轴,height 是短轴
|
||||||
|
axes_major = width
|
||||||
|
axes_minor = height
|
||||||
|
major_angle = angle # 长轴角度就是 angle
|
||||||
|
minor_angle = angle + 90 # 短轴角度 = 长轴角度 + 90度
|
||||||
|
else:
|
||||||
|
# height 是长轴,width 是短轴
|
||||||
|
axes_major = height
|
||||||
|
axes_minor = width
|
||||||
|
major_angle = angle + 90 # 长轴角度 = width角度 + 90度
|
||||||
|
minor_angle = angle # 短轴角度就是 angle
|
||||||
|
|
||||||
|
# 使用 OpenCV 绘制椭圆(绿色,线宽2)
|
||||||
|
cv2.ellipse(img_cv,
|
||||||
|
(cx_ell, cy_ell), # 中心点
|
||||||
|
(int(width / 2), int(height / 2)), # 半宽、半高
|
||||||
|
angle, # 旋转角度(OpenCV需要原始angle)
|
||||||
|
0, 360, # 起始和结束角度
|
||||||
|
(0, 255, 0), # 绿色 (RGB格式)
|
||||||
|
2) # 线宽
|
||||||
|
|
||||||
|
# 绘制椭圆中心点(红色)
|
||||||
|
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
|
||||||
|
|
||||||
|
import math
|
||||||
|
# 绘制短轴(蓝色线条)
|
||||||
|
minor_length = axes_minor / 2
|
||||||
|
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_minor = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
|
||||||
|
pt2_minor = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
|
||||||
|
cv2.line(img_cv, pt1_minor, pt2_minor, (0, 0, 255), 2) # 蓝色 (RGB格式)
|
||||||
|
else:
|
||||||
|
# 如果没有椭圆参数,绘制圆形(红色)
|
||||||
|
cv2.circle(img_cv, (cx, cy), radius, (0, 0, 255), 2)
|
||||||
|
cv2.circle(img_cv, (cx, cy), 2, (0, 0, 255), -1)
|
||||||
|
|
||||||
|
# 转换回 maix image
|
||||||
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
|
|
||||||
|
# 定义颜色对象用于文字
|
||||||
|
try:
|
||||||
|
color_black = image.Color.from_rgb(0, 0, 0)
|
||||||
|
except AttributeError:
|
||||||
|
color_black = image.Color(0, 0, 0)
|
||||||
|
|
||||||
|
# D. 添加文字信息
|
||||||
|
FOCAL_LENGTH_PIX = 1900
|
||||||
|
d = (REAL_RADIUS_CM * FOCAL_LENGTH_PIX) / radius1 / 100.0
|
||||||
|
info_str = f"R:{radius} M:{method} D:{d:.2f}"
|
||||||
|
print(info_str)
|
||||||
|
|
||||||
|
# 计算文字位置,防止超出图片边界
|
||||||
|
r_outer = int(radius * 11.0) if radius else 100
|
||||||
|
text_y = cy - r_outer - 20 if cy > r_outer + 20 else cy + r_outer + 20
|
||||||
|
|
||||||
|
# 调用 draw_string
|
||||||
|
result_img.draw_string(0, 0, info_str, color=color_black, scale=1.0)
|
||||||
|
|
||||||
|
# 5. 保存结果图片
|
||||||
|
base, ext = os.path.splitext(image_path)
|
||||||
|
output_path = f"{base}_result{ext}"
|
||||||
|
try:
|
||||||
|
result_img.save(output_path, quality=100)
|
||||||
|
print(f"[SUCCESS] 结果已保存至: {output_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 保存图片失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# ================= 配置区域 =================
|
||||||
|
|
||||||
|
# 1. 设置要测试的图片路径
|
||||||
|
# 建议将图片放在与脚本同级目录,或者使用绝对路径
|
||||||
|
TARGET_IMAGE = "/root/phot/None_314_258_0_0041.bmp"
|
||||||
|
|
||||||
|
TARGET_DIR = "/root/phot" # 修改为你想要读取的目录路径
|
||||||
|
|
||||||
|
# 支持的图片格式
|
||||||
|
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
|
||||||
|
# ================= 执行区域 =================
|
||||||
|
if 'TARGET_DIR' in locals():
|
||||||
|
# 读取目录下所有图片文件,过滤掉 _result.jpg 后缀的文件
|
||||||
|
image_files = []
|
||||||
|
if os.path.exists(TARGET_DIR) and os.path.isdir(TARGET_DIR):
|
||||||
|
for filename in os.listdir(TARGET_DIR):
|
||||||
|
# 检查文件扩展名
|
||||||
|
if any(filename.lower().endswith(ext) for ext in IMAGE_EXTENSIONS):
|
||||||
|
# 过滤掉 _result.jpg 后缀的文件
|
||||||
|
if not filename.endswith('_result.jpg'):
|
||||||
|
filepath = os.path.join(TARGET_DIR, filename)
|
||||||
|
if os.path.isfile(filepath):
|
||||||
|
image_files.append(filepath)
|
||||||
|
|
||||||
|
# 按文件名排序(可选)
|
||||||
|
image_files.sort()
|
||||||
|
|
||||||
|
print(f"[INFO] 在目录 {TARGET_DIR} 中找到 {len(image_files)} 张图片")
|
||||||
|
|
||||||
|
# 处理每张图片
|
||||||
|
for img_path in image_files:
|
||||||
|
print(f"\n{'=' * 10} 开始处理: {img_path} {'=' * 10}")
|
||||||
|
run_offline_test(img_path)
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] 目录不存在或不是有效目录: {TARGET_DIR}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
run_offline_test(TARGET_IMAGE)
|
||||||
@@ -0,0 +1,635 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
离线测试脚本:直接复用 detect_circle 逻辑进行测试
|
||||||
|
运行环境:MaixPy (Sipeed MAIX)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
# import time
|
||||||
|
from maix import image, time
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# ==================== 全局配置 (与 test_main.py 保持一致) ====================
|
||||||
|
REAL_RADIUS_CM = 20 # 靶心实际半径(厘米)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 复制的核心算法 ====================
|
||||||
|
# 注意:这里直接复制了 detect_circle 的逻辑,避免 import main 导致的冲突
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if area > 50 and circularity > 0.7:
|
||||||
|
print(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-12度(接近0度的红色)
|
||||||
|
# 放宽S/V阈值:S>=30, V>=20 以捕获淡红/暗红
|
||||||
|
lower_red1 = np.array([0, 30, 20])
|
||||||
|
upper_red1 = np.array([12, 255, 255])
|
||||||
|
mask_red1 = cv2.inRange(hsv, lower_red1, upper_red1)
|
||||||
|
|
||||||
|
# 红色范围2: 168-180度(接近180度的红色)
|
||||||
|
lower_red2 = np.array([168, 30, 20])
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 形态学操作:先CLOSE填充空洞,再DILATE加厚环状区域
|
||||||
|
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
||||||
|
mask_red = cv2.dilate(mask_red, kernel_red, iterations=1)
|
||||||
|
|
||||||
|
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
red_pixel_count = np.sum(mask_red > 0)
|
||||||
|
print(f"Debug -> 红色掩码: {red_pixel_count} 像素, {len(contours_red)} 个轮廓")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# 环状轮廓圆度可能偏低,放宽到0.2
|
||||||
|
print(f"Debug -> 红轮廓: 面积={area_red:.1f}, 圆度={circularity_red:.2f}" +
|
||||||
|
f" (面积>15={area_red > 15}, 圆度>0.2={circularity_red > 0.2})")
|
||||||
|
if area_red > 15 and circularity_red > 0.2:
|
||||||
|
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)
|
||||||
|
|
||||||
|
max_distance = yellow_radius * 2.0
|
||||||
|
min_r = min(red_radius, yellow_radius)
|
||||||
|
max_r = max(red_radius, yellow_radius)
|
||||||
|
size_ratio = min_r / max_r if max_r > 0 else 0
|
||||||
|
print(f"Debug -> 圆心距={distance:.1f}(阈值={max_distance:.1f}), "
|
||||||
|
f"大小比={size_ratio:.2f}(阈值=0.5), "
|
||||||
|
f"距离OK={distance < max_distance}, 大小OK={size_ratio > 0.5}")
|
||||||
|
|
||||||
|
# 允许红圈在黄圈外侧或内侧,只要大小相近(较小/较大 >= 0.5)
|
||||||
|
if distance < max_distance and size_ratio > 0.5:
|
||||||
|
found_valid_red = True
|
||||||
|
print(
|
||||||
|
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:
|
||||||
|
# 如果黄圈非常可靠(大且圆),在没有红圈验证时仍接受
|
||||||
|
if area > 30 and circularity > 0.85:
|
||||||
|
print(f"[target] -> 黄圈高置信度(面积:{area:.0f}, 圆度:{circularity:.2f}),跳过红圈验证直接接受")
|
||||||
|
valid_targets.append({
|
||||||
|
'center': yellow_center,
|
||||||
|
'radius': yellow_radius,
|
||||||
|
'ellipse': yellow_ellipse,
|
||||||
|
'area': area
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
print("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(frame):
|
||||||
|
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)"""
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
# gray = cv2.cvtColor(img_cv, cv2.COLOR_RGB2GRAY)
|
||||||
|
# blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
||||||
|
# edged = cv2.Canny(blurred, 50, 150)
|
||||||
|
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
# ceroded = cv2.erode(cv2.dilate(edged, kernel), kernel)
|
||||||
|
|
||||||
|
# contours, _ = cv2.findContours(ceroded, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
# best_center = best_radius = best_radius1 = method = None
|
||||||
|
|
||||||
|
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
# h, s, v = cv2.split(hsv)
|
||||||
|
# s = np.clip(s * 2, 0, 255).astype(np.uint8)
|
||||||
|
# hsv = cv2.merge((h, s, v))
|
||||||
|
# lower_yellow = np.array([7, 80, 0])
|
||||||
|
# upper_yellow = np.array([32, 255, 182])
|
||||||
|
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
# mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
|
||||||
|
# mask = cv2.morphologyEx(mask, cv2.MORPH_DILATE, kernel)
|
||||||
|
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
# if contours:
|
||||||
|
# largest = max(contours, key=cv2.contourArea)
|
||||||
|
# if cv2.contourArea(largest) > 50:
|
||||||
|
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
||||||
|
# best_center = (int(x), int(y))
|
||||||
|
# best_radius = int(radius)
|
||||||
|
# best_radius1 = radius * 5
|
||||||
|
# method = "v2"
|
||||||
|
|
||||||
|
# auto
|
||||||
|
# R:31 M:v2 D:2.410110127692767
|
||||||
|
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
# h, s, v = cv2.split(hsv)
|
||||||
|
|
||||||
|
# # 1. 增强饱和度(模糊照片需要更强的增强)
|
||||||
|
# s = np.clip(s * 2.5, 0, 255).astype(np.uint8) # 从2.0改为2.5
|
||||||
|
|
||||||
|
# # 2. 增强亮度(模糊照片可能偏暗)
|
||||||
|
# v = np.clip(v * 1.2, 0, 255).astype(np.uint8) # 新增:提升亮度
|
||||||
|
|
||||||
|
# hsv = cv2.merge((h, s, v))
|
||||||
|
|
||||||
|
# # 3. 放宽HSV颜色范围(特别是模糊照片)
|
||||||
|
# # 降低饱和度下限,提高亮度上限
|
||||||
|
# lower_yellow = np.array([5, 50, 30]) # H:5-35, S:50-255, V:30-255
|
||||||
|
# upper_yellow = np.array([35, 255, 255])
|
||||||
|
|
||||||
|
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
|
||||||
|
# # 4. 增强形态学操作(连接被分割的区域)
|
||||||
|
# kernel_small = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
# kernel_large = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9)) # 更大的核
|
||||||
|
|
||||||
|
# # 先开运算去除噪声
|
||||||
|
# mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_small)
|
||||||
|
# # 多次膨胀连接区域(模糊照片需要更多膨胀)
|
||||||
|
# mask = cv2.dilate(mask, kernel_large, iterations=2) # 增加迭代次数
|
||||||
|
# mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_large) # 闭运算填充空洞
|
||||||
|
|
||||||
|
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
# if contours:
|
||||||
|
# largest = max(contours, key=cv2.contourArea)
|
||||||
|
# area = cv2.contourArea(largest)
|
||||||
|
# if area > 50:
|
||||||
|
# # 5. 使用面积计算等效半径(更准确)
|
||||||
|
# equivalent_radius = np.sqrt(area / np.pi)
|
||||||
|
|
||||||
|
# # 6. 同时使用minEnclosingCircle作为备选(取较大值)
|
||||||
|
# (x, y), enclosing_radius = cv2.minEnclosingCircle(largest)
|
||||||
|
|
||||||
|
# # 取两者中的较大值,确保不遗漏
|
||||||
|
# radius = max(equivalent_radius, enclosing_radius)
|
||||||
|
|
||||||
|
# best_center = (int(x), int(y))
|
||||||
|
# best_radius = int(radius)
|
||||||
|
# best_radius1 = radius * 5
|
||||||
|
# method = "v2"
|
||||||
|
|
||||||
|
# codegee
|
||||||
|
# R:24 M:v2 D:3.061493895819174
|
||||||
|
# R:22 M:v2 D:3.3644971681267077 np.clip(s * 1.1, 0, 255)
|
||||||
|
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
h, s, v = cv2.split(hsv)
|
||||||
|
|
||||||
|
# 2. 调整饱和度策略:
|
||||||
|
# 不要暴力翻倍,可以尝试稍微增强,或者使用 CLAHE 增强亮度/对比度
|
||||||
|
# 这里我们稍微增加一点饱和度,并确保不溢出
|
||||||
|
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
||||||
|
# 对亮度通道 v 也可以做一点 CLAHE 处理来增强对比度(可选)
|
||||||
|
# clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
|
||||||
|
# v = clahe.apply(v)
|
||||||
|
|
||||||
|
hsv = cv2.merge((h, s, v))
|
||||||
|
|
||||||
|
# 3. 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
||||||
|
# 降低 S 的下限 (80 -> 35),提高 V 的上限 (182 -> 255)
|
||||||
|
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
||||||
|
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
||||||
|
|
||||||
|
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
|
||||||
|
# 4. 调整形态学操作
|
||||||
|
# 去掉 MORPH_OPEN,因为它会减小面积。
|
||||||
|
# 使用 MORPH_CLOSE (先膨胀后腐蚀) 来填充内部小黑洞,连接近邻区域
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
||||||
|
# 再进行一次膨胀,确保边缘被包含进来
|
||||||
|
# mask = cv2.dilate(mask, kernel, iterations=1)
|
||||||
|
|
||||||
|
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
if contours:
|
||||||
|
largest = max(contours, key=cv2.contourArea)
|
||||||
|
|
||||||
|
# 这里可以适当降低面积阈值,或者保持不变
|
||||||
|
if cv2.contourArea(largest) > 50:
|
||||||
|
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
||||||
|
# best_center = (int(x), int(y))
|
||||||
|
# best_radius = int(radius)
|
||||||
|
|
||||||
|
# --- 核心修改开始 ---
|
||||||
|
# 1. 尝试拟合椭圆 (需要轮廓点至少为5个)
|
||||||
|
if len(largest) >= 5:
|
||||||
|
# 返回值: ((中心x, 中心y), (长轴, 短轴), 旋转角度)
|
||||||
|
(x, y), (axes_major, axes_minor), angle = cv2.fitEllipse(largest)
|
||||||
|
|
||||||
|
# 2. 计算半径
|
||||||
|
# 选项A:取长短轴的平均值 (比较稳健)
|
||||||
|
# radius = (axes_major + axes_minor) / 4
|
||||||
|
|
||||||
|
# 选项B:直接取短轴的一半 (抗模糊最强,推荐)
|
||||||
|
radius = axes_minor / 2
|
||||||
|
|
||||||
|
best_center = (int(x), int(y))
|
||||||
|
best_radius = int(radius)
|
||||||
|
method = "v2_ellipse"
|
||||||
|
else:
|
||||||
|
# 如果点太少无法拟合椭圆,降级回 minEnclosingCircle
|
||||||
|
(x, y), radius = cv2.minEnclosingCircle(largest)
|
||||||
|
best_center = (int(x), int(y))
|
||||||
|
best_radius = int(radius)
|
||||||
|
method = "v2"
|
||||||
|
# --- 核心修改结束 ---
|
||||||
|
|
||||||
|
# 你的后续逻辑
|
||||||
|
best_radius1 = radius * 5
|
||||||
|
|
||||||
|
# operas 4.5
|
||||||
|
# R:25 M:v2 D:2.9554872521538527
|
||||||
|
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
# h, s, v = cv2.split(hsv)
|
||||||
|
|
||||||
|
# # 1. 适度增强饱和度(不要过度,否则噪声也会增强)
|
||||||
|
# s = np.clip(s * 1.5, 0, 255).astype(np.uint8)
|
||||||
|
# hsv = cv2.merge((h, s, v))
|
||||||
|
|
||||||
|
# # 2. 放宽 HSV 阈值范围(关键改动)
|
||||||
|
# # - 饱和度下限从 80 降到 40(捕捉淡黄色)
|
||||||
|
# # - 亮度上限从 182 提高到 255(允许更亮的黄色)
|
||||||
|
# lower_yellow = np.array([7, 40, 30])
|
||||||
|
# upper_yellow = np.array([35, 255, 255])
|
||||||
|
|
||||||
|
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
|
||||||
|
# # 3. 调整形态学操作:用 CLOSE 替代 OPEN
|
||||||
|
# # CLOSE(先膨胀后腐蚀):填充内部空洞,连接相邻区域
|
||||||
|
# # OPEN(先腐蚀后膨胀):会缩小区域,不适合模糊图像
|
||||||
|
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) # 稍大的核
|
||||||
|
# mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
||||||
|
# mask = cv2.dilate(mask, kernel, iterations=1) # 额外膨胀,确保边缘被包含
|
||||||
|
|
||||||
|
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
# if contours:
|
||||||
|
# largest = max(contours, key=cv2.contourArea)
|
||||||
|
# if cv2.contourArea(largest) > 50:
|
||||||
|
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
||||||
|
# best_center = (int(x), int(y))
|
||||||
|
# best_radius = int(radius)
|
||||||
|
# best_radius1 = radius * 5
|
||||||
|
# method = "v2"
|
||||||
|
|
||||||
|
# # --- 新增:将 Mask 叠加到原图上用于调试 ---
|
||||||
|
# # 创建一个彩色掩码(红色通道为255,其他为0)
|
||||||
|
# mask_overlay = np.zeros_like(img_cv)
|
||||||
|
# mask_overlay[:, :, 2] = mask # 将掩码放在红色通道 (BGR中的R)
|
||||||
|
#
|
||||||
|
# cv2.addWeighted(img_cv, 0.6, mask_overlay, 0.4, 0, img_cv)
|
||||||
|
|
||||||
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
|
return result_img, best_center, best_radius, method, best_radius1
|
||||||
|
|
||||||
|
|
||||||
|
def detect_circle_v2(frame):
|
||||||
|
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本"""
|
||||||
|
global REAL_RADIUS_CM
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
|
||||||
|
best_center = best_radius = best_radius1 = method = None
|
||||||
|
ellipse_params = None # 存储椭圆参数 ((x, y), (axes_major, axes_minor), angle)
|
||||||
|
|
||||||
|
# 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 = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
|
||||||
|
# 调整形态学操作
|
||||||
|
# 使用 MORPH_CLOSE (先膨胀后腐蚀) 来填充内部小黑洞,连接近邻区域
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
||||||
|
|
||||||
|
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
if contours:
|
||||||
|
largest = max(contours, key=cv2.contourArea)
|
||||||
|
|
||||||
|
if cv2.contourArea(largest) > 50:
|
||||||
|
# 尝试拟合椭圆 (需要轮廓点至少为5个)
|
||||||
|
if len(largest) >= 5:
|
||||||
|
# 返回值: ((中心x, 中心y), (width, height), 旋转角度)
|
||||||
|
# 注意:width 和 height 是外接矩形的尺寸,不是长轴和短轴
|
||||||
|
(x, y), (width, height), angle = cv2.fitEllipse(largest)
|
||||||
|
|
||||||
|
# 保存椭圆参数(保持原始顺序,用于绘制)
|
||||||
|
ellipse_params = ((x, y), (width, height), angle)
|
||||||
|
|
||||||
|
# 计算半径:使用较小的尺寸作为短轴
|
||||||
|
axes_minor = min(width, height)
|
||||||
|
radius = axes_minor / 2
|
||||||
|
|
||||||
|
best_center = (int(x), int(y))
|
||||||
|
best_radius = int(radius)
|
||||||
|
method = "v2_ellipse"
|
||||||
|
else:
|
||||||
|
# 如果点太少无法拟合椭圆,降级回 minEnclosingCircle
|
||||||
|
(x, y), radius = cv2.minEnclosingCircle(largest)
|
||||||
|
best_center = (int(x), int(y))
|
||||||
|
best_radius = int(radius)
|
||||||
|
method = "v2"
|
||||||
|
ellipse_params = None # 圆形,没有椭圆参数
|
||||||
|
|
||||||
|
best_radius1 = radius * 5
|
||||||
|
|
||||||
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
|
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 测试逻辑 ====================
|
||||||
|
|
||||||
|
def run_offline_test(image_path):
|
||||||
|
"""读取图片,检测圆,绘制结果,保存图片"""
|
||||||
|
|
||||||
|
# 1. 检查文件是否存在
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
print(f"[ERROR] 找不到图片文件: {image_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. 使用 maix.image 读取图片 (适配 MaixPy v4)
|
||||||
|
try:
|
||||||
|
# 使用 image.load 读取文件,返回 Image 对象
|
||||||
|
img = image.load(image_path)
|
||||||
|
print(f"[INFO] 成功读取图片: {image_path} (尺寸: {img.width()}x{img.height()})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 读取图片失败: {e}")
|
||||||
|
print("提示:请确认 MaixPy 版本是否为 v4,且图片路径正确。")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. 调用 detect_circle_v2 函数
|
||||||
|
print("[INFO] 正在调用 detect_circle_v2 进行检测...")
|
||||||
|
start_time = time.ticks_ms()
|
||||||
|
|
||||||
|
result_img, center, radius, method, radius1, ellipse_params = detect_circle_v3(img)
|
||||||
|
|
||||||
|
cost_time = time.ticks_ms() - start_time
|
||||||
|
print(f"[INFO] 检测完成,耗时: {cost_time}ms")
|
||||||
|
print(f" 结果 -> 圆心: {center}, 半径: {radius}, 方法: {method}")
|
||||||
|
if ellipse_params:
|
||||||
|
(ell_center, (width, height), angle) = ellipse_params
|
||||||
|
print(
|
||||||
|
f" 椭圆 -> 中心: ({ell_center[0]:.1f}, {ell_center[1]:.1f}), 长轴: {max(width, height):.1f}, 短轴: {min(width, height):.1f}, 角度: {angle:.1f}°")
|
||||||
|
|
||||||
|
# 4. 绘制辅助线(可选,用于调试)
|
||||||
|
if center and radius:
|
||||||
|
# 为了绘制椭圆,需要转换回 cv2 图像
|
||||||
|
img_cv = image.image2cv(result_img, False, False)
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
# 确定长轴和短轴
|
||||||
|
if width >= height:
|
||||||
|
# width 是长轴,height 是短轴
|
||||||
|
axes_major = width
|
||||||
|
axes_minor = height
|
||||||
|
major_angle = angle # 长轴角度就是 angle
|
||||||
|
minor_angle = angle + 90 # 短轴角度 = 长轴角度 + 90度
|
||||||
|
else:
|
||||||
|
# height 是长轴,width 是短轴
|
||||||
|
axes_major = height
|
||||||
|
axes_minor = width
|
||||||
|
major_angle = angle + 90 # 长轴角度 = width角度 + 90度
|
||||||
|
minor_angle = angle # 短轴角度就是 angle
|
||||||
|
|
||||||
|
# 使用 OpenCV 绘制椭圆(绿色,线宽2)
|
||||||
|
cv2.ellipse(img_cv,
|
||||||
|
(cx_ell, cy_ell), # 中心点
|
||||||
|
(int(width / 2), int(height / 2)), # 半宽、半高
|
||||||
|
angle, # 旋转角度(OpenCV需要原始angle)
|
||||||
|
0, 360, # 起始和结束角度
|
||||||
|
(0, 255, 0), # 绿色 (RGB格式)
|
||||||
|
2) # 线宽
|
||||||
|
|
||||||
|
# 绘制椭圆中心点(红色)
|
||||||
|
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
|
||||||
|
|
||||||
|
import math
|
||||||
|
# 绘制短轴(蓝色线条)
|
||||||
|
minor_length = axes_minor / 2
|
||||||
|
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_minor = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
|
||||||
|
pt2_minor = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
|
||||||
|
cv2.line(img_cv, pt1_minor, pt2_minor, (0, 0, 255), 2) # 蓝色 (RGB格式)
|
||||||
|
else:
|
||||||
|
# 如果没有椭圆参数,绘制圆形(红色)
|
||||||
|
cv2.circle(img_cv, (cx, cy), radius, (0, 0, 255), 2)
|
||||||
|
cv2.circle(img_cv, (cx, cy), 2, (0, 0, 255), -1)
|
||||||
|
|
||||||
|
# 转换回 maix image
|
||||||
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
|
|
||||||
|
# 定义颜色对象用于文字
|
||||||
|
try:
|
||||||
|
color_black = image.Color.from_rgb(0, 0, 0)
|
||||||
|
except AttributeError:
|
||||||
|
color_black = image.Color(0, 0, 0)
|
||||||
|
|
||||||
|
# D. 添加文字信息
|
||||||
|
FOCAL_LENGTH_PIX = 1900
|
||||||
|
d = (REAL_RADIUS_CM * FOCAL_LENGTH_PIX) / radius1 / 100.0
|
||||||
|
info_str = f"R:{radius} M:{method} D:{d:.2f}"
|
||||||
|
print(info_str)
|
||||||
|
|
||||||
|
# 计算文字位置,防止超出图片边界
|
||||||
|
r_outer = int(radius * 11.0) if radius else 100
|
||||||
|
text_y = cy - r_outer - 20 if cy > r_outer + 20 else cy + r_outer + 20
|
||||||
|
|
||||||
|
# 调用 draw_string
|
||||||
|
result_img.draw_string(0, 0, info_str, color=color_black, scale=1.0)
|
||||||
|
|
||||||
|
# 5. 保存结果图片
|
||||||
|
output_path = image_path.replace(".bmp", "_result.bmp")
|
||||||
|
output_path = image_path.replace(".jpg", "_result.jpg")
|
||||||
|
try:
|
||||||
|
result_img.save(output_path, quality=100)
|
||||||
|
print(f"[SUCCESS] 结果已保存至: {output_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 保存图片失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# ================= 配置区域 =================
|
||||||
|
|
||||||
|
# 1. 设置要测试的图片路径
|
||||||
|
# 建议将图片放在与脚本同级目录,或者使用绝对路径
|
||||||
|
TARGET_IMAGE = "/root/phot/None_314_258_0_0041.bmp"
|
||||||
|
|
||||||
|
TARGET_DIR = "/root/phot" # 修改为你想要读取的目录路径
|
||||||
|
|
||||||
|
# 支持的图片格式
|
||||||
|
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
|
||||||
|
# ================= 执行区域 =================
|
||||||
|
if 'TARGET_DIR' in locals():
|
||||||
|
# 读取目录下所有图片文件,过滤掉 _result.jpg 后缀的文件
|
||||||
|
image_files = []
|
||||||
|
if os.path.exists(TARGET_DIR) and os.path.isdir(TARGET_DIR):
|
||||||
|
for filename in os.listdir(TARGET_DIR):
|
||||||
|
# 检查文件扩展名
|
||||||
|
if any(filename.lower().endswith(ext) for ext in IMAGE_EXTENSIONS):
|
||||||
|
# 过滤掉 _result.jpg 后缀的文件
|
||||||
|
if filename.endswith('no_target.jpg'):
|
||||||
|
filepath = os.path.join(TARGET_DIR, filename)
|
||||||
|
if os.path.isfile(filepath):
|
||||||
|
image_files.append(filepath)
|
||||||
|
|
||||||
|
# 按文件名排序(可选)
|
||||||
|
image_files.sort()
|
||||||
|
|
||||||
|
print(f"[INFO] 在目录 {TARGET_DIR} 中找到 {len(image_files)} 张图片")
|
||||||
|
|
||||||
|
# 处理每张图片
|
||||||
|
for img_path in image_files:
|
||||||
|
print(f"\n{'=' * 10} 开始处理: {img_path} {'=' * 10}")
|
||||||
|
run_offline_test(img_path)
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] 目录不存在或不是有效目录: {TARGET_DIR}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
run_offline_test(TARGET_IMAGE)
|
||||||
+203
-129
@@ -1,172 +1,246 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
激光模块测试脚本
|
M01激光测距模块测试脚本 - 修正版
|
||||||
用于诊断激光开关问题
|
基于文档中的完整命令示例
|
||||||
|
|
||||||
使用方法:
|
|
||||||
python test_laser.py
|
|
||||||
|
|
||||||
功能:
|
|
||||||
1. 初始化串口
|
|
||||||
2. 循环测试激光开/关
|
|
||||||
3. 打印详细调试信息
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from maix import uart, pinmap, time
|
from maix import uart, pinmap, time
|
||||||
|
import binascii
|
||||||
|
|
||||||
# ==================== 配置 ====================
|
# ==================== 配置 ====================
|
||||||
UART_PORT = "/dev/ttyS1" # 激光模块连接的串口(UART1)
|
UART_PORT = "/dev/ttyS1"
|
||||||
BAUDRATE = 9600 # 波特率
|
BAUDRATE = 9600
|
||||||
|
|
||||||
# 引脚映射(确保与硬件连接一致)
|
|
||||||
print("=" * 50)
|
|
||||||
print("🔧 步骤1: 配置引脚映射")
|
|
||||||
print("=" * 50)
|
|
||||||
|
|
||||||
|
# 初始化串口
|
||||||
try:
|
try:
|
||||||
pinmap.set_pin_function("A18", "UART1_RX")
|
pinmap.set_pin_function("A18", "UART1_RX")
|
||||||
print("✅ A18 -> UART1_RX")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ A18 配置失败: {e}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
pinmap.set_pin_function("A19", "UART1_TX")
|
pinmap.set_pin_function("A19", "UART1_TX")
|
||||||
print("✅ A19 -> UART1_TX")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ A19 配置失败: {e}")
|
|
||||||
|
|
||||||
# ==================== 激光控制指令 ====================
|
|
||||||
MODULE_ADDR = 0x00
|
|
||||||
|
|
||||||
# 原始命令
|
|
||||||
LASER_ON_CMD = bytes([0xAA, MODULE_ADDR, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x01, 0xC1])
|
|
||||||
LASER_OFF_CMD = bytes([0xAA, MODULE_ADDR, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x00, 0xC0])
|
|
||||||
|
|
||||||
# 备用命令格式(如果原始命令不工作,可以尝试这些)
|
|
||||||
# 格式1: 简化命令
|
|
||||||
LASER_ON_CMD_ALT1 = bytes([0xAA, 0x01, 0x01])
|
|
||||||
LASER_OFF_CMD_ALT1 = bytes([0xAA, 0x01, 0x00])
|
|
||||||
|
|
||||||
# 格式2: 不同的协议头
|
|
||||||
LASER_ON_CMD_ALT2 = bytes([0x55, 0xAA, 0x01])
|
|
||||||
LASER_OFF_CMD_ALT2 = bytes([0x55, 0xAA, 0x00])
|
|
||||||
|
|
||||||
print("\n" + "=" * 50)
|
|
||||||
print("🔧 步骤2: 初始化串口")
|
|
||||||
print("=" * 50)
|
|
||||||
print(f"设备: {UART_PORT}")
|
|
||||||
print(f"波特率: {BAUDRATE}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
laser_uart = uart.UART(UART_PORT, BAUDRATE)
|
laser_uart = uart.UART(UART_PORT, BAUDRATE)
|
||||||
print(f"✅ 串口初始化成功: {laser_uart}")
|
print("✅ 硬件初始化完成")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 串口初始化失败: {e}")
|
print(f"❌ 初始化失败: {e}")
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
# ==================== 测试函数 ====================
|
# ==================== 根据文档的完整命令集 ====================
|
||||||
def send_and_check(cmd, name):
|
# 1. 激光开关(文档2.3.10,已验证可用)
|
||||||
"""发送命令并检查回包"""
|
LASER_ON_CMD = bytes([0xAA, 0x00, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x01, 0xC1])
|
||||||
print(f"\n📤 发送: {name}")
|
LASER_OFF_CMD = bytes([0xAA, 0x00, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x00, 0xC0])
|
||||||
print(f" 命令字节: {cmd.hex()}")
|
|
||||||
print(f" 命令长度: {len(cmd)} 字节")
|
# 2. 尝试不同的测距命令格式
|
||||||
|
TEST_COMMANDS = [
|
||||||
# 清空接收缓冲区
|
# 格式1:文档2.3.12的单次测量(您测试失败的)
|
||||||
|
{
|
||||||
|
"name": "单次测量 (0x0020)",
|
||||||
|
"cmd": bytes([0xAA, 0x00, 0x00, 0x20, 0x00, 0x01, 0x00, 0x00, 0x21]),
|
||||||
|
"desc": "文档2.3.12 示例命令"
|
||||||
|
},
|
||||||
|
# 格式2:文档2.3.7的读取测量结果
|
||||||
|
{
|
||||||
|
"name": "读取测量结果 (0x0022)",
|
||||||
|
"cmd": bytes([0xAA, 0x80, 0x00, 0x22, 0xA2]),
|
||||||
|
"desc": "文档2.3.7 读取测量结果"
|
||||||
|
},
|
||||||
|
# 格式3:文档2.3.13的快速测量
|
||||||
|
{
|
||||||
|
"name": "快速测量 (0x0022带数据)",
|
||||||
|
"cmd": bytes([0xAA, 0x00, 0x00, 0x22, 0x00, 0x01, 0x00, 0x00, 0x23]),
|
||||||
|
"desc": "文档2.3.13 快速测量"
|
||||||
|
},
|
||||||
|
# 格式4:连续测量模式
|
||||||
|
{
|
||||||
|
"name": "连续测量模式 (0x0021)",
|
||||||
|
"cmd": bytes([0xAA, 0x00, 0x00, 0x21, 0x00, 0x01, 0x00, 0x00, 0x22]),
|
||||||
|
"desc": "文档2.3.14 连续测量"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def clear_buffer():
|
||||||
|
"""清空串口缓冲区"""
|
||||||
try:
|
try:
|
||||||
old_data = laser_uart.read(-1)
|
data = laser_uart.read(-1)
|
||||||
if old_data:
|
if data:
|
||||||
print(f" 清空缓冲区: {len(old_data)} 字节")
|
print(f"清空: {len(data)}字节")
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def send_and_wait(cmd, name, wait_time=2000):
|
||||||
|
"""发送命令并等待响应"""
|
||||||
|
print(f"\n📤 发送: {name}")
|
||||||
|
print(f" 命令: {cmd.hex()}")
|
||||||
|
|
||||||
|
clear_buffer()
|
||||||
|
|
||||||
# 发送命令
|
|
||||||
try:
|
try:
|
||||||
written = laser_uart.write(cmd)
|
laser_uart.write(cmd)
|
||||||
print(f" 写入字节数: {written}")
|
print(f" 已发送 {len(cmd)} 字节")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ❌ 写入失败: {e}")
|
print(f" ❌ 发送失败: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 等待响应
|
# 等待响应
|
||||||
time.sleep_ms(100)
|
start_time = time.ticks_ms()
|
||||||
|
response = b""
|
||||||
|
|
||||||
# 读取回包
|
while time.ticks_ms() - start_time < wait_time:
|
||||||
try:
|
try:
|
||||||
resp = laser_uart.read(50)
|
chunk = laser_uart.read(1)
|
||||||
if resp:
|
if chunk:
|
||||||
print(f" 📥 收到回包: {resp.hex()} ({len(resp)} 字节)")
|
response += chunk
|
||||||
return resp
|
# 完整响应通常是9或13字节
|
||||||
else:
|
if len(response) >= 9:
|
||||||
print(f" ⚠️ 无回包")
|
# 检查是否完整帧
|
||||||
return None
|
if response[0] in [0xAA, 0xEE]:
|
||||||
except Exception as e:
|
if len(response) >= 13: # 测距完整响应
|
||||||
print(f" ❌ 读取失败: {e}")
|
break
|
||||||
return None
|
elif response[0] == 0xEE: # 错误响应
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep_ms(10)
|
||||||
|
|
||||||
|
if response:
|
||||||
|
print(f" 📥 响应: {response.hex()}")
|
||||||
|
print(f" 长度: {len(response)} 字节")
|
||||||
|
|
||||||
|
# 解析错误码
|
||||||
|
if response[0] == 0xEE and len(response) >= 9:
|
||||||
|
err_code = (response[7] << 8) | response[8]
|
||||||
|
error_mapping = {
|
||||||
|
0x0000: "无错误",
|
||||||
|
0x0001: "硬件错误",
|
||||||
|
0x0002: "无输出数据",
|
||||||
|
0x0003: "反射信号太弱",
|
||||||
|
0x0004: "反射信号太强",
|
||||||
|
0x0005: "温度太高(>40℃)",
|
||||||
|
0x0006: "温度太低(<-10℃)",
|
||||||
|
0x0007: "电源电压低(<2.5V)",
|
||||||
|
0x0008: "超出量程",
|
||||||
|
0x0009: "读通讯错误",
|
||||||
|
0x000A: "写通讯错误",
|
||||||
|
0x000B: "地址错误"
|
||||||
|
}
|
||||||
|
err_msg = error_mapping.get(err_code, f"未知错误: 0x{err_code:04X}")
|
||||||
|
print(f" ❌ 模块错误: {err_msg}")
|
||||||
|
else:
|
||||||
|
print(" ⚠️ 无响应")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
def test_laser_cycle(on_cmd, off_cmd, cmd_name="标准命令"):
|
def parse_distance_data(response):
|
||||||
"""测试一个开关周期"""
|
"""解析距离数据"""
|
||||||
print(f"\n{'='*50}")
|
if not response or len(response) < 13:
|
||||||
print(f"🧪 测试 {cmd_name}")
|
return None
|
||||||
print(f"{'='*50}")
|
|
||||||
|
|
||||||
print("\n>>> 测试开启激光")
|
if response[0] != 0xAA or response[3] not in [0x20, 0x21, 0x22]:
|
||||||
send_and_check(on_cmd, f"{cmd_name} - 开启")
|
return None
|
||||||
print(" ⏱️ 等待 2 秒观察激光是否亮起...")
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
print("\n>>> 测试关闭激光")
|
# 解析4字节BCD码
|
||||||
send_and_check(off_cmd, f"{cmd_name} - 关闭")
|
bcd_bytes = response[6:10]
|
||||||
print(" ⏱️ 等待 2 秒观察激光是否熄灭...")
|
distance_int = 0
|
||||||
time.sleep(2)
|
|
||||||
|
for byte in bcd_bytes:
|
||||||
|
high = (byte >> 4) & 0x0F
|
||||||
|
low = byte & 0x0F
|
||||||
|
|
||||||
|
if high > 9 or low > 9:
|
||||||
|
return None
|
||||||
|
|
||||||
|
distance_int = distance_int * 100 + high * 10 + low
|
||||||
|
|
||||||
|
distance_m = distance_int / 1000.0
|
||||||
|
|
||||||
|
# 信号质量
|
||||||
|
signal = 0
|
||||||
|
if len(response) >= 12:
|
||||||
|
signal = (response[10] << 8) | response[11]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'meters': distance_m,
|
||||||
|
'millimeters': distance_m * 1000,
|
||||||
|
'signal': signal,
|
||||||
|
'raw': response.hex()
|
||||||
|
}
|
||||||
|
|
||||||
# ==================== 主测试 ====================
|
# ==================== 主测试 ====================
|
||||||
print("\n" + "=" * 50)
|
print("\n" + "="*50)
|
||||||
print("🚀 开始激光测试")
|
print("M01激光测距模块详细测试")
|
||||||
print("=" * 50)
|
print("="*50)
|
||||||
print("\n请观察激光模块的状态变化...")
|
|
||||||
print("测试将依次尝试不同的命令格式\n")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 测试1: 标准命令
|
# 1. 测试基本连接
|
||||||
test_laser_cycle(LASER_ON_CMD, LASER_OFF_CMD, "标准命令")
|
print("\n1. 测试模块连接...")
|
||||||
|
version_cmd = bytes([0xAA, 0x80, 0x00, 0x0A, 0x8A])
|
||||||
|
resp = send_and_wait(version_cmd, "读取硬件版本")
|
||||||
|
|
||||||
input("\n按回车继续测试备用命令1...")
|
if resp and resp[0] == 0xAA and resp[3] == 0x0A:
|
||||||
|
print(f"✅ 模块正常,版本: {resp[6]:02X}{resp[7]:02X}")
|
||||||
|
else:
|
||||||
|
print("❌ 模块连接测试失败")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
# 测试2: 备用命令格式1
|
# 2. 开启激光
|
||||||
test_laser_cycle(LASER_ON_CMD_ALT1, LASER_OFF_CMD_ALT1, "备用命令1 (简化)")
|
print("\n2. 开启激光...")
|
||||||
|
resp = send_and_wait(LASER_ON_CMD, "开启激光", 1000)
|
||||||
|
if resp and resp.hex() == "aa0001be00010001c1":
|
||||||
|
print("✅ 激光已开启")
|
||||||
|
|
||||||
input("\n按回车继续测试备用命令2...")
|
print(" 等待激光稳定...")
|
||||||
|
time.sleep(2) # 重要等待时间
|
||||||
|
|
||||||
# 测试3: 备用命令格式2
|
# 3. 尝试不同的测距命令
|
||||||
test_laser_cycle(LASER_ON_CMD_ALT2, LASER_OFF_CMD_ALT2, "备用命令2 (0x55AA头)")
|
print("\n3. 测试不同测距命令...")
|
||||||
|
|
||||||
print("\n" + "=" * 50)
|
for i, test_cmd in enumerate(TEST_COMMANDS):
|
||||||
|
print(f"\n{'='*30}")
|
||||||
|
print(f"测试 {i+1}: {test_cmd['name']}")
|
||||||
|
print(f"{test_cmd['desc']}")
|
||||||
|
print(f"{'='*30}")
|
||||||
|
|
||||||
|
resp = send_and_wait(test_cmd['cmd'], test_cmd['name'], 3000)
|
||||||
|
|
||||||
|
if resp:
|
||||||
|
if resp[0] == 0xAA and len(resp) >= 13:
|
||||||
|
result = parse_distance_data(resp)
|
||||||
|
if result:
|
||||||
|
print(f"✅ 测距成功!")
|
||||||
|
print(f" 距离: {result['meters']:.3f} m")
|
||||||
|
print(f" 距离: {result['millimeters']:.1f} mm")
|
||||||
|
print(f" 信号质量: {result['signal']}")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print("❌ 无法解析距离数据")
|
||||||
|
elif resp[0] == 0xEE:
|
||||||
|
print("❌ 命令执行错误")
|
||||||
|
else:
|
||||||
|
print("❌ 无效响应格式")
|
||||||
|
else:
|
||||||
|
print("❌ 无响应")
|
||||||
|
|
||||||
|
time.sleep(1) # 命令间间隔
|
||||||
|
|
||||||
|
# 4. 关闭激光
|
||||||
|
print("\n4. 关闭激光...")
|
||||||
|
send_and_wait(LASER_OFF_CMD, "关闭激光", 1000)
|
||||||
|
|
||||||
|
print("\n" + "="*50)
|
||||||
print("🏁 测试完成")
|
print("🏁 测试完成")
|
||||||
print("=" * 50)
|
print("="*50)
|
||||||
print("\n诊断建议:")
|
|
||||||
print("1. 如果激光始终不亮/始终亮:")
|
print("\n📋 测试总结:")
|
||||||
print(" - 检查激光模块的电源连接")
|
print("1. 模块通信: ✅ 正常")
|
||||||
print(" - 检查串口TX/RX是否接反")
|
print("2. 激光控制: ✅ 正常")
|
||||||
print(" - 尝试不同的波特率 (4800/19200)")
|
print("3. 测距功能: ❌ 有问题")
|
||||||
print("")
|
print("\n建议:")
|
||||||
print("2. 如果有回包但激光无反应:")
|
print("1. 检查激光是否实际发光(在暗处观察红点)")
|
||||||
print(" - 命令格式可能正确但激光硬件问题")
|
print("2. 确保测量目标在有效范围内(0.2-60米)")
|
||||||
print("")
|
print("3. 确保目标有足够反射率(白色平面最佳)")
|
||||||
print("3. 如果某个备用命令有效:")
|
print("4. 如果所有测距命令都返回ERR_ADDR,可能是固件版本问题")
|
||||||
print(" - 需要更新 config.py 中的命令格式")
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\n\n🛑 测试被中断")
|
print("\n\n🛑 用户中断")
|
||||||
# 确保激光关闭
|
|
||||||
laser_uart.write(LASER_OFF_CMD)
|
laser_uart.write(LASER_OFF_CMD)
|
||||||
print("✅ 已发送关闭指令")
|
print("✅ 已发送关闭指令")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\n❌ 测试出错: {e}")
|
print(f"\n❌ 测试出错: {e}")
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from maix import gpio, pinmap, time
|
||||||
|
|
||||||
|
|
||||||
|
#设置引脚为输出
|
||||||
|
led = gpio.GPIO("A25", gpio.Mode.OUT)
|
||||||
|
#设置低电平
|
||||||
|
led.value(0)
|
||||||
|
|
||||||
|
while 1:
|
||||||
|
# time.sleep_ms(1000)
|
||||||
|
#对该引脚的电平进行取反(原高-》现低)
|
||||||
|
# led.toggle()
|
||||||
|
led.value(1)
|
||||||
|
#延时
|
||||||
|
time.sleep_ms(5000)
|
||||||
|
led.value(0)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Read the digital voltage level on the MaixCAM P21 pin.
|
||||||
|
|
||||||
|
P21 is a digital GPIO pin, not the MaixCAM analog ADC input. Therefore this
|
||||||
|
script can only distinguish LOW and HIGH. For a continuous voltage value,
|
||||||
|
connect the signal to the board's B3/ADC pin and use ADC channel 0 instead.
|
||||||
|
|
||||||
|
Do not apply more than 3.3 V to P21. Always connect the signal ground to the
|
||||||
|
MaixCAM ground.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from maix import app, gpio, pinmap, time
|
||||||
|
|
||||||
|
|
||||||
|
PIN = "P21"
|
||||||
|
IO_HIGH_VOLTAGE = 3.3
|
||||||
|
SAMPLE_INTERVAL_MS = 200
|
||||||
|
|
||||||
|
|
||||||
|
def find_gpio_function(pin):
|
||||||
|
"""Return the GPIO function supported by the requested physical pin."""
|
||||||
|
functions = pinmap.get_pin_functions(pin)
|
||||||
|
gpio_functions = [name for name in functions if name.startswith("GPIO")]
|
||||||
|
|
||||||
|
print(f"{pin} supported functions: {', '.join(functions)}")
|
||||||
|
if not gpio_functions:
|
||||||
|
raise RuntimeError(f"{pin} does not provide a GPIO input function")
|
||||||
|
|
||||||
|
return gpio_functions[0]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
gpio_function = find_gpio_function(PIN)
|
||||||
|
pinmap.set_pin_function(PIN, gpio_function)
|
||||||
|
voltage_input = gpio.GPIO(gpio_function, gpio.Mode.IN)
|
||||||
|
|
||||||
|
print(f"Reading {PIN} through {gpio_function}")
|
||||||
|
print("P21 only reports LOW/HIGH; displayed voltage is an estimate.")
|
||||||
|
print("Press the MaixCAM exit key to stop.")
|
||||||
|
|
||||||
|
while not app.need_exit():
|
||||||
|
level = voltage_input.value()
|
||||||
|
estimated_voltage = IO_HIGH_VOLTAGE if level else 0.0
|
||||||
|
state = "HIGH" if level else "LOW"
|
||||||
|
print(
|
||||||
|
f"{PIN}: level={level}, state={state}, "
|
||||||
|
f"estimated_voltage={estimated_voltage:.1f} V"
|
||||||
|
)
|
||||||
|
time.sleep_ms(SAMPLE_INTERVAL_MS)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception as error:
|
||||||
|
print(f"P21 voltage detection failed: {error}")
|
||||||
|
print("Check that this MaixCAM model exposes P21 as a GPIO pin.")
|
||||||
|
raise
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeTime:
|
||||||
|
@staticmethod
|
||||||
|
def sleep(_seconds):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sleep_ms(_milliseconds):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ticks_ms():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ticks_diff(left, right):
|
||||||
|
return left - right
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeLogger:
|
||||||
|
def debug(self, *_args, **_kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def info(self, *_args, **_kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def warning(self, *_args, **_kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def error(self, *_args, **_kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSocket:
|
||||||
|
def __init__(self, recv_data=b""):
|
||||||
|
self.recv_data = recv_data
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
def recv(self, _size, *_flags):
|
||||||
|
return self.recv_data
|
||||||
|
|
||||||
|
|
||||||
|
class _StopAfterCallback:
|
||||||
|
def __init__(self):
|
||||||
|
self.stopped = False
|
||||||
|
|
||||||
|
def is_set(self):
|
||||||
|
return self.stopped
|
||||||
|
|
||||||
|
|
||||||
|
maix_module = types.ModuleType("maix")
|
||||||
|
maix_module.time = _FakeTime
|
||||||
|
maix_module.network = types.SimpleNamespace()
|
||||||
|
maix_module.err = types.SimpleNamespace()
|
||||||
|
sys.modules.setdefault("maix", maix_module)
|
||||||
|
sys.modules.setdefault("ujson", json)
|
||||||
|
|
||||||
|
netcore_module = types.ModuleType("archery_netcore")
|
||||||
|
netcore_module.get_config = lambda: {"SERVER_IP": "127.0.0.1", "SERVER_PORT": 1234}
|
||||||
|
netcore_module.parse_packet = lambda _packet: (0, {})
|
||||||
|
netcore_module.make_packet = lambda *_args, **_kwargs: b""
|
||||||
|
netcore_module.actions_for_inner_cmd = lambda *_args, **_kwargs: []
|
||||||
|
sys.modules["archery_netcore"] = netcore_module
|
||||||
|
|
||||||
|
hardware_module = types.ModuleType("hardware")
|
||||||
|
hardware_module.hardware_manager = types.SimpleNamespace()
|
||||||
|
sys.modules["hardware"] = hardware_module
|
||||||
|
|
||||||
|
power_module = types.ModuleType("power")
|
||||||
|
power_module.get_bus_voltage = lambda: 0
|
||||||
|
power_module.voltage_to_percent = lambda _voltage: 0
|
||||||
|
sys.modules["power"] = power_module
|
||||||
|
|
||||||
|
import logger_manager
|
||||||
|
import wifi
|
||||||
|
import network
|
||||||
|
|
||||||
|
|
||||||
|
class WiFiFailoverTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
logger_manager.logger_manager._logger = _FakeLogger()
|
||||||
|
|
||||||
|
def test_monitor_switches_when_sta_association_is_lost(self):
|
||||||
|
manager = wifi.wifi_manager
|
||||||
|
stop_event = _StopAfterCallback()
|
||||||
|
callbacks = []
|
||||||
|
|
||||||
|
manager._wifi_socket = _FakeSocket()
|
||||||
|
manager._wifi_quality_stop_event = stop_event
|
||||||
|
manager._network_type_callback = lambda: "wifi"
|
||||||
|
manager.is_sta_associated = lambda: False
|
||||||
|
manager._get_wifi_rssi_dbm = lambda: None
|
||||||
|
|
||||||
|
def on_poor_quality():
|
||||||
|
callbacks.append(True)
|
||||||
|
stop_event.stopped = True
|
||||||
|
|
||||||
|
manager._on_poor_quality_callback = on_poor_quality
|
||||||
|
manager._quality_monitor_loop()
|
||||||
|
|
||||||
|
self.assertEqual(callbacks, [True])
|
||||||
|
self.assertIsNone(manager.last_wifi_rtt_ms)
|
||||||
|
|
||||||
|
def test_tls_connection_check_rejects_lost_sta_association(self):
|
||||||
|
manager = network.network_manager
|
||||||
|
sock = _FakeSocket()
|
||||||
|
wifi.wifi_manager._wifi_socket = sock
|
||||||
|
wifi.wifi_manager._wifi_connected = True
|
||||||
|
wifi.wifi_manager._wifi_ip = "192.168.1.2"
|
||||||
|
wifi.wifi_manager.is_sta_associated = lambda: False
|
||||||
|
manager._tcp_connected = True
|
||||||
|
|
||||||
|
self.assertFalse(manager._check_wifi_connection())
|
||||||
|
self.assertTrue(sock.closed)
|
||||||
|
self.assertIsNone(wifi.wifi_manager.wifi_socket)
|
||||||
|
self.assertFalse(manager.tcp_connected)
|
||||||
|
|
||||||
|
def test_receive_eof_marks_wifi_tcp_disconnected(self):
|
||||||
|
manager = network.network_manager
|
||||||
|
sock = _FakeSocket(recv_data=b"")
|
||||||
|
wifi.wifi_manager._wifi_socket = sock
|
||||||
|
manager._tcp_connected = True
|
||||||
|
|
||||||
|
self.assertEqual(manager.receive_tcp_data_via_wifi(), b"")
|
||||||
|
self.assertTrue(sock.closed)
|
||||||
|
self.assertIsNone(wifi.wifi_manager.wifi_socket)
|
||||||
|
self.assertFalse(manager.tcp_connected)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,343 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Stage2 黑三角 YOLO —— 在 Maix 设备上用本地图片测试(与线上 target_roi_yolo.try_black_triangle_boxes_work 完全一致)。
|
||||||
|
|
||||||
|
不在 PC 上跑 NPU;需把脚本与 config / target_roi_yolo.py 同步到设备,并在设备上执行。
|
||||||
|
|
||||||
|
典型用法
|
||||||
|
--------
|
||||||
|
# 输入已是 Stage1 裁切(与你保存的 stage2_roi_*.jpg 一致)
|
||||||
|
python test/test_stage2_black_yolo_device.py /root/phot/stage2_roi_xxx.jpg
|
||||||
|
|
||||||
|
# 输入为整幅相机图,手动给出 Stage1 环靶 ROI(与线上日志 ring全图=[rx0,ry0,rx1,ry1] 一致)
|
||||||
|
python test/test_stage2_black_yolo_device.py /root/phot/full.jpg --roi 197,196,507,461
|
||||||
|
|
||||||
|
# 对比 native / letterbox 坐标映射(排查 contain 训练与推理对齐)
|
||||||
|
python test/test_stage2_black_yolo_device.py ./crop.jpg --compare-coord
|
||||||
|
|
||||||
|
# 覆盖置信度、模型路径(仍读其余项自 config)
|
||||||
|
python test/test_stage2_black_yolo_device.py ./crop.jpg --conf 0.25 -m /maixapp/apps/t11/model_270648.mud
|
||||||
|
|
||||||
|
# 只看 NPU 原始框(映射前):判断坐标是 ~224 网络空间还是归一化 0~1
|
||||||
|
python test/test_stage2_black_yolo_device.py ./crop.jpg --conf 0.05 --dump-raw 15
|
||||||
|
|
||||||
|
依赖:MaixPy(maix.nn)、OpenCV(cv2)、numpy;项目根须在 sys.path(本脚本已插入上级目录)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
if _ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_roi(s: str) -> tuple[int, int, int, int]:
|
||||||
|
parts = [p.strip() for p in s.replace(" ", "").split(",")]
|
||||||
|
if len(parts) != 4:
|
||||||
|
raise ValueError("ROI 需要 4 个整数:x0,y0,x1,y1")
|
||||||
|
return tuple(int(x) for x in parts) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_rgb_numpy(path: str) -> "object":
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
bgr = cv2.imread(path, cv2.IMREAD_COLOR)
|
||||||
|
if bgr is None:
|
||||||
|
raise FileNotFoundError(f"cv2.imread 失败: {path}")
|
||||||
|
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||||
|
return np.ascontiguousarray(rgb, dtype=np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_boxes_on_crop(
|
||||||
|
slab_rgb,
|
||||||
|
boxes: list[tuple[int, int, int, int]],
|
||||||
|
labels: list[str] | None = None,
|
||||||
|
):
|
||||||
|
"""slab_rgb: H×W×3 RGB uint8;boxes 为扩 margin 后的 Stage2 子框(与线上绿框一致)。"""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
vis = slab_rgb.copy()
|
||||||
|
bgr = cv2.cvtColor(vis, cv2.COLOR_RGB2BGR)
|
||||||
|
rh, rw = bgr.shape[:2]
|
||||||
|
for i, (bx0, by0, bx1, by1) in enumerate(boxes):
|
||||||
|
x0, y0 = int(bx0), int(by0)
|
||||||
|
x1, y1 = int(bx1) - 1, int(by1) - 1
|
||||||
|
x1 = max(x0, min(x1, rw - 1))
|
||||||
|
y1 = max(y0, min(y1, rh - 1))
|
||||||
|
cv2.rectangle(bgr, (x0, y0), (x1, y1), (0, 255, 0), 2)
|
||||||
|
tag = labels[i] if labels and i < len(labels) else f"s2_{i}"
|
||||||
|
cv2.putText(
|
||||||
|
bgr,
|
||||||
|
tag,
|
||||||
|
(x0, max(0, y0 - 4)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.5,
|
||||||
|
(0, 255, 0),
|
||||||
|
1,
|
||||||
|
cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
return bgr
|
||||||
|
|
||||||
|
|
||||||
|
class _PrintLogger:
|
||||||
|
def info(self, msg):
|
||||||
|
print(msg)
|
||||||
|
|
||||||
|
def warning(self, msg):
|
||||||
|
print(msg)
|
||||||
|
|
||||||
|
def error(self, msg):
|
||||||
|
print(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_once(yroi_mod, img_rgb, roi_xyxy, logger):
|
||||||
|
boxes = yroi_mod.try_black_triangle_boxes_work(img_rgb, roi_xyxy, logger)
|
||||||
|
rx0, ry0, rx1, ry1 = roi_xyxy
|
||||||
|
slab = img_rgb[ry0:ry1, rx0:rx1].copy()
|
||||||
|
return boxes, slab
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_dump_raw_rows(yroi_mod, objs):
|
||||||
|
"""把 Maix detect 返回对象拷贝成基础类型,避免 native 对象跨下一次 detect 存活。"""
|
||||||
|
rows = []
|
||||||
|
for o in objs:
|
||||||
|
cid = yroi_mod._det_obj_class_id(o)
|
||||||
|
try:
|
||||||
|
sc = float(getattr(o, "score", 0.0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
sc = 0.0
|
||||||
|
rows.append((cid, sc, float(o.x), float(o.y), float(o.w), float(o.h)))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _dump_raw_and_hard_exit(det, yroi_mod, slab_for_det, rw_s, rh_s, net_w, net_h, conf_th, iou_th, limit):
|
||||||
|
"""
|
||||||
|
MaixPy 某些版本在 YOLO detect 返回对象正常析构时会 SIGSEGV/pure virtual。
|
||||||
|
raw dump 是诊断路径,打印完成后硬退出,绕过 Python/native 析构链。
|
||||||
|
"""
|
||||||
|
from maix import image as maix_image
|
||||||
|
|
||||||
|
roi_maix = maix_image.cv2image(slab_for_det, False, False)
|
||||||
|
raw = det.detect(roi_maix, conf_th=conf_th, iou_th=iou_th)
|
||||||
|
objs = yroi_mod._normalize_objs(raw if raw is not None else [])
|
||||||
|
dump_rows = _copy_dump_raw_rows(yroi_mod, objs)
|
||||||
|
raw_count = len(dump_rows)
|
||||||
|
print(
|
||||||
|
f"[DUMP-RAW] slab={rw_s}×{rh_s} net={net_w}×{net_h} "
|
||||||
|
f"conf={conf_th} iou={iou_th} → NMS 后 raw 框数={raw_count}(与 coord_mode 无关)"
|
||||||
|
)
|
||||||
|
npr = min(int(limit), raw_count)
|
||||||
|
for i in range(npr):
|
||||||
|
cid, sc, x, y, ww, hh = dump_rows[i]
|
||||||
|
print(f" #{i} cls={cid} score={sc:.4f} xywh=({x:.3f},{y:.3f},{ww:.3f},{hh:.3f})")
|
||||||
|
if dump_rows:
|
||||||
|
xs = [r[2] for r in dump_rows]
|
||||||
|
ws = [r[4] for r in dump_rows]
|
||||||
|
print(
|
||||||
|
f"[DUMP-RAW] hint: x 范围≈[{min(xs):.2f},{max(xs):.2f}] "
|
||||||
|
f"w 范围≈[{min(ws):.2f},{max(ws):.2f}] — "
|
||||||
|
f"若整体在 0~{net_w} 量级多为网络画布坐标→应用 letterbox;"
|
||||||
|
f"若 x,w 多在 0~1→可能是归一化,需在代码里乘 net 尺寸"
|
||||||
|
)
|
||||||
|
print("[INFO] --dump-raw 已完成;为规避 MaixPy YOLO native 析构崩溃,测试进程将直接退出。")
|
||||||
|
sys.stdout.flush()
|
||||||
|
sys.stderr.flush()
|
||||||
|
os._exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="Stage2 黑三角 YOLO 设备本地图测试",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=__doc__,
|
||||||
|
)
|
||||||
|
ap.add_argument("image", help="本地图片路径(设备上的路径)")
|
||||||
|
ap.add_argument(
|
||||||
|
"--roi",
|
||||||
|
default="",
|
||||||
|
metavar="x0,y0,x1,y1",
|
||||||
|
help="可选。若填写:image 为整幅图,在此图上取 Stage1 ROI 再跑 Stage2;"
|
||||||
|
"留空:image 本身就是 Stage1 裁切图(默认)",
|
||||||
|
)
|
||||||
|
ap.add_argument("-o", "--output", default="", help="输出可视化路径;默认 原名_stage2_vis.jpg")
|
||||||
|
ap.add_argument("-m", "--model", default="", help="覆盖 config.TRIANGLE_BLACK_YOLO_MODEL_PATH")
|
||||||
|
ap.add_argument("--conf", type=float, default=None, help="覆盖 TRIANGLE_BLACK_YOLO_CONF_TH")
|
||||||
|
ap.add_argument("--iou", type=float, default=None, help="覆盖 TRIANGLE_BLACK_YOLO_IOU_TH")
|
||||||
|
ap.add_argument(
|
||||||
|
"--coord",
|
||||||
|
choices=["native", "letterbox"],
|
||||||
|
default="",
|
||||||
|
help="覆盖 TRIANGLE_BLACK_YOLO_COORD_MODE;默认用 config",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--compare-coord",
|
||||||
|
action="store_true",
|
||||||
|
help="各跑一次 native 与 letterbox,输出两张图 *_stage2_native.jpg / *_stage2_letterbox.jpg",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--fresh-detector",
|
||||||
|
action="store_true",
|
||||||
|
help="清掉 YOLO 缓存再测(换模型或排查缓存时用)",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--allow-save-roi",
|
||||||
|
action="store_true",
|
||||||
|
help="不强制关闭 TRIANGLE_BLACK_YOLO_SAVE_ROI_CROP(默认测试时会关掉以免写满相册目录)",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--dump-raw",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
metavar="N",
|
||||||
|
help="打印前 N 个 detect 原始框 x,y,w,h,score,cls(coord 映射前;native/letterbox 共用同一批 raw)",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
img_path = os.path.abspath(args.image)
|
||||||
|
if not os.path.isfile(img_path):
|
||||||
|
print(f"[ERR] 找不到图片: {img_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import config as cfg
|
||||||
|
import target_roi_yolo as yroi
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"[ERR] 无法导入 config / target_roi_yolo: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.fresh_detector:
|
||||||
|
yroi.reset_yolo_detector_cache()
|
||||||
|
|
||||||
|
# 备份并临时覆盖 config(单进程顺序跑)
|
||||||
|
bak: dict[str, object] = {}
|
||||||
|
|
||||||
|
def _patch(key: str, val: object):
|
||||||
|
if key not in bak:
|
||||||
|
bak[key] = getattr(cfg, key, None)
|
||||||
|
setattr(cfg, key, val)
|
||||||
|
|
||||||
|
def _restore():
|
||||||
|
for k, v in bak.items():
|
||||||
|
setattr(cfg, k, v)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_patch("TRIANGLE_BLACK_YOLO_ENABLE", True)
|
||||||
|
if not args.allow_save_roi:
|
||||||
|
_patch("TRIANGLE_BLACK_YOLO_SAVE_ROI_CROP", False)
|
||||||
|
if args.model.strip():
|
||||||
|
_patch("TRIANGLE_BLACK_YOLO_MODEL_PATH", args.model.strip())
|
||||||
|
if args.conf is not None:
|
||||||
|
_patch("TRIANGLE_BLACK_YOLO_CONF_TH", float(args.conf))
|
||||||
|
if args.iou is not None:
|
||||||
|
_patch("TRIANGLE_BLACK_YOLO_IOU_TH", float(args.iou))
|
||||||
|
if args.coord and not args.compare_coord:
|
||||||
|
_patch("TRIANGLE_BLACK_YOLO_COORD_MODE", args.coord)
|
||||||
|
|
||||||
|
mp = getattr(cfg, "TRIANGLE_BLACK_YOLO_MODEL_PATH", "") or ""
|
||||||
|
if not os.path.isfile(mp):
|
||||||
|
print(f"[ERR] 模型文件不存在: {mp}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
img_rgb = _load_rgb_numpy(img_path)
|
||||||
|
h, w = int(img_rgb.shape[0]), int(img_rgb.shape[1])
|
||||||
|
|
||||||
|
if args.roi.strip():
|
||||||
|
roi_xyxy = _parse_roi(args.roi.strip())
|
||||||
|
rx0, ry0, rx1, ry1 = [int(round(float(v))) for v in roi_xyxy]
|
||||||
|
if rx1 <= rx0 or ry1 <= ry0:
|
||||||
|
print("[ERR] ROI 无效:需满足 x1>x0 且 y1>y0")
|
||||||
|
sys.exit(1)
|
||||||
|
# 与 target_roi_yolo.try_black_triangle_boxes_work 相同的 clip
|
||||||
|
rx0 = max(0, min(rx0, w - 1))
|
||||||
|
ry0 = max(0, min(ry0, h - 1))
|
||||||
|
rx1 = max(rx0 + 1, min(rx1, w))
|
||||||
|
ry1 = max(ry0 + 1, min(ry1, h))
|
||||||
|
ring_roi = (rx0, ry0, rx1, ry1)
|
||||||
|
print(f"[INFO] 模式=整图+ROI ring={ring_roi} image={w}×{h}")
|
||||||
|
else:
|
||||||
|
ring_roi = (0, 0, w, h)
|
||||||
|
print(f"[INFO] 模式=已是 Stage1 裁切 crop={w}×{h}")
|
||||||
|
|
||||||
|
logger = _PrintLogger()
|
||||||
|
det = yroi._get_detector(mp)
|
||||||
|
if det is None:
|
||||||
|
print("[ERR] 无法加载 nn.YOLOv5(检查模型路径与 Maix 环境)")
|
||||||
|
sys.exit(1)
|
||||||
|
net_w = int(det.input_width())
|
||||||
|
net_h = int(det.input_height())
|
||||||
|
print(f"[INFO] model={mp} net_in={net_w}×{net_h}")
|
||||||
|
|
||||||
|
rx0, ry0, rx1, ry1 = ring_roi
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
slab_for_det = np.ascontiguousarray(img_rgb[ry0:ry1, rx0:rx1], dtype=np.uint8).copy()
|
||||||
|
rh_s, rw_s = int(slab_for_det.shape[0]), int(slab_for_det.shape[1])
|
||||||
|
|
||||||
|
modes = ["native", "letterbox"] if args.compare_coord else [
|
||||||
|
(args.coord or getattr(cfg, "TRIANGLE_BLACK_YOLO_COORD_MODE", "native"))
|
||||||
|
]
|
||||||
|
|
||||||
|
base, ext = os.path.splitext(img_path)
|
||||||
|
ext = ext if ext else ".jpg"
|
||||||
|
|
||||||
|
for mode in modes:
|
||||||
|
_patch("TRIANGLE_BLACK_YOLO_COORD_MODE", mode)
|
||||||
|
cur_coord = getattr(cfg, "TRIANGLE_BLACK_YOLO_COORD_MODE", mode)
|
||||||
|
print(f"[INFO] --- TRIANGLE_BLACK_YOLO_COORD_MODE={cur_coord} ---")
|
||||||
|
|
||||||
|
boxes, slab = _run_once(yroi, img_rgb, ring_roi, logger)
|
||||||
|
print(
|
||||||
|
f"[INFO] 子框数量={len(boxes)} conf={getattr(cfg, 'TRIANGLE_BLACK_YOLO_CONF_TH', '?')} "
|
||||||
|
f"coord={cur_coord}"
|
||||||
|
)
|
||||||
|
for i, b in enumerate(boxes):
|
||||||
|
print(f" s2_{i}: {b}")
|
||||||
|
|
||||||
|
if args.compare_coord:
|
||||||
|
out_path = f"{base}_stage2_{mode}{ext}"
|
||||||
|
elif args.output.strip():
|
||||||
|
out_path = args.output.strip()
|
||||||
|
else:
|
||||||
|
out_path = base + "_stage2_vis" + ext
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
bgr = _draw_boxes_on_crop(slab, boxes)
|
||||||
|
cv2.imwrite(out_path, bgr, [int(cv2.IMWRITE_JPEG_QUALITY), 92])
|
||||||
|
print(f"[OK] saved: {out_path}")
|
||||||
|
|
||||||
|
if args.compare_coord:
|
||||||
|
print(
|
||||||
|
"[HINT] contain 训练时若 letterbox 对齐更好,请将 config 里 "
|
||||||
|
"TRIANGLE_BLACK_YOLO_COORD_MODE 设为 letterbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.dump_raw > 0:
|
||||||
|
conf_th = float(getattr(cfg, "TRIANGLE_BLACK_YOLO_CONF_TH", 0.5))
|
||||||
|
iou_th = float(getattr(cfg, "TRIANGLE_BLACK_YOLO_IOU_TH", 0.45))
|
||||||
|
print("\n[INFO] --dump-raw 放在最后执行,避免 raw native 对象影响 compare-coord 流程。")
|
||||||
|
_dump_raw_and_hard_exit(
|
||||||
|
det,
|
||||||
|
yroi,
|
||||||
|
slab_for_det,
|
||||||
|
rw_s,
|
||||||
|
rh_s,
|
||||||
|
net_w,
|
||||||
|
net_h,
|
||||||
|
conf_th,
|
||||||
|
iou_th,
|
||||||
|
args.dump_raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
_restore()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
单张图片快速测试:三角形四角标记识别 + 单应性落点 + PnP 估距
|
||||||
|
|
||||||
|
用法(在板子上):
|
||||||
|
python3 test/test_triangle_one_image.py --image /root/phot/xxx.jpg --out /root/phot/tri_out.jpg
|
||||||
|
|
||||||
|
调参对比(不改代码,临时覆盖 config.TRIANGLE_*):
|
||||||
|
python3 test/test_triangle_one_image.py --image /root/phot/xxx.jpg --preset shadow
|
||||||
|
python3 test/test_triangle_one_image.py --image /root/phot/xxx.jpg --max-interior-gray 160 --min-dark-ratio 0.20
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Tuple
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
import config
|
||||||
|
import triangle_target as tri_mod
|
||||||
|
from triangle_target import (
|
||||||
|
detect_triangle_markers,
|
||||||
|
load_camera_from_xml,
|
||||||
|
load_triangle_positions,
|
||||||
|
try_triangle_scoring,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_overrides(args) -> None:
|
||||||
|
# 预设:阴影/低对比度场景更宽松(尽量保持速度:不启 CLAHE)
|
||||||
|
if args.preset == "shadow":
|
||||||
|
setattr(config, "TRIANGLE_ENABLE_CLAHE_FALLBACK", False)
|
||||||
|
setattr(config, "TRIANGLE_MIN_CONTRAST_DIFF", 0)
|
||||||
|
setattr(config, "TRIANGLE_MAX_INTERIOR_GRAY", 160)
|
||||||
|
setattr(config, "TRIANGLE_DARK_PIXEL_GRAY", 160)
|
||||||
|
setattr(config, "TRIANGLE_MIN_DARK_RATIO", 0.20)
|
||||||
|
# adaptive 只在 Otsu 失败时尝试,保持尝试次数很少
|
||||||
|
setattr(config, "TRIANGLE_ADAPTIVE_BLOCK_SIZES", (21,))
|
||||||
|
|
||||||
|
# 手动覆盖(优先级高于 preset)
|
||||||
|
if args.max_interior_gray is not None:
|
||||||
|
setattr(config, "TRIANGLE_MAX_INTERIOR_GRAY", int(args.max_interior_gray))
|
||||||
|
if args.dark_pixel_gray is not None:
|
||||||
|
setattr(config, "TRIANGLE_DARK_PIXEL_GRAY", int(args.dark_pixel_gray))
|
||||||
|
if args.min_dark_ratio is not None:
|
||||||
|
setattr(config, "TRIANGLE_MIN_DARK_RATIO", float(args.min_dark_ratio))
|
||||||
|
if args.min_contrast_diff is not None:
|
||||||
|
setattr(config, "TRIANGLE_MIN_CONTRAST_DIFF", int(args.min_contrast_diff))
|
||||||
|
if args.detect_scale is not None:
|
||||||
|
setattr(config, "TRIANGLE_DETECT_SCALE", float(args.detect_scale))
|
||||||
|
if args.adaptive_blocks is not None:
|
||||||
|
bs = tuple(int(x) for x in args.adaptive_blocks.split(",") if x.strip())
|
||||||
|
setattr(config, "TRIANGLE_ADAPTIVE_BLOCK_SIZES", bs)
|
||||||
|
|
||||||
|
|
||||||
|
def _dump_config() -> Dict[str, Any]:
|
||||||
|
keys = [
|
||||||
|
"TRIANGLE_DETECT_SCALE",
|
||||||
|
"TRIANGLE_SIZE_RANGE",
|
||||||
|
"TRIANGLE_MAX_INTERIOR_GRAY",
|
||||||
|
"TRIANGLE_DARK_PIXEL_GRAY",
|
||||||
|
"TRIANGLE_MIN_DARK_RATIO",
|
||||||
|
"TRIANGLE_MIN_CONTRAST_DIFF",
|
||||||
|
"TRIANGLE_ADAPTIVE_BLOCK_SIZES",
|
||||||
|
"TRIANGLE_MAX_FILTERED_FOR_COMBO",
|
||||||
|
"TRIANGLE_EARLY_EXIT_CANDIDATES",
|
||||||
|
"TRIANGLE_ENABLE_CLAHE_FALLBACK",
|
||||||
|
]
|
||||||
|
out = {}
|
||||||
|
for k in keys:
|
||||||
|
out[k] = getattr(config, k, None)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_tri_debug(img_bgr: np.ndarray, tri: Dict[str, Any]) -> np.ndarray:
|
||||||
|
out = img_bgr.copy()
|
||||||
|
markers = tri.get("markers") or []
|
||||||
|
|
||||||
|
# 画三角形轮廓 + center + id
|
||||||
|
for m in markers:
|
||||||
|
corners = np.array(m.get("corners", []), dtype=np.int32)
|
||||||
|
if corners.size == 0:
|
||||||
|
continue
|
||||||
|
cv2.polylines(out, [corners], True, (0, 255, 0), 2)
|
||||||
|
c = m.get("center") or (corners[:, 0].mean(), corners[:, 1].mean())
|
||||||
|
cx, cy = int(c[0]), int(c[1])
|
||||||
|
cv2.circle(out, (cx, cy), 4, (0, 0, 255), -1)
|
||||||
|
mid = m.get("id", "?")
|
||||||
|
cv2.putText(out, f"T{mid}", (cx - 18, cy - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 0), 1)
|
||||||
|
|
||||||
|
# 若有 homography,画靶心(把 (0,0) 反投影到图像)
|
||||||
|
H = tri.get("homography")
|
||||||
|
if H is not None:
|
||||||
|
try:
|
||||||
|
H = np.array(H, dtype=np.float64)
|
||||||
|
H_inv = np.linalg.inv(H)
|
||||||
|
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(out, (ocx, ocy), 5, (0, 0, 255), -1)
|
||||||
|
cv2.circle(out, (ocx, ocy), 10, (0, 0, 255), 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 叠加结果信息
|
||||||
|
lines = []
|
||||||
|
if tri.get("ok"):
|
||||||
|
lines.append("tri_ok=True")
|
||||||
|
if tri.get("dx_cm") is not None and tri.get("dy_cm") is not None:
|
||||||
|
lines.append(f"dx,dy=({tri['dx_cm']:.2f},{tri['dy_cm']:.2f})cm")
|
||||||
|
if tri.get("distance_m") is not None:
|
||||||
|
lines.append(f"dist={float(tri['distance_m']):.2f}m")
|
||||||
|
else:
|
||||||
|
lines.append("tri_ok=False")
|
||||||
|
|
||||||
|
y0 = 22
|
||||||
|
for i, t in enumerate(lines):
|
||||||
|
cv2.putText(out, t, (10, y0 + i * 18), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 0), 1)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--image", required=True, help="输入图片路径(jpg/png)")
|
||||||
|
ap.add_argument("--out", default="", help="输出标注图片路径(可选)")
|
||||||
|
ap.add_argument("--laser-x", type=int, default=-1, help="激光点 x(像素),默认用图像中心")
|
||||||
|
ap.add_argument("--laser-y", type=int, default=-1, help="激光点 y(像素),默认用图像中心")
|
||||||
|
ap.add_argument("--preset", choices=["", "shadow"], default="", help="调参预设(shadow=阴影更鲁棒,不启 CLAHE)")
|
||||||
|
ap.add_argument("--max-interior-gray", type=int, default=None)
|
||||||
|
ap.add_argument("--dark-pixel-gray", type=int, default=None)
|
||||||
|
ap.add_argument("--min-dark-ratio", type=float, default=None)
|
||||||
|
ap.add_argument("--min-contrast-diff", type=int, default=None)
|
||||||
|
ap.add_argument("--detect-scale", type=float, default=None)
|
||||||
|
ap.add_argument("--adaptive-blocks", default=None, help="例如: 11,21 ;为空表示不改")
|
||||||
|
ap.add_argument("--verbose", action="store_true", help="输出更多检测阶段信息")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
_apply_overrides(args)
|
||||||
|
# triangle_target.py 的日志默认写到 logger_manager;在离线脚本里 logger 可能未初始化。
|
||||||
|
# verbose 模式下把 _log 重定向为 print,方便直接看到诊断信息。
|
||||||
|
if args.verbose:
|
||||||
|
try:
|
||||||
|
tri_mod._log = lambda msg: print(str(msg))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
img_bgr = cv2.imread(args.image, cv2.IMREAD_COLOR)
|
||||||
|
if img_bgr is None:
|
||||||
|
raise SystemExit(f"读图失败:{args.image}")
|
||||||
|
# triangle_target.try_triangle_scoring 约定输入为 RGB;OpenCV imread 返回 BGR
|
||||||
|
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
||||||
|
|
||||||
|
h, w = img_bgr.shape[:2]
|
||||||
|
if args.laser_x >= 0 and args.laser_y >= 0:
|
||||||
|
laser_point = (int(args.laser_x), int(args.laser_y))
|
||||||
|
else:
|
||||||
|
laser_point = (w // 2, h // 2)
|
||||||
|
|
||||||
|
K, dist = load_camera_from_xml(getattr(config, "CAMERA_CALIB_XML", ""))
|
||||||
|
pos = load_triangle_positions(getattr(config, "TRIANGLE_POSITIONS_JSON", ""))
|
||||||
|
|
||||||
|
print("[tri-test] image:", args.image, "shape:", (h, w))
|
||||||
|
print("[tri-test] laser_point:", laser_point)
|
||||||
|
print("[tri-test] calib_ok:", bool(K is not None and dist is not None), "pos_ok:", bool(pos))
|
||||||
|
print("[tri-test] config:", json.dumps(_dump_config(), ensure_ascii=False))
|
||||||
|
|
||||||
|
# 先单独跑一次三角形候选检测,便于区分“没找到候选” vs “找到候选但评分/单应性失败”
|
||||||
|
scale = float(getattr(config, "TRIANGLE_DETECT_SCALE", 0.5) or 0.5)
|
||||||
|
if not (0.05 <= scale <= 1.0):
|
||||||
|
scale = 0.5
|
||||||
|
long_side = max(h, w)
|
||||||
|
max_dim = max(64, int(long_side * scale))
|
||||||
|
if long_side > max_dim:
|
||||||
|
det_scale = max_dim / long_side
|
||||||
|
det_w = int(w * det_scale)
|
||||||
|
det_h = int(h * det_scale)
|
||||||
|
img_det = cv2.resize(img_bgr, (det_w, det_h), interpolation=cv2.INTER_LINEAR)
|
||||||
|
inv_scale = 1.0 / det_scale
|
||||||
|
size_range_det = (
|
||||||
|
max(4, int(getattr(config, "TRIANGLE_SIZE_RANGE", (8, 500))[0] * det_scale)),
|
||||||
|
max(8, int(getattr(config, "TRIANGLE_SIZE_RANGE", (8, 500))[1] * det_scale)),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
img_det = img_bgr
|
||||||
|
inv_scale = 1.0
|
||||||
|
size_range_det = getattr(config, "TRIANGLE_SIZE_RANGE", (8, 500))
|
||||||
|
|
||||||
|
gray = cv2.cvtColor(img_det, cv2.COLOR_BGR2GRAY)
|
||||||
|
markers_det = detect_triangle_markers(
|
||||||
|
gray,
|
||||||
|
orig_gray=gray,
|
||||||
|
size_range=size_range_det,
|
||||||
|
verbose=bool(args.verbose),
|
||||||
|
)
|
||||||
|
if inv_scale != 1.0 and markers_det:
|
||||||
|
for m in markers_det:
|
||||||
|
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"]]
|
||||||
|
|
||||||
|
print("[tri-test] markers_found:", len(markers_det), "ids:", [m.get("id") for m in markers_det])
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
tri = try_triangle_scoring(
|
||||||
|
img_rgb, # try_triangle_scoring 期望 RGB
|
||||||
|
laser_point,
|
||||||
|
pos,
|
||||||
|
K,
|
||||||
|
dist,
|
||||||
|
size_range=getattr(config, "TRIANGLE_SIZE_RANGE", (8, 500)),
|
||||||
|
)
|
||||||
|
dt_ms = int(round((time.time() - t0) * 1000))
|
||||||
|
|
||||||
|
print("[tri-test] elapsed_ms:", dt_ms)
|
||||||
|
print(json.dumps(tri, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
if args.out:
|
||||||
|
out_path = args.out
|
||||||
|
# 允许传目录(如 ./),自动生成文件名;未带扩展名时默认 .jpg
|
||||||
|
if out_path.endswith("/") or out_path.endswith("\\") or os.path.isdir(out_path):
|
||||||
|
out_path = os.path.join(out_path, "tri_out.jpg")
|
||||||
|
root, ext = os.path.splitext(out_path)
|
||||||
|
if not ext:
|
||||||
|
out_path = root + ".jpg"
|
||||||
|
|
||||||
|
# 若 try_triangle_scoring 失败且没带回 markers,至少把候选 markers 画出来,方便肉眼判断
|
||||||
|
tri_for_draw = tri if isinstance(tri, dict) else {"ok": False}
|
||||||
|
if not tri_for_draw.get("markers") and markers_det:
|
||||||
|
tri_for_draw = dict(tri_for_draw)
|
||||||
|
tri_for_draw["markers"] = markers_det
|
||||||
|
out_img = _draw_tri_debug(img_bgr, tri_for_draw)
|
||||||
|
ok = cv2.imwrite(out_path, out_img)
|
||||||
|
if not ok:
|
||||||
|
raise SystemExit(f"写图失败(可能是不支持的扩展名):{out_path}")
|
||||||
|
print("[tri-test] wrote:", out_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
本地图片 → Maix YOLOv5 检测 → 画框保存(用于核对坐标 mode / 多框 union)。
|
||||||
|
|
||||||
|
运行环境:MaixCAM / MaixPy(需 maix.image / maix.nn),在项目根或任意目录执行均可。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
python test/test_yolo_draw_boxes.py /root/phot/shot_xxx.jpg
|
||||||
|
python test/test_yolo_draw_boxes.py shot.jpg --loader cv2_rgb --conf 0.25
|
||||||
|
python test/test_yolo_draw_boxes.py shot.jpg --debug
|
||||||
|
python -h # 查看 --loader / --debug / --union 等全部参数
|
||||||
|
|
||||||
|
脚本版本(与设备同步用):20260206-yolo-vis
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
if _ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_maix_image(path: str, image_mod):
|
||||||
|
"""maix.image.load(部分 JPEG 解码后与 camera.read() 像素布局不一致,可能导致 NPU 全空)。"""
|
||||||
|
return image_mod.load(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_cv2_rgb_as_maix(path: str, image_mod):
|
||||||
|
"""
|
||||||
|
OpenCV 读盘为 BGR → 转 RGB → 与 shoot_manager 里 image2cv 逆过程一致,供 YOLO input type: rgb。
|
||||||
|
"""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
arr = cv2.imread(path, cv2.IMREAD_COLOR)
|
||||||
|
if arr is None:
|
||||||
|
raise FileNotFoundError(f"cv2.imread 失败: {path}")
|
||||||
|
arr = cv2.cvtColor(arr, cv2.COLOR_BGR2RGB)
|
||||||
|
return image_mod.cv2image(arr, False, False)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="YOLO 画框测试(Maix)",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="若提示 unrecognized arguments: --debug,说明设备上脚本未更新,请同步仓库中的 test/test_yolo_draw_boxes.py",
|
||||||
|
)
|
||||||
|
ap.add_argument("image", help="输入图片路径")
|
||||||
|
ap.add_argument("-o", "--output", default="", help="输出图片路径;默认 原名_yolo_vis.jpg")
|
||||||
|
ap.add_argument("-m", "--model", default="", help="覆盖 config.TRIANGLE_YOLO_MODEL_PATH")
|
||||||
|
ap.add_argument("--conf", type=float, default=None, help="置信度阈值")
|
||||||
|
ap.add_argument("--iou", type=float, default=None, help="NMS IoU")
|
||||||
|
ap.add_argument(
|
||||||
|
"--coord",
|
||||||
|
choices=["native", "letterbox"],
|
||||||
|
default="",
|
||||||
|
help="坐标映射;默认读 config.TRIANGLE_YOLO_COORD_MODE",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--union",
|
||||||
|
action="store_true",
|
||||||
|
help="按 TRIANGLE_YOLO_RING_CLASS_IDS 过滤后画合并外接矩形(与线上 ROI merge=union 一致)",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--loader",
|
||||||
|
choices=["auto", "maix", "cv2_rgb"],
|
||||||
|
default="auto",
|
||||||
|
help="auto: 先 maix.load,0 框则改用 cv2 RGB(推荐排查「有图但始终 0 框」)",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--debug",
|
||||||
|
action="store_true",
|
||||||
|
help="打印 detect 原始返回类型与 repr(截断)",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from maix import image, nn
|
||||||
|
except ImportError:
|
||||||
|
print("[ERR] 需要 MaixPy(maix.image / maix.nn),请在 MaixCAM 上运行。")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
import config as cfg
|
||||||
|
import target_roi_yolo as yroi
|
||||||
|
|
||||||
|
img_path = os.path.abspath(args.image)
|
||||||
|
if not os.path.isfile(img_path):
|
||||||
|
print(f"[ERR] 找不到图片: {img_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
model_path = (args.model or getattr(cfg, "TRIANGLE_YOLO_MODEL_PATH", "") or "").strip()
|
||||||
|
if not os.path.isfile(model_path):
|
||||||
|
print(f"[ERR] 模型文件不存在: {model_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
conf_th = (
|
||||||
|
float(args.conf)
|
||||||
|
if args.conf is not None
|
||||||
|
else float(getattr(cfg, "TRIANGLE_YOLO_CONF_TH", 0.5))
|
||||||
|
)
|
||||||
|
iou_th = (
|
||||||
|
float(args.iou)
|
||||||
|
if args.iou is not None
|
||||||
|
else float(getattr(cfg, "TRIANGLE_YOLO_IOU_TH", 0.45))
|
||||||
|
)
|
||||||
|
coord_mode = (args.coord or getattr(cfg, "TRIANGLE_YOLO_COORD_MODE", "native")).lower()
|
||||||
|
|
||||||
|
out_path = args.output.strip()
|
||||||
|
if not out_path:
|
||||||
|
base, ext = os.path.splitext(img_path)
|
||||||
|
ext = ext if ext else ".jpg"
|
||||||
|
out_path = base + "_yolo_vis" + ext
|
||||||
|
|
||||||
|
det = nn.YOLOv5(model=model_path, dual_buff=False)
|
||||||
|
net_w = int(det.input_width())
|
||||||
|
net_h = int(det.input_height())
|
||||||
|
|
||||||
|
def _run_detect(maix_img, tag: str):
|
||||||
|
r = det.detect(maix_img, conf_th=conf_th, iou_th=iou_th)
|
||||||
|
if args.debug:
|
||||||
|
rlen = len(r) if r is not None and hasattr(r, "__len__") else "n/a"
|
||||||
|
rrepr = repr(r)
|
||||||
|
if len(rrepr) > 300:
|
||||||
|
rrepr = rrepr[:300] + "..."
|
||||||
|
print(f"[DEBUG] loader={tag} raw_type={type(r)} len={rlen} repr={rrepr}")
|
||||||
|
return yroi._normalize_objs(r if r is not None else []), maix_img, tag
|
||||||
|
|
||||||
|
img = None
|
||||||
|
load_tag = ""
|
||||||
|
objs = []
|
||||||
|
|
||||||
|
if args.loader == "cv2_rgb":
|
||||||
|
img = _load_cv2_rgb_as_maix(img_path, image)
|
||||||
|
load_tag = "cv2_rgb"
|
||||||
|
objs, img, load_tag = _run_detect(img, load_tag)
|
||||||
|
elif args.loader == "maix":
|
||||||
|
img = _load_maix_image(img_path, image)
|
||||||
|
load_tag = "maix_load"
|
||||||
|
objs, img, load_tag = _run_detect(img, load_tag)
|
||||||
|
else:
|
||||||
|
# auto
|
||||||
|
img = _load_maix_image(img_path, image)
|
||||||
|
load_tag = "maix_load"
|
||||||
|
objs, img, load_tag = _run_detect(img, load_tag)
|
||||||
|
if len(objs) == 0:
|
||||||
|
print(
|
||||||
|
"[WARN] maix.image.load 在 conf_th=%s 下仍为 0 框,改用 cv2 BGR→RGB→cv2image 重试(常见可恢复)"
|
||||||
|
% conf_th
|
||||||
|
)
|
||||||
|
img2 = _load_cv2_rgb_as_maix(img_path, image)
|
||||||
|
objs, img, load_tag = _run_detect(img2, "cv2_rgb_retry")
|
||||||
|
|
||||||
|
src_w, src_h = img.width(), img.height()
|
||||||
|
|
||||||
|
labels = getattr(det, "labels", None)
|
||||||
|
|
||||||
|
def _label(cid: int) -> str:
|
||||||
|
if labels is None:
|
||||||
|
return str(cid)
|
||||||
|
try:
|
||||||
|
return str(labels[int(cid)])
|
||||||
|
except Exception:
|
||||||
|
return str(cid)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[INFO] loader={load_tag} image={src_w}×{src_h}, net_in={net_w}×{net_h}, "
|
||||||
|
f"coord={coord_mode}, conf_th={conf_th}, iou_th={iou_th}"
|
||||||
|
)
|
||||||
|
print(f"[INFO] NMS 后检测框数量={len(objs)} → {out_path}")
|
||||||
|
if len(objs) == 0:
|
||||||
|
print(
|
||||||
|
"[HINT] 仍为 0 框时常见原因:\n"
|
||||||
|
" 1) 强制 cv2 路径: --loader cv2_rgb\n"
|
||||||
|
" 2) NMS 过严: --iou 0.95\n"
|
||||||
|
" 3) 图与训练分布差太大 / 模型未见过该场景\n"
|
||||||
|
" 4) 用 camera.read() 一帧存盘再测,对比 file 与实时是否一致"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 颜色:按类别轮换(仅有 COLOR_* 时常量时用)
|
||||||
|
color_cycle = []
|
||||||
|
for name in ("RED", "GREEN", "BLUE", "ORANGE", "YELLOW", "CYAN", "MAGENTA"):
|
||||||
|
c = getattr(image, f"COLOR_{name}", None)
|
||||||
|
if c is not None:
|
||||||
|
color_cycle.append(c)
|
||||||
|
if not color_cycle:
|
||||||
|
color_cycle = [getattr(image, "COLOR_RED", 0)]
|
||||||
|
|
||||||
|
for i, o in enumerate(objs):
|
||||||
|
cid = yroi._det_obj_class_id(o)
|
||||||
|
if cid is None:
|
||||||
|
cid = -1
|
||||||
|
try:
|
||||||
|
sc = float(o.score)
|
||||||
|
except Exception:
|
||||||
|
sc = 0.0
|
||||||
|
x0, y0, x1, y1 = yroi._det_to_src_xyxy(o, coord_mode, src_w, src_h, net_w, net_h)
|
||||||
|
ix = int(max(0, min(x0, src_w - 1)))
|
||||||
|
iy = int(max(0, min(y0, src_h - 1)))
|
||||||
|
iw = int(max(1, min(x1 - x0, src_w - ix)))
|
||||||
|
ih = int(max(1, min(y1 - y0, src_h - iy)))
|
||||||
|
col = color_cycle[cid % len(color_cycle)] if cid >= 0 else color_cycle[0]
|
||||||
|
img.draw_rect(ix, iy, iw, ih, color=col)
|
||||||
|
ty = max(0, iy - 14)
|
||||||
|
msg = f"{_label(cid)} {sc:.2f}"
|
||||||
|
img.draw_string(ix, ty, msg, color=col)
|
||||||
|
print(f" #{i} cls={cid} {_label(cid)} score={sc:.3f} xywh=({ix},{iy},{iw},{ih})")
|
||||||
|
|
||||||
|
if args.union:
|
||||||
|
class_ids = getattr(cfg, "TRIANGLE_YOLO_RING_CLASS_IDS", (0,))
|
||||||
|
if isinstance(class_ids, int):
|
||||||
|
class_ids = (class_ids,)
|
||||||
|
cand = [o for o in objs if yroi._det_obj_class_id(o) in class_ids]
|
||||||
|
if cand:
|
||||||
|
xy_list = [
|
||||||
|
yroi._det_to_src_xyxy(o, coord_mode, src_w, src_h, net_w, net_h) for o in cand
|
||||||
|
]
|
||||||
|
merged = yroi._merge_roi_xyxy(xy_list, "union")
|
||||||
|
if merged:
|
||||||
|
mx0, my0, mx1, my1 = merged
|
||||||
|
mx0 = max(0, min(mx0, src_w - 1))
|
||||||
|
my0 = max(0, min(my0, src_h - 1))
|
||||||
|
mx1 = max(mx0 + 1, min(mx1, src_w))
|
||||||
|
my1 = max(my0 + 1, min(my1, src_h))
|
||||||
|
uw, uh = int(mx1 - mx0), int(my1 - my0)
|
||||||
|
ucol = getattr(image, "COLOR_GREEN", color_cycle[0])
|
||||||
|
# 画粗一点的 union:描两遍错位矩形简易模拟加粗
|
||||||
|
for d in (0, 2):
|
||||||
|
img.draw_rect(
|
||||||
|
int(mx0) - d,
|
||||||
|
int(my0) - d,
|
||||||
|
uw + 2 * d,
|
||||||
|
uh + 2 * d,
|
||||||
|
color=ucol,
|
||||||
|
)
|
||||||
|
img.draw_string(
|
||||||
|
int(mx0),
|
||||||
|
max(0, int(my0) - 28),
|
||||||
|
f"UNION ({len(cand)} boxes)",
|
||||||
|
color=ucol,
|
||||||
|
)
|
||||||
|
print(f"[INFO] UNION [{int(mx0)},{int(my0)},{int(mx1)},{int(my1)}] from {len(cand)} boxes")
|
||||||
|
else:
|
||||||
|
print("[WARN] --union 但 RING_CLASS_IDS 过滤后无框")
|
||||||
|
|
||||||
|
try:
|
||||||
|
img.save(out_path, quality=95)
|
||||||
|
except TypeError:
|
||||||
|
img.save(out_path)
|
||||||
|
print(f"[OK] saved: {out_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
YOLO11 关键点检测训练脚本(靶纸四角)。
|
||||||
|
|
||||||
|
设备优先级(--device auto):Intel XPU > NVIDIA CUDA > CPU。
|
||||||
|
默认 imgsz=960;批大小默认 4(大图显存紧张时可再降)。
|
||||||
|
|
||||||
|
关于「业务像素误差」:
|
||||||
|
Ultralytics 没有在 yaml 里设定「像素阈值」的选项;反向传播仍由 pose/kobj/box 等内部 loss 驱动。
|
||||||
|
- 监控:--pixel-metrics-every N(每 N 个 epoch 打印 mean/p95,并合并进 runs/.../results.csv,见 pose_pixel_metrics.py)。
|
||||||
|
- 选 best.pt / early stopping:加 --best-by-pixel,用验证集 mean 像素误差(与 pose_pixel_metrics
|
||||||
|
同一口径)代替 mAP 合成 fitness(fitness = -mean_px,越小越好)。
|
||||||
|
多卡 DDP(world_size>1)时会自动退回默认 mAP fitness。
|
||||||
|
|
||||||
|
XPU:Ultralytics BaseTrainer._get_memory / _clear_memory 把非 MPS、非 CPU 一律当 CUDA,
|
||||||
|
会在验证前调用 torch.cuda 而报错;本脚本在选用 XPU 时自动打补丁(见 _patch_ultralytics_trainer_for_xpu)。
|
||||||
|
|
||||||
|
务必使用 pose 任务:YOLO(...) 与 model.train(...) 均指定 task='pose'。若误用默认 detect,
|
||||||
|
会把 17 列 Pose 标注当成检测/分割解析,校验时出现「coordinates > 1」或 [2.] 等假象。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import gc
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from ultralytics import YOLO
|
||||||
|
|
||||||
|
from pose_pixel_metrics import eval_val_pixel_error
|
||||||
|
import warnings
|
||||||
|
warnings.filterwarnings('ignore',
|
||||||
|
message=".*scatter_add_kernel does not have a deterministic implementation.*")
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_ultralytics_label_caches(data_yaml_path: str) -> int:
|
||||||
|
"""删除 data.yaml 的 path 下 labels/*.cache。
|
||||||
|
|
||||||
|
Ultralytics 的校验缓存 hash 仅依赖「标签/图片路径字符串 + 各文件 size 之和」,不含文件内容;
|
||||||
|
修正 *.txt 后若总和巧合不变,可能继续加载旧 cache 并重播旧的 corrupt 日志,训练前应删掉。"""
|
||||||
|
from ultralytics.utils import YAML
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg = YAML.load(data_yaml_path)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
root = cfg.get("path")
|
||||||
|
if not root:
|
||||||
|
return 0
|
||||||
|
root = os.path.abspath(os.path.expanduser(str(root)))
|
||||||
|
pattern = os.path.join(root, "labels", "*.cache")
|
||||||
|
n = 0
|
||||||
|
for p in glob.glob(pattern):
|
||||||
|
try:
|
||||||
|
os.unlink(p)
|
||||||
|
n += 1
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_device(explicit: str | None):
|
||||||
|
"""返回 ultralytics train/predict 可用的 device。"""
|
||||||
|
if explicit and explicit != "auto":
|
||||||
|
e = explicit.lower()
|
||||||
|
if e == "xpu":
|
||||||
|
if getattr(torch, "xpu", None) is None or not torch.xpu.is_available():
|
||||||
|
raise RuntimeError("指定了 --device xpu 但当前环境不可用")
|
||||||
|
return torch.device("xpu")
|
||||||
|
if e in ("0", "cuda", "gpu"):
|
||||||
|
if not torch.cuda.is_available():
|
||||||
|
raise RuntimeError("指定了 CUDA 但不可用")
|
||||||
|
return 0
|
||||||
|
if e == "cpu":
|
||||||
|
return "cpu"
|
||||||
|
return explicit
|
||||||
|
if getattr(torch, "xpu", None) is not None and torch.xpu.is_available():
|
||||||
|
return torch.device("xpu")
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
return 0
|
||||||
|
return "cpu"
|
||||||
|
|
||||||
|
|
||||||
|
def _default_amp(device) -> bool:
|
||||||
|
if isinstance(device, torch.device) and device.type == "xpu":
|
||||||
|
return False
|
||||||
|
if device == "cpu":
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_ultralytics_for_xpu():
|
||||||
|
"""为 Ultralytics 打补丁,使其能在 XPU 环境下正常训练和验证。"""
|
||||||
|
import ultralytics.engine.trainer as ut_trainer
|
||||||
|
import ultralytics.engine.validator as ut_validator
|
||||||
|
from ultralytics.utils.torch_utils import select_device as _original_select_device
|
||||||
|
|
||||||
|
# 1. 覆盖 select_device:Trainer 初始化传入 torch.device("xpu") 会走原版早返回;
|
||||||
|
# 初始化后 args.device 会变成字符串 "xpu",中期 val 用 trainer.device,不调用 select_device;
|
||||||
|
# 训练结束 final_eval 里 Validator 会 select_device("xpu"),且 validator 在 import 时已绑定原函数,
|
||||||
|
# 只改 torch_utils 无效,必须同时修补 trainer/validator 模块内的引用。
|
||||||
|
def _patched_select_device(device="", *args, **kwargs):
|
||||||
|
# Ultralytics 8.4.x: select_device(device="", newline=False, verbose=True)
|
||||||
|
# Older forks sometimes passed extra positional args; forward everything.
|
||||||
|
if isinstance(device, str):
|
||||||
|
d = device.strip().lower()
|
||||||
|
if d == "xpu" or d.startswith("xpu:"):
|
||||||
|
return torch.device(device.strip())
|
||||||
|
return _original_select_device(device, *args, **kwargs)
|
||||||
|
|
||||||
|
import ultralytics.utils.torch_utils
|
||||||
|
|
||||||
|
ultralytics.utils.torch_utils.select_device = _patched_select_device
|
||||||
|
ut_trainer.select_device = _patched_select_device
|
||||||
|
ut_validator.select_device = _patched_select_device
|
||||||
|
|
||||||
|
# 2. 修补 Trainer 的内存函数
|
||||||
|
BT = ut_trainer.BaseTrainer
|
||||||
|
if not getattr(BT, "_archery_xpu_memory_patched", False):
|
||||||
|
_orig_get_memory = BT._get_memory
|
||||||
|
_orig_clear_memory = BT._clear_memory
|
||||||
|
|
||||||
|
def _get_memory(self, fraction=False):
|
||||||
|
if self.device.type != "xpu":
|
||||||
|
return _orig_get_memory(self, fraction)
|
||||||
|
# ... (原有的 XPU 内存获取逻辑保持不变) ...
|
||||||
|
memory, total = 0, 0
|
||||||
|
try:
|
||||||
|
idx = self.device.index
|
||||||
|
if idx is None:
|
||||||
|
idx = torch.xpu.current_device()
|
||||||
|
memory = int(torch.xpu.memory_allocated(idx))
|
||||||
|
if fraction:
|
||||||
|
total = int(torch.xpu.get_device_properties(idx).total_memory)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return (memory / total) if fraction and total > 0 else (memory / 2**30)
|
||||||
|
|
||||||
|
def _clear_memory(self, threshold=None):
|
||||||
|
if self.device.type != "xpu":
|
||||||
|
return _orig_clear_memory(self, threshold)
|
||||||
|
if threshold is not None:
|
||||||
|
assert 0 <= threshold <= 1, "Threshold must be between 0 and 1."
|
||||||
|
if self._get_memory(fraction=True) <= threshold:
|
||||||
|
return
|
||||||
|
gc.collect()
|
||||||
|
if hasattr(torch.xpu, "empty_cache"):
|
||||||
|
torch.xpu.empty_cache()
|
||||||
|
|
||||||
|
BT._get_memory = _get_memory
|
||||||
|
BT._clear_memory = _clear_memory
|
||||||
|
BT._archery_xpu_memory_patched = True
|
||||||
|
|
||||||
|
# 3. 修补 Validator 的内存函数 (关键是添加这部分)
|
||||||
|
BV = ut_validator.BaseValidator
|
||||||
|
if not getattr(BV, "_archery_xpu_memory_patched", False):
|
||||||
|
# 为 Validator 添加同样的内存处理方法
|
||||||
|
BV._get_memory = _get_memory
|
||||||
|
BV._clear_memory = _clear_memory
|
||||||
|
BV._archery_xpu_memory_patched = True
|
||||||
|
|
||||||
|
|
||||||
|
def _install_best_by_pixel_validate(data_yaml: str, imgsz: int, conf: float) -> None:
|
||||||
|
"""用验证集关键点像素 mean 替代 mAP fitness,驱动 best.pt 与 patience early stopping。"""
|
||||||
|
import ultralytics.engine.trainer as ut
|
||||||
|
from ultralytics.utils import RANK
|
||||||
|
|
||||||
|
BT = ut.BaseTrainer
|
||||||
|
if getattr(BT, "_archery_best_by_pixel_installed", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
_orig_validate = BT.validate
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
import torch.distributed as dist
|
||||||
|
|
||||||
|
if self.ema and self.world_size > 1:
|
||||||
|
for buffer in self.ema.ema.buffers():
|
||||||
|
dist.broadcast(buffer, src=0)
|
||||||
|
metrics = self.validator(self)
|
||||||
|
if metrics is None:
|
||||||
|
return None, None
|
||||||
|
orig_fitness = metrics.pop("fitness", -self.loss.detach().cpu().numpy())
|
||||||
|
|
||||||
|
use_pixel = self.world_size <= 1 and RANK in {-1, 0}
|
||||||
|
mean_px: float | None = None
|
||||||
|
if use_pixel:
|
||||||
|
tmp_path: str | None = None
|
||||||
|
try:
|
||||||
|
fd, tmp_path = tempfile.mkstemp(suffix=".pt", prefix="archery_pxfit_")
|
||||||
|
os.close(fd)
|
||||||
|
from ultralytics.utils.torch_utils import unwrap_model
|
||||||
|
|
||||||
|
core = unwrap_model(self.ema.ema if self.ema else self.model)
|
||||||
|
torch.save({"ema": deepcopy(core).half(), "train_args": vars(self.args)}, tmp_path)
|
||||||
|
probe = YOLO(tmp_path)
|
||||||
|
stats = eval_val_pixel_error(
|
||||||
|
probe,
|
||||||
|
data_yaml,
|
||||||
|
device=self.device,
|
||||||
|
imgsz=imgsz,
|
||||||
|
conf=conf,
|
||||||
|
)
|
||||||
|
mean_px = stats.get("mean_px")
|
||||||
|
if mean_px is None:
|
||||||
|
raise RuntimeError("无有效 mean_px(检查 val 标签与检测是否为空)")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"\n⚠️ [best-by-pixel] 像素探针失败,本 epoch 仍用 mAP fitness: {exc}\n")
|
||||||
|
mean_px = None
|
||||||
|
finally:
|
||||||
|
if tmp_path:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if mean_px is not None:
|
||||||
|
fitness = -float(mean_px)
|
||||||
|
metrics["metrics/mean_px(val)"] = float(mean_px)
|
||||||
|
else:
|
||||||
|
fitness = float(orig_fitness)
|
||||||
|
|
||||||
|
if not self.best_fitness or self.best_fitness < fitness:
|
||||||
|
self.best_fitness = fitness
|
||||||
|
return metrics, fitness
|
||||||
|
|
||||||
|
BT.validate = validate
|
||||||
|
BT._archery_best_by_pixel_installed = True
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_csv_metric(v: float | int | None) -> str:
|
||||||
|
if v is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(v, float):
|
||||||
|
return f"{v:.6g}"
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
|
# 写入 results.csv 的列名(与 --best-by-pixel 的 metrics/mean_px(val) 区分,避免被 last.pt 回调覆盖 EMA 行)
|
||||||
|
_PIXEL_METRIC_COLUMNS: tuple[tuple[str, str], ...] = (
|
||||||
|
("pixel_error/mean_px", "mean_px"),
|
||||||
|
("pixel_error/median_px", "median_px"),
|
||||||
|
("pixel_error/p95_px", "p95_px"),
|
||||||
|
("pixel_error/max_px", "max_px"),
|
||||||
|
("pixel_error/n_points", "n_points"),
|
||||||
|
("pixel_error/n_images", "n_images"),
|
||||||
|
("pixel_error/skip_no_det", "skip_no_det"),
|
||||||
|
("pixel_error/skip_no_gt", "skip_no_gt"),
|
||||||
|
("pixel_error/skip_kpt_mismatch", "skip_kpt_mismatch"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_pixel_metrics_into_results_csv(save_dir: str | Path, epoch_1based: int, stats: dict) -> None:
|
||||||
|
"""在 Ultralytics 写完本 epoch 行之后,把像素指标列合并进 results.csv(扩展表头、补空列)。"""
|
||||||
|
csv_path = Path(save_dir) / "results.csv"
|
||||||
|
if not csv_path.is_file():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(csv_path, newline="", encoding="utf-8") as f:
|
||||||
|
rows = list(csv.reader(f))
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
if len(rows) < 2:
|
||||||
|
return
|
||||||
|
header = list(rows[0])
|
||||||
|
for col_name, _ in _PIXEL_METRIC_COLUMNS:
|
||||||
|
if col_name not in header:
|
||||||
|
header.append(col_name)
|
||||||
|
for ri in range(1, len(rows)):
|
||||||
|
rows[ri].append("")
|
||||||
|
col_ix = {name: i for i, name in enumerate(header)}
|
||||||
|
rows[0] = header
|
||||||
|
target_ri: int | None = None
|
||||||
|
for ri in range(1, len(rows)):
|
||||||
|
row = rows[ri]
|
||||||
|
while len(row) < len(header):
|
||||||
|
row.append("")
|
||||||
|
try:
|
||||||
|
if int(float(row[0].strip())) == int(epoch_1based):
|
||||||
|
target_ri = ri
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
if target_ri is None:
|
||||||
|
return
|
||||||
|
row = rows[target_ri]
|
||||||
|
while len(row) < len(header):
|
||||||
|
row.append("")
|
||||||
|
for col_name, sk in _PIXEL_METRIC_COLUMNS:
|
||||||
|
row[col_ix[col_name]] = _fmt_csv_metric(stats.get(sk))
|
||||||
|
try:
|
||||||
|
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
||||||
|
w = csv.writer(f)
|
||||||
|
w.writerows(rows)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pixel_metrics_callback(data_yaml: str, every: int, imgsz: int, conf: float = 0.25):
|
||||||
|
def on_fit_epoch_end(trainer):
|
||||||
|
from ultralytics.utils import RANK
|
||||||
|
|
||||||
|
if RANK not in {-1, 0}:
|
||||||
|
return
|
||||||
|
if every <= 0:
|
||||||
|
return
|
||||||
|
ep = int(getattr(trainer, "epoch", -1))
|
||||||
|
if (ep + 1) % every != 0:
|
||||||
|
return
|
||||||
|
w = Path(trainer.save_dir) / "weights" / "last.pt"
|
||||||
|
if not w.is_file():
|
||||||
|
return
|
||||||
|
m = YOLO(str(w))
|
||||||
|
stats = eval_val_pixel_error(
|
||||||
|
m,
|
||||||
|
data_yaml,
|
||||||
|
device=trainer.device,
|
||||||
|
imgsz=imgsz,
|
||||||
|
conf=conf,
|
||||||
|
)
|
||||||
|
mean_px = stats.get("mean_px")
|
||||||
|
p95_px = stats.get("p95_px")
|
||||||
|
mean_s = f"{mean_px:.3f}" if mean_px is not None else "n/a"
|
||||||
|
p95_s = f"{p95_px:.3f}" if p95_px is not None else "n/a"
|
||||||
|
print(
|
||||||
|
f"\n[pixel-metrics] epoch {ep + 1}: mean_px={mean_s} p95_px={p95_s} "
|
||||||
|
f"n_points={stats.get('n_points', 0)} "
|
||||||
|
f"skip(det/gt/k)={stats['skip_no_det']}/{stats['skip_no_gt']}/{stats['skip_kpt_mismatch']}\n"
|
||||||
|
)
|
||||||
|
_merge_pixel_metrics_into_results_csv(trainer.save_dir, ep + 1, stats)
|
||||||
|
|
||||||
|
return on_fit_epoch_end
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="YOLO Pose 训练(XPU/CUDA/CPU)")
|
||||||
|
ap.add_argument("--data", default="datasets/dataset_pose.yaml", help="data.yaml")
|
||||||
|
ap.add_argument("--model", default="yolo11x-pose.pt", help="预训练权重")
|
||||||
|
ap.add_argument("--epochs", type=int, default=100)
|
||||||
|
ap.add_argument("--imgsz", type=int, default=960, help="训练输入边长(默认 960)")
|
||||||
|
ap.add_argument("--batch", type=int, default=4, help="批大小;OOM 时减小")
|
||||||
|
ap.add_argument(
|
||||||
|
"--device",
|
||||||
|
default="auto",
|
||||||
|
help="auto | xpu | 0 | cuda | cpu(auto:XPU 优先)",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--no-amp",
|
||||||
|
action="store_true",
|
||||||
|
help="关闭混合精度(默认:CUDA 开启,XPU/CPU 关闭)",
|
||||||
|
)
|
||||||
|
ap.add_argument("--project", default="runs/pose")
|
||||||
|
ap.add_argument("--name", default="target_pose_train")
|
||||||
|
ap.add_argument("--workers", type=int, default=4)
|
||||||
|
ap.add_argument(
|
||||||
|
"--pixel-metrics-every",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help="每 N 个 epoch 在 val 上打印像素误差并写入 results.csv 对应 epoch 行(0=关闭);需 labels 与 data.yaml 布局一致",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--pixel-metrics-conf",
|
||||||
|
type=float,
|
||||||
|
default=0.25,
|
||||||
|
help="--pixel-metrics-every 时 predict 置信度阈值(默认 0.25)",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--best-by-pixel",
|
||||||
|
action="store_true",
|
||||||
|
help="best.pt 与 early stopping 按验证集 mean 像素误差(同 pose_pixel_metrics),fitness=-mean_px;单卡有效,DDP 自动退回 mAP",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--pixel-fitness-conf",
|
||||||
|
type=float,
|
||||||
|
default=0.25,
|
||||||
|
help="--best-by-pixel 时 predict 置信度阈值(默认与 pixel-metrics 一致)",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--export-onnx",
|
||||||
|
action="store_true",
|
||||||
|
help="训练结束后导出 ONNX(需再设 --onnx-imgsz)",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--onnx-imgsz",
|
||||||
|
type=int,
|
||||||
|
nargs=2,
|
||||||
|
metavar=("H", "W"),
|
||||||
|
default=[224, 320],
|
||||||
|
help="导出 ONNX 的 [高, 宽],默认 224 320(Maix 常用)",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--clear-label-cache",
|
||||||
|
action="store_true",
|
||||||
|
help="启动训练前删除 data.yaml 中 path 下的 labels/*.cache(修正标注后仍报 corrupt 时用)",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
device = _pick_device(None if args.device == "auto" else args.device)
|
||||||
|
use_amp = False if args.no_amp else _default_amp(device)
|
||||||
|
|
||||||
|
if isinstance(device, torch.device) and device.type == "xpu":
|
||||||
|
print(f"✅ 使用 Intel XPU: {device}")
|
||||||
|
elif device == 0 or device == "0":
|
||||||
|
print(f"✅ 使用 CUDA: {torch.cuda.get_device_name(0)}")
|
||||||
|
else:
|
||||||
|
print("⚠️ 使用 CPU,训练会较慢")
|
||||||
|
|
||||||
|
if isinstance(device, torch.device) and device.type == "xpu":
|
||||||
|
_patch_ultralytics_for_xpu()
|
||||||
|
|
||||||
|
data_yaml = args.data
|
||||||
|
if not os.path.isabs(data_yaml):
|
||||||
|
data_yaml = os.path.join(os.path.dirname(os.path.abspath(__file__)), data_yaml)
|
||||||
|
if not os.path.exists(data_yaml):
|
||||||
|
print(f"❌ 数据集配置不存在: {data_yaml}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.clear_label_cache:
|
||||||
|
n_rm = _clear_ultralytics_label_caches(data_yaml)
|
||||||
|
print(f"🗑️ 已删除标签目录缓存 {n_rm} 个(labels/*.cache),将强制重新扫描标注。")
|
||||||
|
|
||||||
|
print(f"📦 加载模型: {args.model}(固定 task=pose)")
|
||||||
|
model = YOLO(args.model, task="pose")
|
||||||
|
|
||||||
|
if args.best_by_pixel:
|
||||||
|
_install_best_by_pixel_validate(data_yaml, args.imgsz, args.pixel_fitness_conf)
|
||||||
|
print(
|
||||||
|
"📌 已启用 --best-by-pixel:best.pt / patience 按验证集 mean 像素误差(fitness=-mean_px);"
|
||||||
|
"反向传播仍为 Ultralytics 默认 pose/box loss。"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.pixel_metrics_every > 0:
|
||||||
|
model.add_callback(
|
||||||
|
"on_fit_epoch_end",
|
||||||
|
_make_pixel_metrics_callback(
|
||||||
|
data_yaml, args.pixel_metrics_every, args.imgsz, conf=args.pixel_metrics_conf
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
model.train(
|
||||||
|
task="pose",
|
||||||
|
data=data_yaml,
|
||||||
|
epochs=args.epochs,
|
||||||
|
imgsz=args.imgsz,
|
||||||
|
batch=args.batch,
|
||||||
|
name=args.name,
|
||||||
|
project=args.project,
|
||||||
|
exist_ok=True,
|
||||||
|
save=True,
|
||||||
|
save_period=5,
|
||||||
|
device=device,
|
||||||
|
workers=args.workers,
|
||||||
|
lr0=0.0001,
|
||||||
|
lrf=0.01,
|
||||||
|
optimizer="AdamW",
|
||||||
|
momentum=0.937,
|
||||||
|
weight_decay=0.001,
|
||||||
|
warmup_epochs=0,
|
||||||
|
warmup_momentum=0.8,
|
||||||
|
warmup_bias_lr=0.1,
|
||||||
|
hsv_h=0.015,
|
||||||
|
hsv_s=0.7,
|
||||||
|
hsv_v=0.4,
|
||||||
|
degrees=5.0,
|
||||||
|
translate=0.0,
|
||||||
|
scale=0.2,
|
||||||
|
shear=0.0,
|
||||||
|
perspective=0.0000,
|
||||||
|
flipud=0.0,
|
||||||
|
fliplr=0.5,
|
||||||
|
mosaic=0.0,
|
||||||
|
mixup=0.0,
|
||||||
|
copy_paste=0.0,
|
||||||
|
box=6,
|
||||||
|
cls=0.5,
|
||||||
|
dfl=1.5,
|
||||||
|
pose=18.0,
|
||||||
|
kobj=0.5,
|
||||||
|
freeze=0,
|
||||||
|
seed=42,
|
||||||
|
verbose=True,
|
||||||
|
amp=use_amp,
|
||||||
|
patience=100,
|
||||||
|
cos_lr=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n✅ 训练完成!")
|
||||||
|
print(f"📁 best: {args.project}/{args.name}/weights/best.pt")
|
||||||
|
print(f"📁 last: {args.project}/{args.name}/weights/last.pt")
|
||||||
|
print("📊 仅看像素误差可运行: python pose_pixel_metrics.py --model <best.pt> --data <yaml> --imgsz", args.imgsz)
|
||||||
|
|
||||||
|
if args.export_onnx:
|
||||||
|
h, w = args.onnx_imgsz
|
||||||
|
print(f"📦 导出 ONNX imgsz=[{h}, {w}] ...")
|
||||||
|
model.export(format="onnx", imgsz=[h, w], simplify=True, opset=17, dynamic=False)
|
||||||
|
print("✅ ONNX 完成")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
+1865
File diff suppressed because it is too large
Load Diff
+39
@@ -0,0 +1,39 @@
|
|||||||
|
# 1.2.0 开始使用C++编译成.so,替换部分代码
|
||||||
|
# 1.2.1 ota使用加密包
|
||||||
|
# 1.2.2 支持wifi ota,并且设定时区,并使用单独线程保存图片
|
||||||
|
# 1.2.3 修改ADC_TRIGGER_THRESHOLD 为2300,支持上传日志到服务器
|
||||||
|
# 1.2.4 修改ADC_TRIGGER_THRESHOLD 为3000,并默认关闭摄像头的显示,并把ADC的采样间隔从50ms降低到10ms
|
||||||
|
# 1.2.5 支持空气传感器采样,并默认关闭日志。优化断网时的发送队列丢消息问题,解决 WiFi 断线检测不可靠问题。
|
||||||
|
# 1.2.6 在链接 wifi 前先判断 wifi 的可用性,假如不可用,则不落盘。增加日志批量压缩上传功能
|
||||||
|
# 1.2.7 修复OTA失败的bug, 空气压力传感器的阈值是2500
|
||||||
|
# 1.2.8 (1) 加快 wifi 下数据传输的速度。(2) 调整射箭时处理的逻辑,优先上报数据,再存照片之类的操作。(3)假如是用户打开激光的,射箭触发后不再关闭激光,因为是调瞄阶段
|
||||||
|
# 1.2.9 增加电源板的控制和自动关机的功能
|
||||||
|
# 1.2.10 config formal
|
||||||
|
# 1.2.11 增加三角形的单应性算法,适配对应的靶纸
|
||||||
|
# 1.2.110 关掉了黑色三角形算法,只用于测试
|
||||||
|
# 1.2.13 修改wifi连接
|
||||||
|
# 1.2.14 修改了icc登录部分
|
||||||
|
# 2.15.3 新版本ota,去除ai算环数方法
|
||||||
|
# 2.15.4 更新版本号
|
||||||
|
# 2.15.5 打印ota进度
|
||||||
|
# 2.15.6 更新版本号
|
||||||
|
# 2.15.7 更新版本号
|
||||||
|
# 2.15.8 启动不加载预加载yolo
|
||||||
|
# 2.15.9 20cm
|
||||||
|
# 2.15.10 不保存图片
|
||||||
|
# 2.15.11 优化内存
|
||||||
|
# 2.15.12 优化算法
|
||||||
|
# 2.15.13 优化算法
|
||||||
|
# 2.15.14 优化算法
|
||||||
|
# 2.15.15 优化wifi连接
|
||||||
|
# 2.15.16 修复wifi连接问题
|
||||||
|
# 2.15.17 修复wifi连接问题
|
||||||
|
# 2.15.18 wifi连接成功重新登录
|
||||||
|
# 2.15.20 加了充电关机,激光也同时关闭
|
||||||
|
# 2.15.21 测试4g 扩大了缓存池和改了心跳时间
|
||||||
|
# 2.15.22 修复了4g网络和wifi切换问题
|
||||||
|
# 2.15.23 合并充电关机与稳定版网络修复
|
||||||
|
# 2.15.24 空改测试
|
||||||
|
# 2.15.25 修复整合后关机失败和ota格式更新问题
|
||||||
|
# 2.15.26
|
||||||
|
# 2.16.1 修复压力传感触发判断按照增量
|
||||||
+1
-20
@@ -4,25 +4,6 @@
|
|||||||
应用版本号
|
应用版本号
|
||||||
每次 OTA 更新时,只需要更新这个文件中的版本号
|
每次 OTA 更新时,只需要更新这个文件中的版本号
|
||||||
"""
|
"""
|
||||||
VERSION = '1.2.10'
|
VERSION = '2.16.1'
|
||||||
|
|
||||||
# 1.2.0 开始使用C++编译成.so,替换部分代码
|
|
||||||
# 1.2.1 ota使用加密包
|
|
||||||
# 1.2.2 支持wifi ota,并且设定时区,并使用单独线程保存图片
|
|
||||||
# 1.2.3 修改ADC_TRIGGER_THRESHOLD 为2300,支持上传日志到服务器
|
|
||||||
# 1.2.4 修改ADC_TRIGGER_THRESHOLD 为3000,并默认关闭摄像头的显示,并把ADC的采样间隔从50ms降低到10ms
|
|
||||||
# 1.2.5 支持空气传感器采样,并默认关闭日志。优化断网时的发送队列丢消息问题,解决 WiFi 断线检测不可靠问题。
|
|
||||||
# 1.2.6 在链接 wifi 前先判断 wifi 的可用性,假如不可用,则不落盘。增加日志批量压缩上传功能
|
|
||||||
# 1.2.7 修复OTA失败的bug, 空气压力传感器的阈值是2500
|
|
||||||
# 1.2.8 (1) 加快 wifi 下数据传输的速度。(2) 调整射箭时处理的逻辑,优先上报数据,再存照片之类的操作。(3)假如是用户打开激光的,射箭触发后不再关闭激光,因为是调瞄阶段
|
|
||||||
# 1.2.9 增加电源板的控制和自动关机的功能
|
|
||||||
# 1.2.10 config formal
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+477
-234
@@ -14,10 +14,6 @@ from maix import image
|
|||||||
import config
|
import config
|
||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
|
|
||||||
# 导入ArUco检测器(如果启用)
|
|
||||||
if config.USE_ARUCO:
|
|
||||||
from aruco_detector import detect_target_with_aruco, aruco_detector
|
|
||||||
|
|
||||||
# 存图队列 + worker
|
# 存图队列 + worker
|
||||||
_save_queue = queue.Queue(maxsize=16)
|
_save_queue = queue.Queue(maxsize=16)
|
||||||
_save_worker_started = False
|
_save_worker_started = False
|
||||||
@@ -217,7 +213,7 @@ def check_image_sharpness(frame, threshold=100.0, save_debug_images=False):
|
|||||||
|
|
||||||
# 保存原始图像
|
# 保存原始图像
|
||||||
img_orig = image.cv2image(img_cv, False, False)
|
img_orig = image.cv2image(img_cv, False, False)
|
||||||
orig_filename = f"{debug_dir}/sharpness_debug_orig_{img_count:04d}.bmp"
|
orig_filename = f"{debug_dir}/sharpness_debug_orig_{img_count:04d}.jpg"
|
||||||
img_orig.save(orig_filename)
|
img_orig.save(orig_filename)
|
||||||
|
|
||||||
# # 保存边缘检测结果(可视化)
|
# # 保存边缘检测结果(可视化)
|
||||||
@@ -294,7 +290,7 @@ def save_calibration_image(frame, laser_pos, photo_dir=None):
|
|||||||
img_count = 0
|
img_count = 0
|
||||||
|
|
||||||
x, y = laser_pos
|
x, y = laser_pos
|
||||||
filename = f"{photo_dir}/calibration_{int(x)}_{int(y)}_{img_count:04d}.bmp"
|
filename = f"{photo_dir}/calibration_{int(x)}_{int(y)}_{img_count:04d}.jpg"
|
||||||
|
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
if logger:
|
if logger:
|
||||||
@@ -334,196 +330,454 @@ def save_calibration_image(frame, laser_pos, photo_dir=None):
|
|||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def detect_circle_v3(frame, laser_point=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,会选择最接近激光点的目标
|
如果提供 laser_point,会选择最接近激光点的目标
|
||||||
|
优化:
|
||||||
|
1. 缩图到 MAX_DET_DIM 后再做 HSV/形态学,最长边 640->320 可获得 ~4x 加速
|
||||||
|
2. 红色掩码在黄色轮廓循环外只计算一次,避免 N 次重复计算
|
||||||
|
3. img_cv 可由外部传入(与其他线程共享转换结果),为 None 时自动转换
|
||||||
Args:
|
Args:
|
||||||
frame: 图像帧
|
frame: 图像帧(img_cv 为 None 时使用)
|
||||||
laser_point: 激光点坐标 (x, y),用于多目标场景下的目标选择
|
laser_point: 激光点坐标 (x, y),用于多目标场景下的目标选择
|
||||||
|
img_cv: 已转换的 numpy BGR/RGB 图像;不为 None 时跳过 image2cv 转换
|
||||||
Returns:
|
Returns:
|
||||||
(result_img, best_center, best_radius, method, best_radius1, ellipse_params)
|
(result_img, best_center, best_radius, method, best_radius1, ellipse_params)
|
||||||
"""
|
"""
|
||||||
img_cv = image.image2cv(frame, False, False)
|
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 = 480
|
||||||
|
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
|
best_center = best_radius = best_radius1 = method = None
|
||||||
ellipse_params = None
|
ellipse_params = None
|
||||||
|
|
||||||
# HSV 黄色掩码检测(模糊靶心)
|
logger.debug(f"[detect_circle_v3] step 1 fin {datetime.now()}")
|
||||||
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
|
||||||
|
# -- 2. HSV + 黄色掩码
|
||||||
|
hsv = cv2.cvtColor(img_det, cv2.COLOR_RGB2HSV)
|
||||||
h, s, v = cv2.split(hsv)
|
h, s, v = cv2.split(hsv)
|
||||||
|
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
||||||
# 调整饱和度策略:稍微增强,不要过度
|
|
||||||
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
|
||||||
|
|
||||||
hsv = cv2.merge((h, s, v))
|
hsv = cv2.merge((h, s, v))
|
||||||
|
lower_yellow = np.array([7, 80, 0])
|
||||||
# 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
upper_yellow = np.array([32, 255, 255])
|
||||||
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
|
||||||
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
|
||||||
|
|
||||||
mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
|
||||||
# 调整形态学操作
|
|
||||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
|
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
|
||||||
|
|
||||||
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
logger.debug(f"[detect_circle_v3] step 2 fin {datetime.now()}")
|
||||||
|
|
||||||
# 存储所有有效的黄色-红色组合
|
# -- 3. 红色掩码:在循环外只算一次
|
||||||
valid_targets = []
|
mask_red = cv2.bitwise_or(
|
||||||
|
cv2.inRange(hsv, np.array([0, 30, 20]), np.array([12, 255, 255])),
|
||||||
if contours_yellow:
|
cv2.inRange(hsv, np.array([168, 30, 20]), np.array([180, 255, 255])),
|
||||||
for cnt_yellow in contours_yellow:
|
)
|
||||||
area = cv2.contourArea(cnt_yellow)
|
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
perimeter = cv2.arcLength(cnt_yellow, True)
|
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
||||||
|
# 再加一次膨胀,加厚环状区域避免碎片化
|
||||||
# 计算圆度
|
mask_red = cv2.dilate(mask_red, kernel_red, iterations=1)
|
||||||
if perimeter > 0:
|
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
circularity = (4 * np.pi * area) / (perimeter * perimeter)
|
# 预先把红色轮廓筛选成 (center, radius) 列表,后续直接查表
|
||||||
else:
|
red_candidates = []
|
||||||
circularity = 0
|
for cnt_r in contours_red:
|
||||||
|
ar = cv2.contourArea(cnt_r)
|
||||||
logger = logger_manager.logger
|
if ar <= 10:
|
||||||
if area > 50 and circularity > 0.7:
|
continue
|
||||||
if logger:
|
pr = cv2.arcLength(cnt_r, True)
|
||||||
logger.info(f"[target] -> 面积:{area}, 圆度:{circularity:.2f}")
|
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.2:
|
||||||
# 尝试拟合椭圆
|
continue
|
||||||
yellow_center = None
|
if len(cnt_r) >= 5:
|
||||||
yellow_radius = None
|
(xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r)
|
||||||
yellow_ellipse = None
|
red_candidates.append({"center": (int(xr), int(yr)), "radius": int(min(wr, hr) / 2)})
|
||||||
|
|
||||||
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:
|
else:
|
||||||
# 如果没有激光点,选择面积最大的目标
|
(xr, yr), rr = cv2.minEnclosingCircle(cnt_r)
|
||||||
best_target = max(valid_targets, key=lambda t: t['area'])
|
red_candidates.append({"center": (int(xr), int(yr)), "radius": int(rr)})
|
||||||
best_center = best_target['center']
|
|
||||||
best_radius = best_target['radius']
|
logger.debug(f"[detect_circle_v3] step 3 fin {datetime.now()}")
|
||||||
ellipse_params = best_target['ellipse']
|
|
||||||
|
# -- 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 <= 15:
|
||||||
|
continue
|
||||||
|
perimeter = cv2.arcLength(cnt_yellow, True)
|
||||||
|
if perimeter <= 0:
|
||||||
|
continue
|
||||||
|
circularity = (4 * np.pi * area) / (perimeter * perimeter)
|
||||||
|
if circularity <= 0.5:
|
||||||
|
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)
|
||||||
|
max_dist = yellow_radius * 2.0
|
||||||
|
min_r = min(rc["radius"], yellow_radius)
|
||||||
|
max_r = max(rc["radius"], yellow_radius)
|
||||||
|
size_ratio = min_r / max_r if max_r > 0 else 0
|
||||||
|
if dist_centers < max_dist and size_ratio >= 0.3:
|
||||||
|
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:
|
||||||
|
# 黄圈高置信度兜底:大且圆时跳过红圈验证
|
||||||
|
if area > 30 and circularity > 0.8:
|
||||||
|
valid_targets.append({
|
||||||
|
"center": yellow_center,
|
||||||
|
"radius": yellow_radius,
|
||||||
|
"ellipse": yellow_ellipse,
|
||||||
|
"area": area,
|
||||||
|
})
|
||||||
|
elif 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"
|
method = "v3_ellipse_red_validated"
|
||||||
best_radius1 = best_radius * 5
|
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)
|
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
|
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
||||||
|
|
||||||
|
|
||||||
def estimate_distance(pixel_radius):
|
def estimate_distance(pixel_radius):
|
||||||
"""根据像素半径估算实际距离(单位:米)"""
|
"""根据像素半径估算实际距离(单位:米)"""
|
||||||
if not pixel_radius:
|
if not pixel_radius:
|
||||||
return 0.0
|
return 0.0
|
||||||
return (config.REAL_RADIUS_CM * config.FOCAL_LENGTH_PIX) / pixel_radius / 100.0
|
return (config.REAL_RADIUS_CM * config.FOCAL_LENGTH_PIX) / pixel_radius / 100.0
|
||||||
|
|
||||||
|
def _draw_yolo_roi_on_rgb_numpy(img_cv, yolo_roi_xyxy):
|
||||||
|
"""
|
||||||
|
在 RGB numpy 图像上绘制靶环 YOLO ROI(与原先 shoot_manager 主线程绘制语义一致)。
|
||||||
|
供存图 worker 异步调用,不阻塞射箭主流程。
|
||||||
|
"""
|
||||||
|
if yolo_roi_xyxy is None:
|
||||||
|
return
|
||||||
|
if not getattr(config, "TRIANGLE_YOLO_DRAW_ROI_ON_SHOT", True):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
rx0, ry0, rx1, ry1 = (int(round(float(v))) for v in yolo_roi_xyxy)
|
||||||
|
ih, iw = img_cv.shape[:2]
|
||||||
|
rx0 = max(0, min(rx0, iw - 1))
|
||||||
|
ry0 = max(0, min(ry0, ih - 1))
|
||||||
|
rx1 = max(rx0 + 1, min(rx1, iw))
|
||||||
|
ry1 = max(ry0 + 1, min(ry1, ih))
|
||||||
|
cv2.rectangle(
|
||||||
|
img_cv,
|
||||||
|
(rx0, ry0),
|
||||||
|
(rx1 - 1, ry1 - 1),
|
||||||
|
(0, 255, 255),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
cv2.putText(
|
||||||
|
img_cv,
|
||||||
|
"YOLO ROI",
|
||||||
|
(max(0, rx0), max(16, ry0 - 4)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.55,
|
||||||
|
(0, 255, 255),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def prune_old_images_in_dir(photo_dir, max_images, logger=None, log_prefix="[VISION]"):
|
||||||
|
"""
|
||||||
|
若目录内 bmp/jpg/jpeg 超过 max_images,按 mtime 从最旧开始删,直到数量 ≤ max_images。
|
||||||
|
与射箭主图目录清理规则一致,供 PHOTO_DIR、stage2_roi 等共用。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
max_images = int(max_images)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return
|
||||||
|
if max_images <= 0 or not photo_dir:
|
||||||
|
return
|
||||||
|
if logger is None:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
try:
|
||||||
|
if not os.path.isdir(photo_dir):
|
||||||
|
return
|
||||||
|
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
|
||||||
|
if len(image_files) <= max_images:
|
||||||
|
return
|
||||||
|
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"{log_prefix} 删除旧图片: {fname}")
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"{log_prefix} 删除旧图片失败 {fname}: {e}")
|
||||||
|
if logger and deleted_count > 0:
|
||||||
|
logger.info(
|
||||||
|
f"{log_prefix} 已清理 {deleted_count} 张旧图,"
|
||||||
|
f"目录保留至多 {max_images} 张: {photo_dir}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"{log_prefix} 清理旧图片时出错(可忽略): {e}")
|
||||||
|
|
||||||
|
|
||||||
def estimate_pixel(physical_distance_cm, target_distance_m):
|
def estimate_pixel(physical_distance_cm, target_distance_m):
|
||||||
"""
|
"""
|
||||||
根据物理距离和目标距离计算对应的像素偏移
|
根据物理距离和目标距离计算对应的像素偏移
|
||||||
@@ -542,7 +796,8 @@ def estimate_pixel(physical_distance_cm, target_distance_m):
|
|||||||
|
|
||||||
|
|
||||||
def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
|
def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
|
||||||
laser_point, distance_m, shot_id=None, photo_dir=None):
|
laser_point, distance_m, shot_id=None, photo_dir=None,
|
||||||
|
yolo_roi_xyxy=None):
|
||||||
"""
|
"""
|
||||||
内部实现:在 img_cv (numpy HWC RGB) 上绘制标注并保存。
|
内部实现:在 img_cv (numpy HWC RGB) 上绘制标注并保存。
|
||||||
由 save_shot_image(同步)和存图 worker(异步)调用。
|
由 save_shot_image(同步)和存图 worker(异步)调用。
|
||||||
@@ -560,11 +815,13 @@ def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
|
|||||||
|
|
||||||
x, y = laser_point
|
x, y = laser_point
|
||||||
if shot_id:
|
if shot_id:
|
||||||
if center is None or radius is None:
|
# 之前是用 center/radius 判定 no_target;但三角形路径会返回 center=None(正常)
|
||||||
filename = f"{photo_dir}/shot_{shot_id}_no_target.bmp"
|
# 这里改为:只要 method 有值,就按 method 命名;否则才回退 no_target
|
||||||
|
method_str = (method or "").strip()
|
||||||
|
if method_str:
|
||||||
|
filename = f"{photo_dir}/shot_{shot_id}_{method_str}.jpg"
|
||||||
else:
|
else:
|
||||||
method_str = method or "unknown"
|
filename = f"{photo_dir}/shot_{shot_id}_no_target.jpg"
|
||||||
filename = f"{photo_dir}/shot_{shot_id}_{method_str}.bmp"
|
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
all_images = [f for f in os.listdir(photo_dir) if f.endswith(('.bmp', '.jpg', '.jpeg'))]
|
all_images = [f for f in os.listdir(photo_dir) if f.endswith(('.bmp', '.jpg', '.jpeg'))]
|
||||||
@@ -577,7 +834,9 @@ def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
|
|||||||
else:
|
else:
|
||||||
method_str = method or "unknown"
|
method_str = method or "unknown"
|
||||||
distance_str = str(round((distance_m or 0.0) * 100))
|
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"
|
filename = f"{photo_dir}/{method_str}_{int(x)}_{int(y)}_{distance_str}_{img_count:04d}.jpg"
|
||||||
|
|
||||||
|
_draw_yolo_roi_on_rgb_numpy(img_cv, yolo_roi_xyxy)
|
||||||
|
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
if logger:
|
if logger:
|
||||||
@@ -591,16 +850,16 @@ def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
|
|||||||
else:
|
else:
|
||||||
logger.info(f"结果 -> 未检测到靶心,保存原始图像(激光点: ({x}, {y}))")
|
logger.info(f"结果 -> 未检测到靶心,保存原始图像(激光点: ({x}, {y}))")
|
||||||
|
|
||||||
laser_color = (config.LASER_COLOR[0], config.LASER_COLOR[1], config.LASER_COLOR[2])
|
# 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_thickness = int(max(getattr(config, "LASER_THICKNESS", 1), 1))
|
||||||
cross_length = int(max(getattr(config, "LASER_LENGTH", 10), 10))
|
# 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 - 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.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)
|
# cv2.circle(img_cv, (int(x), int(y)), 1, laser_color, cross_thickness)
|
||||||
ring_thickness = 1
|
# ring_thickness = 1
|
||||||
cv2.circle(img_cv, (int(x), int(y)), 10, laser_color, ring_thickness)
|
# 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)), 5, laser_color, ring_thickness)
|
||||||
cv2.circle(img_cv, (int(x), int(y)), 2, laser_color, -1)
|
# cv2.circle(img_cv, (int(x), int(y)), 2, laser_color, -1)
|
||||||
|
|
||||||
if center and radius:
|
if center and radius:
|
||||||
cx, cy = center
|
cx, cy = center
|
||||||
@@ -630,37 +889,7 @@ def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
|
|||||||
else:
|
else:
|
||||||
logger.debug(f"图像已保存(无靶心,含激光十字线): {filename}")
|
logger.debug(f"图像已保存(无靶心,含激光十字线): {filename}")
|
||||||
|
|
||||||
# 清理旧图片:如果目录下图片超过100张,删除最老的
|
prune_old_images_in_dir(photo_dir, config.MAX_IMAGES, logger, "[VISION]")
|
||||||
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
|
return filename
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -708,7 +937,8 @@ def start_save_shot_worker():
|
|||||||
|
|
||||||
|
|
||||||
def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
||||||
laser_point, distance_m, shot_id=None, photo_dir=None):
|
laser_point, distance_m, shot_id=None, photo_dir=None,
|
||||||
|
yolo_roi_xyxy=None):
|
||||||
"""
|
"""
|
||||||
将存图任务放入队列,由 worker 异步保存。主线程传入 result_img 的复制,不阻塞。
|
将存图任务放入队列,由 worker 异步保存。主线程传入 result_img 的复制,不阻塞。
|
||||||
"""
|
"""
|
||||||
@@ -724,7 +954,18 @@ def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
|||||||
if logger:
|
if logger:
|
||||||
logger.error(f"[VISION] enqueue_save_shot 复制图像失败: {e}")
|
logger.error(f"[VISION] enqueue_save_shot 复制图像失败: {e}")
|
||||||
return
|
return
|
||||||
task = (img_copy, center, radius, method, ellipse_params, laser_point, distance_m, shot_id, photo_dir)
|
task = (
|
||||||
|
img_copy,
|
||||||
|
center,
|
||||||
|
radius,
|
||||||
|
method,
|
||||||
|
ellipse_params,
|
||||||
|
laser_point,
|
||||||
|
distance_m,
|
||||||
|
shot_id,
|
||||||
|
photo_dir,
|
||||||
|
yolo_roi_xyxy,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
_save_queue.put_nowait(task)
|
_save_queue.put_nowait(task)
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
@@ -734,7 +975,8 @@ def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
|||||||
|
|
||||||
|
|
||||||
def save_shot_image(result_img, center, radius, method, ellipse_params,
|
def save_shot_image(result_img, center, radius, method, ellipse_params,
|
||||||
laser_point, distance_m, shot_id=None, photo_dir=None):
|
laser_point, distance_m, shot_id=None, photo_dir=None,
|
||||||
|
yolo_roi_xyxy=None):
|
||||||
"""
|
"""
|
||||||
保存射击图像(带标注)。同步调用,会阻塞。
|
保存射击图像(带标注)。同步调用,会阻塞。
|
||||||
主流程建议使用 enqueue_save_shot;此处保留供校准、测试等场景使用。
|
主流程建议使用 enqueue_save_shot;此处保留供校准、测试等场景使用。
|
||||||
@@ -745,8 +987,18 @@ def save_shot_image(result_img, center, radius, method, ellipse_params,
|
|||||||
photo_dir = config.PHOTO_DIR
|
photo_dir = config.PHOTO_DIR
|
||||||
try:
|
try:
|
||||||
img_cv = image.image2cv(result_img, False, False)
|
img_cv = image.image2cv(result_img, False, False)
|
||||||
return _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
|
return _save_shot_image_impl(
|
||||||
laser_point, distance_m, shot_id, photo_dir)
|
img_cv,
|
||||||
|
center,
|
||||||
|
radius,
|
||||||
|
method,
|
||||||
|
ellipse_params,
|
||||||
|
laser_point,
|
||||||
|
distance_m,
|
||||||
|
shot_id,
|
||||||
|
photo_dir,
|
||||||
|
yolo_roi_xyxy,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
if logger:
|
if logger:
|
||||||
@@ -767,18 +1019,9 @@ def detect_target(frame, laser_point=None):
|
|||||||
与detect_circle_v3保持相同的返回格式
|
与detect_circle_v3保持相同的返回格式
|
||||||
"""
|
"""
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
|
||||||
if config.USE_ARUCO:
|
# 项目当前统一使用黄色靶心检测(圆/椭圆),不再保留 ArUco 路径
|
||||||
# 使用ArUco检测
|
if logger:
|
||||||
if logger:
|
logger.debug("[VISION] 使用传统黄色靶心检测")
|
||||||
logger.debug("[VISION] 使用ArUco标记检测靶心")
|
return detect_circle_v3(frame, laser_point)
|
||||||
|
|
||||||
# 延迟导入以避免循环依赖
|
|
||||||
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)
|
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ from maix import time
|
|||||||
|
|
||||||
import config
|
import config
|
||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
|
from wpa_supplicant_conf import build_sta_conf_open, build_sta_conf_psk
|
||||||
|
|
||||||
|
|
||||||
class WiFiManager:
|
class WiFiManager:
|
||||||
@@ -40,6 +41,7 @@ class WiFiManager:
|
|||||||
# WiFi 质量监测(后台线程)
|
# WiFi 质量监测(后台线程)
|
||||||
self._wifi_quality_monitor_thread = None
|
self._wifi_quality_monitor_thread = None
|
||||||
self._wifi_quality_stop_event = threading.Event()
|
self._wifi_quality_stop_event = threading.Event()
|
||||||
|
self._wifi_quality_lock = threading.Lock()
|
||||||
self._last_wifi_rtt_ms = None # 最近一次测量的 RTT
|
self._last_wifi_rtt_ms = None # 最近一次测量的 RTT
|
||||||
self._last_wifi_rssi_dbm = None # 最近一次测量的 RSSI
|
self._last_wifi_rssi_dbm = None # 最近一次测量的 RSSI
|
||||||
|
|
||||||
@@ -144,6 +146,13 @@ class WiFiManager:
|
|||||||
wifi = network.wifi.Wifi()
|
wifi = network.wifi.Wifi()
|
||||||
if wifi.is_connected():
|
if wifi.is_connected():
|
||||||
self._wifi_connected = True
|
self._wifi_connected = True
|
||||||
|
# MaixPy 的 is_connected 可能不会同步填充 IP,这里用系统命令补齐一次
|
||||||
|
try:
|
||||||
|
ip = os.popen("ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
|
||||||
|
if ip:
|
||||||
|
self._wifi_ip = ip
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return True
|
return True
|
||||||
except:
|
except:
|
||||||
self.logger.warning("Failed to check WiFi connection using MaixPy network", exc_info=True)
|
self.logger.warning("Failed to check WiFi connection using MaixPy network", exc_info=True)
|
||||||
@@ -163,23 +172,25 @@ class WiFiManager:
|
|||||||
|
|
||||||
def connect_wifi(self, ssid, password, verify_callback=None, persist=True, timeout_s=20):
|
def connect_wifi(self, ssid, password, verify_callback=None, persist=True, timeout_s=20):
|
||||||
"""
|
"""
|
||||||
连接 Wi-Fi(先用新凭证尝试连接并验证可用性;失败自动回滚;成功后再决定是否落盘)
|
连接 Wi-Fi(唯一实现:写 wpa_supplicant + /boot 凭证,MaixPy Wifi.connect,再等 IP 与可选校验)。
|
||||||
|
|
||||||
重要:系统的 /etc/init.d/S30wifi 通常会读取 /boot/wifi.ssid 与 /boot/wifi.pass 来连接 WiFi。
|
``NetworkManager.connect_wifi`` 仅封装本方法(通过 ``verify_callback`` 传入 host/port 校验)。
|
||||||
因此要"真正尝试连接新 WiFi",必须临时写入 /boot/ 触发重启;若失败则把旧值写回去(回滚)。
|
|
||||||
|
重要:``/boot/wpa_supplicant.conf`` 存在时 S30wifi 会优先 cp,避免 shell 传中文 SSID。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ssid: WiFi SSID
|
ssid: WiFi SSID
|
||||||
password: WiFi密码
|
password: WiFi密码
|
||||||
verify_callback: 验证回调函数,接收 (ip) 参数,返回 (success: bool, error: str)
|
verify_callback: 可选;``(ip) -> (success: bool, error: str)``,在拿到 IP 后调用
|
||||||
persist: 是否持久化保存凭证
|
persist: 是否持久化保存凭证(False 时成功后回滚 /boot 与 /etc 中的本次写入)
|
||||||
timeout_s: 连接超时时间(秒)
|
timeout_s: 等待 DHCP / 轮询 IP 的超时基数(秒);Maix 连接超时亦据此推导
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(ip, error): IP地址和错误信息(成功时error为None)
|
(ip, error): IP地址和错误信息(成功时 error 为 None)
|
||||||
"""
|
"""
|
||||||
# 配置文件路径定义
|
# 配置文件路径定义
|
||||||
conf_path = "/etc/wpa_supplicant.conf"
|
conf_path = "/etc/wpa_supplicant.conf"
|
||||||
|
boot_wpa_path = "/boot/wpa_supplicant.conf"
|
||||||
ssid_file = "/boot/wifi.ssid"
|
ssid_file = "/boot/wifi.ssid"
|
||||||
pass_file = "/boot/wifi.pass"
|
pass_file = "/boot/wifi.pass"
|
||||||
|
|
||||||
@@ -215,33 +226,54 @@ class WiFiManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _restore_boot_wpa(old_wpa: str | None):
|
||||||
|
try:
|
||||||
|
if old_wpa is None:
|
||||||
|
if os.path.exists(boot_wpa_path):
|
||||||
|
os.remove(boot_wpa_path)
|
||||||
|
else:
|
||||||
|
_write_text(boot_wpa_path, old_wpa)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
old_conf = _read_text(conf_path)
|
old_conf = _read_text(conf_path)
|
||||||
old_boot_ssid = _read_text(ssid_file)
|
old_boot_ssid = _read_text(ssid_file)
|
||||||
old_boot_pass = _read_text(pass_file)
|
old_boot_pass = _read_text(pass_file)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 生成 wpa_supplicant 配置(写 /etc 作为辅助,具体是否生效取决于 S30wifi 脚本)
|
try:
|
||||||
net_conf = os.popen(f'wpa_passphrase "{ssid}" "{password}"').read()
|
full_conf = build_sta_conf_psk(ssid.strip(), password.strip())
|
||||||
if "network={" not in net_conf:
|
except ValueError as ve:
|
||||||
raise RuntimeError("Failed to generate wpa config")
|
raise RuntimeError(str(ve)) from ve
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_write_text(
|
_write_text(conf_path, full_conf)
|
||||||
conf_path,
|
except Exception:
|
||||||
"ctrl_interface=/var/run/wpa_supplicant\n"
|
pass
|
||||||
"update_config=1\n\n"
|
# 删除 wpa_supplicant.conf,让 S30wifi 回退读 ssid/pass
|
||||||
+ net_conf,
|
try:
|
||||||
)
|
if os.path.exists(boot_wpa_path):
|
||||||
|
os.remove(boot_wpa_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
# 不强制要求写 /etc 成功(某些系统只用 /boot)
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# ====== 临时写入 /boot 凭证,触发 WiFi 服务真正尝试连接新 SSID ======
|
|
||||||
_write_text(ssid_file, ssid.strip())
|
_write_text(ssid_file, ssid.strip())
|
||||||
_write_text(pass_file, password.strip())
|
_write_text(pass_file, password.strip())
|
||||||
|
|
||||||
# 重启 Wi-Fi 服务
|
from maix import err as maix_err
|
||||||
os.system("/etc/init.d/S30wifi restart")
|
from maix import network as maix_net
|
||||||
|
|
||||||
|
self.logger.info(f"[WIFI] Maix connect start ssid={ssid!r}")
|
||||||
|
w = maix_net.wifi.Wifi()
|
||||||
|
connect_timeout_s = int(timeout_s) if timeout_s and timeout_s > 0 else 60
|
||||||
|
connect_timeout_s = max(10, min(connect_timeout_s, 120))
|
||||||
|
e = w.connect(ssid, password, wait=True, timeout=connect_timeout_s)
|
||||||
|
maix_err.check_raise(e, "connect wifi failed")
|
||||||
|
try:
|
||||||
|
maix_ip = w.get_ip()
|
||||||
|
except Exception:
|
||||||
|
maix_ip = None
|
||||||
|
self.logger.info(f"[WIFI] Maix connect ok ip={maix_ip!r}")
|
||||||
|
|
||||||
# 等待获取 IP
|
# 等待获取 IP
|
||||||
wait_s = int(timeout_s) if timeout_s and timeout_s > 0 else 20
|
wait_s = int(timeout_s) if timeout_s and timeout_s > 0 else 20
|
||||||
@@ -294,7 +326,7 @@ class WiFiManager:
|
|||||||
|
|
||||||
def persist_sta_credentials(self, ssid: str, password: str, restart_service: bool = True):
|
def persist_sta_credentials(self, ssid: str, password: str, restart_service: bool = True):
|
||||||
"""
|
"""
|
||||||
仅写入 STA 凭证(/etc/wpa_supplicant.conf + /boot/wifi.ssid|pass),
|
仅写入 STA 凭证(/etc/wpa_supplicant.conf、/boot/wpa_supplicant.conf、/boot/wifi.ssid|pass),
|
||||||
可选是否立即 /etc/init.d/S30wifi restart。
|
可选是否立即 /etc/init.d/S30wifi restart。
|
||||||
不做可达性验证。用于热点配网页提交后切换到连接指定路由器。
|
不做可达性验证。用于热点配网页提交后切换到连接指定路由器。
|
||||||
password 为空时按开放网络(key_mgmt=NONE)写入。
|
password 为空时按开放网络(key_mgmt=NONE)写入。
|
||||||
@@ -307,6 +339,7 @@ class WiFiManager:
|
|||||||
return False, "SSID 为空"
|
return False, "SSID 为空"
|
||||||
|
|
||||||
conf_path = "/etc/wpa_supplicant.conf"
|
conf_path = "/etc/wpa_supplicant.conf"
|
||||||
|
boot_wpa_path = "/boot/wpa_supplicant.conf"
|
||||||
ssid_file = "/boot/wifi.ssid"
|
ssid_file = "/boot/wifi.ssid"
|
||||||
pass_file = "/boot/wifi.pass"
|
pass_file = "/boot/wifi.pass"
|
||||||
|
|
||||||
@@ -316,23 +349,17 @@ class WiFiManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if password:
|
if password:
|
||||||
net_conf = os.popen(f'wpa_passphrase "{ssid}" "{password}"').read()
|
full_conf = build_sta_conf_psk(ssid, password)
|
||||||
if "network={" not in net_conf:
|
|
||||||
return False, "wpa_passphrase 失败"
|
|
||||||
else:
|
else:
|
||||||
esc = ssid.replace("\\", "\\\\").replace('"', '\\"')
|
full_conf = build_sta_conf_open(ssid)
|
||||||
net_conf = (
|
_write_text(conf_path, full_conf)
|
||||||
"network={\n"
|
try:
|
||||||
f' ssid="{esc}"\n'
|
if os.path.exists(boot_wpa_path):
|
||||||
" key_mgmt=NONE\n"
|
os.remove(boot_wpa_path)
|
||||||
"}\n"
|
except Exception:
|
||||||
)
|
pass
|
||||||
_write_text(
|
except ValueError as e:
|
||||||
conf_path,
|
return False, str(e)
|
||||||
"ctrl_interface=/var/run/wpa_supplicant\n"
|
|
||||||
"update_config=1\n\n"
|
|
||||||
+ net_conf,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
@@ -514,72 +541,88 @@ class WiFiManager:
|
|||||||
|
|
||||||
def start_quality_monitor(self, network_type_callback, on_poor_quality_callback):
|
def start_quality_monitor(self, network_type_callback, on_poor_quality_callback):
|
||||||
"""
|
"""
|
||||||
启动 WiFi 质量后台监测线程(每 5 秒测量一次 RTT 和 RSSI)
|
启动 WiFi 质量后台监测线程(每 5 秒检查 STA 关联状态和 RSSI)
|
||||||
只在 WiFi 连接时运行,不影响业务发送性能
|
只在 WiFi 连接时运行,不影响业务发送性能
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
network_type_callback: 获取当前网络类型的回调函数
|
network_type_callback: 获取当前网络类型的回调函数
|
||||||
on_poor_quality_callback: WiFi质量差时的回调函数
|
on_poor_quality_callback: WiFi质量差时的回调函数
|
||||||
"""
|
"""
|
||||||
if self._wifi_quality_monitor_thread is not None:
|
with self._wifi_quality_lock:
|
||||||
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
if self._wifi_quality_monitor_thread is not None and self._wifi_quality_monitor_thread.is_alive():
|
||||||
return
|
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
||||||
|
return
|
||||||
self._network_type_callback = network_type_callback
|
|
||||||
self._on_poor_quality_callback = on_poor_quality_callback
|
self._network_type_callback = network_type_callback
|
||||||
self._wifi_quality_stop_event.clear()
|
self._on_poor_quality_callback = on_poor_quality_callback
|
||||||
self._wifi_quality_monitor_thread = threading.Thread(
|
self._wifi_quality_stop_event.clear()
|
||||||
target=self._quality_monitor_loop,
|
self._wifi_quality_monitor_thread = threading.Thread(
|
||||||
daemon=True,
|
target=self._quality_monitor_loop,
|
||||||
name="wifi_quality_monitor"
|
daemon=True,
|
||||||
)
|
name="wifi_quality_monitor"
|
||||||
self._wifi_quality_monitor_thread.start()
|
)
|
||||||
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
|
self._wifi_quality_monitor_thread.start()
|
||||||
|
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
|
||||||
|
|
||||||
def stop_quality_monitor(self):
|
def stop_quality_monitor(self):
|
||||||
"""停止 WiFi 质量监测线程"""
|
"""停止 WiFi 质量监测线程"""
|
||||||
if self._wifi_quality_monitor_thread is None:
|
with self._wifi_quality_lock:
|
||||||
return
|
t = self._wifi_quality_monitor_thread
|
||||||
|
if t is None:
|
||||||
|
return
|
||||||
|
if not t.is_alive():
|
||||||
|
self._wifi_quality_monitor_thread = None
|
||||||
|
return
|
||||||
|
|
||||||
self._wifi_quality_stop_event.set()
|
self._wifi_quality_stop_event.set()
|
||||||
try:
|
try:
|
||||||
self._wifi_quality_monitor_thread.join(timeout=2.0)
|
t.join(timeout=2.0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
|
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
|
||||||
finally:
|
|
||||||
self._wifi_quality_monitor_thread = None
|
with self._wifi_quality_lock:
|
||||||
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
|
if t is self._wifi_quality_monitor_thread:
|
||||||
|
if t.is_alive():
|
||||||
|
self.logger.warning("[WiFi Monitor] 线程未在超时内退出,保留引用防止重复创建")
|
||||||
|
else:
|
||||||
|
self._wifi_quality_monitor_thread = None
|
||||||
|
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
|
||||||
|
|
||||||
def _quality_monitor_loop(self):
|
def _quality_monitor_loop(self):
|
||||||
"""
|
"""
|
||||||
WiFi 质量监测循环(后台线程)
|
WiFi 质量监测循环(后台线程)
|
||||||
每 5 秒测量一次 RTT 和 RSSI,发现质量差则触发切换
|
每 5 秒检查 STA 关联状态和 RSSI,发现断链或质量差则触发切换
|
||||||
"""
|
"""
|
||||||
while not self._wifi_quality_stop_event.is_set():
|
while not self._wifi_quality_stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
# 只在 WiFi 连接时才测量
|
# 只在 WiFi 连接时才测量
|
||||||
network_type = self._network_type_callback()
|
network_type = self._network_type_callback()
|
||||||
if network_type == "wifi" and self._wifi_socket:
|
if network_type == "wifi" and self._wifi_socket:
|
||||||
# # 测量 RTT(1 个样本,快速测量)
|
# RTT 测量当前禁用;STA 关联状态用于判断物理 WiFi 链路是否仍存在。
|
||||||
# rtt_ms, reachable = self._measure_wifi_tcp_rtt_ms(
|
# 不能把禁用的 RTT 伪装成 0ms,否则关闭热点后会一直被判为正常。
|
||||||
# self._server_ip, self._server_port,
|
reachable = self.is_sta_associated()
|
||||||
# samples=1, per_sample_timeout_ms=600
|
rtt_ms = None
|
||||||
# )
|
|
||||||
|
|
||||||
# 获取 RSSI
|
# 获取 RSSI
|
||||||
rssi_dbm = self._get_wifi_rssi_dbm()
|
rssi_dbm = self._get_wifi_rssi_dbm()
|
||||||
|
|
||||||
# 更新缓存
|
# 更新缓存
|
||||||
# 不使用 RTT 测量
|
self._last_wifi_rtt_ms = rtt_ms
|
||||||
rtt_ms = 0
|
|
||||||
reachable = True
|
|
||||||
self._last_wifi_rtt_ms = rtt_ms if reachable else None
|
|
||||||
self._last_wifi_rssi_dbm = rssi_dbm
|
self._last_wifi_rssi_dbm = rssi_dbm
|
||||||
self.logger.debug(f"[WiFi Monitor] - RTT={rtt_ms:.0f}ms, RSSI={rssi_dbm:.0f}dBm")
|
_rtt_s = f"{rtt_ms:.0f}ms" if rtt_ms is not None else "n/a"
|
||||||
|
_rssi_s = f"{rssi_dbm:.0f}" if rssi_dbm is not None else "n/a"
|
||||||
|
self.logger.debug(
|
||||||
|
f"[WiFi Monitor] - associated={reachable}, RTT={_rtt_s}, RSSI={_rssi_s}dBm"
|
||||||
|
)
|
||||||
|
|
||||||
# 判断质量是否差(切换前做 2 次快速复测,防止瞬时抖动)
|
# 判断质量是否差(切换前做 2 次快速复测,防止瞬时抖动)
|
||||||
def _is_bad_now(_reachable, _rtt, _rssi):
|
def _is_bad_now(_reachable, _rtt, _rssi):
|
||||||
if (not _reachable) or (_rtt is None) or (_rtt == float("inf")):
|
if not _reachable:
|
||||||
|
return True
|
||||||
|
# RTT 未启用时不参与质量判断;链路状态仍由 STA 关联保证。
|
||||||
|
if _rtt is None:
|
||||||
|
return False
|
||||||
|
if _rtt == float("inf"):
|
||||||
return True
|
return True
|
||||||
return self._is_wifi_quality_bad(_rtt, _rssi)
|
return self._is_wifi_quality_bad(_rtt, _rssi)
|
||||||
|
|
||||||
@@ -589,13 +632,8 @@ class WiFiManager:
|
|||||||
|
|
||||||
for retry_idx in range(2):
|
for retry_idx in range(2):
|
||||||
time.sleep_ms(1000)
|
time.sleep_ms(1000)
|
||||||
# 不使用 RTT 测量
|
reachable2 = self.is_sta_associated()
|
||||||
rtt2 = 0
|
rtt2 = None
|
||||||
reachable2 = True
|
|
||||||
# rtt2, reachable2 = self._measure_wifi_tcp_rtt_ms(
|
|
||||||
# self._server_ip, self._server_port,
|
|
||||||
# samples=1, per_sample_timeout_ms=600
|
|
||||||
# )
|
|
||||||
rssi2 = self._get_wifi_rssi_dbm()
|
rssi2 = self._get_wifi_rssi_dbm()
|
||||||
|
|
||||||
# 更新缓存,便于外部查看最新状态
|
# 更新缓存,便于外部查看最新状态
|
||||||
@@ -604,9 +642,10 @@ class WiFiManager:
|
|||||||
|
|
||||||
bad2 = _is_bad_now(reachable2, rtt2, rssi2)
|
bad2 = _is_bad_now(reachable2, rtt2, rssi2)
|
||||||
try:
|
try:
|
||||||
|
_rtt_disp = f"{rtt2:.0f}ms" if rtt2 is not None else "n/a"
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"[WiFi Monitor] 复测{retry_idx+1}/2: reachable={reachable2}, "
|
f"[WiFi Monitor] 复测{retry_idx+1}/2: reachable={reachable2}, "
|
||||||
f"rtt={rtt2 if rtt2 != float('inf') else -1:.0f}ms, rssi={rssi2}, bad={bad2}"
|
f"rtt={_rtt_disp}, rssi={rssi2}, bad={bad2}"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -383,6 +383,27 @@ def _ensure_hostapd_modern_security(logger=None) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_ap_flag_if_needed(logger):
|
||||||
|
"""若 /boot/wifi.ap 残留,删除它并恢复 /boot/wifi.sta,避免 main.py 误判为 AP 配网模式。"""
|
||||||
|
ap_flag = "/boot/wifi.ap"
|
||||||
|
sta_flag = "/boot/wifi.sta"
|
||||||
|
if not os.path.exists(ap_flag):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
os.remove(ap_flag)
|
||||||
|
logger.info(f"[WIFI-AP] 已清理残留标记 {ap_flag}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[WIFI-AP] 清理 {ap_flag} 失败: {e}")
|
||||||
|
return
|
||||||
|
if not os.path.exists(sta_flag):
|
||||||
|
try:
|
||||||
|
with open(sta_flag, "w", encoding="utf-8") as f:
|
||||||
|
f.write("")
|
||||||
|
logger.info(f"[WIFI-AP] 已恢复 {sta_flag}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[WIFI-AP] 恢复 {sta_flag} 失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _switch_boot_to_ap_mode(logger):
|
def _switch_boot_to_ap_mode(logger):
|
||||||
"""
|
"""
|
||||||
去掉 STA 标志、建立 AP 标志,由 S30wifi 起 hostapd(与 Maix start_ap 二选一,以系统脚本为准)。
|
去掉 STA 标志、建立 AP 标志,由 S30wifi 起 hostapd(与 Maix start_ap 二选一,以系统脚本为准)。
|
||||||
@@ -449,6 +470,8 @@ def maybe_start_wifi_ap_fallback(logger=None):
|
|||||||
logger.info(f"[WIFI-AP] 兜底检测(quick):sta关联={wifi_ok}, 4g={g4_ok}")
|
logger.info(f"[WIFI-AP] 兜底检测(quick):sta关联={wifi_ok}, 4g={g4_ok}")
|
||||||
if wifi_ok or g4_ok:
|
if wifi_ok or g4_ok:
|
||||||
logger.info("[WIFI-AP] STA 或 4G 可用,不启动热点配网")
|
logger.info("[WIFI-AP] STA 或 4G 可用,不启动热点配网")
|
||||||
|
# 清理上次开机可能残留的 /boot/wifi.ap 标记,避免 main.py 误判为 AP 配网模式
|
||||||
|
_cleanup_ap_flag_if_needed(logger)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 两者均不可用:再按配置等待一段时间后复检,避免开机瞬态误判
|
# 两者均不可用:再按配置等待一段时间后复检,避免开机瞬态误判
|
||||||
@@ -466,6 +489,7 @@ def maybe_start_wifi_ap_fallback(logger=None):
|
|||||||
|
|
||||||
if wifi_ok or g4_ok:
|
if wifi_ok or g4_ok:
|
||||||
logger.info("[WIFI-AP] STA 或 4G 可用,不启动热点配网")
|
logger.info("[WIFI-AP] STA 或 4G 可用,不启动热点配网")
|
||||||
|
_cleanup_ap_flag_if_needed(logger)
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.warning("[WIFI-AP] STA 与 4G 均不可用,启动热点配网(/boot/wifi.ap + HTTP)")
|
logger.warning("[WIFI-AP] STA 与 4G 均不可用,启动热点配网(/boot/wifi.ap + HTTP)")
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
生成 wpa_supplicant STA 配置(不经过 shell / wpa_passphrase),避免中文 SSID 在 /bin/sh 传参时被破坏。
|
||||||
|
|
||||||
|
与 wpa_passphrase 一致:PMK = PBKDF2-SHA1(password_utf8, ssid_utf8, 4096, 32),
|
||||||
|
ssid 行使用 UTF-8 字节的十六进制(无引号),与 wpa_supplicant 文档一致。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
_CTRL_HEADER = (
|
||||||
|
"ctrl_interface=/var/run/wpa_supplicant\n"
|
||||||
|
"update_config=1\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ssid_utf8_bytes(ssid: str) -> bytes:
|
||||||
|
b = (ssid or "").encode("utf-8")
|
||||||
|
if not b:
|
||||||
|
raise ValueError("SSID 为空")
|
||||||
|
if len(b) > 32:
|
||||||
|
raise ValueError("SSID UTF-8 超过 32 字节")
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def build_sta_conf_psk(ssid: str, password: str) -> str:
|
||||||
|
"""WPA2-PSK STA:完整 wpa_supplicant.conf 文本。"""
|
||||||
|
ssid_b = _ssid_utf8_bytes(ssid)
|
||||||
|
pw = (password or "").encode("utf-8")
|
||||||
|
if len(pw) < 8 or len(pw) > 63:
|
||||||
|
raise ValueError("WPA2-PSK 密码长度应为 8–63 字节(UTF-8)")
|
||||||
|
pmk = hashlib.pbkdf2_hmac("sha1", pw, ssid_b, 4096, 32)
|
||||||
|
net = (
|
||||||
|
"network={\n"
|
||||||
|
f"\tssid={ssid_b.hex()}\n"
|
||||||
|
f"\tpsk={pmk.hex()}\n"
|
||||||
|
"}\n"
|
||||||
|
)
|
||||||
|
return _CTRL_HEADER + net
|
||||||
|
|
||||||
|
|
||||||
|
def build_sta_conf_open(ssid: str) -> str:
|
||||||
|
"""开放网络 STA:完整 wpa_supplicant.conf 文本。"""
|
||||||
|
ssid_b = _ssid_utf8_bytes(ssid)
|
||||||
|
net = (
|
||||||
|
"network={\n"
|
||||||
|
f"\tssid={ssid_b.hex()}\n"
|
||||||
|
"\tkey_mgmt=NONE\n"
|
||||||
|
"}\n"
|
||||||
|
)
|
||||||
|
return _CTRL_HEADER + net
|
||||||
Reference in New Issue
Block a user