AIoT 视觉接入 HA 完整备忘

这份备忘是结合你从零踩坑到成功跑通的全过程整理的,涵盖了原理、概念、完整复现步骤、踩坑记录以及调参指南 。


一、 基本原理与架构

整个链路就像一个"全自动保安系统":

摄像头/视频 -> Python脚本(大脑) -> MQTT(对讲机) -> Home Assistant(控制台)。

  1. Python脚本(保安的大脑):读取视频画面,对比"没人时的背景"(背景减除算法 MOG2)。发现画面有差异(有东西在动),就画上绿框,算出运动面积占比。
  2. MQTT(对讲机):把算出的结果(带框的画面、状态、面积)打包,通过不同的"频道"(主题 Topic)广播出去。
  3. Home Assistant(控制台):订阅这些频道,把画面展示在网页上,把状态接入自动化。

数据流向与 MQTT 主题映射:

  • aiot/camera1/image (二进制JPEG) -> HA 的 camera.camera1 (显示画面)
  • aiot/camera1/motion (ON/OFF) -> HA 的 binary_sensor.camera1motion (触发自动化)
  • aiot/camera1/motion_area (数字%) -> HA 的 sensor.camera1motionarea (仪表盘展示)
  • aiot/camera1/event (JSON) -> 备用,以后接 ThingsBoard 或存档。

二、 核心概念

  • venv(虚拟环境) :独立沙盒,避免 Python 依赖包冲突导致 HA 崩溃。位置在 ~/aiot-camera/.venv。每次操作前必须 source .venv/bin/activate。
  • camera vs binary_sensor :camera 是给"人眼"看的(图像流);binary_sensor 是给"机器"用的(只判断 ON/OFF,用于自动化)。
  • MQTT 发布/订阅:脚本只管"发布"消息,HA 只管"订阅"消息。只要有相同的 Topic 和 Broker 地址,两边就能对接。
  • systemd:Linux 后台服务管家。把脚本交给他,就能实现开机自启、崩溃自动重启,不必一直开着终端窗口。

三、 完整复现步骤

1. 构建独立环境与依赖

bash 复制代码
sudo apt install -y python3-venv mosquitto-clients
mkdir -p ~/aiot-camera && cd ~/aiot-camera
python3 -m venv .venv
source .venv/bin/activate
pip install opencv-python-headless paho-mqtt numpy

(把 camera_motion.py、make_demo_video.py 上传到该目录)

2. 测试脚本与 MQTT 链路(先不碰 HA)

  • 终端1(生成视频并跑脚本) :

    bash 复制代码
    source ~/aiot-camera/.venv/bin/activate
    cd ~/aiot-camera
    python make_demo_video.py
    python camera_motion.py demo_video.mp4
  • 终端2(监听 MQTT) :

    bash 复制代码
    mosquitto_sub -h 127.0.0.1 -t "aiot/camera1/#" -v
  • 验收标准 :看到 motion ON、motion OFF 和 motion_area 在跳,图片流显示乱码(正常二进制)。

3. 修改 HA 配置( 必须改对路径)

路径警告 :必须是 /home/dragon/.homeassistant/(带点的隐藏目录),不要改到 /homeassistant/ 无点的影子目录!

  • 修改 configuration.yaml :

    在 mqtt: 下方、与 sensor: 平级的位置,追加 camera 和 binary_sensor 块:

    yaml 复制代码
      camera:
        - name: "Camera1"
          unique_id: cam1_image
          topic: "aiot/camera1/image"
          availability_topic: "aiot/camera1/availability"
      binary_sensor:
        - name: "Camera1Motion"
          unique_id: cam1_motion
          state_topic: "aiot/camera1/motion"
          payload_on: "ON"
          payload_off: "OFF"
          device_class: motion
          availability_topic: "aiot/camera1/availability"

    ⚠️ 追加到原有 sensor: 列表末尾(不要新建第二个 sensor:):

    yaml 复制代码
        - unique_id: cam1_motion_area
          name: "Camera1MotionArea"
          state_topic: "aiot/camera1/motion_area"
          unit_of_measurement: "%"
          availability_topic: "aiot/camera1/availability"

    (可选) 添加自动化:在文件已有 automation: 列表末尾追加:

    yaml 复制代码
      - alias: "摄像头检测到运动"
        triggers:
          - trigger: state
            entity_id: binary_sensor.camera1motion
            to: "on"
        actions:
          - action: persistent_notification.create
            data:
              title: "视觉告警"
              message: "摄像头1检测到运动"
  • 修改 ui-lovelace.yaml :

    在 views: 下新增一页(注意缩进对齐平级):

    yaml 复制代码
      - title: 摄像头
        path: camera
        icon: mdi:cctv
        cards:
          - type: picture-entity
            entity: camera.camera1
            show_state: false
          - type: entities
            title: 运动检测
            entities:
              - entity: binary_sensor.camera1motion
              - entity: sensor.camera1motionarea

    (避坑:HA 实体 ID 强制全小写,千万不能写 camera1motion 之类的大小写混用)

