Browse Source

姿态报警功能: 多动作识别 + 独立报警页面

后端:
- FallAnalyzer::analyze 支持多动作: 跌倒/倒地/举手呼救/异常蹲坐
  - 举手呼救: 手腕高于肩部(9/10 vs 5/6)
  - 异常蹲坐: 髋-膝距离过近且躯干压缩
- 新增 pose_alarms 表(时间/通道/动作/得分/截图), 独立于围栏报警
- AlarmHistory: recordPoseAlarm/getPoseAlarms/getPoseAlarmCount/deletePoseAlarm/clearPoseAlarms
- cleanup 同步清理姿态报警及截图
- API: GET /api/pose/alarms, /count; POST /delete, /clear
- config 新增 pose_hand_raise/pose_crouch 开关

前端:
- 新增"姿态报警"页面: 摄像头/动作类型/时间范围筛选, 列表+截图弹窗+删除
- 动作类型颜色区分, 10秒自动刷新

实测: 举手呼救检测正常(score 0.71-1.0), 截图入库, API/页面可用
dev 1 week ago
parent
commit
1f62f2a612
8 changed files with 606 additions and 43 deletions
  1. 6 4
      config/config.json
  2. 196 2
      src/alarm_history.cpp
  3. 29 0
      src/alarm_history.h
  4. 108 31
      src/main.cpp
  5. 88 0
      src/pose_detector.cpp
  6. 28 1
      src/pose_detector.h
  7. 31 0
      src/web_server.cpp
  8. 120 5
      web/index.html

+ 6 - 4
config/config.json

@@ -45,6 +45,7 @@
         }
     ],
     "conf_thresh": 0.3499999940395355,
+    "det_core_mask": 1,
     "enable_bucket": false,
     "fall_frames": 3,
     "fall_relays": [
@@ -70,6 +71,7 @@
         "person"
     ],
     "pose_conf_thresh": 0.35,
+    "pose_core_mask": 2,
     "pose_enabled": true,
     "pose_input_size": 640,
     "pose_model_path": "models/yolov8n-pose_rk3588_fp.rknn",
@@ -173,14 +175,14 @@
         }
     ],
     "rtsp_timeout": 10,
-    "skip_frames": 3,
+    "skip_frames": 4,
     "steel_output": false,
-    "target_enabled": true,
+    "target_enabled": false,
     "web_auth": {
         "password": "admin",
         "username": "admin"
     },
     "web_port": 8080,
-    "det_core_mask": 1,
-    "pose_core_mask": 2
+    "pose_hand_raise": true,
+    "pose_crouch": true
 }

+ 196 - 2
src/alarm_history.cpp

@@ -122,8 +122,12 @@ void AlarmHistory::workerLoop() {
         else
             fprintf(stderr, "[AlarmHistory] 空帧跳过截图: %s\n",
                     item.rel_path.c_str());
-        insertRow(item.timestamp, item.channel, item.fence_name,
-                  item.class_name, item.confidence, item.rel_path);
+        if (item.kind == 1)
+            insertPoseRow(item.timestamp, item.channel, item.action,
+                          item.score, item.rel_path);
+        else
+            insertRow(item.timestamp, item.channel, item.fence_name,
+                      item.class_name, item.confidence, item.rel_path);
     }
 }
 
@@ -159,9 +163,180 @@ void AlarmHistory::createTable() {
     if (err) {
         fprintf(stderr, "[AlarmHistory] createActionTable error: %s\n", err);
         sqlite3_free(err);
+        err = nullptr;
+    }
+
+    // 姿态报警表
+    const char* sql3 =
+        "CREATE TABLE IF NOT EXISTS pose_alarms ("
+        "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+        "timestamp TEXT NOT NULL,"
+        "channel INTEGER NOT NULL,"
+        "action TEXT NOT NULL,"
+        "score REAL NOT NULL,"
+        "screenshot_path TEXT NOT NULL"
+        ");";
+    sqlite3_exec(static_cast<sqlite3*>(db_), sql3, nullptr, nullptr, &err);
+    if (err) {
+        fprintf(stderr, "[AlarmHistory] createPoseTable error: %s\n", err);
+        sqlite3_free(err);
     }
 }
 
