激光追踪打蚊器模拟

把视觉追踪打蚊器的思路搬到浏览器:Canvas 模拟监控画面、瞄准、激光发射,并附 OpenCV 真实硬件版代码。

场景:硬件 / 视觉追踪 技术栈:Canvas / OpenCV 来源:GitHub 参考
命中 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()

操作步骤

  1. 上方 Demo 可玩:移动瞄准、点击发射激光,命中蚊子计分。
  2. 真实硬件思路:摄像头帧差检测运动目标 → 追踪坐标 → 舵机云台带动激光器。
  3. 安全提醒:激光功率必须低于安全标准,仅用于展示,请勿照射人眼。

完整可运行工程

可直接运行浏览器模拟零依赖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 小目标检测

运行方式

  1. 新建一个目录,把上面全部文件按文件名分别保存(用「一键复制整份工程」可一次拿全)。
  2. 前端部分:直接双击 index.html 即可运行;若浏览器限制本地文件读取,执行 python -m http.server 8000 后访问 http://localhost:8000/。
  3. 逻辑自测:各文件 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 顶部常量里

复制结果为 # ===== 文件名 ===== 分隔的多文件文本,按文件名分别保存即可;也可逐块用右上角“复制代码”单独取用。

来源参考

实现涉及的官方文档(可直接访问):