4. 重启与验收

开发者工具 -> YAML -> 检查配置 -> 重启 HA -> Ctrl+F5 强制刷新网页 -> 进"摄像头"标签页。保持 Python 脚本运行,看到带绿框的画面和传感器跳动即为成功。

5. 后台常驻(交付使用)

创建服务文件:sudo nano /etc/systemd/system/aiot-camera.service

ini 复制代码
[Unit]
Description=AIoT camera motion detector
After=network.target mosquitto.service

[Service]
User=dragon
WorkingDirectory=/home/dragon/aiot-camera
ExecStart=/home/dragon/aiot-camera/.venv/bin/python camera_motion.py 0
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

生效并启动:

bash 复制代码
sudo systemctl daemon-reload
sudo systemctl enable --now aiot-camera
journalctl -u aiot-camera -f  # 查看运行日志

四、 🚨踩坑记录

  1. 文件路径的"双胞胎"陷阱 :HA 真正读取的是 /home/dragon/.homeassistant/(隐藏目录)。你曾经改错到 homeassistant/config/ 导致怎么改都没反应。以后改配置,一定要用 grep 验证,或使用绝对路径。
  2. YAML 格式错乱致死 :YAML 对缩进极其严格。多一个空格、用中文冒号、错误缩进(如把 binary_sensor 写进了 sensor 列表),都会导致 HA 配置检查失败,整个新配置全部罢工。
  3. 集成冲突与 Token 失效 :小米集成 invalid refresh token 报错会直接卡住 HA 启动,导致 MQTT 配置不加载。遇到莫名卡顿,去 设置 -> 设备与服务 里禁用/删除报错的集成。
  4. HA 重启无效的错觉 :如果是 configuration.yaml 的改动,必须重启 HA 服务(设置 -> 系统 -> 重启)。仅仅刷新网页或重载 Lovelace 是不会生效的。
  5. USB 摄像头连接"独木桥" :
    • 罗技 BRIO 这类高清摄像头,必须把 VMware 的 USB 控制器设为 USB 3.1,否则带宽不够,设备无法识别。
    • 连接前先彻底关机虚拟机,调整设置后再开机。
    • 插上摄像头后,在 VMware 菜单栏"虚拟机 -> 可移动设备"中手动点击 Logitech BRIO 才能穿透进虚拟机。
    • 穿透后,Windows 本机(腾讯会议等)将无法使用该摄像头。
  6. 权限导致的"无头案" :执行过 sudo usermod -aG video dragon 后,必须重启虚拟机(或注销重登) ,用户组权限才会生效。否则脚本会报 Permission denied。

五、 调参修改指南(灵活调整)

如果后续更换视频源或需要优化检测效果,修改 camera_motion.py 顶部配置:

  • 更换视频源 :
    • 假视频:python camera_motion.py demo_video.mp4
    • USB 摄像头:python camera_motion.py 0(修改 systemd 服务里的 ExecStart 也要改成 0)
    • 手机IP摄像头:python camera_motion.py "http://手机IP:8080/video"
    • 网络摄像头(RTSP):python camera_motion.py "rtsp://用户:密码@IP:554/xxx"
  • 灵敏度调节 :
    • 没人也乱触发 -> 调大 MIN_AREA_PCT(如 1.0)或调大 NOISE_AREA_PCT(如 0.5)。
    • 人走过没触发 -> 调小 MIN_AREA_PCT(如 0.2)。
    • 状态来回闪烁 -> 调大 HOLD_SECONDS(如 5 秒)。
  • 性能优化 :
    • 虚拟机卡顿 -> 调大 IMG_INTERVAL(如 2.0),或调小 WIDTH(如 480)和 JPEG_QUALITY(如 50)。
  • 未来升级(YOLO) :
    • 只需要替换 camera_motion.py 中"背景减除"那段算法为 YOLO 模型,输出人数 person_count。MQTT 主题和 HA 配置文件完全不用动。