+void AlarmHistory::recordPoseAlarm(int channel, const std::string& action,
+                                   float score, const cv::Mat& frame) {
+    Pending p;
+    p.timestamp = currentTimestamp();
+    p.channel = channel;
+    p.action = action;
+    p.score = score;
+    p.confidence = score;
+    p.class_name = action;
+    p.fence_name = "姿态报警";
+    p.rel_path = screenshot_dir_ + "/pose_" + generateFilename();
+    p.kind = 1;
+    if (!frame.empty()) p.frame = frame.clone();
+
+    std::lock_guard<std::mutex> lock(mtx_);
+    if (pending_.size() > 30) pending_.pop();
+    pending_.push(std::move(p));
+    pending_cv_.notify_one();
+}
+
+void AlarmHistory::insertPoseRow(const std::string& timestamp, int channel,
+                                 const std::string& action, float score,
+                                 const std::string& rel_path) {
+    std::lock_guard<std::mutex> lock(mtx_);
+    const char* sql =
+        "INSERT INTO pose_alarms (timestamp, channel, action, score, screenshot_path) "
+        "VALUES (?, ?, ?, ?, ?);";
+    sqlite3_stmt* stmt = nullptr;
+    if (sqlite3_prepare_v2(static_cast<sqlite3*>(db_), sql, -1, &stmt, nullptr) != SQLITE_OK) {
+        fprintf(stderr, "[AlarmHistory] pose prepare error: %s\n",
+                sqlite3_errmsg(static_cast<sqlite3*>(db_)));
+        return;
+    }
+    sqlite3_bind_text(stmt, 1, timestamp.c_str(), -1, SQLITE_STATIC);
+    sqlite3_bind_int(stmt, 2, channel);
+    sqlite3_bind_text(stmt, 3, action.c_str(), -1, SQLITE_STATIC);
+    sqlite3_bind_double(stmt, 4, score);
+    sqlite3_bind_text(stmt, 5, rel_path.c_str(), -1, SQLITE_STATIC);
+    if (sqlite3_step(stmt) != SQLITE_DONE)
+        fprintf(stderr, "[AlarmHistory] pose insert error: %s\n",
+                sqlite3_errmsg(static_cast<sqlite3*>(db_)));
+    sqlite3_finalize(stmt);
+}
+
+std::vector<PoseAlarmRecord> AlarmHistory::getPoseAlarms(
+    int limit, int offset, int channel, const std::string& action,
+    const std::string& start, const std::string& end) {
+    std::lock_guard<std::mutex> lock(mtx_);
+    std::vector<PoseAlarmRecord> result;
+    std::string sql =
+        "SELECT id, timestamp, channel, action, score, screenshot_path "
+        "FROM pose_alarms WHERE 1=1";
+    if (channel >= 0) sql += " AND channel = ?";
+    if (!action.empty()) sql += " AND action = ?";
+    if (!start.empty()) sql += " AND timestamp >= ?";
+    if (!end.empty()) sql += " AND timestamp <= ?";
+    sql += " ORDER BY timestamp DESC LIMIT ? OFFSET ?;";
+    sqlite3_stmt* stmt = nullptr;
+    if (sqlite3_prepare_v2(static_cast<sqlite3*>(db_), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
+        return result;
+    int idx = 1;
+    auto bindStr = [&](const std::string& s) {
+        sqlite3_bind_text(stmt, idx++, s.c_str(), -1, SQLITE_TRANSIENT);
+    };
+    if (channel >= 0) sqlite3_bind_int(stmt, idx++, channel);
+    if (!action.empty()) bindStr(action);
+    if (!start.empty()) bindStr(start);
+    if (!end.empty()) bindStr(end);
+    sqlite3_bind_int(stmt, idx++, limit);
+    sqlite3_bind_int(stmt, idx++, offset);
+    while (sqlite3_step(stmt) == SQLITE_ROW) {
+        PoseAlarmRecord r;
+        r.id = sqlite3_column_int64(stmt, 0);
+        auto txt = [&](int c) {
+            auto p = sqlite3_column_text(stmt, c);
+            return p ? std::string(reinterpret_cast<const char*>(p)) : std::string();
+        };
+        r.timestamp = txt(1);
+        r.channel = sqlite3_column_int(stmt, 2);
+        r.action = txt(3);
+        r.score = static_cast<float>(sqlite3_column_double(stmt, 4));
+        r.screenshot_path = txt(5);
+        result.push_back(std::move(r));
+    }
+    sqlite3_finalize(stmt);
+    return result;
+}
+
+int AlarmHistory::getPoseAlarmCount(int channel, const std::string& action,
+                                    const std::string& start,
+                                    const std::string& end) {
+    std::lock_guard<std::mutex> lock(mtx_);
+    int count = 0;
+    std::string sql = "SELECT COUNT(*) FROM pose_alarms WHERE 1=1";
+    if (channel >= 0) sql += " AND channel = ?";
+    if (!action.empty()) sql += " AND action = ?";
+    if (!start.empty()) sql += " AND timestamp >= ?";
+    if (!end.empty()) sql += " AND timestamp <= ?;";
+    sqlite3_stmt* stmt = nullptr;
+    if (sqlite3_prepare_v2(static_cast<sqlite3*>(db_), sql.c_str(), -1, &stmt, nullptr) == SQLITE_OK) {
+        int idx = 1;
+        auto bindStr = [&](const std::string& s) {
+            sqlite3_bind_text(stmt, idx++, s.c_str(), -1, SQLITE_TRANSIENT);
+        };
+        if (channel >= 0) sqlite3_bind_int(stmt, idx++, channel);
+        if (!action.empty()) bindStr(action);
+        if (!start.empty()) bindStr(start);
+        if (!end.empty()) bindStr(end);
+        if (sqlite3_step(stmt) == SQLITE_ROW) count = sqlite3_column_int(stmt, 0);
+    }
+    sqlite3_finalize(stmt);
+    return count;
+}
+
+bool AlarmHistory::deletePoseAlarm(int64_t id) {
+    std::lock_guard<std::mutex> lock(mtx_);
+    std::string screenshot;
+    sqlite3_stmt* stmt = nullptr;
+    const char* sel = "SELECT screenshot_path FROM pose_alarms WHERE id = ?;";
+    if (sqlite3_prepare_v2(static_cast<sqlite3*>(db_), sel, -1, &stmt, nullptr) == SQLITE_OK) {
+        sqlite3_bind_int64(stmt, 1, id);
+        if (sqlite3_step(stmt) == SQLITE_ROW) {
+            auto p = sqlite3_column_text(stmt, 0);
+            if (p) screenshot = reinterpret_cast<const char*>(p);
+        }
+    }
+    sqlite3_finalize(stmt);
+    if (!screenshot.empty() && fs::exists(screenshot)) fs::remove(screenshot);
+    const char* del = "DELETE FROM pose_alarms WHERE id = ?;";
+    sqlite3_prepare_v2(static_cast<sqlite3*>(db_), del, -1, &stmt, nullptr);
+    sqlite3_bind_int64(stmt, 1, id);
+    int rc = sqlite3_step(stmt);
+    sqlite3_finalize(stmt);
+    return rc == SQLITE_DONE;
+}
+
+void AlarmHistory::clearPoseAlarms() {
+    std::lock_guard<std::mutex> lock(mtx_);
+    sqlite3_stmt* stmt = nullptr;
+    const char* sel = "SELECT screenshot_path FROM pose_alarms;";
+    if (sqlite3_prepare_v2(static_cast<sqlite3*>(db_), sel, -1, &stmt, nullptr) == SQLITE_OK) {
+        while (sqlite3_step(stmt) == SQLITE_ROW) {
+            auto p = sqlite3_column_text(stmt, 0);
+            if (p) {
+                std::string path = reinterpret_cast<const char*>(p);
+                if (fs::exists(path)) fs::remove(path);
+            }
+        }
+    }
+    sqlite3_finalize(stmt);
+    sqlite3_exec(static_cast<sqlite3*>(db_), "DELETE FROM pose_alarms;",
+                 nullptr, nullptr, nullptr);
+}
+
 void AlarmHistory::recordAction(const std::string& source,
                                 const std::string& detail,
                                 const std::string& channels) {
@@ -411,4 +586,23 @@ void AlarmHistory::cleanup(int keep_days) {
     sqlite3_bind_text(stmt, 1, cutoff, -1, SQLITE_STATIC);
     sqlite3_step(stmt);
     sqlite3_finalize(stmt);
+
+    // 姿态报警: 先删截图文件, 再删记录
+    const char* selPose = "SELECT screenshot_path FROM pose_alarms WHERE timestamp < ?;";
+    if (sqlite3_prepare_v2(static_cast<sqlite3*>(db_), selPose, -1, &stmt, nullptr) == SQLITE_OK) {
+        sqlite3_bind_text(stmt, 1, cutoff, -1, SQLITE_STATIC);
+        while (sqlite3_step(stmt) == SQLITE_ROW) {
+            auto p = sqlite3_column_text(stmt, 0);
+            if (p) {
+                std::string path = reinterpret_cast<const char*>(p);
+                if (fs::exists(path)) fs::remove(path);
+            }
+        }
+    }
+    sqlite3_finalize(stmt);
+    const char* delPose = "DELETE FROM pose_alarms WHERE timestamp < ?;";
+    sqlite3_prepare_v2(static_cast<sqlite3*>(db_), delPose, -1, &stmt, nullptr);
+    sqlite3_bind_text(stmt, 1, cutoff, -1, SQLITE_STATIC);
+    sqlite3_step(stmt);
+    sqlite3_finalize(stmt);
 }

+ 29 - 0
src/alarm_history.h

@@ -18,6 +18,16 @@ struct AlarmRecord {
     std::string screenshot_path; // relative path like "data/screenshots/1234567890.jpg"
 };
 
+// 姿态报警记录
+struct PoseAlarmRecord {
+    int64_t id;
+    std::string timestamp;
+    int channel;
+    std::string action;         // 跌倒/倒地/举手呼救/异常蹲坐
+    float score;
+    std::string screenshot_path;
+};
+
 struct RelayAction {
     int64_t id;
     std::string timestamp;
@@ -58,6 +68,19 @@ public:
     std::vector<RelayAction> getActions(int limit = 200);
     void clearActions();
 
+    // ---- 姿态报警历史 ----
+    void recordPoseAlarm(int channel, const std::string& action, float score,
+                         const cv::Mat& frame);
+    std::vector<PoseAlarmRecord> getPoseAlarms(int limit = 100, int offset = 0,
+                                               int channel = -1,
+                                               const std::string& action = "",
+                                               const std::string& start = "",
+                                               const std::string& end = "");
+    int getPoseAlarmCount(int channel = -1, const std::string& action = "",
+                          const std::string& start = "", const std::string& end = "");
+    bool deletePoseAlarm(int64_t id);
+    void clearPoseAlarms();
+
     void cleanup(int keep_days = 30);
 
 private:
@@ -67,6 +90,9 @@ private:
                    float confidence, const std::string& rel_path);
     void insertActionRow(const std::string& timestamp, const std::string& source,
                          const std::string& detail, const std::string& channels);
+    void insertPoseRow(const std::string& timestamp, int channel,
+                       const std::string& action, float score,
+                       const std::string& rel_path);
     void workerLoop();
 
     struct Pending {
@@ -77,6 +103,9 @@ private:
         float confidence;
         std::string rel_path;
         cv::Mat frame;
+        int kind = 0;        // 0=围栏报警 1=姿态报警
+        std::string action;  // 姿态动作
+        float score = 0.0f;  // 姿态得分
     };
 
     std::mutex mtx_;

+ 108 - 31
src/main.cpp

@@ -60,12 +60,14 @@ struct ChannelState {
     vector<FenceEvent> events;
     vector<float> rgb_buf;
     vector<PersonPose> poses;              // 姿态识别结果
-    vector<PersonPose> fall_poses;         // 判定为跌倒的人员
+    vector<PersonPose> fall_poses;         // 判定为跌倒/异常姿态的人员
     bool pose_running = false;             // 本通道姿态推理开关
-    int pose_fall_count = 0;               // 连续跌倒帧数(防抖)
-    std::atomic<uint64_t> fall_seq{0};     // 跌倒事件序号(沿触发)
-    bool fall_latched = false;             // 跌倒报警沿触发锁存
+    int pose_fall_count = 0;               // 连续命中帧数(防抖)
+    std::atomic<uint64_t> fall_seq{0};     // 姿态事件序号(沿触发)
+    bool fall_latched = false;             // 姿态报警沿触发锁存
     chrono::steady_clock::time_point fall_latch_ts{};
+    string pose_action;                    // 当前命中的姿态动作
+    float pose_score = 0.0f;               // 当前动作得分
     cv::Mat display_frame;
     cv::Mat fence_fill;              // 围栏填充缓存(纯色图, 与帧内容无关)
     uint64_t fence_ver = 0;          // 对应 FenceEngine 版本
@@ -247,17 +249,18 @@ static void drawPoseDets(cv::Mat& frame, const vector<PersonPose>& poses,
     }
 }
 
-static void drawPoseBanner(cv::Mat& shot, int channel, const PersonPose& fall) {
+static void drawPoseBanner(cv::Mat& shot, int channel, const PersonPose& fall,
+                           const string& action = "跌倒") {
     char tbuf[32];
     time_t t = time(nullptr);
     struct tm tmv;
     localtime_r(&t, &tmv);
     strftime(tbuf, sizeof(tbuf), "%Y-%m-%d %H:%M:%S", &tmv);
 
-    string line1 = "!! FALL !!  CH" + to_string(channel) + "  " + tbuf;
+    string line1 = "!! " + action + " !!  CH" + to_string(channel) + "  " + tbuf;
     char line2[128];
-    snprintf(line2, sizeof(line2), "person %.0f%% 姿态跌倒",
-             fall.confidence * 100);
+    snprintf(line2, sizeof(line2), "person %.0f%% 姿态%s",
+             fall.confidence * 100, action.c_str());
 
     const double fs = 0.45;
     const int thick = 1, lh = 18, pad = 4;
@@ -427,6 +430,13 @@ int main(int argc, char* argv[]) {
     PoseDetector poseDetector;
     FallAnalyzer fallAnalyzer;
     fallAnalyzer.setThreshold(fall_thresh);
+    bool pose_hand_raise = cfg.value("pose_hand_raise", true);
+    bool pose_crouch = cfg.value("pose_crouch", true);
+    fallAnalyzer.setHandRaiseEnabled(pose_hand_raise);
+    fallAnalyzer.setCrouchEnabled(pose_crouch);
+    cout << "[Pose] 动作类型: 跌倒/倒地(固定)"
+         << " 举手呼救=" << (pose_hand_raise ? "开" : "关")
+         << " 异常蹲坐=" << (pose_crouch ? "开" : "关") << endl;
     bool pose_ok = false;
     if (pose_enabled) {
         if (poseDetector.load(pose_model_path, pose_input_size,
@@ -1451,6 +1461,61 @@ int main(int argc, char* argv[]) {
         return r.dump();
     });
 
+    // ========== 姿态报警历史 API ==========
+    web.setGetHandlerQ("/api/pose/alarms", [&](const map<string, string>& q) -> string {
+        auto geti = [&](const string& k, int def) -> int {
+            auto it = q.find(k);
+            if (it == q.end() || it->second.empty()) return def;
+            return atoi(it->second.c_str());
+        };
+        auto gets = [&](const string& k) -> string {
+            auto it = q.find(k);
+            return it == q.end() ? string() : it->second;
+        };
+        int limit = clamp(geti("limit", 200), 1, 500);
+        int ch = geti("channel", -1);
+        string action = gets("action");
+        string start = gets("start");
+        string end = gets("end");
+
+        json arr = json::array();
+        auto list = alarmHistory.getPoseAlarms(limit, 0, ch, action, start, end);
+        for (auto& a : list) {
+            json aj;
+            aj["id"] = a.id;
+            aj["timestamp"] = a.timestamp;
+            aj["channel"] = a.channel;
+            aj["action"] = a.action;
+            aj["score"] = a.score;
+            aj["screenshot"] = a.screenshot_path;
+            arr.push_back(aj);
+        }
+        json r;
+        r["total"] = alarmHistory.getPoseAlarmCount(ch, action, start, end);
+        r["items"] = arr;
+        return r.dump();
+    });
+
+    web.setPostHandler("/api/pose/alarms/delete", [&](const string& body) -> string {
+        try {
+            json j = json::parse(body);
+            int64_t id = j.value("id", 0);
+            if (alarmHistory.deletePoseAlarm(id)) return R"({"ok":true})";
+        } catch (...) {}
+        return R"({"ok":false})";
+    });
+
+    web.setPostHandler("/api/pose/alarms/clear", [&](const string&) -> string {
+        alarmHistory.clearPoseAlarms();
+        return R"({"ok":true})";
+    });
+
+    web.setGetHandler("/api/pose/alarms/count", [&]() -> string {
+        json r;
+        r["count"] = alarmHistory.getPoseAlarmCount();
+        return r.dump();
+    });
+
     // 通道帧提供器已在创建时逐路注册(registerChannelProviders)
 
     // ========== 姿态识别 Web API ==========
@@ -1787,35 +1852,46 @@ int main(int argc, char* argv[]) {
                 // ========== 姿态识别(跌倒检测) ==========
                 if (pose_ok && ch->pose_running) {
                     vector<PersonPose> falls;
+                    // 对每个人做多动作分析, 取最高分动作
+                    PoseAction bestAction = PoseAction::NONE;
+                    float bestScore = 0.0f;
                     for (auto& p : poses) {
                         if (pose_alarm_classes.empty() ||
                             find(pose_alarm_classes.begin(),
                                  pose_alarm_classes.end(),
-                                 "person") != pose_alarm_classes.end()) {
-                            if (fallAnalyzer.isFall(p)) falls.push_back(p);
+                                 "person") == pose_alarm_classes.end())
+                            continue;
+                        auto ar = fallAnalyzer.analyze(p);
+                        if (ar.action != PoseAction::NONE &&
+                            ar.action != PoseAction::LYING &&
+                            ar.score > bestScore) {
+                            bestAction = ar.action;
+                            bestScore = ar.score;
                         }
                     }
-                    // 连续 N 帧确认跌倒(防抖), 并按上升沿触发动作
-                    if (!falls.empty()) {
-                        ch->pose_fall_count++;
-                    } else {
-                        ch->pose_fall_count = 0;
-                    }
-                    bool fall_rising = (ch->pose_fall_count == fall_frames);
+                    bool hit = (bestAction != PoseAction::NONE);
+                    // 连续 N 帧确认(防抖), 并按上升沿触发动作
+                    if (hit) ch->pose_fall_count++;
+                    else ch->pose_fall_count = 0;
+                    bool pose_rising = (ch->pose_fall_count == fall_frames);
                     {
                         lock_guard<mutex> lk(ch->mtx);
                         ch->poses = poses;
-                        ch->fall_poses = falls;
+                        ch->fall_poses = hit ? poses : vector<PersonPose>{};
+                        ch->pose_action = hit ? poseActionName(bestAction) : "";
+                        ch->pose_score = bestScore;
                     }
-                    if (fall_rising) {
+                    if (pose_rising) {
                         ch->fall_seq.fetch_add(1, std::memory_order_relaxed);
-                        cout << "[Pose] CH" << ch->id << " 检测到跌倒("
+                        cout << "[Pose] CH" << ch->id << " 检测到"
+                             << poseActionName(bestAction) << "("
                              << ch->pose_fall_count << "帧) score="
-                             << fallAnalyzer.score(falls[0]) << endl;
+                             << bestScore << endl;
                     }
-                    if (fall_rising && !ch->fall_latched) {
+                    if (pose_rising && !ch->fall_latched) {
                         ch->fall_latched = true;
                         ch->fall_latch_ts = chrono::steady_clock::now();
+                        string actName = poseActionName(bestAction);
 
                         // 联动报警: 触发继电器(可配置 fall_relays)
                         if (!fall_relays.empty()) {
@@ -1826,11 +1902,11 @@ int main(int argc, char* argv[]) {
                                 chans += t;
                             }
                             alarmHistory.recordAction("姿态报警",
-                                "CH" + to_string(ch->id) + " 检测到人员跌倒",
+                                "CH" + to_string(ch->id) + " 检测到" + actName,
                                 chans);
                         }
 
-                        // 截图记录到报警历史
+                        // 截图记录到姿态报警历史
                         std::vector<uint8_t> frgb;
                         int fout_w, fout_h;
                         ch->rga.processToRgb(nv12, frgb, fout_w, fout_h);
@@ -1839,15 +1915,16 @@ int main(int argc, char* argv[]) {
                             cv::cvtColor(fshot, fshot, cv::COLOR_RGB2BGR);
                             float fs = (nv12.width > 0)
                                        ? (float)fshot.cols / nv12.width : 1.0f;
-                            drawPoseDets(fshot, falls, falls, fs);
-                            drawPoseBanner(fshot, ch->id, falls[0]);
-                            alarmHistory.record(ch->id, "姿态报警",
-                                                "person", falls[0].confidence,
-                                                fshot);
+                            drawPoseDets(fshot, poses, poses, fs);
+                            drawPoseBanner(fshot, ch->id,
+                                           poses.empty() ? PersonPose() : poses[0],
+                                           actName);
+                            alarmHistory.recordPoseAlarm(ch->id, actName,
+                                                         bestScore, fshot);
                         }
                     }
-                    // 跌倒消失(或恢复站立)持续 rearm_sec 秒后重新武装
-                    if (ch->fall_latched && falls.empty()) {
+                    // 动作消失(或恢复正常)持续 rearm_sec 秒后重新武装
+                    if (ch->fall_latched && !hit) {
                         auto fnow = chrono::steady_clock::now();
                         if (chrono::duration_cast<chrono::seconds>(
                                 fnow - ch->fall_latch_ts).count() >= rearm_sec)

+ 88 - 0
src/pose_detector.cpp

@@ -325,3 +325,91 @@ float FallAnalyzer::score(const PersonPose& p) const {
                   0.10f * f_head_low + 0.10f * f_vspread + 0.10f * f_hip_low;
     return max(0.0f, min(1.0f, score));
 }
+
+const char* poseActionName(PoseAction a) {
+    switch (a) {
+        case PoseAction::FALL:       return "跌倒";
+        case PoseAction::LYING:      return "倒地";
+        case PoseAction::HAND_RAISE: return "举手呼救";
+        case PoseAction::CROUCH:     return "异常蹲坐";
+        default:                     return "无";
+    }
+}
+
+PoseActionResult FallAnalyzer::analyze(const PersonPose& p) const {
+    PoseActionResult r;
+    if (p.keypoints.size() < 17 || p.keypoint_conf.size() < 17) return r;
+
+    auto vis = [&](int i, float th = 0.3f) {
+        return i >= 0 && i < 17 && p.keypoint_conf[i] > th;
+    };
+    const auto& b = p.bbox;
+    float bw = (float)b.width, bh = (float)b.height;
+    if (bw < 1.0f || bh < 1.0f) return r;
+
+    // ===== 1. 跌倒/倒地 =====
+    float fall = score(p);
+    if (fall >= thr_) {
+        r.action = PoseAction::FALL;
+        r.score = fall;
+        return r;
+    }
+    // 倒地: 宽高比明显横向 + 躯干接近水平(阈值放宽)
+    float aspect = bw / bh;
+    if (aspect > 1.2f) {
+        r.action = PoseAction::LYING;
+        r.score = min(1.0f, (aspect - 0.8f) / 1.5f);
+        return r;
+    }
+
+    // 关键点: 5=左肩 6=右肩 9=左手腕 10=右手腕 11=左髋 12=右髋 13/14=膝 15/16=踝
+    float sh_y = 0;
+    bool has_sh = false;
+    if (vis(5) || vis(6)) {
+        float s = 0; int n = 0;
+        if (vis(5)) { s += p.keypoints[5].y; n++; }
+        if (vis(6)) { s += p.keypoints[6].y; n++; }
+        sh_y = s / n; has_sh = true;
+    }
+
+    // ===== 2. 举手呼救: 手腕高于肩部 (y 更小) =====
+    if (hand_raise_en_ && has_sh) {
+        float best = 0.0f;
+        for (int i = 0; i < 2; i++) {
+            int w = (i == 0) ? 9 : 10;
+            if (!vis(w)) continue;
+            float dy = sh_y - p.keypoints[w].y;   // 正值=手腕高于肩
+            if (dy > 0) {
+                float rel = dy / max(1.0f, (float)b.height);
+                best = max(best, min(1.0f, rel / 0.15f));
+            }
+        }
+        if (best >= 0.6f) {
+            r.action = PoseAction::HAND_RAISE;
+            r.score = best;
+            return r;
+        }
+    }
+
+    // ===== 3. 异常蹲坐: 髋部大幅下降接近膝部, 且躯干仍竖直 =====
+    if (crouch_en_ && has_sh && vis(11) && vis(12)) {
+        float hip_y = (p.keypoints[11].y + p.keypoints[12].y) * 0.5f;
+        float knee_y = 1e9f;
+        if (vis(13)) knee_y = min(knee_y, p.keypoints[13].y);
+        if (vis(14)) knee_y = min(knee_y, p.keypoints[14].y);
+        // 站立时 肩->髋 距离占身体高度约 35-45%; 蹲下时髋接近膝
+        float body = max(1.0f, (float)b.height);
+        float hip_to_knee = (knee_y < 1e8f) ? fabsf(knee_y - hip_y) / body : 1.0f;
+        float torso = fabsf(hip_y - sh_y) / body;   // 躯干竖直长度比例
+        // 蹲坐: 髋-膝距离很小 且 躯干占比例较短(整体压缩)
+        float bbox_ratio = bh / body;   // 恒为1, 占位
+        (void)bbox_ratio;
+        if (hip_to_knee < 0.12f && torso < 0.32f && torso > 0.10f) {
+            r.action = PoseAction::CROUCH;
+            r.score = min(1.0f, (0.12f - hip_to_knee) / 0.12f * 0.6f + 0.4f);
+            return r;
+        }
+    }
+
+    return r;
+}

+ 28 - 1
src/pose_detector.h

@@ -40,7 +40,23 @@ private:
     static void nms(std::vector<PersonPose>& poses, float threshold);
 };
 
-// 跌倒分析: 基于关键点 + 包围盒的启发式判定
+// 姿态报警动作类型
+enum class PoseAction {
+    NONE = 0,
+    FALL,        // 跌倒
+    LYING,       // 倒地/躺卧
+    HAND_RAISE,  // 举手呼救
+    CROUCH,      // 异常蹲坐
+};
+
+const char* poseActionName(PoseAction a);
+
+struct PoseActionResult {
+    PoseAction action = PoseAction::NONE;
+    float score = 0.0f;      // 该动作的置信得分 [0,1]
+};
+
+// 姿态分析: 基于关键点 + 包围盒的启发式判定
 class FallAnalyzer {
 public:
     FallAnalyzer() = default;
@@ -51,6 +67,17 @@ public:
     float score(const PersonPose& p) const;
     bool isFall(const PersonPose& p) const { return score(p) >= thr_; }
 
+    // 多动作分析: 返回最匹配的动作及得分
+    PoseActionResult analyze(const PersonPose& p) const;
+
+    // 开关
+    void setHandRaiseEnabled(bool e) { hand_raise_en_ = e; }
+    void setCrouchEnabled(bool e) { crouch_en_ = e; }
+    bool handRaiseEnabled() const { return hand_raise_en_; }
+    bool crouchEnabled() const { return crouch_en_; }
+
 private:
     float thr_ = 0.5f;
+    bool hand_raise_en_ = true;
+    bool crouch_en_ = true;
 };

+ 31 - 0
src/web_server.cpp

@@ -452,6 +452,37 @@ public:
             }
         });
 
+        svr.Get("/api/pose/alarms", [this](const httplib::Request& req, httplib::Response& res) {
+            if (!authed(req)) { denyJson(res); return; }
+            auto it = getq_handlers.find("/api/pose/alarms");
+            if (it != getq_handlers.end()) {
+                std::map<std::string, std::string> ps;
+                for (auto& p : req.params) ps[p.first] = p.second;
+                res.set_content(it->second(ps), "application/json");
+            }
+        });
+
+        svr.Post("/api/pose/alarms/delete", [this](const httplib::Request& req, httplib::Response& res) {
+            if (!authed(req)) { denyJson(res); return; }
+            auto it = post_handlers.find("/api/pose/alarms/delete");
+            if (it != post_handlers.end())
+                res.set_content(it->second(req.body), "application/json");
+        });
+
+        svr.Post("/api/pose/alarms/clear", [this](const httplib::Request& req, httplib::Response& res) {
+            if (!authed(req)) { denyJson(res); return; }
+            auto it = post_handlers.find("/api/pose/alarms/clear");
+            if (it != post_handlers.end())
+                res.set_content(it->second(req.body), "application/json");
+        });
+
+        svr.Get("/api/pose/alarms/count", [this](const httplib::Request& req, httplib::Response& res) {
+            if (!authed(req)) { denyJson(res); return; }
+            auto it = get_handlers.find("/api/pose/alarms/count");
+            if (it != get_handlers.end())
+                res.set_content(it->second(), "application/json");
+        });
+
         svr.Get("/api/relay/aliases", [this](const httplib::Request& req, httplib::Response& res) {
             if (!authed(req)) { denyJson(res); return; }
             auto it = get_handlers.find("/api/relay/aliases");

+ 120 - 5
web/index.html

@@ -150,6 +150,7 @@ border-left:3px solid #e94560;padding:8px 10px;margin-bottom:8px;cursor:pointer}
       <div class="nav-item active" data-page="monitor" onclick="switchPage('monitor')"><span class="ico">监</span><span class="nav-label">实时监控</span></div>
       <div class="nav-item" data-page="fence" onclick="switchPage('fence')"><span class="ico">围</span><span class="nav-label">电子围栏</span></div>
       <div class="nav-item" data-page="alarm" onclick="switchPage('alarm')"><span class="ico">警</span><span class="nav-label">报警历史</span></div>
+      <div class="nav-item" data-page="posealarm" onclick="switchPage('posealarm')"><span class="ico">姿</span><span class="nav-label">姿态报警</span></div>
       <div class="nav-item" data-page="action" onclick="switchPage('action')"><span class="ico">动</span><span class="nav-label">动作记录</span></div>
       <div class="nav-item" data-page="relay" onclick="switchPage('relay')"><span class="ico">继</span><span class="nav-label">继电器控制</span></div>
       <div class="nav-item" data-page="channel" onclick="switchPage('channel')"><span class="ico">通</span><span class="nav-label">通道管理</span></div>
@@ -217,6 +218,36 @@ border-left:3px solid #e94560;padding:8px 10px;margin-bottom:8px;cursor:pointer}
       </div>
     </section>
 
+    <section id="page-posealarm" class="page pad">
+      <div class="card">
+        <div class="alarm-head">
+          <h3>姿态报警记录 <span id="poseAlarmCount" style="font-size:12px;color:#888"></span></h3>
+          <button class="btn btn-danger" style="padding:5px 14px" onclick="clearPoseAlarms()">清空全部</button>
+        </div>
+        <div class="filter-bar">
+          <div class="fitem"><label>摄像头</label>
+            <select id="pfltCh" onchange="loadPoseAlarms()"><option value="">全部</option></select></div>
+          <div class="fitem"><label>动作类型</label>
+            <select id="pfltAction" onchange="loadPoseAlarms()">
+              <option value="">全部</option>
+              <option value="跌倒">跌倒</option>
+              <option value="举手呼救">举手呼救</option>
+              <option value="异常蹲坐">异常蹲坐</option>
+            </select></div>
+          <div class="fitem"><label>开始时间</label>
+            <input type="datetime-local" id="pfltStart"></div>
+          <div class="fitem"><label>结束时间</label>
+            <input type="datetime-local" id="pfltEnd"></div>
+          <div class="fitem"><label>&nbsp;</label>
+            <button class="btn btn-primary" onclick="loadPoseAlarms()">查询</button></div>
+          <div class="fitem"><label>&nbsp;</label>
+            <button class="btn btn-outline" onclick="resetPoseFilters()">重置</button></div>
+        </div>
+        <div id="poseAlarmList"></div>
+        <div id="poseAlarmEmpty" class="empty-tip" style="height:200px;display:none">暂无姿态报警记录</div>
+      </div>
+    </section>
+
     <section id="page-action" class="page pad">
       <div class="card">
         <div class="alarm-head">
@@ -430,6 +461,7 @@ function switchPage(p) {
     stage.style.display = 'none';
   }
   if (p === 'alarm') loadAlarms();
+  if (p === 'posealarm') loadPoseAlarms();
   if (p === 'action') loadActions();
   if (p === 'relay') { loadRelayStatus(); loadBindings(); }
   if (p === 'channel') renderChannelList();
@@ -868,11 +900,19 @@ async function saveFences() {
 
 function syncAlarmFilterOptions() {
   const sel = document.getElementById('fltCh');
-  if (!sel) return;
-  const cur = sel.value;
-  sel.innerHTML = '<option value="">全部</option>' +
-    CHANNELS.map(c => `<option value="${c.id}">CH${c.id} ${c.name}</option>`).join('');
-  sel.value = [...sel.options].some(o => o.value === cur) ? cur : '';
+  if (sel) {
+    const cur = sel.value;
+    sel.innerHTML = '<option value="">全部</option>' +
+      CHANNELS.map(c => `<option value="${c.id}">CH${c.id} ${c.name}</option>`).join('');
+    sel.value = [...sel.options].some(o => o.value === cur) ? cur : '';
+  }
+  const psel = document.getElementById('pfltCh');
+  if (psel) {
+    const cur = psel.value;
+    psel.innerHTML = '<option value="">全部</option>' +
+      CHANNELS.map(c => `<option value="${c.id}">CH${c.id} ${c.name}</option>`).join('');
+    psel.value = [...psel.options].some(o => o.value === cur) ? cur : '';
+  }
 }
 
 /* ================= 报警历史 ================= */
@@ -934,6 +974,80 @@ async function clearAlarms() {
   try { await fetch('/api/alarms/clear', {method:'POST'}); loadAlarms(); } catch (e) {}
 }
 
+/* ================= 姿态报警历史 ================= */
+function poseAlarmQuery() {
+  const qs = new URLSearchParams();
+  const ch = document.getElementById('pfltCh').value;
+  if (ch !== '') qs.set('channel', ch);
+  const act = document.getElementById('pfltAction').value;
+  if (act) qs.set('action', act);
+  const st = document.getElementById('pfltStart').value.replace('T', ' ');
+  const en = document.getElementById('pfltEnd').value.replace('T', ' ');
+  if (st) qs.set('start', st + ':00');
+  if (en) qs.set('end', en + ':59');
+  return qs.toString();
+}
+function resetPoseFilters() {
+  document.getElementById('pfltCh').value = '';
+  document.getElementById('pfltAction').value = '';
+  document.getElementById('pfltStart').value = '';
+  document.getElementById('pfltEnd').value = '';
+  loadPoseAlarms();
+}
+function poseActionColor(a) {
+  if (a === '跌倒') return '#e94560';
+  if (a === '举手呼救') return '#f39c12';
+  if (a === '异常蹲坐') return '#3498db';
+  return '#8892a8';
+}
+async function loadPoseAlarms() {
+  try {
+    const qs = poseAlarmQuery();
+    const r = await fetch('/api/pose/alarms' + (qs ? '?' + qs : ''));
+    if (r.status === 401) { location.href = '/login'; return; }
+    const d = await r.json();
+    document.getElementById('poseAlarmCount').textContent =
+      d.total + '条' + (qs ? '(筛选后)' : '');
+    const list = document.getElementById('poseAlarmList');
+    const empty = document.getElementById('poseAlarmEmpty');
+    if (!d.items.length) { list.innerHTML = ''; empty.style.display = 'flex'; return; }
+    empty.style.display = 'none';
+    list.innerHTML = d.items.map(a => {
+      const c = chById(a.channel);
+      return `
+      <div class="alist-row" style="border-left-color:${poseActionColor(a.action)}"
+           onclick="showPoseAlarm('${a.screenshot}','${a.timestamp}',${a.channel},'${a.action}',${a.score},${a.id})">
+        <img src="/${a.screenshot}" loading="lazy" alt="">
+        <div class="col-t">${a.timestamp}</div>
+        <div class="col-ch">CH${a.channel}<div class="nm">${c ? c.name : ''}</div></div>
+        <div class="col-f" style="color:${poseActionColor(a.action)}">${a.action}</div>
+        <div class="col-conf">${(a.score*100).toFixed(0)}%</div>
+        <button class="btn btn-outline" style="padding:3px 10px;font-size:12px"
+                onclick="event.stopPropagation();delPoseAlarm(${a.id})">删除</button>
+      </div>`;
+    }).join('');
+  } catch (e) {}
+}
+function showPoseAlarm(screenshot, time, ch, action, score, id) {
+  document.getElementById('alarmScreenshot').src = '/' + screenshot;
+  const c = chById(ch);
+  document.getElementById('alarmMeta').innerHTML =
+    `时间: ${time}<br>通道: CH${ch} ${c ? c.name : ''}<br>动作: <span style="color:${poseActionColor(action)}">${action}</span><br>得分: ${(score*100).toFixed(1)}%`;
+  document.getElementById('alarmModal').classList.add('show');
+}
+async function delPoseAlarm(id) {
+  if (!confirm('删除此条姿态报警?')) return;
+  try {
+    await fetch('/api/pose/alarms/delete', {method:'POST',
+      headers:{'Content-Type':'application/json'}, body:JSON.stringify({id})});
+    loadPoseAlarms();
+  } catch (e) {}
+}
+async function clearPoseAlarms() {
+  if (!confirm('清空所有姿态报警记录?')) return;
+  try { await fetch('/api/pose/alarms/clear', {method:'POST'}); loadPoseAlarms(); } catch (e) {}
+}
+
 /* ================= 继电器动作记录 ================= */
 async function loadActions() {
   try {
@@ -1433,6 +1547,7 @@ loadRelayStatus();
 loadBindings();
 setInterval(loadStatus, 5000);
 setInterval(() => { if (curPage === 'alarm') loadAlarms(); }, 10000);
+setInterval(() => { if (curPage === 'posealarm') loadPoseAlarms(); }, 10000);
 setInterval(loadRelayStatus, 1000);
 </script>
 </body>