激光追踪打蚊器模拟
把视觉追踪打蚊器的思路搬到浏览器:Canvas 模拟监控画面、瞄准、激光发射,并附 OpenCV 真实硬件版代码。
命中 0 / 发射 0
移动鼠标瞄准,点击发射激光打蚊子;下方代码为视觉追踪思路。
代码示例:视觉追踪打蚊器思路
py
# Python + OpenCV:运动目标检测(真实硬件版核心)
import cv2
import numpy as np
cap = cv2.VideoCapture(0) # 摄像头
fgbg = cv2.createBackgroundSubtractorMOG2() # 背景建模
while True:
ret, frame = cap.read()
if not ret:
break
mask = fgbg.apply(frame) # 前景掩码
mask = cv2.erode(mask, None, iterations=1)
mask = cv2.dilate(mask, None, iterations=2)
cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
target = None
for c in cnts:
area = cv2.contourArea(c)
if area < 200: # 过滤小噪点
continue
(x, y, w, h) = cv2.boundingRect(c)
if target is None or w * h > target[4]:
target = (x + w // 2, y + h // 2, w, h, w * h)
if target is not None:
cx, cy = target[0], target[1]
cv2.circle(frame, (cx, cy), 8, (0, 0, 255), 2)
# 将像素坐标映射为舵机角度,控制云台转向
pan = int(np.interp(cx, [0, frame.shape[1]], [0, 180]))
tilt = int(np.interp(cy, [0, frame.shape[0]], [90, 0]))
# send_to_servo(pan, tilt)
cv2.imshow("tracking", frame)
if cv2.waitKey(1) & 0xFF == ord("q"):
break
cap.release()
操作步骤
- 上方 Demo 可玩:移动瞄准、点击发射激光,命中蚊子计分。
- 真实硬件思路:摄像头帧差检测运动目标 → 追踪坐标 → 舵机云台带动激光器。
- 安全提醒:激光功率必须低于安全标准,仅用于展示,请勿照射人眼。
完整可运行工程
可直接运行浏览器模拟零依赖Python 版需 pip install
工程由两部分组成:浏览器 Canvas 模拟(瞄准 / 开火 / 命中 / 蚊群动力学)+ 真实硬件版核心 tracking.py(帧差检测 → 中心误差 → PID),后者自带 --self-test,不接摄像头也能验证链路。
laser-mosquito/
├─ index.html 模拟画布 + 状态
├─ app.js 蚊群推进 / 命中判定 / 渲染
├─ styles.css 样式(跟随系统深浅色)
├─ tracking.py OpenCV 硬件版核心(含 --self-test)
├─ requirements.txt
└─ README.md 运行方式与扩展点
index.html
模拟画布 + 状态栏
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>激光追踪打蚊器模拟 · 独立工程版</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="wrap">
<h1>激光追踪打蚊器模拟</h1>
<p class="sub">Canvas 模拟监控画面:移动鼠标瞄准、点击发射激光;附 OpenCV 真实硬件版核心代码。</p>
<section class="panel">
<div class="row">
<button id="add" type="button" class="btn ghost">加一只</button>
<span class="msg" id="stat" style="margin:0">命中 0 / 发射 0</span>
</div>
</section>
<h2>模拟画面</h2>
<div class="panel" style="position:relative;padding:8px">
<canvas id="scene" height="320"></canvas>
</div>
<p class="msg">提示:点击画布即开火,命中判定半径 32px。</p>
</main>
<script src="app.js"></script>
</body>
</html>
app.js
蚊群推进 / 命中判定(hitTest 可测) / 渲染
javascript
/* app.js —— 激光打蚊器模拟(含可复用的命中判定纯函数) */
var HIT_RADIUS = 32;
/* hitTest:返回被命中的蚊子下标,未命中返回 -1(纯函数,node 可测) */
function hitTest(mosq, x, y, r) {
r = r || HIT_RADIUS;
for (var i = 0; i < mosq.length; i++) {
var dx = mosq[i].x - x, dy = mosq[i].y - y;
if (dx * dx + dy * dy < r * r) return i;
}
return -1;
}
/* stepAll:推进一帧(边界反弹 + 寿命) */
function stepAll(mosq, w, h) {
for (var i = mosq.length - 1; i >= 0; i--) {
var m = mosq[i];
m.x += m.vx; m.y += m.vy;
if (m.x < 10 || m.x > w - 10) m.vx *= -1;
if (m.y < 10 || m.y > h - 40) m.vy *= -1;
m.life--;
}
return mosq.filter(function (m) { return m.life > 0; });
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { HIT_RADIUS: HIT_RADIUS, hitTest: hitTest, stepAll: stepAll };
}
if (typeof document !== "undefined") {
document.addEventListener("DOMContentLoaded", function () {
var canvas = document.getElementById("scene"), ctx = canvas.getContext("2d");
var statEl = document.getElementById("stat"), addBtn = document.getElementById("add");
var W = 0, H = 320, mosq = [], score = 0, shots = 0;
var mouse = { x: -100, y: -100 };
function resize() {
var dpr = Math.min(window.devicePixelRatio || 1, 2);
W = canvas.clientWidth;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function spawn() {
mosq.push({ x: 20 + Math.random() * (W - 40), y: 20 + Math.random() * (H - 60),
vx: (Math.random() - 0.5) * 1.4, vy: (Math.random() - 0.5) * 1.4,
r: 4 + Math.random() * 2, life: 260 + Math.random() * 120 });
}
function draw() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "#0b1118";
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = "rgba(90,140,90,0.28)";
for (var gx = 0; gx < W; gx += 40) { ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, H); ctx.stroke(); }
for (var gy = 0; gy < H; gy += 40) { ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(W, gy); ctx.stroke(); }
mosq.forEach(function (m) {
ctx.fillStyle = "#d9a066";
ctx.beginPath(); ctx.arc(m.x, m.y, m.r, 0, Math.PI * 2); ctx.fill();
});
ctx.strokeStyle = "rgba(231,76,60,0.55)";
ctx.setLineDash([6, 6]);
ctx.beginPath(); ctx.moveTo(mouse.x, mouse.y - 60); ctx.lineTo(mouse.x, mouse.y); ctx.stroke();
ctx.setLineDash([]);
}
function tick() {
mosq = stepAll(mosq, W, H);
if (mosq.length < 8 && Math.random() < 0.01) spawn();
draw();
requestAnimationFrame(tick);
}
function fire() {
shots++;
var hit = hitTest(mosq, mouse.x, mouse.y);
if (hit >= 0) { score++; mosq.splice(hit, 1); spawn(); }
statEl.textContent = "命中 " + score + " / 发射 " + shots;
}
canvas.addEventListener("mousemove", function (e) {
var rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
});
canvas.addEventListener("click", fire);
canvas.addEventListener("pointerdown", function (e) { if (e.pointerType === "touch") fire(); });
addBtn.addEventListener("click", function () { spawn(); });
window.addEventListener("resize", resize);
resize();
spawn(); spawn(); spawn();
tick();
});
}
styles.css
样式:跟随系统深浅色
css
:root {
--bg: #0e1014; --panel: #171a20; --ink: #e8ecf1; --muted: #9aa4b2; --line: #262b33; --brand: #6fb3a8;
}
@media (prefers-color-scheme: light) {
:root { --bg: #f6f7f8; --panel: #ffffff; --ink: #1a1d21; --muted: #5b636e; --line: #e3e6ea; }
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 24px 16px 48px; background: var(--bg); color: var(--ink);
font: 15px/1.6 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
}
.wrap { max-width: 760px; margin: 0 auto; }
h1 { font-size: 1.5rem; margin: 0 0 6px; }
h2 { font-size: 1.05rem; margin: 22px 0 10px; }
.sub { color: var(--muted); margin: 0 0 18px; }
.panel, .log-wrap { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; }
.q { font-size: 1.06rem; margin: 0 0 12px; }
.btns, .row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.btn {
padding: 8px 16px; border-radius: 8px; border: 1px solid var(--brand);
background: var(--brand); color: #0b0f12; cursor: pointer; font-size: .92rem;
}
.btn.ghost { background: transparent; color: var(--brand); }
.msg { color: var(--muted); margin: 12px 0 0; font-size: .92rem; }
.log { max-height: 200px; overflow: auto; font-size: .88rem; color: var(--muted); }
.log div { padding: 3px 0; border-bottom: 1px dashed var(--line); }
input, select {
padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line);
background: var(--bg); color: var(--ink); font: inherit;
}
canvas { width: 100%; display: block; border-radius: 10px; }
tracking.py
OpenCV 硬件版核心:运动检测 + PID(含 --self-test)
python
# tracking.py —— OpenCV 真实硬件版核心:运动目标检测 + 激光指向
# 依赖:opencv-python(见 requirements.txt)
# 运行:python tracking.py --self-test # 无需摄像头,用合成帧验证算法链路
# python tracking.py --camera 0 # 接摄像头实测
import argparse
import numpy as np
def detect_motion(prev_gray, gray, min_area=40):
# 帧差法找运动目标,返回按面积降序的候选框 [(x, y, w, h), ...]
diff = cv2_absdiff(prev_gray, gray)
blurred = cv2_blur(diff)
thresh = (blurred > 25).astype("uint8") * 255
boxes = cv2_find_contours(thresh)
boxes = [b for b in boxes if b[2] * b[3] >= min_area]
return sorted(boxes, key=lambda b: b[2] * b[3], reverse=True)
def aim_error(box, frame_w, frame_h):
# 目标框中心相对画面中心的偏移(激光云台 PID 的输入误差)
cx = box[0] + box[2] / 2.0
cy = box[1] + box[3] / 2.0
return cx - frame_w / 2.0, cy - frame_h / 2.0
def pid_step(err_x, err_y, kp=0.35, kd=0.12, last=(0.0, 0.0)):
# 最小 PID:输出两轴增量角度,返回 (out_x, out_y, 新误差缓存)
out_x = kp * err_x + kd * (err_x - last[0])
out_y = kp * err_y + kd * (err_y - last[1])
return out_x, out_y, (err_x, err_y)
# ---- 以下为无 OpenCV 时的纯 numpy 替身,保证 --self-test 在最小环境下也能跑通 ----
def cv2_absdiff(a, b):
return np.abs(a.astype("int16") - b.astype("int16")).astype("uint8")
def cv2_blur(img, k=5):
pad = k // 2
out = np.zeros_like(img, dtype="float32")
padded = np.pad(img.astype("float32"), pad, mode="edge")
for dy in range(k):
for dx in range(k):
out += padded[dy:dy + img.shape[0], dx:dx + img.shape[1]]
return (out / (k * k)).astype("uint8")
def cv2_find_contours(mask, thr=200):
# 极简连通域:按行扫描把超过阈值的像素聚成矩形(足够演示用)
ys, xs = np.where(mask > thr)
if len(xs) == 0:
return []
return [(int(xs.min()), int(ys.min()), int(xs.max() - xs.min() + 1), int(ys.max() - ys.min() + 1))]
def self_test():
h, w = 120, 160
frames = []
for i in range(3):
f = np.zeros((h, w), dtype="uint8")
f[40 + i * 6:70 + i * 6, 50 + i * 8:90 + i * 8] = 255 # 模拟一只移动的蚊子
frames.append(f)
boxes = detect_motion(frames[0], frames[-1])
assert boxes, "未检测到运动目标"
ex, ey = aim_error(boxes[0], w, h)
ox, oy, cache = pid_step(ex, ey)
print("目标框:", boxes[0])
print("画面中心误差: x=%.1f y=%.1f" % (ex, ey))
print("PID 输出增量: x=%.2f y=%.2f" % (ox, oy))
print("self-test OK")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--self-test", action="store_true")
ap.add_argument("--camera", type=int, default=-1)
a = ap.parse_args()
if a.self_test:
self_test()
return
if a.camera < 0:
print("请指定 --camera 0 或使用 --self-test")
return
import cv2 # 真实运行时才需要
cap = cv2.VideoCapture(a.camera)
ok, prev = cap.read()
prev_gray = cv2.cvtColor(prev, cv2.COLOR_BGR2GRAY)
cache = (0.0, 0.0)
while ok:
ok, frame = cap.read()
if not ok:
break
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
for box in detect_motion(prev_gray, gray):
ex, ey = aim_error(box, frame.shape[1], frame.shape[0])
_, _, cache = pid_step(ex, ey, last=cache)
prev_gray = gray
cap.release()
if __name__ == "__main__":
main()
requirements.txt
Python 依赖清单
text
numpy>=1.24
# 接真实摄像头时才需要:
# opencv-python>=4.8
README.md
运行方式与扩展点
markdown
# 激光追踪打蚊器模拟(独立可运行工程)
作品站 fun 区「激光追踪打蚊器模拟」的独立工程版:浏览器模拟画面 + 真实硬件版 OpenCV 核心。
## 文件
- `index.html` / `app.js` / `styles.css`:Canvas 模拟(瞄准、开火、命中判定、蚊群边界反弹)
- `tracking.py`:真实硬件版核心链路(帧差运动检测 → 目标中心误差 → PID 增量)
- `requirements.txt`:Python 依赖(最小环境只需 numpy,接摄像头再加 opencv-python)
## 运行
- 浏览器模拟:双击 `index.html`,点击画布开火
- 命中判定自测:`node -e "console.log(require('./app.js').hitTest([{x:10,y:10}], 20, 20))"` → 期望 `0`
- 硬件版链路自测:`pip install -r requirements.txt` 后 `python tracking.py --self-test`
- 接摄像头实测:`python tracking.py --camera 0`(需安装 opencv-python)
## 扩展点
- 真实激光云台:把 `pid_step` 的输出接到串口(如 pyserial 控制二维云台舵机)
- 更稳的检测:把帧差法换成背景建模(MOG2)或 YOLO 小目标检测
运行方式
- 新建一个目录,把上面全部文件按文件名分别保存(用「一键复制整份工程」可一次拿全)。
- 前端部分:直接双击
index.html即可运行;若浏览器限制本地文件读取,执行python -m http.server 8000后访问http://localhost:8000/。 - 逻辑自测:各文件
README.md中给出了对应的 Node / Python 自测命令(均为真实可跑的最小用例)。
标注:可直接运行、无需构建;要改造只需动 app.js 顶部的常量与 styles.css 里的变量。
依赖清单:浏览器模拟:无依赖;硬件版核心:numpy(最小环境),接摄像头再加 opencv-python
可直接运行:可直接运行 双击 index.html 点画布开火;链路自测 python tracking.py --self-test(装完 numpy 即可)
需先改的配置项:接真摄像头:改 --camera 索引并安装 opencv-python;PID 三个增益在 tracking.py 顶部常量里
来源参考
实现涉及的官方文档(可直接访问):