附录:

python 复制代码
#!/usr/bin/env python3
"""
camera_motion.py ------ 摄像头运动检测 → MQTT → Home Assistant

用法:
    python camera_motion.py                  # 使用下面 SOURCE 的默认值
    python camera_motion.py demo_video.mp4   # 视频文件(循环播放,用来模拟摄像头)
    python camera_motion.py 0                # USB 摄像头 /dev/video0
    python camera_motion.py "http://手机IP:8080/video"   # 手机 IP Webcam / 网络摄像头
    python camera_motion.py "rtsp://用户:密码@摄像头IP:554/xxx"

发布的 MQTT 主题 (BASE 默认 aiot/camera1):
    {BASE}/image          JPEG 画面(带检测框)          -> HA 的 mqtt camera
    {BASE}/motion         ON / OFF (retain)            -> HA 的 mqtt binary_sensor
    {BASE}/motion_area    运动面积占画面的百分比        -> HA 的 mqtt sensor
    {BASE}/event          JSON 事件(开始/结束)          -> 以后转发给 ThingsBoard / 存档
    {BASE}/availability   online / offline (遗嘱消息)
"""
import json
import sys
import time

import cv2
import paho.mqtt.client as mqtt

# ==================== 配置(按需修改) ====================
SOURCE = "demo_video.mp4"   # 视频源,命令行参数优先
MQTT_HOST = "127.0.0.1"     # MQTT broker 地址(和 HA 里配置的一致)
MQTT_PORT = 1883
MQTT_USER = None            # broker 有账号密码就填,如 "user"
MQTT_PASS = None
BASE = "aiot/camera1"

WIDTH = 640                 # 处理前把画面缩放到这个宽度(越小越省 CPU)
MIN_AREA_PCT = 0.5          # 运动面积占画面 % 超过它,才算"有运动"
NOISE_AREA_PCT = 0.2        # 单个色块小于画面的这个 %,当作噪点忽略
HOLD_SECONDS = 3            # 运动消失后,保持 ON 的时间(防抖,避免开关闪烁)
WARMUP_FRAMES = 30          # 开头几帧用来学习背景,不判断
IMG_INTERVAL = 1.0          # 每隔多少秒发布一张画面/面积(太快会拖慢 HA)
JPEG_QUALITY = 70
# ========================================================


def make_client():
    try:                                        # paho-mqtt 2.x
        client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
    except AttributeError:                      # paho-mqtt 1.x
        client = mqtt.Client()
    if MQTT_USER:
        client.username_pw_set(MQTT_USER, MQTT_PASS)
    client.will_set(f"{BASE}/availability", "offline", retain=True)
    client.connect(MQTT_HOST, MQTT_PORT, 60)
    client.loop_start()
    return client


def open_source(src):
    cap = cv2.VideoCapture(src)
    if not cap.isOpened():
        raise RuntimeError(f"打不开视频源: {src}")
    return cap


def parse_source():
    src = sys.argv[1] if len(sys.argv) > 1 else SOURCE
    if isinstance(src, str) and src.isdigit():
        src = int(src)                          # "0" -> USB 摄像头编号
    return src


def main():
    src = parse_source()
    is_file = isinstance(src, str) and not src.lower().startswith(("rtsp://", "http://", "https://"))

    client = make_client()
    client.publish(f"{BASE}/availability", "online", retain=True)
    client.publish(f"{BASE}/motion", "OFF", retain=True)

    cap = open_source(src)
    fps = cap.get(cv2.CAP_PROP_FPS) or 25
    bg = cv2.createBackgroundSubtractorMOG2(history=200, varThreshold=32, detectShadows=False)
    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))

    motion_on = False
    last_motion_ts = 0.0
    last_img_ts = 0.0
    frame_count = 0
    print(f"开始检测,视频源={src},MQTT={MQTT_HOST}:{MQTT_PORT},主题前缀={BASE}")

    try:
        while True:
            ok, frame = cap.read()
            if not ok:
                if is_file:                     # 视频文件播完,回到开头循环
                    cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
                    continue
                print("读取画面失败,2 秒后重连...")
                cap.release()
                time.sleep(2)
                try:
                    cap = open_source(src)
                except RuntimeError as e:
                    print(e)
                continue

            h, w = frame.shape[:2]
            frame = cv2.resize(frame, (WIDTH, int(h * WIDTH / w)))
            frame_area = frame.shape[0] * frame.shape[1]
            frame_count += 1
            learning = frame_count <= WARMUP_FRAMES     # 开头几帧只学习背景

            # 1) 背景减除:得到"前景(运动)"掩膜,再去噪、膨胀
            mask = bg.apply(frame)
            mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
            mask = cv2.dilate(mask, kernel, iterations=2)

            # 2) 找轮廓,过滤小噪点,累计运动面积,画框
            total = 0.0
            contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
            for c in ([] if learning else contours):
                area = cv2.contourArea(c)
                if area < frame_area * NOISE_AREA_PCT / 100:
                    continue
                total += area
                x, y, bw, bh = cv2.boundingRect(c)
                cv2.rectangle(frame, (x, y), (x + bw, y + bh), (0, 255, 0), 2)
            pct = round(total * 100 / frame_area, 2)

            now = time.time()

            # 3) 状态机:开始 -> 保持 -> 结束(带防抖)
            if not learning:
                if pct >= MIN_AREA_PCT:
                    last_motion_ts = now
                    if not motion_on:
                        motion_on = True
                        client.publish(f"{BASE}/motion", "ON", retain=True)
                        client.publish(f"{BASE}/event", json.dumps(
                            {"event": "motion_start", "area_pct": pct, "ts": int(now)}))
                        print(f"[{time.strftime('%H:%M:%S')}] 运动开始 area={pct}%")
                elif motion_on and now - last_motion_ts > HOLD_SECONDS:
                    motion_on = False
                    client.publish(f"{BASE}/motion", "OFF", retain=True)
                    client.publish(f"{BASE}/event", json.dumps(
                        {"event": "motion_end", "ts": int(now)}))
                    print(f"[{time.strftime('%H:%M:%S')}] 运动结束")

            # 4) 定时发布画面和面积(画面上的文字用英文,OpenCV 不支持中文)
            if now - last_img_ts >= IMG_INTERVAL:
                label = "MOTION" if motion_on else "IDLE"
                color = (0, 0, 255) if motion_on else (0, 200, 0)
                cv2.putText(frame, f"{label} {pct}%", (10, 28),
                            cv2.FONT_HERSHEY_SIMPLEX, 0.8, color, 2)
                ok2, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY])
                if ok2:
                    client.publish(f"{BASE}/image", buf.tobytes())
                    client.publish(f"{BASE}/motion_area", str(pct))
                last_img_ts = now

            if is_file:                         # 视频文件按原帧率播放,模拟真实摄像头
                time.sleep(1.0 / fps)
    except KeyboardInterrupt:
        print("收到 Ctrl+C,退出")
    finally:
        client.publish(f"{BASE}/availability", "offline", retain=True)
        client.loop_stop()
        client.disconnect()
        cap.release()


if __name__ == "__main__":
    main()
python 复制代码
#!/usr/bin/env python3
"""
make_demo_video.py ------ 生成一段演示视频 demo_video.mp4(没有摄像头/没有素材时使用)

内容(20 秒,15 帧/秒,640x480):
    0-5 秒    空场景
    5-12 秒   一个"人"从左走到右
    12-16 秒  空场景
    16-20 秒  另一个"人"从右走到左
画面带轻微噪声,用来模拟真实摄像头。
"""
import cv2
import numpy as np

W, H, FPS, SECONDS = 640, 480, 15, 20
rng = np.random.default_rng(0)

# 背景:上下渐变的灰色,再画几条"地面线"
bg = np.tile(np.linspace(90, 140, H, dtype=np.uint8)[:, None], (1, W))
bg = cv2.cvtColor(bg, cv2.COLOR_GRAY2BGR)
for y in (300, 360, 420):
    cv2.line(bg, (0, y), (W, y), (110, 110, 110), 1)

out = cv2.VideoWriter("demo_video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), FPS, (W, H))
for i in range(FPS * SECONDS):
    t = i / FPS
    frame = bg.copy()

    if 5 <= t < 12:                              # 从左到右
        x = int(-60 + (t - 5) / 7 * (W + 120))
    elif 16 <= t < 20:                           # 从右到左
        x = int(W + 60 - (t - 16) / 4 * (W + 120))
    else:
        x = None

    if x is not None:
        cv2.rectangle(frame, (x, 230), (x + 60, 400), (40, 60, 200), -1)   # 身体
        cv2.circle(frame, (x + 30, 210), 22, (60, 120, 220), -1)           # 头

    noise = rng.normal(0, 3, frame.shape)        # 轻微噪声
    frame = np.clip(frame.astype(np.float32) + noise, 0, 255).astype(np.uint8)
    out.write(frame)

out.release()
print("已生成 demo_video.mp4")

HA 自动化的原理:一句话就是"如果...就..."

专业说法叫 ECA 模型:触发器(Trigger)→ 条件(Condition,可选)→ 动作(Action)。

概述

HA 后台一直在监听所有实体的状态变化。每当某个实体的值变了(温度从 25 跳到 36,运动传感器从 OFF 变 ON),HA 就把这次变化拿去跟你写的每一条自动化规则做比对:

  1. 触发器:这次变化符不符合我要等的那个条件?比如"温度变化"这个事件发生了。
  2. 条件 (可选,用来加限制):现在还满足别的要求吗?比如"现在是不是白天"。不满足就不往下走,但不会重新等触发器,只是这次不执行。
  3. 动作:都满足,就去执行------开空调、发通知、关插座。

它就是:

当(Trigger)某件事发生了,并且(Condition)当前状态也符合,就去做(Action)某个动作。

关键点:动作之后会形成闭环

动作执行后,会改变某个设备的状态(比如插座被关掉),这个"状态改变"本身又是一次新的变化,可能触发别的自动化。这就是你之前理解的"状态变化→事件→动作→新状态→新事件"的循环,是对的。

举个项目里的例子

复制代码
触发器:光照传感器数值 < 50
条件  :现在是晚上
动作  :打开房间灯

总结:光照一变暗,如果又是晚上,就开灯。白天光照也可能变暗(比如阴天、遮挡),但条件不满足,就不会误开灯------条件的作用就是防止触发器"误判"。

相关推荐
pride.li1 小时前
ISP标定-BLC标定(Black Level Calibration,黑电平校准)
人工智能·计算机视觉·接口隔离原则
isyoungboy1 小时前
浓淡补正(阴影校正/光照不均匀校正)算法综述:从原理到工程实现
计算机视觉
李航19832 小时前
自动动手开发图形引擎,不仅能AI建模,还能AI渲染
人工智能·python·计算机视觉·ai·ai编程
pride.li3 小时前
ISP标定-NR标定(Noise Reduction,降噪校准)
算法·计算机视觉·isp
小蒋观天下5 小时前
两轮车检测AI摄像头行业竞争格局与企业突围路径解析
大数据·人工智能·安全·计算机视觉·ai大模型
xailorliu5 小时前
矩阵定性-正定负定不定半正定半负定
c++·opencv·线性代数·计算机视觉·矩阵
一见已难忘18 小时前
产业AI落地技术解析:边缘部署、工业视觉检测与多传感器融合预警的工程实践(燎原:我看见的智能中国)
人工智能·计算机视觉·视觉检测
鬼手点金1 天前
Claude Code示范案例-进阶学习路径
java·服务器·前端·学习·计算机视觉·前向传播
闭包不眠1 天前
端侧AI能省多少服务器钱:把账换成字节算
运维·服务器·图像处理·人工智能·计算机视觉