feat: initialize managed portal
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
node_modules/
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
dist/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
coverage/
|
||||||
|
*.log
|
||||||
|
managed-portal
|
||||||
37
Dockerfile
Normal file
37
Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/golang:1.25.4-alpine AS builder
|
||||||
|
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY go.mod ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||||
|
-a -installsuffix cgo \
|
||||||
|
-o managed-portal \
|
||||||
|
./cmd/managed-portal
|
||||||
|
|
||||||
|
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/alpine:3.23
|
||||||
|
|
||||||
|
ENV TZ=Asia/Shanghai \
|
||||||
|
MANAGED_PORTAL_HTTP_ADDR=:8080 \
|
||||||
|
MANAGED_PORTAL_REGISTRY_PATH=/app/managed_services.yaml
|
||||||
|
|
||||||
|
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
|
||||||
|
apk add --no-cache docker-cli ca-certificates tzdata && \
|
||||||
|
rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /build/managed-portal /app/managed-portal
|
||||||
|
COPY managed_services.yaml /app/managed_services.yaml
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["/app/managed-portal"]
|
||||||
60
README.md
Normal file
60
README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Managed Portal
|
||||||
|
|
||||||
|
Standalone management portal for:
|
||||||
|
|
||||||
|
- managed child services
|
||||||
|
- web device scanning and proxy access
|
||||||
|
- copied child service source under `managed/`
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
This repository is being split out from `video_recognition_local`.
|
||||||
|
|
||||||
|
Current target shape:
|
||||||
|
|
||||||
|
- backend: Go service on `:8080`
|
||||||
|
- frontend: Vue 3 + Element Plus
|
||||||
|
- public entry: `:13000`
|
||||||
|
|
||||||
|
## Planned local commands
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
go run ./cmd/managed-portal
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy
|
||||||
|
docker compose --env-file managed-portal.env up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Child services
|
||||||
|
|
||||||
|
The child service source is part of this repository:
|
||||||
|
|
||||||
|
- `managed/store_dwell_alert`
|
||||||
|
- `managed/people_flow_project`
|
||||||
|
|
||||||
|
The default Compose stack builds and starts both child services alongside the portal.
|
||||||
|
|
||||||
|
Before building the child service images, provide the runtime weights expected by
|
||||||
|
their Dockerfiles:
|
||||||
|
|
||||||
|
- `managed/store_dwell_alert/weights/yolo11n.pt`
|
||||||
|
- `managed/people_flow_project/weights/yolo11n.pt`
|
||||||
|
- `managed/people_flow_project/weights/deepface/age_model_weights.h5`
|
||||||
|
- `managed/people_flow_project/weights/deepface/gender_model_weights.h5`
|
||||||
|
- `managed/people_flow_project/weights/deepface/retinaface.h5`
|
||||||
82
deploy/docker-compose.yml
Normal file
82
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
services:
|
||||||
|
managed-portal:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VERSION: ${IMAGE_VERSION:-dev}
|
||||||
|
image: managed-portal:${IMAGE_VERSION:-dev}
|
||||||
|
container_name: managed-portal
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- store-dwell-alert
|
||||||
|
- people-flow-project
|
||||||
|
environment:
|
||||||
|
TZ: ${TZ:-Asia/Shanghai}
|
||||||
|
MANAGED_PORTAL_HTTP_ADDR: ":8080"
|
||||||
|
MANAGED_PORTAL_REGISTRY_PATH: "/app/managed_services.yaml"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
networks:
|
||||||
|
- managed-portal
|
||||||
|
|
||||||
|
store-dwell-alert:
|
||||||
|
build:
|
||||||
|
context: ../managed/store_dwell_alert
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: store-dwell-alert:${IMAGE_VERSION:-dev}
|
||||||
|
container_name: store-dwell-alert
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
TZ: ${TZ:-Asia/Shanghai}
|
||||||
|
CAMERA_ID: ${MANAGED_STORE_DWELL_CAMERA_ID:-store_cam_01}
|
||||||
|
RTSP_URL: ${MANAGED_STORE_DWELL_RTSP_URL:-}
|
||||||
|
EVENT_SINK_PATH: ${MANAGED_STORE_DWELL_EVENT_SINK_PATH:-logs/events.jsonl}
|
||||||
|
API_HOST: 0.0.0.0
|
||||||
|
API_PORT: 18081
|
||||||
|
CONFIG_PATH: /app/config/local.yaml
|
||||||
|
volumes:
|
||||||
|
- ${MANAGED_STORE_DWELL_CONFIG_DIR:-../managed/store_dwell_alert/config}:/app/config
|
||||||
|
- ${MANAGED_STORE_DWELL_DATA_DIR:-../managed/store_dwell_alert/data}:/app/data
|
||||||
|
networks:
|
||||||
|
- managed-portal
|
||||||
|
|
||||||
|
people-flow-project:
|
||||||
|
build:
|
||||||
|
context: ../managed/people_flow_project
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: people-flow-project:${IMAGE_VERSION:-dev}
|
||||||
|
container_name: people-flow-project
|
||||||
|
restart: unless-stopped
|
||||||
|
gpus: all
|
||||||
|
shm_size: "1gb"
|
||||||
|
environment:
|
||||||
|
TZ: ${TZ:-Asia/Shanghai}
|
||||||
|
CONFIG_PATH: /opt/people-flow/config/local.yaml
|
||||||
|
RTSP_URL: ${MANAGED_PEOPLE_FLOW_RTSP_URL:-}
|
||||||
|
OUTPUT_DIR: /opt/people-flow/outputs
|
||||||
|
API_HOST: 0.0.0.0
|
||||||
|
API_PORT: 18082
|
||||||
|
volumes:
|
||||||
|
- ${MANAGED_PEOPLE_FLOW_CONFIG_DIR:-../managed/people_flow_project/config}:/opt/people-flow/config
|
||||||
|
- ${MANAGED_PEOPLE_FLOW_OUTPUT_DIR:-../managed/people_flow_project/outputs}:/opt/people-flow/outputs
|
||||||
|
networks:
|
||||||
|
- managed-portal
|
||||||
|
|
||||||
|
managed-portal-web:
|
||||||
|
build:
|
||||||
|
context: ../web
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: managed-portal-web:${IMAGE_VERSION:-dev}
|
||||||
|
container_name: managed-portal-web
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- managed-portal
|
||||||
|
ports:
|
||||||
|
- "${MANAGED_PORTAL_WEB_PORT:-13000}:80"
|
||||||
|
networks:
|
||||||
|
- managed-portal
|
||||||
|
|
||||||
|
networks:
|
||||||
|
managed-portal:
|
||||||
|
driver: bridge
|
||||||
13
deploy/managed-portal.env
Normal file
13
deploy/managed-portal.env
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
IMAGE_VERSION=dev
|
||||||
|
TZ=Asia/Shanghai
|
||||||
|
MANAGED_PORTAL_WEB_PORT=13000
|
||||||
|
|
||||||
|
MANAGED_STORE_DWELL_CAMERA_ID=store_cam_01
|
||||||
|
MANAGED_STORE_DWELL_RTSP_URL=
|
||||||
|
MANAGED_STORE_DWELL_EVENT_SINK_PATH=logs/events.jsonl
|
||||||
|
MANAGED_STORE_DWELL_CONFIG_DIR=../managed/store_dwell_alert/config
|
||||||
|
MANAGED_STORE_DWELL_DATA_DIR=../managed/store_dwell_alert/data
|
||||||
|
|
||||||
|
MANAGED_PEOPLE_FLOW_RTSP_URL=
|
||||||
|
MANAGED_PEOPLE_FLOW_CONFIG_DIR=../managed/people_flow_project/config
|
||||||
|
MANAGED_PEOPLE_FLOW_OUTPUT_DIR=../managed/people_flow_project/outputs
|
||||||
59
docs/rollout-10.8.0.14.md
Normal file
59
docs/rollout-10.8.0.14.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# `10.8.0.14` Rollout Notes
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Deploy the standalone `managed-portal` project to `10.8.0.14` and expose it on port `13000` without changing the existing `3000` stack.
|
||||||
|
|
||||||
|
## Expected Target Layout
|
||||||
|
|
||||||
|
- project root: to be finalized after local verification
|
||||||
|
- backend container: `managed-portal`
|
||||||
|
- frontend container: `managed-portal-web`
|
||||||
|
- public entry: `http://10.8.0.14:13000/`
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
- Docker daemon is running on `10.8.0.14`
|
||||||
|
- child service runtime weights have been placed under `managed/*/weights`
|
||||||
|
- the host can access the target LAN segments used by web-device scanning
|
||||||
|
- `/var/run/docker.sock` is available to the backend container
|
||||||
|
|
||||||
|
## Planned Steps
|
||||||
|
|
||||||
|
1. Copy the local `managed-portal` repo to `10.8.0.14`
|
||||||
|
2. Review and adjust `managed_services.yaml` for the remote host
|
||||||
|
3. Review `deploy/managed-portal.env`
|
||||||
|
4. Build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t managed-portal:dev .
|
||||||
|
docker build -t managed-portal-web:dev -f web/Dockerfile ./web
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy
|
||||||
|
docker compose --env-file managed-portal.env up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Verify:
|
||||||
|
|
||||||
|
- `http://10.8.0.14:13000/` opens
|
||||||
|
- managed-services list works
|
||||||
|
- managed-services detail works
|
||||||
|
- RTSP update works
|
||||||
|
- restart works
|
||||||
|
- web-device scan works
|
||||||
|
- existing `3000` stack remains available
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
If the new portal misbehaves, stop only the new stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy
|
||||||
|
docker compose --env-file managed-portal.env down
|
||||||
|
```
|
||||||
|
|
||||||
|
This rollback must leave the existing `3000` stack untouched.
|
||||||
44
go.mod
Normal file
44
go.mod
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
module managed-portal
|
||||||
|
|
||||||
|
go 1.25.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-contrib/cors v1.7.6
|
||||||
|
github.com/gin-gonic/gin v1.11.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
|
golang.org/x/crypto v0.40.0 // indirect
|
||||||
|
golang.org/x/mod v0.25.0 // indirect
|
||||||
|
golang.org/x/net v0.42.0 // indirect
|
||||||
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
|
golang.org/x/text v0.27.0 // indirect
|
||||||
|
golang.org/x/tools v0.34.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
|
)
|
||||||
100
go.sum
Normal file
100
go.sum
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||||
|
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||||
|
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||||
|
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||||
|
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
|
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
|
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||||
|
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||||
|
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||||
|
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||||
|
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
|
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
|
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||||
|
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||||
|
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||||
|
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||||
|
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||||
|
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||||
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||||
|
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||||
|
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||||
|
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||||
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
29
internal/config/config.go
Normal file
29
internal/config/config.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
HTTPAddr string
|
||||||
|
WebDistDir string
|
||||||
|
RegistryPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() *Config {
|
||||||
|
cfg := &Config{
|
||||||
|
HTTPAddr: ":8080",
|
||||||
|
WebDistDir: "web/dist",
|
||||||
|
RegistryPath: "managed_services.yaml",
|
||||||
|
}
|
||||||
|
|
||||||
|
if value := os.Getenv("MANAGED_PORTAL_HTTP_ADDR"); value != "" {
|
||||||
|
cfg.HTTPAddr = value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("MANAGED_PORTAL_WEB_DIST_DIR"); value != "" {
|
||||||
|
cfg.WebDistDir = value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("MANAGED_PORTAL_REGISTRY_PATH"); value != "" {
|
||||||
|
cfg.RegistryPath = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
98
internal/managed/docker_runtime.go
Normal file
98
internal/managed/docker_runtime.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package managed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DockerRuntime interface {
|
||||||
|
GetContainerStatus(containerName string) (string, error)
|
||||||
|
RestartContainer(containerName string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommandRunner func(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||||
|
|
||||||
|
type DockerController struct {
|
||||||
|
runner CommandRunner
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerController() *DockerController {
|
||||||
|
return &DockerController{
|
||||||
|
runner: func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
return exec.CommandContext(ctx, name, args...).CombinedOutput()
|
||||||
|
},
|
||||||
|
timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerControllerWithRunner(runner CommandRunner) *DockerController {
|
||||||
|
return &DockerController{
|
||||||
|
runner: runner,
|
||||||
|
timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeContainerStatus(raw string) string {
|
||||||
|
switch strings.TrimSpace(strings.ToLower(raw)) {
|
||||||
|
case "running":
|
||||||
|
return "running"
|
||||||
|
case "created", "exited", "paused":
|
||||||
|
return "stopped"
|
||||||
|
case "dead", "removing", "restarting":
|
||||||
|
return "failed"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DockerController) GetContainerStatus(containerName string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
output, err := c.runner(ctx, "docker", "inspect", "--format", "{{.State.Status}}", containerName)
|
||||||
|
status := NormalizeContainerStatus(string(output))
|
||||||
|
if status != "unknown" {
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return status, fmt.Errorf("docker inspect %s: %w", containerName, err)
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DockerController) RestartContainer(containerName string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
output, err := c.runner(ctx, "docker", "restart", containerName)
|
||||||
|
if err != nil {
|
||||||
|
trimmed := strings.TrimSpace(string(output))
|
||||||
|
if trimmed != "" {
|
||||||
|
return fmt.Errorf("docker restart %s: %w: %s", containerName, err, trimmed)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("docker restart %s: %w", containerName, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsDockerUnavailable(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var execErr *exec.Error
|
||||||
|
if errors.As(err, &execErr) {
|
||||||
|
return execErr.Err == exec.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
message := err.Error()
|
||||||
|
return errors.Is(err, exec.ErrNotFound) ||
|
||||||
|
strings.Contains(message, `exec: "docker": executable file not found`) ||
|
||||||
|
strings.Contains(message, "failed to connect to the docker API") ||
|
||||||
|
strings.Contains(message, "docker.sock")
|
||||||
|
}
|
||||||
88
internal/managed/docker_runtime_test.go
Normal file
88
internal/managed/docker_runtime_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package managed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeContainerStatus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := map[string]string{
|
||||||
|
"running": "running",
|
||||||
|
"created": "stopped",
|
||||||
|
" exited \n": "stopped",
|
||||||
|
"paused": "stopped",
|
||||||
|
"dead": "failed",
|
||||||
|
"removing": "failed",
|
||||||
|
"restarting": "failed",
|
||||||
|
"unknown": "unknown",
|
||||||
|
"": "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
for input, want := range cases {
|
||||||
|
if got := NormalizeContainerStatus(input); got != want {
|
||||||
|
t.Fatalf("NormalizeContainerStatus(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerControllerGetContainerStatus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
controller := NewDockerControllerWithRunner(func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
if name != "docker" {
|
||||||
|
t.Fatalf("name = %q", name)
|
||||||
|
}
|
||||||
|
return []byte("running\n"), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
status, err := controller.GetContainerStatus("store-dwell-alert")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetContainerStatus() error = %v", err)
|
||||||
|
}
|
||||||
|
if status != "running" {
|
||||||
|
t.Fatalf("status = %q", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerControllerRestartContainer(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
called := false
|
||||||
|
controller := NewDockerControllerWithRunner(func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
called = true
|
||||||
|
return []byte("store-dwell-alert"), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := controller.RestartContainer("store-dwell-alert"); err != nil {
|
||||||
|
t.Fatalf("RestartContainer() error = %v", err)
|
||||||
|
}
|
||||||
|
if !called {
|
||||||
|
t.Fatal("runner was not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerControllerRestartContainerIncludesOutputOnError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
controller := NewDockerControllerWithRunner(func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
return []byte("permission denied"), errors.New("exit status 1")
|
||||||
|
})
|
||||||
|
|
||||||
|
err := controller.RestartContainer("store-dwell-alert")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "permission denied") {
|
||||||
|
t.Fatalf("RestartContainer() error = %v, want output included", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDockerUnavailable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if !IsDockerUnavailable(&exec.Error{Name: "docker", Err: exec.ErrNotFound}) {
|
||||||
|
t.Fatal("IsDockerUnavailable() = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
209
internal/managed/manager.go
Normal file
209
internal/managed/manager.go
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
package managed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
registry *Registry
|
||||||
|
docker DockerRuntime
|
||||||
|
remote *RemoteClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceState struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
ProjectType string `json:"project_type"`
|
||||||
|
ProjectRoot string `json:"project_root"`
|
||||||
|
ContainerName string `json:"container_name"`
|
||||||
|
APIBaseURL string `json:"api_base_url"`
|
||||||
|
ServiceName string `json:"service_name"`
|
||||||
|
ConfigPath string `json:"config_path"`
|
||||||
|
RTSPField string `json:"rtsp_field"`
|
||||||
|
ResultType string `json:"result_type"`
|
||||||
|
ResultPaths map[string]string `json:"result_paths"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
RTSP string `json:"rtsp,omitempty"`
|
||||||
|
Summary *ResultSummary `json:"summary,omitempty"`
|
||||||
|
ResultFiles []ResultFile `json:"result_files,omitempty"`
|
||||||
|
ConfigError string `json:"config_error,omitempty"`
|
||||||
|
ResultError string `json:"result_error,omitempty"`
|
||||||
|
ServiceError string `json:"service_error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(registry *Registry, docker DockerRuntime, remote *RemoteClient) *Manager {
|
||||||
|
if registry == nil {
|
||||||
|
registry = EmptyRegistry()
|
||||||
|
}
|
||||||
|
if docker == nil {
|
||||||
|
docker = NewDockerController()
|
||||||
|
}
|
||||||
|
if remote == nil {
|
||||||
|
remote = NewRemoteClient(nil)
|
||||||
|
}
|
||||||
|
return &Manager{
|
||||||
|
registry: registry,
|
||||||
|
docker: docker,
|
||||||
|
remote: remote,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) List() []*ServiceState {
|
||||||
|
states := make([]*ServiceState, 0, len(m.registry.Services))
|
||||||
|
for _, service := range m.registry.Services {
|
||||||
|
states = append(states, m.snapshot(service, false))
|
||||||
|
}
|
||||||
|
return states
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Detail(id string) (*ServiceState, error) {
|
||||||
|
service, err := m.lookup(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m.snapshot(service, true), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Summary(id string) (*ResultSummary, error) {
|
||||||
|
service, err := m.lookup(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m.remote.GetSummary(context.Background(), service)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Files(id string) ([]ResultFile, error) {
|
||||||
|
service, err := m.lookup(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m.remote.GetFiles(context.Background(), service)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) PreviewFile(id, path string, lines int) (*FilePreview, error) {
|
||||||
|
service, err := m.lookup(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m.remote.PreviewFile(context.Background(), service, path, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Download(ctx context.Context, id, path string) (*http.Response, error) {
|
||||||
|
service, err := m.lookup(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m.remote.Download(ctx, service, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) UpdateRTSP(id, rtsp string) (*ServiceState, error) {
|
||||||
|
service, err := m.lookup(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rtsp) == "" {
|
||||||
|
return nil, fmt.Errorf("rtsp url is required")
|
||||||
|
}
|
||||||
|
if _, err := m.remote.UpdateRTSP(context.Background(), service, rtsp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m.snapshot(service, true), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Restart(id string) (*ServiceState, error) {
|
||||||
|
service, err := m.lookup(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := m.docker.RestartContainer(service.ContainerName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m.snapshot(service, true), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) lookup(id string) (Service, error) {
|
||||||
|
service, ok := m.registry.Get(id)
|
||||||
|
if !ok {
|
||||||
|
return Service{}, fmt.Errorf("%w: %s", ErrServiceNotFound, id)
|
||||||
|
}
|
||||||
|
return service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) snapshot(service Service, includeFiles bool) *ServiceState {
|
||||||
|
state := &ServiceState{
|
||||||
|
ID: service.ID,
|
||||||
|
DisplayName: service.DisplayName,
|
||||||
|
ProjectType: service.ProjectType,
|
||||||
|
ProjectRoot: service.ProjectRoot,
|
||||||
|
ContainerName: service.ContainerName,
|
||||||
|
APIBaseURL: service.APIBaseURL,
|
||||||
|
ServiceName: service.ServiceName,
|
||||||
|
ConfigPath: service.ConfigPath,
|
||||||
|
RTSPField: service.RTSPField,
|
||||||
|
ResultType: service.ResultType,
|
||||||
|
ResultPaths: service.ResultPaths,
|
||||||
|
Status: "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload, err := m.remote.GetConfig(context.Background(), service); err == nil {
|
||||||
|
state.RTSP = extractRTSP(payload)
|
||||||
|
state.ConfigPath = extractConfigPath(payload)
|
||||||
|
} else {
|
||||||
|
state.ConfigError = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
if status, err := m.docker.GetContainerStatus(service.ContainerName); err == nil {
|
||||||
|
state.Status = status
|
||||||
|
} else {
|
||||||
|
state.Status = status
|
||||||
|
if !IsDockerUnavailable(err) {
|
||||||
|
state.ServiceError = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if summary, err := m.remote.GetSummary(context.Background(), service); err == nil {
|
||||||
|
state.Summary = summary
|
||||||
|
} else {
|
||||||
|
state.ResultError = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeFiles {
|
||||||
|
if files, err := m.remote.GetFiles(context.Background(), service); err == nil {
|
||||||
|
state.ResultFiles = files
|
||||||
|
} else if state.ResultError == "" {
|
||||||
|
state.ResultError = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractRTSP(payload map[string]any) string {
|
||||||
|
if payload == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if stream, ok := payload["stream"].(map[string]any); ok {
|
||||||
|
if rtsp, ok := stream["rtsp_url"].(string); ok {
|
||||||
|
return strings.TrimSpace(rtsp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if runtime, ok := payload["runtime"].(map[string]any); ok {
|
||||||
|
if rtsp, ok := runtime["rtsp_url"].(string); ok {
|
||||||
|
return strings.TrimSpace(rtsp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractConfigPath(payload map[string]any) string {
|
||||||
|
if payload == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if configPath, ok := payload["config_path"].(string); ok {
|
||||||
|
return strings.TrimSpace(configPath)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
194
internal/managed/manager_test.go
Normal file
194
internal/managed/manager_test.go
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
package managed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeDockerRuntime struct {
|
||||||
|
statusByContainer map[string]string
|
||||||
|
restarted []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDockerRuntime) GetContainerStatus(containerName string) (string, error) {
|
||||||
|
if status, ok := f.statusByContainer[containerName]; ok {
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
return "unknown", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDockerRuntime) RestartContainer(containerName string) error {
|
||||||
|
f.restarted = append(f.restarted, containerName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagedDockerAndRemoteAPI(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
storeConfig := map[string]any{
|
||||||
|
"config_path": "/srv/store/config/local.yaml",
|
||||||
|
"stream": map[string]any{
|
||||||
|
"rtsp_url": "rtsp://store-old/stream",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
response := func(status int, body any) (*http.Response, error) {
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: status,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(bytes.NewReader(data)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/config":
|
||||||
|
return response(http.StatusOK, storeConfig)
|
||||||
|
case r.Method == http.MethodPut && r.URL.Path == "/store/api/manage/config":
|
||||||
|
var payload map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode update payload: %v", err)
|
||||||
|
}
|
||||||
|
storeConfig["stream"].(map[string]any)["rtsp_url"] = payload["rtsp_url"]
|
||||||
|
return response(http.StatusOK, storeConfig)
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/summary":
|
||||||
|
return response(http.StatusOK, ResultSummary{
|
||||||
|
ResultType: "store_dwell_alert",
|
||||||
|
Headline: "Latest report shows 1 active customers, longest dwell 900s",
|
||||||
|
LastResultTime: "2026-04-16T10:00:00+08:00",
|
||||||
|
Metrics: map[string]any{
|
||||||
|
"active_customer_count": 1,
|
||||||
|
"longest_dwell_seconds": 900,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/files":
|
||||||
|
return response(http.StatusOK, map[string]any{
|
||||||
|
"files": []ResultFile{{
|
||||||
|
Path: "logs/events.jsonl",
|
||||||
|
Name: "events.jsonl",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/files/preview":
|
||||||
|
return response(http.StatusOK, FilePreview{
|
||||||
|
Path: "logs/events.jsonl",
|
||||||
|
Lines: []string{"line1", "line2"},
|
||||||
|
Count: 2,
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/files/download":
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{
|
||||||
|
"Content-Disposition": []string{`attachment; filename="events.jsonl"`},
|
||||||
|
},
|
||||||
|
Body: io.NopCloser(strings.NewReader("downloaded")),
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected child request: %s %s", r.Method, r.URL.String())
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
|
||||||
|
registry := &Registry{
|
||||||
|
Services: []Service{{
|
||||||
|
ID: "store_dwell_alert",
|
||||||
|
DisplayName: "Store Dwell Alert",
|
||||||
|
ProjectType: "store_dwell_alert",
|
||||||
|
ProjectRoot: "/srv/store",
|
||||||
|
ContainerName: "store-dwell-alert",
|
||||||
|
ServiceName: "store-dwell-alert",
|
||||||
|
APIBaseURL: "http://managed.invalid/store",
|
||||||
|
ResultType: "store_dwell_alert",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
docker := &fakeDockerRuntime{
|
||||||
|
statusByContainer: map[string]string{
|
||||||
|
"store-dwell-alert": "running",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
manager := NewManager(registry, docker, NewRemoteClient(client))
|
||||||
|
|
||||||
|
states := manager.List()
|
||||||
|
if len(states) != 1 {
|
||||||
|
t.Fatalf("len(List()) = %d, want 1", len(states))
|
||||||
|
}
|
||||||
|
if states[0].Status != "running" {
|
||||||
|
t.Fatalf("List()[0].Status = %q", states[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := manager.Detail("store_dwell_alert")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Detail() error = %v", err)
|
||||||
|
}
|
||||||
|
if state.Status != "running" {
|
||||||
|
t.Fatalf("state.Status = %q", state.Status)
|
||||||
|
}
|
||||||
|
if state.RTSP != "rtsp://store-old/stream" {
|
||||||
|
t.Fatalf("state.RTSP = %q", state.RTSP)
|
||||||
|
}
|
||||||
|
if state.ConfigPath != "/srv/store/config/local.yaml" {
|
||||||
|
t.Fatalf("state.ConfigPath = %q", state.ConfigPath)
|
||||||
|
}
|
||||||
|
if state.Summary == nil || state.Summary.Metrics["longest_dwell_seconds"] != float64(900) {
|
||||||
|
t.Fatalf("unexpected summary: %#v", state.Summary)
|
||||||
|
}
|
||||||
|
if len(state.ResultFiles) != 1 {
|
||||||
|
t.Fatalf("len(ResultFiles) = %d", len(state.ResultFiles))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := manager.UpdateRTSP("store_dwell_alert", "rtsp://store-new/stream"); err != nil {
|
||||||
|
t.Fatalf("UpdateRTSP() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := storeConfig["stream"].(map[string]any)["rtsp_url"]; got != "rtsp://store-new/stream" {
|
||||||
|
t.Fatalf("updated rtsp = %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := manager.Summary("store_dwell_alert")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Summary() error = %v", err)
|
||||||
|
}
|
||||||
|
if summary.Headline == "" {
|
||||||
|
t.Fatalf("Summary().Headline is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := manager.Files("store_dwell_alert")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Files() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 1 {
|
||||||
|
t.Fatalf("len(Files()) = %d", len(files))
|
||||||
|
}
|
||||||
|
|
||||||
|
preview, err := manager.PreviewFile("store_dwell_alert", "logs/events.jsonl", 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PreviewFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if preview.Count != 2 {
|
||||||
|
t.Fatalf("preview.Count = %d", preview.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := manager.Download(context.Background(), "store_dwell_alert", "logs/events.jsonl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Download() error = %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if !strings.Contains(resp.Header.Get("Content-Disposition"), "events.jsonl") {
|
||||||
|
t.Fatalf("Content-Disposition = %q", resp.Header.Get("Content-Disposition"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := manager.Restart("store_dwell_alert"); err != nil {
|
||||||
|
t.Fatalf("Restart() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(docker.restarted) != 1 || docker.restarted[0] != "store-dwell-alert" {
|
||||||
|
t.Fatalf("restarted = %#v", docker.restarted)
|
||||||
|
}
|
||||||
|
}
|
||||||
134
internal/managed/registry.go
Normal file
134
internal/managed/registry.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package managed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrServiceNotFound = errors.New("managed service not found")
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
Services []Service `yaml:"services"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
ID string `yaml:"id" json:"id"`
|
||||||
|
DisplayName string `yaml:"display_name" json:"display_name"`
|
||||||
|
ProjectType string `yaml:"project_type" json:"project_type"`
|
||||||
|
ProjectRoot string `yaml:"project_root" json:"project_root"`
|
||||||
|
ContainerName string `yaml:"container_name" json:"container_name"`
|
||||||
|
APIBaseURL string `yaml:"api_base_url" json:"api_base_url"`
|
||||||
|
ServiceName string `yaml:"service_name" json:"service_name"`
|
||||||
|
ConfigPath string `yaml:"config_path" json:"config_path"`
|
||||||
|
RTSPField string `yaml:"rtsp_field" json:"rtsp_field"`
|
||||||
|
ResultType string `yaml:"result_type" json:"result_type"`
|
||||||
|
ResultPaths map[string]string `yaml:"result_paths" json:"result_paths"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func EmptyRegistry() *Registry {
|
||||||
|
return &Registry{Services: []Service{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadRegistry(path string) (*Registry, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read managed registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var registry Registry
|
||||||
|
if err := yaml.Unmarshal(data, ®istry); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse managed registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseDir := filepath.Dir(path)
|
||||||
|
ids := make(map[string]struct{}, len(registry.Services))
|
||||||
|
for i := range registry.Services {
|
||||||
|
svc := ®istry.Services[i]
|
||||||
|
if err := normalizeService(baseDir, svc); err != nil {
|
||||||
|
return nil, fmt.Errorf("service[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
if _, exists := ids[svc.ID]; exists {
|
||||||
|
return nil, fmt.Errorf("duplicate service id %q", svc.ID)
|
||||||
|
}
|
||||||
|
ids[svc.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if registry.Services == nil {
|
||||||
|
registry.Services = []Service{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ®istry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Get(id string) (Service, bool) {
|
||||||
|
for _, svc := range r.Services {
|
||||||
|
if svc.ID == id {
|
||||||
|
return svc, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Service{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeService(baseDir string, svc *Service) error {
|
||||||
|
svc.ID = strings.TrimSpace(svc.ID)
|
||||||
|
svc.DisplayName = strings.TrimSpace(svc.DisplayName)
|
||||||
|
svc.ProjectType = strings.TrimSpace(svc.ProjectType)
|
||||||
|
svc.ContainerName = strings.TrimSpace(svc.ContainerName)
|
||||||
|
svc.APIBaseURL = strings.TrimSpace(svc.APIBaseURL)
|
||||||
|
svc.ServiceName = strings.TrimSpace(svc.ServiceName)
|
||||||
|
svc.ConfigPath = strings.TrimSpace(svc.ConfigPath)
|
||||||
|
svc.RTSPField = strings.TrimSpace(svc.RTSPField)
|
||||||
|
svc.ResultType = strings.TrimSpace(svc.ResultType)
|
||||||
|
|
||||||
|
if svc.ID == "" {
|
||||||
|
return errors.New("id is required")
|
||||||
|
}
|
||||||
|
if svc.DisplayName == "" {
|
||||||
|
return errors.New("display_name is required")
|
||||||
|
}
|
||||||
|
if svc.ProjectType == "" {
|
||||||
|
return errors.New("project_type is required")
|
||||||
|
}
|
||||||
|
if svc.ContainerName == "" {
|
||||||
|
return errors.New("container_name is required")
|
||||||
|
}
|
||||||
|
if svc.APIBaseURL == "" {
|
||||||
|
return errors.New("api_base_url is required")
|
||||||
|
}
|
||||||
|
if svc.ResultType == "" {
|
||||||
|
return errors.New("result_type is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
projectRoot := strings.TrimSpace(svc.ProjectRoot)
|
||||||
|
if projectRoot == "" {
|
||||||
|
return errors.New("project_root is required")
|
||||||
|
}
|
||||||
|
svc.ProjectRoot = resolvePath(baseDir, projectRoot)
|
||||||
|
if svc.ServiceName == "" {
|
||||||
|
svc.ServiceName = svc.ContainerName
|
||||||
|
}
|
||||||
|
if svc.ConfigPath != "" {
|
||||||
|
svc.ConfigPath = resolvePath(baseDir, svc.ConfigPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if svc.ResultPaths == nil {
|
||||||
|
svc.ResultPaths = map[string]string{}
|
||||||
|
}
|
||||||
|
for key, path := range svc.ResultPaths {
|
||||||
|
svc.ResultPaths[key] = resolvePath(baseDir, strings.TrimSpace(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePath(baseDir, path string) string {
|
||||||
|
if filepath.IsAbs(path) {
|
||||||
|
return filepath.Clean(path)
|
||||||
|
}
|
||||||
|
return filepath.Clean(filepath.Join(baseDir, path))
|
||||||
|
}
|
||||||
101
internal/managed/registry_test.go
Normal file
101
internal/managed/registry_test.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package managed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadRegistry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
registryPath := filepath.Join(root, "managed_services.yaml")
|
||||||
|
writeFile(t, registryPath, `
|
||||||
|
services:
|
||||||
|
- id: store_dwell_alert
|
||||||
|
display_name: Store Dwell Alert
|
||||||
|
project_type: store_dwell_alert
|
||||||
|
project_root: ./store_dwell_alert
|
||||||
|
container_name: store-dwell-alert
|
||||||
|
api_base_url: http://store-dwell-alert:18081
|
||||||
|
config_path: ./configs/store.yaml
|
||||||
|
result_type: store_dwell_alert
|
||||||
|
- id: people_flow_project
|
||||||
|
display_name: People Flow Project
|
||||||
|
project_type: people_flow_project
|
||||||
|
project_root: ./people_flow_project
|
||||||
|
container_name: people-flow-project
|
||||||
|
api_base_url: http://people-flow-project:18082
|
||||||
|
result_type: people_flow_project
|
||||||
|
`)
|
||||||
|
|
||||||
|
registry, err := LoadRegistry(registryPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(registry.Services) != 2 {
|
||||||
|
t.Fatalf("len(Services) = %d, want 2", len(registry.Services))
|
||||||
|
}
|
||||||
|
|
||||||
|
store := registry.Services[0]
|
||||||
|
if store.ID != "store_dwell_alert" {
|
||||||
|
t.Fatalf("store.ID = %q", store.ID)
|
||||||
|
}
|
||||||
|
if store.ProjectRoot != filepath.Join(root, "store_dwell_alert") {
|
||||||
|
t.Fatalf("store.ProjectRoot = %q", store.ProjectRoot)
|
||||||
|
}
|
||||||
|
if store.ConfigPath != filepath.Join(root, "configs", "store.yaml") {
|
||||||
|
t.Fatalf("store.ConfigPath = %q", store.ConfigPath)
|
||||||
|
}
|
||||||
|
if store.ServiceName != "store-dwell-alert" {
|
||||||
|
t.Fatalf("store.ServiceName = %q", store.ServiceName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRegistryRejectsDuplicateIDs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
registryPath := filepath.Join(root, "managed_services.yaml")
|
||||||
|
writeFile(t, registryPath, `
|
||||||
|
services:
|
||||||
|
- id: repeated
|
||||||
|
display_name: One
|
||||||
|
project_type: store
|
||||||
|
project_root: ./one
|
||||||
|
container_name: one
|
||||||
|
api_base_url: http://one
|
||||||
|
result_type: store
|
||||||
|
- id: repeated
|
||||||
|
display_name: Two
|
||||||
|
project_type: people
|
||||||
|
project_root: ./two
|
||||||
|
container_name: two
|
||||||
|
api_base_url: http://two
|
||||||
|
result_type: people
|
||||||
|
`)
|
||||||
|
|
||||||
|
_, err := LoadRegistry(registryPath)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `duplicate service id "repeated"`) {
|
||||||
|
t.Fatalf("LoadRegistry() error = %v, want duplicate id", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRegistryRejectsMissingRequiredFields(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
registryPath := filepath.Join(root, "managed_services.yaml")
|
||||||
|
writeFile(t, registryPath, `
|
||||||
|
services:
|
||||||
|
- id: missing_fields
|
||||||
|
display_name: Missing Fields
|
||||||
|
`)
|
||||||
|
|
||||||
|
_, err := LoadRegistry(registryPath)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "project_type is required") {
|
||||||
|
t.Fatalf("LoadRegistry() error = %v, want missing field error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
154
internal/managed/remote_client.go
Normal file
154
internal/managed/remote_client.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package managed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HTTPDoer interface {
|
||||||
|
Do(req *http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RemoteClient struct {
|
||||||
|
httpClient HTTPDoer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRemoteClient(client HTTPDoer) *RemoteClient {
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 5 * time.Second}
|
||||||
|
}
|
||||||
|
return &RemoteClient{httpClient: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RemoteClient) GetConfig(ctx context.Context, service Service) (map[string]any, error) {
|
||||||
|
var payload map[string]any
|
||||||
|
if err := c.getJSON(ctx, service, "/api/manage/config", &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RemoteClient) UpdateRTSP(ctx context.Context, service Service, rtsp string) (map[string]any, error) {
|
||||||
|
body := strings.NewReader(fmt.Sprintf(`{"rtsp_url":%q}`, rtsp))
|
||||||
|
req, err := c.newRequest(ctx, http.MethodPut, service, "/api/manage/config", body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request %s %s: %w", req.Method, req.URL.String(), err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, decodeAPIError(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode response %s: %w", req.URL.String(), err)
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RemoteClient) GetSummary(ctx context.Context, service Service) (*ResultSummary, error) {
|
||||||
|
var summary ResultSummary
|
||||||
|
if err := c.getJSON(ctx, service, "/api/manage/summary", &summary); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RemoteClient) GetFiles(ctx context.Context, service Service) ([]ResultFile, error) {
|
||||||
|
var payload struct {
|
||||||
|
Files []ResultFile `json:"files"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, service, "/api/manage/files", &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return payload.Files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RemoteClient) PreviewFile(ctx context.Context, service Service, path string, lines int) (*FilePreview, error) {
|
||||||
|
query := url.Values{}
|
||||||
|
query.Set("path", path)
|
||||||
|
query.Set("lines", fmt.Sprintf("%d", lines))
|
||||||
|
|
||||||
|
var preview FilePreview
|
||||||
|
if err := c.getJSON(ctx, service, "/api/manage/files/preview?"+query.Encode(), &preview); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &preview, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RemoteClient) Download(ctx context.Context, service Service, path string) (*http.Response, error) {
|
||||||
|
query := url.Values{}
|
||||||
|
query.Set("path", path)
|
||||||
|
req, err := c.newRequest(ctx, http.MethodGet, service, "/api/manage/files/download?"+query.Encode(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request %s %s: %w", req.Method, req.URL.String(), err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return nil, decodeAPIError(resp)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RemoteClient) getJSON(ctx context.Context, service Service, endpoint string, target any) error {
|
||||||
|
req, err := c.newRequest(ctx, http.MethodGet, service, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("request %s %s: %w", req.Method, req.URL.String(), err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return decodeAPIError(resp)
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
|
||||||
|
return fmt.Errorf("decode response %s: %w", req.URL.String(), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RemoteClient) newRequest(ctx context.Context, method string, service Service, endpoint string, body io.Reader) (*http.Request, error) {
|
||||||
|
base := strings.TrimRight(service.APIBaseURL, "/")
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, base+endpoint, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request for %s%s: %w", base, endpoint, err)
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeAPIError(resp *http.Response) error {
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(data, &payload); err == nil {
|
||||||
|
if message, ok := payload["error"].(string); ok && strings.TrimSpace(message) != "" {
|
||||||
|
return errors.New(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message := strings.TrimSpace(string(data))
|
||||||
|
if message == "" {
|
||||||
|
message = resp.Status
|
||||||
|
}
|
||||||
|
return errors.New(message)
|
||||||
|
}
|
||||||
141
internal/managed/remote_client_test.go
Normal file
141
internal/managed/remote_client_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package managed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type roundTripFunc func(req *http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return fn(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteClientRoundTrip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
storeConfig := map[string]any{
|
||||||
|
"config_path": "/srv/store/config/local.yaml",
|
||||||
|
"stream": map[string]any{
|
||||||
|
"rtsp_url": "rtsp://store-old/stream",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewRemoteClient(&http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
response := func(status int, body any) (*http.Response, error) {
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: status,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(bytes.NewReader(data)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/config":
|
||||||
|
return response(http.StatusOK, storeConfig)
|
||||||
|
case r.Method == http.MethodPut && r.URL.Path == "/store/api/manage/config":
|
||||||
|
var payload map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode update payload: %v", err)
|
||||||
|
}
|
||||||
|
storeConfig["stream"].(map[string]any)["rtsp_url"] = payload["rtsp_url"]
|
||||||
|
return response(http.StatusOK, storeConfig)
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/summary":
|
||||||
|
return response(http.StatusOK, ResultSummary{
|
||||||
|
ResultType: "store_dwell_alert",
|
||||||
|
Headline: "Latest report shows 1 active customers, longest dwell 900s",
|
||||||
|
LastResultTime: "2026-04-16T10:00:00+08:00",
|
||||||
|
Metrics: map[string]any{
|
||||||
|
"active_customer_count": 1,
|
||||||
|
"longest_dwell_seconds": 900,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/files":
|
||||||
|
return response(http.StatusOK, map[string]any{
|
||||||
|
"files": []ResultFile{{
|
||||||
|
Path: "logs/events.jsonl",
|
||||||
|
Name: "events.jsonl",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/files/preview":
|
||||||
|
return response(http.StatusOK, FilePreview{
|
||||||
|
Path: "logs/events.jsonl",
|
||||||
|
Lines: []string{"line1", "line2"},
|
||||||
|
Count: 2,
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/files/download":
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{
|
||||||
|
"Content-Disposition": []string{`attachment; filename="events.jsonl"`},
|
||||||
|
},
|
||||||
|
Body: io.NopCloser(strings.NewReader("downloaded")),
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected child request: %s %s", r.Method, r.URL.String())
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
})})
|
||||||
|
|
||||||
|
service := Service{
|
||||||
|
ID: "store_dwell_alert",
|
||||||
|
APIBaseURL: "http://managed.invalid/store",
|
||||||
|
}
|
||||||
|
|
||||||
|
configPayload, err := client.GetConfig(context.Background(), service)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := configPayload["config_path"]; got != "/srv/store/config/local.yaml" {
|
||||||
|
t.Fatalf("config_path = %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.UpdateRTSP(context.Background(), service, "rtsp://store-new/stream"); err != nil {
|
||||||
|
t.Fatalf("UpdateRTSP() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := storeConfig["stream"].(map[string]any)["rtsp_url"]; got != "rtsp://store-new/stream" {
|
||||||
|
t.Fatalf("updated rtsp = %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := client.GetSummary(context.Background(), service)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSummary() error = %v", err)
|
||||||
|
}
|
||||||
|
if summary.ResultType != "store_dwell_alert" {
|
||||||
|
t.Fatalf("summary.ResultType = %q", summary.ResultType)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := client.GetFiles(context.Background(), service)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFiles() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 1 || files[0].Path != "logs/events.jsonl" {
|
||||||
|
t.Fatalf("files = %#v", files)
|
||||||
|
}
|
||||||
|
|
||||||
|
preview, err := client.PreviewFile(context.Background(), service, "logs/events.jsonl", 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PreviewFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if preview.Count != 2 {
|
||||||
|
t.Fatalf("preview.Count = %d", preview.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Download(context.Background(), service, "logs/events.jsonl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Download() error = %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if !strings.Contains(resp.Header.Get("Content-Disposition"), "events.jsonl") {
|
||||||
|
t.Fatalf("Content-Disposition = %q", resp.Header.Get("Content-Disposition"))
|
||||||
|
}
|
||||||
|
}
|
||||||
19
internal/managed/test_helpers_test.go
Normal file
19
internal/managed/test_helpers_test.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package managed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(%q): %v", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(%q): %v", path, err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
41
internal/managed/types.go
Normal file
41
internal/managed/types.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package managed
|
||||||
|
|
||||||
|
type ResultSummary struct {
|
||||||
|
ResultType string `json:"result_type"`
|
||||||
|
Headline string `json:"headline"`
|
||||||
|
LastResultTime string `json:"last_result_time,omitempty"`
|
||||||
|
Metrics map[string]any `json:"metrics,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoreDwellWindowStat struct {
|
||||||
|
WindowStart string `json:"window_start"`
|
||||||
|
WindowEnd string `json:"window_end"`
|
||||||
|
ActiveCustomerCount int `json:"active_customer_count"`
|
||||||
|
ActiveWaitSeconds []int `json:"active_wait_seconds"`
|
||||||
|
ClosedWaitSeconds []int `json:"closed_wait_seconds"`
|
||||||
|
MaxWaitSeconds int `json:"max_wait_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PeopleFlowWindowStat struct {
|
||||||
|
WindowStart string `json:"window_start"`
|
||||||
|
WindowEnd string `json:"window_end"`
|
||||||
|
TotalPeople int `json:"total_people"`
|
||||||
|
AgeCounts map[string]int `json:"age_counts"`
|
||||||
|
GenderCounts map[string]int `json:"gender_counts"`
|
||||||
|
UnknownAttributes int `json:"unknown_attributes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResultFile struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ModifiedAt string `json:"modified_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilePreview struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Lines []string `json:"lines"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
271
internal/server/managed_handlers_test.go
Normal file
271
internal/server/managed_handlers_test.go
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"managed-portal/internal/managed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeDockerRuntime struct {
|
||||||
|
statusByContainer map[string]string
|
||||||
|
restarted []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type roundTripFunc func(req *http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return fn(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDockerRuntime) GetContainerStatus(containerName string) (string, error) {
|
||||||
|
if status, ok := f.statusByContainer[containerName]; ok {
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
return "unknown", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDockerRuntime) RestartContainer(containerName string) error {
|
||||||
|
f.restarted = append(f.restarted, containerName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagedServicesHandlers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
storeConfigPath := "/srv/store/config/local.yaml"
|
||||||
|
storeRTSP := "rtsp://store-old/stream"
|
||||||
|
|
||||||
|
srv := New(nil)
|
||||||
|
registry := &managed.Registry{
|
||||||
|
Services: []managed.Service{{
|
||||||
|
ID: "store_dwell_alert",
|
||||||
|
DisplayName: "Store Dwell Alert",
|
||||||
|
ProjectType: "store_dwell_alert",
|
||||||
|
ProjectRoot: "/srv/store",
|
||||||
|
ContainerName: "store-dwell-alert",
|
||||||
|
ServiceName: "store-dwell-alert",
|
||||||
|
APIBaseURL: "http://managed.invalid/store",
|
||||||
|
ResultType: "store_dwell_alert",
|
||||||
|
}, {
|
||||||
|
ID: "people_flow_project",
|
||||||
|
DisplayName: "People Flow Project",
|
||||||
|
ProjectType: "people_flow_project",
|
||||||
|
ProjectRoot: "/srv/people",
|
||||||
|
ContainerName: "people-flow-project",
|
||||||
|
ServiceName: "people-flow-project",
|
||||||
|
APIBaseURL: "http://managed.invalid/people",
|
||||||
|
ResultType: "people_flow_project",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
response := func(status int, body any) (*http.Response, error) {
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: status,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(bytes.NewReader(data)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/config":
|
||||||
|
return response(http.StatusOK, map[string]any{
|
||||||
|
"config_path": storeConfigPath,
|
||||||
|
"stream": map[string]any{"rtsp_url": storeRTSP},
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodPut && r.URL.Path == "/store/api/manage/config":
|
||||||
|
var payload map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode store config update: %v", err)
|
||||||
|
}
|
||||||
|
storeRTSP = payload["rtsp_url"]
|
||||||
|
return response(http.StatusOK, map[string]any{
|
||||||
|
"config_path": storeConfigPath,
|
||||||
|
"stream": map[string]any{"rtsp_url": storeRTSP},
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/summary":
|
||||||
|
return response(http.StatusOK, managed.ResultSummary{
|
||||||
|
ResultType: "store_dwell_alert",
|
||||||
|
Headline: "Latest report shows 2 active customers, longest dwell 850s",
|
||||||
|
LastResultTime: "2026-04-16T09:30:00+08:00",
|
||||||
|
Metrics: map[string]any{
|
||||||
|
"longest_dwell_seconds": 850,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/files":
|
||||||
|
return response(http.StatusOK, map[string]any{
|
||||||
|
"files": []managed.ResultFile{{
|
||||||
|
Path: "logs/events.jsonl",
|
||||||
|
Name: "events.jsonl",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/files/preview":
|
||||||
|
return response(http.StatusOK, managed.FilePreview{
|
||||||
|
Path: "logs/events.jsonl",
|
||||||
|
Lines: []string{"preview"},
|
||||||
|
Count: 1,
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/files/download":
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{
|
||||||
|
"Content-Disposition": []string{`attachment; filename="events.jsonl"`},
|
||||||
|
},
|
||||||
|
Body: io.NopCloser(strings.NewReader("store-download")),
|
||||||
|
}, nil
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/people/api/manage/config":
|
||||||
|
return response(http.StatusOK, map[string]any{
|
||||||
|
"config_path": "/srv/people/config/local.yaml",
|
||||||
|
"runtime": map[string]any{"rtsp_url": "rtsp://people-old/stream"},
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/people/api/manage/summary":
|
||||||
|
return response(http.StatusOK, managed.ResultSummary{
|
||||||
|
ResultType: "people_flow_project",
|
||||||
|
Headline: "Latest window counted 5 people",
|
||||||
|
LastResultTime: "2026-04-16T09:00:00+08:00",
|
||||||
|
Metrics: map[string]any{
|
||||||
|
"recent_window_stats": []map[string]any{{"total_people": 5}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/people/api/manage/files":
|
||||||
|
return response(http.StatusOK, map[string]any{"files": []managed.ResultFile{}})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected child request: %s %s", r.Method, r.URL.String())
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
|
||||||
|
docker := &fakeDockerRuntime{
|
||||||
|
statusByContainer: map[string]string{
|
||||||
|
"store-dwell-alert": "running",
|
||||||
|
"people-flow-project": "stopped",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
srv.managedManager = managed.NewManager(registry, docker, managed.NewRemoteClient(client))
|
||||||
|
|
||||||
|
t.Run("GET /api/managed-services", func(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/managed-services", nil)
|
||||||
|
srv.engine.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Services []managed.ServiceState `json:"services"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(payload.Services) != 2 {
|
||||||
|
t.Fatalf("len(services) = %d", len(payload.Services))
|
||||||
|
}
|
||||||
|
if payload.Services[0].RTSP == "" {
|
||||||
|
t.Fatalf("expected RTSP in list response")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET /api/managed-services/:id", func(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/managed-services/store_dwell_alert", nil)
|
||||||
|
srv.engine.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "longest_dwell_seconds") {
|
||||||
|
t.Fatalf("detail response missing summary metrics: %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PUT /api/managed-services/:id/config", func(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
body := bytes.NewBufferString(`{"rtsp_url":"rtsp://store-new/stream"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/managed-services/store_dwell_alert/config", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
srv.engine.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if storeRTSP != "rtsp://store-new/stream" {
|
||||||
|
t.Fatalf("storeRTSP = %q", storeRTSP)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("POST /api/managed-services/:id/restart", func(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/managed-services/people_flow_project/restart", nil)
|
||||||
|
srv.engine.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if len(docker.restarted) != 1 || docker.restarted[0] != "people-flow-project" {
|
||||||
|
t.Fatalf("restarted = %#v", docker.restarted)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET /api/managed-services/:id/results/summary", func(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/managed-services/store_dwell_alert/results/summary", nil)
|
||||||
|
srv.engine.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "active customers") {
|
||||||
|
t.Fatalf("summary response = %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET /api/managed-services/:id/results/files", func(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/managed-services/store_dwell_alert/results/files", nil)
|
||||||
|
srv.engine.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "events.jsonl") {
|
||||||
|
t.Fatalf("files response missing expected file: %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET /api/managed-services/:id/results/preview", func(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/managed-services/store_dwell_alert/results/preview?path=logs/events.jsonl&lines=1", nil)
|
||||||
|
srv.engine.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "preview") {
|
||||||
|
t.Fatalf("preview response = %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET /api/managed-services/:id/results/download", func(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/managed-services/store_dwell_alert/results/download?path=logs/events.jsonl", nil)
|
||||||
|
srv.engine.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if got := recorder.Body.String(); got != "store-download" {
|
||||||
|
t.Fatalf("download body = %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
223
internal/server/server.go
Normal file
223
internal/server/server.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/cors"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"managed-portal/internal/config"
|
||||||
|
"managed-portal/internal/managed"
|
||||||
|
"managed-portal/internal/webdevice"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
cfg *config.Config
|
||||||
|
engine *gin.Engine
|
||||||
|
managedManager *managed.Manager
|
||||||
|
webDeviceSvc *webdevice.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg *config.Config) *Server {
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = config.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := gin.New()
|
||||||
|
engine.Use(gin.Logger(), gin.Recovery())
|
||||||
|
engine.Use(cors.Default())
|
||||||
|
|
||||||
|
srv := &Server{
|
||||||
|
cfg: cfg,
|
||||||
|
engine: engine,
|
||||||
|
}
|
||||||
|
srv.managedManager = managed.NewManager(loadRegistry(cfg.RegistryPath), nil, nil)
|
||||||
|
srv.webDeviceSvc = webdevice.NewService()
|
||||||
|
srv.registerRoutes()
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) registerRoutes() {
|
||||||
|
api := s.engine.Group("/api")
|
||||||
|
api.GET("/health", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
api.GET("/managed-services", s.listManagedServices)
|
||||||
|
api.GET("/managed-services/:id", s.getManagedService)
|
||||||
|
api.PUT("/managed-services/:id/config", s.updateManagedServiceConfig)
|
||||||
|
api.POST("/managed-services/:id/restart", s.restartManagedService)
|
||||||
|
api.GET("/managed-services/:id/results/summary", s.getManagedServiceSummary)
|
||||||
|
api.GET("/managed-services/:id/results/files", s.listManagedServiceFiles)
|
||||||
|
api.GET("/managed-services/:id/results/preview", s.previewManagedServiceFile)
|
||||||
|
api.GET("/managed-services/:id/results/download", s.downloadManagedServiceFile)
|
||||||
|
api.GET("/web-devices/scan", s.scanWebDevices)
|
||||||
|
s.engine.Any("/proxy/web/:ip/*proxyPath", s.proxyWebDevice)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Engine() *gin.Engine {
|
||||||
|
return s.engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Start() error {
|
||||||
|
return s.engine.Run(s.cfg.HTTPAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRegistry(path string) *managed.Registry {
|
||||||
|
registry, err := managed.LoadRegistry(path)
|
||||||
|
if err != nil {
|
||||||
|
return managed.EmptyRegistry()
|
||||||
|
}
|
||||||
|
return registry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listManagedServices(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"services": s.managedManager.List(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getManagedService(c *gin.Context) {
|
||||||
|
service, err := s.managedManager.Detail(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
s.handleManagedError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"service": service})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateManagedServiceConfig(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
RTSPURL string `json:"rtsp_url"`
|
||||||
|
RTSP string `json:"rtsp"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rtsp := strings.TrimSpace(req.RTSPURL)
|
||||||
|
if rtsp == "" {
|
||||||
|
rtsp = strings.TrimSpace(req.RTSP)
|
||||||
|
}
|
||||||
|
|
||||||
|
service, err := s.managedManager.UpdateRTSP(c.Param("id"), rtsp)
|
||||||
|
if err != nil {
|
||||||
|
s.handleManagedError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"service": service})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) restartManagedService(c *gin.Context) {
|
||||||
|
service, err := s.managedManager.Restart(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
s.handleManagedError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"service": service})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getManagedServiceSummary(c *gin.Context) {
|
||||||
|
summary, err := s.managedManager.Summary(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
s.handleManagedError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"summary": summary})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listManagedServiceFiles(c *gin.Context) {
|
||||||
|
files, err := s.managedManager.Files(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
s.handleManagedError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"files": files})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) previewManagedServiceFile(c *gin.Context) {
|
||||||
|
lines := 2000
|
||||||
|
if raw := strings.TrimSpace(c.Query("lines")); raw != "" {
|
||||||
|
parsed, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "lines 参数必须为正整数"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if parsed > 2000 {
|
||||||
|
parsed = 2000
|
||||||
|
}
|
||||||
|
lines = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
preview, err := s.managedManager.PreviewFile(c.Param("id"), c.Query("path"), lines)
|
||||||
|
if err != nil {
|
||||||
|
s.handleManagedError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) downloadManagedServiceFile(c *gin.Context) {
|
||||||
|
resp, err := s.managedManager.Download(c.Request.Context(), c.Param("id"), c.Query("path"))
|
||||||
|
if err != nil {
|
||||||
|
s.handleManagedError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
|
||||||
|
c.Header("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
if contentDisposition := resp.Header.Get("Content-Disposition"); contentDisposition != "" {
|
||||||
|
c.Header("Content-Disposition", contentDisposition)
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(c.Writer, resp.Body); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleManagedError(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, managed.ErrServiceNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "被管理服务不存在"})
|
||||||
|
case os.IsNotExist(err):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case strings.Contains(err.Error(), "rtsp url"):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
case strings.Contains(err.Error(), "invalid file path"):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) scanWebDevices(c *gin.Context) {
|
||||||
|
result, err := s.webDeviceSvc.Scan(c.Request)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取网卡信息失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) proxyWebDevice(c *gin.Context) {
|
||||||
|
err := s.webDeviceSvc.ProxyHTTP(c.Writer, c.Request, c.Param("ip"), c.Param("proxyPath"))
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return
|
||||||
|
case errors.Is(err, webdevice.ErrInvalidTargetIP):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持内网IPv4地址"})
|
||||||
|
case errors.Is(err, webdevice.ErrTargetNotAllowed):
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "目标IP未在扫描结果中,请先扫描网页设备"})
|
||||||
|
case errors.Is(err, webdevice.ErrInvalidProxyURL):
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "代理目标无效"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
23
internal/server/server_test.go
Normal file
23
internal/server/server_test.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHealth(t *testing.T) {
|
||||||
|
srv := New(nil)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
srv.Engine().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if body := rec.Body.String(); !strings.Contains(body, `"status":"ok"`) {
|
||||||
|
t.Fatalf("unexpected body: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
83
internal/server/webdevice_handlers_test.go
Normal file
83
internal/server/webdevice_handlers_test.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"managed-portal/internal/webdevice"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWebDeviceHandlers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("GET /api/web-devices/scan", func(t *testing.T) {
|
||||||
|
srv := New(nil)
|
||||||
|
svc := webdevice.NewService()
|
||||||
|
svc.SetInterfaceGetter(func() ([]webdevice.InterfaceInfo, error) {
|
||||||
|
return []webdevice.InterfaceInfo{{
|
||||||
|
Name: "eth0",
|
||||||
|
IP: "10.8.0.14",
|
||||||
|
Netmask: "255.255.255.0",
|
||||||
|
}}, nil
|
||||||
|
})
|
||||||
|
svc.SetTCPScanner(func(ip, netmask string, port int, excludeIPs map[string]bool) ([]webdevice.TCPDevice, error) {
|
||||||
|
return []webdevice.TCPDevice{{IP: "192.168.1.124", Port: 80}}, nil
|
||||||
|
})
|
||||||
|
svc.SetForwarderFactory(func(ip string, port int, listenAddress, targetAddress string) (*webdevice.WebDeviceForwarder, error) {
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
srv.webDeviceSvc = svc
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://10.8.0.14:13000/api/web-devices/scan", nil)
|
||||||
|
srv.engine.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "192.168.1.124") {
|
||||||
|
t.Fatalf("scan response = %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ANY /proxy/web/:ip/*proxyPath rejects unscanned IP", func(t *testing.T) {
|
||||||
|
srv := New(nil)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://portal/proxy/web/192.168.1.124/", nil)
|
||||||
|
srv.engine.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ANY /proxy/web/:ip/*proxyPath proxies allowed IP", func(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = w.Write([]byte(`<html><head></head><body><img src="/doc/logo.png"></body></html>`))
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
srv := New(nil)
|
||||||
|
svc := webdevice.NewService()
|
||||||
|
svc.AllowIP("192.168.1.124")
|
||||||
|
svc.SetProxyTargetResolver(func(ip string) string {
|
||||||
|
return upstream.URL
|
||||||
|
})
|
||||||
|
srv.webDeviceSvc = svc
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://portal/proxy/web/192.168.1.124/", nil)
|
||||||
|
srv.engine.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "/proxy/web/192.168.1.124/doc/logo.png") {
|
||||||
|
t.Fatalf("proxy response = %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
101
internal/webdevice/proxy.go
Normal file
101
internal/webdevice/proxy.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package webdevice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidTargetIP = errors.New("invalid target ip")
|
||||||
|
ErrTargetNotAllowed = errors.New("target ip not allowed")
|
||||||
|
ErrInvalidProxyURL = errors.New("invalid proxy target")
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) ProxyHTTP(w http.ResponseWriter, r *http.Request, targetIP, proxyPath string) error {
|
||||||
|
if !IsPrivateIPv4Literal(targetIP) {
|
||||||
|
return ErrInvalidTargetIP
|
||||||
|
}
|
||||||
|
if !s.IsAllowed(targetIP) {
|
||||||
|
return ErrTargetNotAllowed
|
||||||
|
}
|
||||||
|
|
||||||
|
rawTarget := s.ProxyTargetURL(targetIP)
|
||||||
|
targetURL, err := url.Parse(rawTarget)
|
||||||
|
if err != nil || targetURL.Scheme == "" || targetURL.Host == "" {
|
||||||
|
return ErrInvalidProxyURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if proxyPath == "" {
|
||||||
|
proxyPath = "/"
|
||||||
|
}
|
||||||
|
rawQuery := r.URL.RawQuery
|
||||||
|
|
||||||
|
proxy := &httputil.ReverseProxy{
|
||||||
|
Director: func(req *http.Request) {
|
||||||
|
req.URL.Scheme = targetURL.Scheme
|
||||||
|
req.URL.Host = targetURL.Host
|
||||||
|
req.URL.Path = JoinProxyTargetPath(targetURL.Path, proxyPath)
|
||||||
|
req.URL.RawPath = ""
|
||||||
|
req.URL.RawQuery = rawQuery
|
||||||
|
req.Host = targetURL.Host
|
||||||
|
req.Header.Del("Accept-Encoding")
|
||||||
|
},
|
||||||
|
ModifyResponse: func(resp *http.Response) error {
|
||||||
|
proxyPrefix := "/proxy/web/" + targetIP
|
||||||
|
if location := resp.Header.Get("Location"); location != "" {
|
||||||
|
resp.Header.Set("Location", RewriteLocation(targetIP, targetURL, location))
|
||||||
|
}
|
||||||
|
if cookies := resp.Header.Values("Set-Cookie"); len(cookies) > 0 {
|
||||||
|
resp.Header.Del("Set-Cookie")
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
resp.Header.Add("Set-Cookie", RewriteSetCookie(cookie, proxyPrefix))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
if ShouldRewriteBody(contentType) {
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
rewritten := RewriteText(string(body), proxyPrefix, targetURL, contentType)
|
||||||
|
rewrittenBytes := []byte(rewritten)
|
||||||
|
resp.Body = io.NopCloser(strings.NewReader(rewritten))
|
||||||
|
resp.ContentLength = int64(len(rewrittenBytes))
|
||||||
|
resp.Header.Set("Content-Length", strconv.Itoa(len(rewrittenBytes)))
|
||||||
|
resp.Header.Del("Content-Encoding")
|
||||||
|
resp.Header.Del("Content-MD5")
|
||||||
|
resp.Header.Del("Etag")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
http.Error(w, "代理访问失败: "+err.Error(), http.StatusBadGateway)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy.ServeHTTP(closeNotifyWriter{ResponseWriter: w}, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type closeNotifyWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w closeNotifyWriter) CloseNotify() <-chan bool {
|
||||||
|
ch := make(chan bool, 1)
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w closeNotifyWriter) Flush() {
|
||||||
|
if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
93
internal/webdevice/proxy_test.go
Normal file
93
internal/webdevice/proxy_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package webdevice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRewriteLocation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
targetURL, _ := url.Parse("http://192.168.1.124:80")
|
||||||
|
got := RewriteLocation("192.168.1.124", targetURL, "http://192.168.1.124/ISAPI/Security")
|
||||||
|
if got != "/proxy/web/192.168.1.124/ISAPI/Security" {
|
||||||
|
t.Fatalf("RewriteLocation() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteSetCookie(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := RewriteSetCookie("SID=1; Path=/; Domain=192.168.1.124; HttpOnly", "/proxy/web/192.168.1.124")
|
||||||
|
if strings.Contains(strings.ToLower(got), "domain=") {
|
||||||
|
t.Fatalf("RewriteSetCookie() kept domain: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "Path=/proxy/web/192.168.1.124/") {
|
||||||
|
t.Fatalf("RewriteSetCookie() path = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteText(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
targetURL, _ := url.Parse("http://192.168.1.124:80")
|
||||||
|
body := `<html><head></head><body><img src="/doc/logo.png"><a href="http://192.168.1.124/ISAPI/x">x</a></body></html>`
|
||||||
|
got := RewriteText(body, "/proxy/web/192.168.1.124", targetURL, "text/html")
|
||||||
|
|
||||||
|
if !strings.Contains(got, `/proxy/web/192.168.1.124/doc/logo.png`) {
|
||||||
|
t.Fatalf("rewritten body missing proxied relative URL: %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `data-web-proxy-runtime`) {
|
||||||
|
t.Fatalf("rewritten body missing runtime injection: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyHTTPRejectsUnscannedIP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc := NewService()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://portal/proxy/web/192.168.1.124/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
err := svc.ProxyHTTP(rec, req, "192.168.1.124", "/")
|
||||||
|
if err != ErrTargetNotAllowed {
|
||||||
|
t.Fatalf("ProxyHTTP() error = %v, want ErrTargetNotAllowed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyHTTPServesAllowedTarget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Location", "http://192.168.1.124/ISAPI/test")
|
||||||
|
w.Header().Add("Set-Cookie", "SID=1; Path=/")
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = w.Write([]byte(`<html><head></head><body><img src="/doc/logo.png"></body></html>`))
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
svc := NewService()
|
||||||
|
svc.allowIP("192.168.1.124")
|
||||||
|
svc.proxyTarget = func(ip string) string {
|
||||||
|
return upstream.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://portal/proxy/web/192.168.1.124/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
if err := svc.ProxyHTTP(rec, req, "192.168.1.124", "/"); err != nil {
|
||||||
|
t.Fatalf("ProxyHTTP() error = %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Location"); got != "http://192.168.1.124/ISAPI/test" {
|
||||||
|
t.Fatalf("Location = %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "/proxy/web/192.168.1.124/doc/logo.png") {
|
||||||
|
t.Fatalf("body = %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
240
internal/webdevice/rewrite.go
Normal file
240
internal/webdevice/rewrite.go
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
package webdevice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func JoinProxyTargetPath(basePath, requestPath string) string {
|
||||||
|
if requestPath == "" {
|
||||||
|
requestPath = "/"
|
||||||
|
}
|
||||||
|
if basePath == "" || basePath == "/" {
|
||||||
|
return requestPath
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(basePath, "/") && strings.HasPrefix(requestPath, "/") {
|
||||||
|
return basePath + strings.TrimPrefix(requestPath, "/")
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(basePath, "/") && !strings.HasPrefix(requestPath, "/") {
|
||||||
|
return basePath + "/" + requestPath
|
||||||
|
}
|
||||||
|
return basePath + requestPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func RewriteLocation(targetIP string, targetURL *url.URL, location string) string {
|
||||||
|
locationURL, err := url.Parse(location)
|
||||||
|
if err != nil {
|
||||||
|
return location
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyPrefix := "/proxy/web/" + targetIP
|
||||||
|
if locationURL.Host == "" && strings.HasPrefix(location, "/") {
|
||||||
|
return proxyPrefix + location
|
||||||
|
}
|
||||||
|
|
||||||
|
if locationURL.Host != "" {
|
||||||
|
locationHost := locationURL.Hostname()
|
||||||
|
locationPort := locationURL.Port()
|
||||||
|
if locationPort == "" && (locationURL.Scheme == "" || locationURL.Scheme == "http") {
|
||||||
|
locationPort = "80"
|
||||||
|
}
|
||||||
|
|
||||||
|
targetHost := targetURL.Hostname()
|
||||||
|
targetPort := targetURL.Port()
|
||||||
|
if targetPort == "" && targetURL.Scheme == "http" {
|
||||||
|
targetPort = "80"
|
||||||
|
}
|
||||||
|
|
||||||
|
if locationHost != targetHost || locationPort != targetPort {
|
||||||
|
return location
|
||||||
|
}
|
||||||
|
|
||||||
|
rewrittenPath := locationURL.EscapedPath()
|
||||||
|
if rewrittenPath == "" {
|
||||||
|
rewrittenPath = "/"
|
||||||
|
}
|
||||||
|
if locationURL.RawQuery != "" {
|
||||||
|
rewrittenPath += "?" + locationURL.RawQuery
|
||||||
|
}
|
||||||
|
if locationURL.Fragment != "" {
|
||||||
|
rewrittenPath += "#" + locationURL.Fragment
|
||||||
|
}
|
||||||
|
return proxyPrefix + rewrittenPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return location
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
webProxyQuotedAttrPattern = regexp.MustCompile(`(?i)\b(href|src|action|poster|data-src|data-href)\s*=\s*(['"])([^'"]*)['"]`)
|
||||||
|
webProxyBareAttrPattern = regexp.MustCompile(`(?i)\b(href|src|action|poster|data-src|data-href)\s*=\s*([^'">\s][^>\s]*)`)
|
||||||
|
webProxyCSSURLPattern = regexp.MustCompile(`(?i)url\(\s*(['"]?)([^'"\)\s]+)['"]?\s*\)`)
|
||||||
|
webProxyQuotedURLPattern = regexp.MustCompile(`(['"])(/[^'"<>\s\\)]*)['"]`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func ShouldRewriteBody(contentType string) bool {
|
||||||
|
contentType = strings.ToLower(contentType)
|
||||||
|
return strings.Contains(contentType, "text/html") ||
|
||||||
|
strings.Contains(contentType, "text/css")
|
||||||
|
}
|
||||||
|
|
||||||
|
func RewriteText(body, proxyPrefix string, targetURL *url.URL, contentType string) string {
|
||||||
|
contentType = strings.ToLower(contentType)
|
||||||
|
|
||||||
|
if strings.Contains(contentType, "text/html") {
|
||||||
|
body = webProxyQuotedAttrPattern.ReplaceAllStringFunc(body, func(match string) string {
|
||||||
|
parts := webProxyQuotedAttrPattern.FindStringSubmatch(match)
|
||||||
|
if len(parts) != 4 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
rewritten := rewriteURL(parts[3], proxyPrefix, targetURL)
|
||||||
|
return strings.Replace(match, parts[2]+parts[3]+parts[2], parts[2]+rewritten+parts[2], 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
body = webProxyBareAttrPattern.ReplaceAllStringFunc(body, func(match string) string {
|
||||||
|
parts := webProxyBareAttrPattern.FindStringSubmatch(match)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
rewritten := rewriteURL(parts[2], proxyPrefix, targetURL)
|
||||||
|
return strings.Replace(match, parts[2], rewritten, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
body = webProxyQuotedURLPattern.ReplaceAllStringFunc(body, func(match string) string {
|
||||||
|
parts := webProxyQuotedURLPattern.FindStringSubmatch(match)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
rewritten := rewriteURL(parts[2], proxyPrefix, targetURL)
|
||||||
|
return parts[1] + rewritten + parts[1]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
body = webProxyCSSURLPattern.ReplaceAllStringFunc(body, func(match string) string {
|
||||||
|
parts := webProxyCSSURLPattern.FindStringSubmatch(match)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
rewritten := rewriteURL(parts[2], proxyPrefix, targetURL)
|
||||||
|
return "url(" + parts[1] + rewritten + parts[1] + ")"
|
||||||
|
})
|
||||||
|
|
||||||
|
if strings.Contains(contentType, "text/html") {
|
||||||
|
body = injectRuntime(body, proxyPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func injectRuntime(body, proxyPrefix string) string {
|
||||||
|
if strings.Contains(body, "data-web-proxy-runtime") {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
script := `<script data-web-proxy-runtime>(function(){var p="` + proxyPrefix + `";var d=["/ISAPI","/SDK","/PSIA","/doc","/webSocket"];function q(x){if(x.indexOf(p+"/")===0||x.indexOf("/proxy/web/")===0){return x}for(var i=0;i<d.length;i++){if(x===d[i]||x.indexOf(d[i]+"/")===0||x.indexOf(d[i]+"?")===0){return p+x}}return x}function r(u){if(typeof u!=="string"){return u}if(u.charAt(0)==="/"&&u.indexOf("//")!==0){return q(u)}try{var a=new URL(u,window.location.href);if(a.origin===window.location.origin){var x=a.pathname+a.search+a.hash;var y=q(x);if(y!==x){return y}}}catch(e){}return u}if(window.XMLHttpRequest){var o=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(m,u){arguments[1]=r(u);return o.apply(this,arguments)}}if(window.fetch){var f=window.fetch;window.fetch=function(i,n){if(typeof i==="string"){i=r(i)}else if(i&&i.url){i=new Request(r(i.url),i)}return f.call(this,i,n)}}function a(e){if(!e||!e.getAttribute){return}["src","href","action","data-src","data-href"].forEach(function(k){var v=e.getAttribute(k);if(v){var nv=r(v);if(nv!==v){e.setAttribute(k,nv)}}})}if(window.MutationObserver){new MutationObserver(function(ms){ms.forEach(function(m){if(m.type==="attributes"){a(m.target)}else{Array.prototype.forEach.call(m.addedNodes,function(n){a(n);if(n&&n.querySelectorAll){Array.prototype.forEach.call(n.querySelectorAll("[src],[href],[action],[data-src],[data-href]"),a)}})}})}).observe(document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:["src","href","action","data-src","data-href"]})}})();</script>`
|
||||||
|
|
||||||
|
lower := strings.ToLower(body)
|
||||||
|
if idx := strings.Index(lower, "</head>"); idx >= 0 {
|
||||||
|
return body[:idx] + script + body[idx:]
|
||||||
|
}
|
||||||
|
if idx := strings.Index(lower, "<body"); idx >= 0 {
|
||||||
|
if end := strings.Index(body[idx:], ">"); end >= 0 {
|
||||||
|
insertAt := idx + end + 1
|
||||||
|
return body[:insertAt] + script + body[insertAt:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return script + body
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewriteURL(rawURL, proxyPrefix string, targetURL *url.URL) string {
|
||||||
|
rawURL = strings.TrimSpace(rawURL)
|
||||||
|
if rawURL == "" ||
|
||||||
|
strings.HasPrefix(rawURL, "#") ||
|
||||||
|
strings.HasPrefix(rawURL, "//") ||
|
||||||
|
strings.HasPrefix(rawURL, proxyPrefix+"/") ||
|
||||||
|
strings.HasPrefix(rawURL, "/proxy/web/") {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
|
||||||
|
lower := strings.ToLower(rawURL)
|
||||||
|
for _, prefix := range []string{"data:", "blob:", "mailto:", "tel:", "javascript:"} {
|
||||||
|
if strings.HasPrefix(lower, prefix) {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(rawURL, "/") {
|
||||||
|
return proxyPrefix + rawURL
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil || parsed.Host == "" {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
if targetURL == nil || !sameUpstreamHost(parsed, targetURL) {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
|
||||||
|
rewrittenPath := parsed.EscapedPath()
|
||||||
|
if rewrittenPath == "" {
|
||||||
|
rewrittenPath = "/"
|
||||||
|
}
|
||||||
|
if parsed.RawQuery != "" {
|
||||||
|
rewrittenPath += "?" + parsed.RawQuery
|
||||||
|
}
|
||||||
|
if parsed.Fragment != "" {
|
||||||
|
rewrittenPath += "#" + parsed.Fragment
|
||||||
|
}
|
||||||
|
return proxyPrefix + rewrittenPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameUpstreamHost(left, right *url.URL) bool {
|
||||||
|
leftPort := left.Port()
|
||||||
|
if leftPort == "" && (left.Scheme == "" || left.Scheme == "http") {
|
||||||
|
leftPort = "80"
|
||||||
|
}
|
||||||
|
if leftPort == "" && left.Scheme == "https" {
|
||||||
|
leftPort = "443"
|
||||||
|
}
|
||||||
|
|
||||||
|
rightPort := right.Port()
|
||||||
|
if rightPort == "" && (right.Scheme == "" || right.Scheme == "http") {
|
||||||
|
rightPort = "80"
|
||||||
|
}
|
||||||
|
if rightPort == "" && right.Scheme == "https" {
|
||||||
|
rightPort = "443"
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.EqualFold(left.Hostname(), right.Hostname()) && leftPort == rightPort
|
||||||
|
}
|
||||||
|
|
||||||
|
func RewriteSetCookie(cookie, proxyPrefix string) string {
|
||||||
|
parts := strings.Split(cookie, ";")
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
rewritten := []string{strings.TrimSpace(parts[0])}
|
||||||
|
hasPath := false
|
||||||
|
for _, part := range parts[1:] {
|
||||||
|
attr := strings.TrimSpace(part)
|
||||||
|
if attr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(attr)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(lower, "domain="):
|
||||||
|
continue
|
||||||
|
case strings.HasPrefix(lower, "path="):
|
||||||
|
hasPath = true
|
||||||
|
rewritten = append(rewritten, "Path="+proxyPrefix+"/")
|
||||||
|
default:
|
||||||
|
rewritten = append(rewritten, attr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasPath {
|
||||||
|
rewritten = append(rewritten, "Path="+proxyPrefix+"/")
|
||||||
|
}
|
||||||
|
return strings.Join(rewritten, "; ")
|
||||||
|
}
|
||||||
495
internal/webdevice/service.go
Normal file
495
internal/webdevice/service.go
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
package webdevice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InterfaceInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Netmask string `json:"netmask"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TCPDevice struct {
|
||||||
|
IP string
|
||||||
|
Port int
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceInfo struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Interface string `json:"interface"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
TargetURL string `json:"target_url"`
|
||||||
|
ProxyURL string `json:"proxy_url"`
|
||||||
|
DirectURL string `json:"direct_url,omitempty"`
|
||||||
|
ForwardPort int `json:"forward_port,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScanResult struct {
|
||||||
|
Interfaces []InterfaceInfo `json:"interfaces"`
|
||||||
|
Devices []DeviceInfo `json:"devices"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InterfaceGetter func() ([]InterfaceInfo, error)
|
||||||
|
type TCPScanner func(ip, netmask string, port int, excludeIPs map[string]bool) ([]TCPDevice, error)
|
||||||
|
type ForwarderFactory func(ip string, port int, listenAddress, targetAddress string) (*webDeviceForwarder, error)
|
||||||
|
type ProxyTargetResolver func(ip string) string
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
allowed map[string]time.Time
|
||||||
|
forwarders map[string]*webDeviceForwarder
|
||||||
|
interfaceGetter InterfaceGetter
|
||||||
|
tcpScanner TCPScanner
|
||||||
|
newForwarder ForwarderFactory
|
||||||
|
proxyTarget ProxyTargetResolver
|
||||||
|
forwardTarget func(ip string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService() *Service {
|
||||||
|
return &Service{
|
||||||
|
allowed: make(map[string]time.Time),
|
||||||
|
forwarders: make(map[string]*webDeviceForwarder),
|
||||||
|
interfaceGetter: defaultInterfaceGetter,
|
||||||
|
tcpScanner: scanTCP,
|
||||||
|
newForwarder: newWebDeviceForwarder,
|
||||||
|
proxyTarget: defaultProxyTarget,
|
||||||
|
forwardTarget: defaultForwardTarget,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Scan(r *http.Request) (*ScanResult, error) {
|
||||||
|
interfaces, err := s.interfaceGetter()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(interfaces) == 0 {
|
||||||
|
return &ScanResult{
|
||||||
|
Interfaces: []InterfaceInfo{},
|
||||||
|
Devices: []DeviceInfo{},
|
||||||
|
Message: "未找到有效的网卡",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
excludeIPs := make(map[string]bool)
|
||||||
|
for _, iface := range interfaces {
|
||||||
|
excludeIPs[iface.IP] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &ScanResult{
|
||||||
|
Interfaces: interfaces,
|
||||||
|
Devices: []DeviceInfo{},
|
||||||
|
Errors: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme, host := requestBase(r)
|
||||||
|
for _, iface := range interfaces {
|
||||||
|
devices, scanErr := s.tcpScanner(iface.IP, iface.Netmask, 80, excludeIPs)
|
||||||
|
if scanErr != nil {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", iface.Name, scanErr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, device := range devices {
|
||||||
|
if !IsPrivateIPv4Literal(device.IP) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s.allowIP(device.IP)
|
||||||
|
forwardPort, forwardErr := s.EnsureForwarder(device.IP)
|
||||||
|
if forwardErr != nil {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("%s: 启动网页直连转发失败: %v", device.IP, forwardErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceInfo := DeviceInfo{
|
||||||
|
IP: device.IP,
|
||||||
|
Interface: iface.Name,
|
||||||
|
Port: device.Port,
|
||||||
|
TargetURL: fmt.Sprintf("http://%s/", device.IP),
|
||||||
|
ProxyURL: fmt.Sprintf("/proxy/web/%s/", device.IP),
|
||||||
|
}
|
||||||
|
if forwardErr == nil {
|
||||||
|
deviceInfo.ForwardPort = forwardPort
|
||||||
|
deviceInfo.DirectURL = buildDirectURL(scheme, host, forwardPort)
|
||||||
|
}
|
||||||
|
result.Devices = append(result.Devices, deviceInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(result.Devices, func(i, j int) bool {
|
||||||
|
return ipv4ToUint32(result.Devices[i].IP) < ipv4ToUint32(result.Devices[j].IP)
|
||||||
|
})
|
||||||
|
result.Count = len(result.Devices)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) allowIP(ip string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.allowed[ip] = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AllowIP(ip string) {
|
||||||
|
s.allowIP(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) IsAllowed(ip string) bool {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
_, ok := s.allowed[ip]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetInterfaceGetter(getter InterfaceGetter) {
|
||||||
|
if getter != nil {
|
||||||
|
s.interfaceGetter = getter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetTCPScanner(scanner TCPScanner) {
|
||||||
|
if scanner != nil {
|
||||||
|
s.tcpScanner = scanner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetForwarderFactory(factory ForwarderFactory) {
|
||||||
|
if factory != nil {
|
||||||
|
s.newForwarder = factory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetProxyTargetResolver(resolver ProxyTargetResolver) {
|
||||||
|
if resolver != nil {
|
||||||
|
s.proxyTarget = resolver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) EnsureForwarder(ip string) (int, error) {
|
||||||
|
port, ok := WebDeviceForwardPort(ip)
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("无效的IPv4地址")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if forwarder, ok := s.forwarders[ip]; ok {
|
||||||
|
return forwarder.port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
targetAddress := defaultForwardTarget(ip)
|
||||||
|
if s.forwardTarget != nil {
|
||||||
|
targetAddress = s.forwardTarget(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
forwarder, err := s.newForwarder(ip, port, net.JoinHostPort("0.0.0.0", strconv.Itoa(port)), targetAddress)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
s.forwarders[ip] = forwarder
|
||||||
|
go forwarder.serve()
|
||||||
|
return port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ProxyTargetURL(ip string) string {
|
||||||
|
if s.proxyTarget == nil {
|
||||||
|
return defaultProxyTarget(ip)
|
||||||
|
}
|
||||||
|
return s.proxyTarget(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsPrivateIPv4Literal(value string) bool {
|
||||||
|
ip := net.ParseIP(value)
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ip4 := ip.To4()
|
||||||
|
if ip4 == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ip4.IsPrivate() && !ip4.IsLoopback() && !ip4.IsMulticast() && !ip4.IsUnspecified()
|
||||||
|
}
|
||||||
|
|
||||||
|
func WebDeviceForwardPort(ip string) (int, bool) {
|
||||||
|
parsed := net.ParseIP(ip)
|
||||||
|
if parsed == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
ip4 := parsed.To4()
|
||||||
|
if ip4 == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return 31000 + int(ip4[3]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestBase(r *http.Request) (string, string) {
|
||||||
|
scheme := r.Header.Get("X-Forwarded-Proto")
|
||||||
|
if scheme == "" {
|
||||||
|
scheme = "http"
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
host := r.Header.Get("X-Forwarded-Host")
|
||||||
|
if host == "" {
|
||||||
|
host = r.Host
|
||||||
|
}
|
||||||
|
if hostname, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
host = hostname
|
||||||
|
}
|
||||||
|
return scheme, host
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDirectURL(scheme, host string, port int) string {
|
||||||
|
return scheme + "://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
type webDeviceForwarder struct {
|
||||||
|
ip string
|
||||||
|
port int
|
||||||
|
targetAddress string
|
||||||
|
listener net.Listener
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebDeviceForwarder = webDeviceForwarder
|
||||||
|
|
||||||
|
func newWebDeviceForwarder(ip string, port int, listenAddress, targetAddress string) (*webDeviceForwarder, error) {
|
||||||
|
listener, err := net.Listen("tcp", listenAddress)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if port == 0 {
|
||||||
|
if tcpAddr, ok := listener.Addr().(*net.TCPAddr); ok {
|
||||||
|
port = tcpAddr.Port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &webDeviceForwarder{
|
||||||
|
ip: ip,
|
||||||
|
port: port,
|
||||||
|
targetAddress: targetAddress,
|
||||||
|
listener: listener,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *webDeviceForwarder) serve() {
|
||||||
|
if f == nil || f.listener == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
clientConn, err := f.listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go f.handle(clientConn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *webDeviceForwarder) handle(clientConn net.Conn) {
|
||||||
|
targetConn, err := net.DialTimeout("tcp", f.targetAddress, 10*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
_ = clientConn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 2)
|
||||||
|
go func() {
|
||||||
|
_, err := ioCopy(targetConn, clientConn)
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
_, err := ioCopy(clientConn, targetConn)
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-errCh
|
||||||
|
_ = clientConn.Close()
|
||||||
|
_ = targetConn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var ioCopy = func(dst net.Conn, src net.Conn) (int64, error) {
|
||||||
|
return copyConn(dst, src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyConn(dst net.Conn, src net.Conn) (int64, error) {
|
||||||
|
return io.Copy(dst, src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultProxyTarget(ip string) string {
|
||||||
|
return "http://" + net.JoinHostPort(ip, "80")
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultForwardTarget(ip string) string {
|
||||||
|
return net.JoinHostPort(ip, "80")
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultInterfaceGetter() ([]InterfaceInfo, error) {
|
||||||
|
var interfaces []InterfaceInfo
|
||||||
|
|
||||||
|
ifaces, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取网卡列表失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, iface := range ifaces {
|
||||||
|
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(iface.Name, "docker") ||
|
||||||
|
strings.Contains(iface.Name, "veth") ||
|
||||||
|
strings.Contains(iface.Name, "br-") ||
|
||||||
|
strings.Contains(iface.Name, "tun") ||
|
||||||
|
strings.Contains(iface.Name, "tap") ||
|
||||||
|
strings.HasPrefix(iface.Name, "lo") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
addrs, err := iface.Addrs()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, addr := range addrs {
|
||||||
|
ipNet, ok := addr.(*net.IPNet)
|
||||||
|
if !ok || ipNet.IP.To4() == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
interfaces = append(interfaces, InterfaceInfo{
|
||||||
|
Name: iface.Name,
|
||||||
|
IP: ipNet.IP.String(),
|
||||||
|
Netmask: net.IP(ipNet.Mask).String(),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return interfaces, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanTCP(ip string, netmask string, port int, excludeIPs map[string]bool) ([]TCPDevice, error) {
|
||||||
|
ipRange, err := calculateIPRange(ip, netmask)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if port <= 0 || port > 65535 {
|
||||||
|
return nil, fmt.Errorf("无效的端口: %d", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
var devices []TCPDevice
|
||||||
|
var mu sync.Mutex
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
semaphore := make(chan struct{}, 20)
|
||||||
|
timeout := 2 * time.Second
|
||||||
|
|
||||||
|
current := make(net.IP, len(ipRange.Start))
|
||||||
|
copy(current, ipRange.Start)
|
||||||
|
incrementIP(current)
|
||||||
|
|
||||||
|
for {
|
||||||
|
if current.To4().Equal(ipRange.End.To4()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIP := current.String()
|
||||||
|
if excludeIPs[currentIP] {
|
||||||
|
incrementIP(current)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
semaphore <- struct{}{}
|
||||||
|
go func(targetIP string) {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-semaphore }()
|
||||||
|
|
||||||
|
if scanTCPPort(targetIP, port, timeout) {
|
||||||
|
mu.Lock()
|
||||||
|
devices = append(devices, TCPDevice{IP: targetIP, Port: port})
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}(currentIP)
|
||||||
|
|
||||||
|
incrementIP(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
sort.Slice(devices, func(i, j int) bool {
|
||||||
|
return ipv4ToUint32(devices[i].IP) < ipv4ToUint32(devices[j].IP)
|
||||||
|
})
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ipRange struct {
|
||||||
|
Start net.IP
|
||||||
|
End net.IP
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateIPRange(ip string, netmask string) (*ipRange, error) {
|
||||||
|
parseIP := net.ParseIP(ip)
|
||||||
|
if parseIP == nil {
|
||||||
|
return nil, fmt.Errorf("无效的IP: %s", ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
mask := net.IPMask(net.ParseIP(netmask).To4())
|
||||||
|
if mask == nil {
|
||||||
|
return nil, fmt.Errorf("无效的子网掩码: %s", netmask)
|
||||||
|
}
|
||||||
|
|
||||||
|
network := &net.IPNet{
|
||||||
|
IP: parseIP.Mask(mask),
|
||||||
|
Mask: mask,
|
||||||
|
}
|
||||||
|
broadcast := make(net.IP, len(network.IP))
|
||||||
|
copy(broadcast, network.IP)
|
||||||
|
for i := 0; i < len(mask); i++ {
|
||||||
|
broadcast[i] |= ^mask[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ipRange{Start: network.IP.To4(), End: broadcast}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanTCPPort(ip string, port int, timeout time.Duration) bool {
|
||||||
|
if port <= 0 || port > 65535 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func incrementIP(ip net.IP) {
|
||||||
|
for i := len(ip) - 1; i >= 0; i-- {
|
||||||
|
ip[i]++
|
||||||
|
if ip[i] > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipv4ToUint32(value string) uint32 {
|
||||||
|
parsed := net.ParseIP(value)
|
||||||
|
if parsed == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
ip := parsed.To4()
|
||||||
|
if ip == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
|
||||||
|
}
|
||||||
73
internal/webdevice/service_test.go
Normal file
73
internal/webdevice/service_test.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package webdevice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsPrivateIPv4Literal(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := map[string]bool{
|
||||||
|
"192.168.1.10": true,
|
||||||
|
"10.0.0.8": true,
|
||||||
|
"172.16.5.2": true,
|
||||||
|
"127.0.0.1": false,
|
||||||
|
"8.8.8.8": false,
|
||||||
|
"0.0.0.0": false,
|
||||||
|
"::1": false,
|
||||||
|
"bad-ip": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for input, want := range cases {
|
||||||
|
if got := IsPrivateIPv4Literal(input); got != want {
|
||||||
|
t.Fatalf("IsPrivateIPv4Literal(%q) = %v, want %v", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebDeviceForwardPort(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
port, ok := WebDeviceForwardPort("192.168.1.124")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("WebDeviceForwardPort() ok = false")
|
||||||
|
}
|
||||||
|
if port != 31124 {
|
||||||
|
t.Fatalf("port = %d, want 31124", port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanBuildsDirectURLAndAllowList(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc := NewService()
|
||||||
|
svc.interfaceGetter = func() ([]InterfaceInfo, error) {
|
||||||
|
return []InterfaceInfo{{
|
||||||
|
Name: "eth0",
|
||||||
|
IP: "10.8.0.14",
|
||||||
|
Netmask: "255.255.255.0",
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
svc.tcpScanner = func(ip, netmask string, port int, excludeIPs map[string]bool) ([]TCPDevice, error) {
|
||||||
|
return []TCPDevice{{IP: "192.168.1.124", Port: 80}}, nil
|
||||||
|
}
|
||||||
|
svc.newForwarder = func(ip string, port int, listenAddress, targetAddress string) (*webDeviceForwarder, error) {
|
||||||
|
return &webDeviceForwarder{ip: ip, port: port, targetAddress: targetAddress}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "http://10.8.0.14:13000/api/web-devices/scan", nil)
|
||||||
|
result, err := svc.Scan(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Scan() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Count != 1 {
|
||||||
|
t.Fatalf("result.Count = %d", result.Count)
|
||||||
|
}
|
||||||
|
if !svc.IsAllowed("192.168.1.124") {
|
||||||
|
t.Fatal("expected IP to be allowed after scan")
|
||||||
|
}
|
||||||
|
if result.Devices[0].DirectURL != "http://10.8.0.14:31124/" {
|
||||||
|
t.Fatalf("DirectURL = %q", result.Devices[0].DirectURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
managed/people_flow_project/.dockerignore
Normal file
7
managed/people_flow_project/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
outputs
|
||||||
|
people_flow_project_backup_2026-04-08
|
||||||
|
docs/plans
|
||||||
8
managed/people_flow_project/.gitignore
vendored
Normal file
8
managed/people_flow_project/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.DS_Store
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
config/local.yaml
|
||||||
|
outputs/
|
||||||
|
wheelhouse/
|
||||||
|
weights/*.pt
|
||||||
|
weights/deepface/*.h5
|
||||||
44
managed/people_flow_project/Dockerfile
Normal file
44
managed/people_flow_project/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||||
|
DEEPFACE_HOME=/root/.deepface \
|
||||||
|
TF_CPP_MIN_LOG_LEVEL=2
|
||||||
|
|
||||||
|
WORKDIR /opt/people-flow
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libgl1 \
|
||||||
|
libgomp1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements-docker.txt ./requirements-docker.txt
|
||||||
|
|
||||||
|
RUN python -m pip install --upgrade pip setuptools wheel && \
|
||||||
|
pip install "numpy<2" && \
|
||||||
|
pip install --extra-index-url https://download.pytorch.org/whl/cpu \
|
||||||
|
"torch==2.6.0+cpu" "torchvision==0.21.0+cpu" && \
|
||||||
|
pip install "tensorflow==2.16.1" "tf-keras==2.16.0" && \
|
||||||
|
pip install -r requirements-docker.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
COPY scripts/docker-entrypoint.sh /opt/people-flow/scripts/docker-entrypoint.sh
|
||||||
|
|
||||||
|
RUN test -f /opt/people-flow/weights/yolo11n.pt && \
|
||||||
|
test -f /opt/people-flow/weights/deepface/age_model_weights.h5 && \
|
||||||
|
test -f /opt/people-flow/weights/deepface/gender_model_weights.h5 && \
|
||||||
|
test -f /opt/people-flow/weights/deepface/retinaface.h5 && \
|
||||||
|
mkdir -p /root/.deepface/weights /opt/people-flow/outputs && \
|
||||||
|
cp /opt/people-flow/weights/deepface/*.h5 /root/.deepface/weights/ && \
|
||||||
|
chmod +x /opt/people-flow/scripts/docker-entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 18082
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18082/api/manage/health', timeout=3).read()" || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/opt/people-flow/scripts/docker-entrypoint.sh"]
|
||||||
144
managed/people_flow_project/README.md
Normal file
144
managed/people_flow_project/README.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# People Flow Project
|
||||||
|
|
||||||
|
People flow analysis for street videos using YOLO tracking and face-based demographic estimation.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- Counts unique people when they cross a configured line
|
||||||
|
- Estimates one age bucket per counted track: `minor`, `adult`, or `senior`
|
||||||
|
- Estimates one gender bucket per counted track: `male` or `female`
|
||||||
|
- Writes an annotated output video, per-video JSON, and batch summary CSV
|
||||||
|
|
||||||
|
## Pipeline
|
||||||
|
|
||||||
|
1. Detect and track `person` objects with Ultralytics YOLO.
|
||||||
|
2. Assign a stable `track_id` with BoT-SORT or ByteTrack.
|
||||||
|
3. Count each track once when it crosses the configured line.
|
||||||
|
4. Sample person crops for each track and run DeepFace age/gender analysis.
|
||||||
|
5. Use track-level voting so each counted person lands in only one age bucket and one gender bucket.
|
||||||
|
|
||||||
|
## Project Layout
|
||||||
|
|
||||||
|
- `main.py`: CLI entrypoint
|
||||||
|
- `src/people_flow/`: application modules
|
||||||
|
- `configs/default_config.yaml`: default runtime settings
|
||||||
|
- `outputs/`: generated result files
|
||||||
|
- `docs/plans/`: design and implementation notes
|
||||||
|
|
||||||
|
## Recommended Environment
|
||||||
|
|
||||||
|
- Linux
|
||||||
|
- NVIDIA GPU with CUDA
|
||||||
|
- Python `3.10` or `3.11`
|
||||||
|
|
||||||
|
`deepface` and its transitive dependencies are not a good fit for Python `3.14`, so do not build this environment on the current local interpreter version.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3.11 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Single Video
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py video \
|
||||||
|
--input "/path/to/video.mp4" \
|
||||||
|
--line "0.1,0.55,0.9,0.55"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Batch Directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py batch \
|
||||||
|
--input-dir "/path/to/videos" \
|
||||||
|
--line "0.1,0.55,0.9,0.55"
|
||||||
|
```
|
||||||
|
|
||||||
|
## RTSP Stream
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py --output-dir outputs rtsp \
|
||||||
|
--input "rtsp://user:password@host:554/stream"
|
||||||
|
```
|
||||||
|
|
||||||
|
RTSP mode behaves differently from offline video mode:
|
||||||
|
|
||||||
|
- The stream is sampled at one processed frame per second
|
||||||
|
- Statistics are isolated into 30-minute windows
|
||||||
|
- Each completed window writes one JSON file
|
||||||
|
- `latest.json` is overwritten on every completed window
|
||||||
|
- RTSP mode does not save annotated video by default
|
||||||
|
|
||||||
|
## Output Files
|
||||||
|
|
||||||
|
Each processed video produces:
|
||||||
|
|
||||||
|
- `outputs/<video_stem>/<video_stem>.annotated.mp4`
|
||||||
|
- `outputs/<video_stem>/<video_stem>.json`
|
||||||
|
|
||||||
|
Batch mode also produces:
|
||||||
|
|
||||||
|
- `outputs/batch_summary.csv`
|
||||||
|
|
||||||
|
RTSP mode produces:
|
||||||
|
|
||||||
|
- `outputs/rtsp_stream/latest.json`
|
||||||
|
- `outputs/rtsp_stream/windows/stats_YYYY-MM-DD_HH-MM-SS.json`
|
||||||
|
|
||||||
|
## Docker On Ubuntu 24.04 x86_64
|
||||||
|
|
||||||
|
The project can be packaged for an x86_64 NVIDIA host with Docker. The expected weight layout is:
|
||||||
|
|
||||||
|
- `weights/yolo11n.pt`
|
||||||
|
- `weights/deepface/age_model_weights.h5`
|
||||||
|
- `weights/deepface/gender_model_weights.h5`
|
||||||
|
- `weights/deepface/retinaface.h5`
|
||||||
|
|
||||||
|
Build the image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t people-flow-project:test .
|
||||||
|
```
|
||||||
|
|
||||||
|
The Docker image uses [`requirements-docker.txt`](/Users/zxmacmini1/Documents/人流检测/people_flow_project/requirements-docker.txt) so the container installs `opencv-python-headless` instead of the desktop OpenCV wheel.
|
||||||
|
|
||||||
|
The image bakes in all runtime weights and copies the DeepFace `.h5` files into `~/.deepface/weights` during build.
|
||||||
|
|
||||||
|
Run the management API container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name people-flow-project \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--gpus all \
|
||||||
|
--shm-size 1g \
|
||||||
|
-p 18082:18082 \
|
||||||
|
-e RTSP_URL="rtsp://user:password@host:554/stream" \
|
||||||
|
-v /path/to/config:/opt/people-flow/config \
|
||||||
|
-v /path/to/outputs:/opt/people-flow/outputs \
|
||||||
|
people-flow-project:test
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use Compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build people-flow-project
|
||||||
|
```
|
||||||
|
|
||||||
|
Container behavior:
|
||||||
|
|
||||||
|
- Seeds `config/local.yaml` from `config/config.example.yaml` when needed
|
||||||
|
- Writes RTSP updates through the child API to `runtime.rtsp_url`
|
||||||
|
- Exposes `GET /api/manage/health` on `http://127.0.0.1:18082`
|
||||||
|
- Persists config and outputs through mounted `./config` and `./outputs`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `minor` means age `< 18`
|
||||||
|
- `adult` means age `18-59`
|
||||||
|
- `senior` means age `>= 60`
|
||||||
|
- Tracks without a reliable face result are counted only in `total_people` and `unknown_attributes`
|
||||||
65
managed/people_flow_project/README_NATIVE.md
Normal file
65
managed/people_flow_project/README_NATIVE.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Native RTSP Bundle
|
||||||
|
|
||||||
|
This bundle is for lightweight native deployment on an x86_64 Ubuntu host.
|
||||||
|
|
||||||
|
## What To Edit
|
||||||
|
|
||||||
|
Open [`scripts/run.sh`](/Users/zxmacmini1/Documents/人流检测/people_flow_project/scripts/run.sh) and edit only these two lines:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RTSP_URL="rtsp://..."
|
||||||
|
OUTPUT_DIR="/home/x/people/output"
|
||||||
|
```
|
||||||
|
|
||||||
|
## First-Time Setup
|
||||||
|
|
||||||
|
From the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash scripts/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `.venv`, installs Python dependencies, copies the bundled DeepFace weights into `~/.deepface/weights`, installs the `systemd` unit, starts the service, and enables it on boot.
|
||||||
|
|
||||||
|
## Build An Offline Dependency Pack
|
||||||
|
|
||||||
|
If you want future installs to avoid re-downloading Python packages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build_wheelhouse.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a local `wheelhouse/` directory for Ubuntu 24.04 x86_64 + Python 3.12. After that, `./setup_native_venv.sh` will automatically prefer local wheels.
|
||||||
|
|
||||||
|
## Start The RTSP Task
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status people-flow.service
|
||||||
|
```
|
||||||
|
|
||||||
|
The service runs in the foreground under `systemd`.
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
- Latest half-hour summary: `OUTPUT_DIR/rtsp_stream/latest.json`
|
||||||
|
- Historical half-hour summaries: `OUTPUT_DIR/rtsp_stream/windows/`
|
||||||
|
- Runtime log: `OUTPUT_DIR/rtsp_run.log`
|
||||||
|
|
||||||
|
## Chinese Guide
|
||||||
|
|
||||||
|
- `README_zh.md`
|
||||||
|
|
||||||
|
## Weights
|
||||||
|
|
||||||
|
The project expects these local files:
|
||||||
|
|
||||||
|
- `weights/yolo11n.pt`
|
||||||
|
- `weights/deepface/age_model_weights.h5`
|
||||||
|
- `weights/deepface/gender_model_weights.h5`
|
||||||
|
- `weights/deepface/retinaface.h5`
|
||||||
|
|
||||||
|
At setup time and each RTSP launch, those `.h5` files are copied into the current user's default DeepFace directory:
|
||||||
|
|
||||||
|
- `~/.deepface/weights/`
|
||||||
|
|
||||||
|
That keeps the bundle portable across different unpack paths such as `/home/x/people` and `/home/xiaozheng/people`.
|
||||||
72
managed/people_flow_project/README_zh.md
Normal file
72
managed/people_flow_project/README_zh.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# 人流检测项目中文说明
|
||||||
|
|
||||||
|
这个项目用于基于 `YOLO + DeepFace` 的视频/RTSP 人流检测与属性统计。
|
||||||
|
|
||||||
|
## 当前交付方式
|
||||||
|
|
||||||
|
这个版本已经改成:
|
||||||
|
|
||||||
|
- 使用 `config/local.yaml` 作为本地运行配置
|
||||||
|
- 使用 `scripts/run.sh` 生成本地配置并前台运行
|
||||||
|
- 使用 `systemd` 托管长期运行
|
||||||
|
- 安装完成后自动启动
|
||||||
|
- 开机自动启动
|
||||||
|
|
||||||
|
## 目标机器
|
||||||
|
|
||||||
|
- `Ubuntu 24.04`
|
||||||
|
- `Python 3.12`
|
||||||
|
- NVIDIA 显卡可用
|
||||||
|
- `nvidia-smi` 可正常执行
|
||||||
|
|
||||||
|
## 安装前需要修改
|
||||||
|
|
||||||
|
先编辑 `scripts/run.sh`,至少改:
|
||||||
|
|
||||||
|
- `RTSP_URL`
|
||||||
|
- `OUTPUT_DIR`
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
在项目根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash scripts/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
安装脚本会自动:
|
||||||
|
|
||||||
|
- 检查并安装 `ffmpeg`
|
||||||
|
- 检查并安装 `python3.12-venv`
|
||||||
|
- 创建 `.venv`
|
||||||
|
- 安装 Python 依赖
|
||||||
|
- 复制 DeepFace 权重到 `~/.deepface/weights`
|
||||||
|
- 生成 `config/local.yaml`
|
||||||
|
- 安装 `systemd` 服务
|
||||||
|
- 自动启动服务
|
||||||
|
- 设置开机自启
|
||||||
|
|
||||||
|
## 服务管理
|
||||||
|
|
||||||
|
服务名:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
people-flow.service
|
||||||
|
```
|
||||||
|
|
||||||
|
常用命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status people-flow.service
|
||||||
|
sudo systemctl restart people-flow.service
|
||||||
|
sudo systemctl stop people-flow.service
|
||||||
|
sudo systemctl start people-flow.service
|
||||||
|
sudo systemctl disable people-flow.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## 输出位置
|
||||||
|
|
||||||
|
- 运行日志:`outputs/rtsp_run.log`
|
||||||
|
- 最新半小时汇总:`OUTPUT_DIR/rtsp_stream/latest.json`
|
||||||
|
- 历史窗口汇总:`OUTPUT_DIR/rtsp_stream/windows/`
|
||||||
|
- 本地配置:`config/local.yaml`
|
||||||
31
managed/people_flow_project/build_wheelhouse.sh
Executable file
31
managed/people_flow_project/build_wheelhouse.sh
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$SCRIPT_DIR"
|
||||||
|
WHEELHOUSE_DIR="$PROJECT_ROOT/wheelhouse"
|
||||||
|
|
||||||
|
mkdir -p "$WHEELHOUSE_DIR"
|
||||||
|
|
||||||
|
python3 -m venv "$PROJECT_ROOT/.wheelhouse-venv"
|
||||||
|
source "$PROJECT_ROOT/.wheelhouse-venv/bin/activate"
|
||||||
|
|
||||||
|
python -m pip install --upgrade pip setuptools wheel
|
||||||
|
|
||||||
|
pip download -d "$WHEELHOUSE_DIR" pip setuptools wheel
|
||||||
|
pip download -d "$WHEELHOUSE_DIR" "numpy<2"
|
||||||
|
pip download -d "$WHEELHOUSE_DIR" \
|
||||||
|
--index-url https://download.pytorch.org/whl/cu126 \
|
||||||
|
--extra-index-url https://pypi.nvidia.com \
|
||||||
|
torch torchvision
|
||||||
|
pip download -d "$WHEELHOUSE_DIR" \
|
||||||
|
--extra-index-url https://pypi.nvidia.com \
|
||||||
|
"tensorflow[and-cuda]==2.16.1" "tf-keras==2.16.0"
|
||||||
|
pip download -d "$WHEELHOUSE_DIR" \
|
||||||
|
--find-links "$WHEELHOUSE_DIR" \
|
||||||
|
-c "$PROJECT_ROOT/constraints-wheelhouse.txt" \
|
||||||
|
-r "$PROJECT_ROOT/requirements-native.txt"
|
||||||
|
|
||||||
|
deactivate
|
||||||
|
|
||||||
|
echo "wheelhouse_ready=$WHEELHOUSE_DIR"
|
||||||
41
managed/people_flow_project/config/config.example.yaml
Normal file
41
managed/people_flow_project/config/config.example.yaml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
runtime:
|
||||||
|
rtsp_url: "rtsp://user:password@camera-ip:554/h264/ch1/main/av_stream"
|
||||||
|
output_dir: "outputs"
|
||||||
|
|
||||||
|
yolo:
|
||||||
|
model_path: "weights/yolo11n.pt"
|
||||||
|
tracker: "botsort.yaml"
|
||||||
|
conf: 0.35
|
||||||
|
iou: 0.5
|
||||||
|
imgsz: 1280
|
||||||
|
device: "cuda:0"
|
||||||
|
|
||||||
|
counting:
|
||||||
|
line: [0.1, 0.55, 0.9, 0.55]
|
||||||
|
line_mode: "normalized"
|
||||||
|
crossing_tolerance: 12.0
|
||||||
|
|
||||||
|
attributes:
|
||||||
|
enabled: false
|
||||||
|
sample_every_n_frames: 12
|
||||||
|
max_samples_per_track: 5
|
||||||
|
min_person_box_width: 80
|
||||||
|
min_person_box_height: 160
|
||||||
|
person_crop_padding: 0.15
|
||||||
|
detector_backend: "retinaface"
|
||||||
|
enforce_detection: false
|
||||||
|
|
||||||
|
output:
|
||||||
|
save_video: false
|
||||||
|
save_json: true
|
||||||
|
save_csv: true
|
||||||
|
draw_boxes: false
|
||||||
|
draw_labels: false
|
||||||
|
|
||||||
|
rtsp:
|
||||||
|
sample_interval_seconds: 1.0
|
||||||
|
window_seconds: 1800
|
||||||
|
reconnect_delay_seconds: 5.0
|
||||||
|
stream_open_timeout_seconds: 10.0
|
||||||
|
idle_sleep_seconds: 0.05
|
||||||
|
output_subdir: "rtsp_stream"
|
||||||
37
managed/people_flow_project/configs/default_config.yaml
Normal file
37
managed/people_flow_project/configs/default_config.yaml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
yolo:
|
||||||
|
model_path: "yolo11n.pt"
|
||||||
|
tracker: "botsort.yaml"
|
||||||
|
conf: 0.35
|
||||||
|
iou: 0.5
|
||||||
|
imgsz: 1280
|
||||||
|
device: "cuda:0"
|
||||||
|
|
||||||
|
counting:
|
||||||
|
line: [0.1, 0.55, 0.9, 0.55]
|
||||||
|
line_mode: "normalized"
|
||||||
|
crossing_tolerance: 12.0
|
||||||
|
|
||||||
|
attributes:
|
||||||
|
enabled: true
|
||||||
|
sample_every_n_frames: 12
|
||||||
|
max_samples_per_track: 5
|
||||||
|
min_person_box_width: 80
|
||||||
|
min_person_box_height: 160
|
||||||
|
person_crop_padding: 0.15
|
||||||
|
detector_backend: "retinaface"
|
||||||
|
enforce_detection: false
|
||||||
|
|
||||||
|
output:
|
||||||
|
save_video: true
|
||||||
|
save_json: true
|
||||||
|
save_csv: true
|
||||||
|
draw_boxes: true
|
||||||
|
draw_labels: true
|
||||||
|
|
||||||
|
rtsp:
|
||||||
|
sample_interval_seconds: 1.0
|
||||||
|
window_seconds: 1800
|
||||||
|
reconnect_delay_seconds: 5.0
|
||||||
|
stream_open_timeout_seconds: 10.0
|
||||||
|
idle_sleep_seconds: 0.05
|
||||||
|
output_subdir: "rtsp_stream"
|
||||||
37
managed/people_flow_project/configs/docker_x86_config.yaml
Normal file
37
managed/people_flow_project/configs/docker_x86_config.yaml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
yolo:
|
||||||
|
model_path: "/opt/people-flow/weights/yolo11n.pt"
|
||||||
|
tracker: "botsort.yaml"
|
||||||
|
conf: 0.35
|
||||||
|
iou: 0.5
|
||||||
|
imgsz: 1280
|
||||||
|
device: "cuda:0"
|
||||||
|
|
||||||
|
counting:
|
||||||
|
line: [0.1, 0.55, 0.9, 0.55]
|
||||||
|
line_mode: "normalized"
|
||||||
|
crossing_tolerance: 12.0
|
||||||
|
|
||||||
|
attributes:
|
||||||
|
enabled: true
|
||||||
|
sample_every_n_frames: 12
|
||||||
|
max_samples_per_track: 5
|
||||||
|
min_person_box_width: 80
|
||||||
|
min_person_box_height: 160
|
||||||
|
person_crop_padding: 0.15
|
||||||
|
detector_backend: "retinaface"
|
||||||
|
enforce_detection: false
|
||||||
|
|
||||||
|
output:
|
||||||
|
save_video: false
|
||||||
|
save_json: true
|
||||||
|
save_csv: true
|
||||||
|
draw_boxes: false
|
||||||
|
draw_labels: false
|
||||||
|
|
||||||
|
rtsp:
|
||||||
|
sample_interval_seconds: 1.0
|
||||||
|
window_seconds: 1800
|
||||||
|
reconnect_delay_seconds: 5.0
|
||||||
|
stream_open_timeout_seconds: 10.0
|
||||||
|
idle_sleep_seconds: 0.05
|
||||||
|
output_subdir: "rtsp_stream"
|
||||||
37
managed/people_flow_project/configs/native_x86_config.yaml
Normal file
37
managed/people_flow_project/configs/native_x86_config.yaml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
yolo:
|
||||||
|
model_path: "weights/yolo11n.pt"
|
||||||
|
tracker: "botsort.yaml"
|
||||||
|
conf: 0.35
|
||||||
|
iou: 0.5
|
||||||
|
imgsz: 1280
|
||||||
|
device: "cuda:0"
|
||||||
|
|
||||||
|
counting:
|
||||||
|
line: [0.1, 0.55, 0.9, 0.55]
|
||||||
|
line_mode: "normalized"
|
||||||
|
crossing_tolerance: 12.0
|
||||||
|
|
||||||
|
attributes:
|
||||||
|
enabled: true
|
||||||
|
sample_every_n_frames: 12
|
||||||
|
max_samples_per_track: 5
|
||||||
|
min_person_box_width: 80
|
||||||
|
min_person_box_height: 160
|
||||||
|
person_crop_padding: 0.15
|
||||||
|
detector_backend: "retinaface"
|
||||||
|
enforce_detection: false
|
||||||
|
|
||||||
|
output:
|
||||||
|
save_video: false
|
||||||
|
save_json: true
|
||||||
|
save_csv: true
|
||||||
|
draw_boxes: false
|
||||||
|
draw_labels: false
|
||||||
|
|
||||||
|
rtsp:
|
||||||
|
sample_interval_seconds: 1.0
|
||||||
|
window_seconds: 1800
|
||||||
|
reconnect_delay_seconds: 5.0
|
||||||
|
stream_open_timeout_seconds: 10.0
|
||||||
|
idle_sleep_seconds: 0.05
|
||||||
|
output_subdir: "rtsp_stream"
|
||||||
5
managed/people_flow_project/constraints-wheelhouse.txt
Normal file
5
managed/people_flow_project/constraints-wheelhouse.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
numpy<2
|
||||||
|
tensorflow==2.16.1
|
||||||
|
tf-keras==2.16.0
|
||||||
|
torch==2.11.0+cu126
|
||||||
|
torchvision==0.26.0+cu126
|
||||||
19
managed/people_flow_project/deploy/people-flow.service.tpl
Normal file
19
managed/people_flow_project/deploy/people-flow.service.tpl
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=People Flow RTSP Service
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=__PROJECT_DIR__
|
||||||
|
User=__RUN_USER__
|
||||||
|
Group=__RUN_GROUP__
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=__PROJECT_DIR__/.venv/bin/python __PROJECT_DIR__/main.py --config __CONFIG_PATH__ rtsp
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=append:__PROJECT_DIR__/outputs/rtsp_run.log
|
||||||
|
StandardError=append:__PROJECT_DIR__/outputs/rtsp_run.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
22
managed/people_flow_project/docker-compose.yml
Normal file
22
managed/people_flow_project/docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
people-flow-project:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: people-flow-project:local
|
||||||
|
container_name: people-flow-project
|
||||||
|
restart: unless-stopped
|
||||||
|
gpus: all
|
||||||
|
shm_size: "1gb"
|
||||||
|
ports:
|
||||||
|
- "18082:18082"
|
||||||
|
environment:
|
||||||
|
CONFIG_PATH: /opt/people-flow/config/local.yaml
|
||||||
|
RTSP_URL: ${RTSP_URL:-}
|
||||||
|
OUTPUT_DIR: /opt/people-flow/outputs
|
||||||
|
API_HOST: 0.0.0.0
|
||||||
|
API_PORT: 18082
|
||||||
|
DEVICE: ${DEVICE:-cuda:0}
|
||||||
|
volumes:
|
||||||
|
- ./config:/opt/people-flow/config
|
||||||
|
- ./outputs:/opt/people-flow/outputs
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# People Flow Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Build a standalone project under `Documents/人流检测/people_flow_project` that analyzes street videos and produces:
|
||||||
|
|
||||||
|
- unique people-flow counts
|
||||||
|
- one mutually exclusive age bucket per counted person
|
||||||
|
- one mutually exclusive gender bucket per counted person
|
||||||
|
- annotated videos plus machine-readable summaries
|
||||||
|
|
||||||
|
## Approved Decisions
|
||||||
|
|
||||||
|
- Runtime target: Linux with NVIDIA GPU
|
||||||
|
- Entry points: both single-video mode and batch-directory mode
|
||||||
|
- Count logic: one `track_id` is counted once when it crosses a configured line
|
||||||
|
- Age buckets:
|
||||||
|
- `minor`: age `< 18`
|
||||||
|
- `adult`: age `18-59`
|
||||||
|
- `senior`: age `>= 60`
|
||||||
|
- Gender buckets:
|
||||||
|
- `male`
|
||||||
|
- `female`
|
||||||
|
- Unknown face attributes:
|
||||||
|
- If a counted person does not yield a reliable face result, count that person only in `total_people`
|
||||||
|
- Also increment `unknown_attributes`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The pipeline uses Ultralytics YOLO for person detection and tracking, then DeepFace for face attribute analysis. Person tracking and counting stay separate from attribute inference so the demographic model can be replaced later without touching the counting core.
|
||||||
|
|
||||||
|
The application stores votes per `track_id`. When the video finishes, each counted track is resolved to at most one final age bucket and one final gender bucket by majority voting.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
- `main.py`: CLI parsing and mode dispatch
|
||||||
|
- `src/people_flow/config.py`: config loading and overrides
|
||||||
|
- `src/people_flow/tracking.py`: track extraction from YOLO results
|
||||||
|
- `src/people_flow/counting.py`: line-crossing logic and unique counting
|
||||||
|
- `src/people_flow/attributes.py`: DeepFace integration and voting
|
||||||
|
- `src/people_flow/io_utils.py`: video, JSON, and CSV output helpers
|
||||||
|
- `src/people_flow/pipeline.py`: process orchestration
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
For each video:
|
||||||
|
|
||||||
|
- annotated MP4
|
||||||
|
- JSON summary
|
||||||
|
|
||||||
|
For batch runs:
|
||||||
|
|
||||||
|
- one CSV summary with one row per video
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Missing dependencies should raise clear installation guidance.
|
||||||
|
- If a video cannot be opened, fail that video with a readable error.
|
||||||
|
- If face inference fails for a sample, continue processing and treat that sample as unavailable.
|
||||||
|
- If no video files are found in batch mode, fail fast with a clear message.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Age and gender quality depend on clear, sufficiently large faces.
|
||||||
|
- Street scenes with strong occlusion, side views, masks, or low light will increase `unknown_attributes` and lower reliability.
|
||||||
|
- The default line is a placeholder and should be adjusted per camera view.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# People Flow Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Build a standalone Python project that counts unique line crossings in street videos and adds track-level age/gender summaries.
|
||||||
|
|
||||||
|
**Architecture:** Use Ultralytics YOLO to detect and track persons frame by frame, then run DeepFace on sampled person crops to infer face attributes. Keep counting, tracking, and attribute voting in separate modules so the demographic backend can be swapped later.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, Ultralytics YOLO, OpenCV, DeepFace, PyYAML, pandas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Scaffold the project
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `README.md`
|
||||||
|
- Create: `requirements.txt`
|
||||||
|
- Create: `pyproject.toml`
|
||||||
|
- Create: `configs/default_config.yaml`
|
||||||
|
- Create: `docs/plans/2026-04-07-people-flow-design.md`
|
||||||
|
|
||||||
|
**Step 1: Write the initial files**
|
||||||
|
|
||||||
|
Add installation instructions, runtime expectations, and default settings.
|
||||||
|
|
||||||
|
**Step 2: Verify structure**
|
||||||
|
|
||||||
|
Run: `find . -maxdepth 3 | sed -n '1,120p'`
|
||||||
|
Expected: project files and directories exist.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
This workspace is not a git repository. Skip the commit step unless the user later initializes git here.
|
||||||
|
|
||||||
|
### Task 2: Build the CLI and config loader
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `main.py`
|
||||||
|
- Create: `src/people_flow/__init__.py`
|
||||||
|
- Create: `src/people_flow/config.py`
|
||||||
|
- Create: `src/people_flow/models.py`
|
||||||
|
|
||||||
|
**Step 1: Implement argument parsing**
|
||||||
|
|
||||||
|
Support `video` and `batch` subcommands, config overrides, output directory selection, and line overrides.
|
||||||
|
|
||||||
|
**Step 2: Implement config loading**
|
||||||
|
|
||||||
|
Load YAML defaults and merge CLI overrides into typed dataclasses.
|
||||||
|
|
||||||
|
**Step 3: Verify**
|
||||||
|
|
||||||
|
Run: `python3 -m compileall main.py src`
|
||||||
|
Expected: compile succeeds without syntax errors.
|
||||||
|
|
||||||
|
### Task 3: Implement tracking and counting
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/people_flow/tracking.py`
|
||||||
|
- Create: `src/people_flow/counting.py`
|
||||||
|
|
||||||
|
**Step 1: Extract tracked `person` detections**
|
||||||
|
|
||||||
|
Convert YOLO result objects into simple track observations with `track_id`, bounding box, confidence, and center point.
|
||||||
|
|
||||||
|
**Step 2: Implement line-cross counting**
|
||||||
|
|
||||||
|
Count one crossing per track by monitoring the sign change of the track center relative to the configured line.
|
||||||
|
|
||||||
|
**Step 3: Verify**
|
||||||
|
|
||||||
|
Run: `python3 -m compileall src`
|
||||||
|
Expected: compile succeeds.
|
||||||
|
|
||||||
|
### Task 4: Implement attribute voting and output helpers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/people_flow/attributes.py`
|
||||||
|
- Create: `src/people_flow/io_utils.py`
|
||||||
|
|
||||||
|
**Step 1: Integrate DeepFace**
|
||||||
|
|
||||||
|
Sample person crops, run `age` and `gender` analysis, normalize labels, and store per-track votes.
|
||||||
|
|
||||||
|
**Step 2: Implement output helpers**
|
||||||
|
|
||||||
|
Write JSON summaries, CSV summaries, and draw overlays onto frames.
|
||||||
|
|
||||||
|
**Step 3: Verify**
|
||||||
|
|
||||||
|
Run: `python3 -m compileall src`
|
||||||
|
Expected: compile succeeds.
|
||||||
|
|
||||||
|
### Task 5: Implement the processing pipeline
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/people_flow/pipeline.py`
|
||||||
|
|
||||||
|
**Step 1: Build the main loop**
|
||||||
|
|
||||||
|
Open the video, run YOLO tracking on frames, update counters, sample attributes, draw overlays, and save artifacts.
|
||||||
|
|
||||||
|
**Step 2: Build batch mode**
|
||||||
|
|
||||||
|
Discover supported video files recursively and run the same pipeline per file, then write `outputs/batch_summary.csv`.
|
||||||
|
|
||||||
|
**Step 3: Verify**
|
||||||
|
|
||||||
|
Run: `python3 -m compileall main.py src`
|
||||||
|
Expected: compile succeeds.
|
||||||
|
|
||||||
|
### Task 6: Final verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
**Step 1: Smoke-check the CLI**
|
||||||
|
|
||||||
|
Run: `python3 main.py --help`
|
||||||
|
Expected: help text shows the `video` and `batch` commands.
|
||||||
|
|
||||||
|
**Step 2: Document limitations**
|
||||||
|
|
||||||
|
Make sure README notes Python version constraints and face-quality limitations.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
Skip commit because this workspace is not a git repository.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Portable DeepFace Weights Design
|
||||||
|
|
||||||
|
**Goal:** Make DeepFace reuse bundled project weights regardless of where the project directory is unpacked.
|
||||||
|
|
||||||
|
**Problem:** The current native launcher sets `DEEPFACE_HOME` to a project-local `.deepface` directory. DeepFace then appends its own `.deepface/weights` segment, so runtime lookup becomes `PROJECT_ROOT/.deepface/.deepface/weights`, which bypasses the bundled `weights/deepface` directory and triggers redundant downloads.
|
||||||
|
|
||||||
|
**Approach Options:**
|
||||||
|
|
||||||
|
1. Copy bundled weights into the current user's default `~/.deepface/weights` directory before startup.
|
||||||
|
This matches DeepFace's default lookup behavior and avoids hard-coded absolute paths. It works whether the project lives under `/home/x/people`, `/home/xiaozheng/people`, or any other directory.
|
||||||
|
|
||||||
|
2. Keep using `DEEPFACE_HOME` and reshape the project-local directory tree to match DeepFace's nested expectations.
|
||||||
|
This avoids duplicating files but is more fragile and easier to break when DeepFace internals change.
|
||||||
|
|
||||||
|
**Recommendation:** Use option 1. Update the native setup and launcher scripts to sync `weights/deepface/*.h5` into `~/.deepface/weights` and stop overriding `DEEPFACE_HOME`.
|
||||||
|
|
||||||
|
**Validation:** Confirm the RTSP process starts without downloading `retinaface.h5`, `age_model_weights.h5`, or `gender_model_weights.h5`, and verify the launcher still works after changing only the project root path.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Portable DeepFace Weights Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Make the native RTSP bundle reuse bundled DeepFace weights from any unpack location without extra downloads.
|
||||||
|
|
||||||
|
**Architecture:** Remove the custom `DEEPFACE_HOME` override from the native runtime path. Before setup and launch, copy the bundled DeepFace weight files from `weights/deepface/` into the current user's default `~/.deepface/weights/` directory so DeepFace resolves them through its own standard path logic.
|
||||||
|
|
||||||
|
**Tech Stack:** Bash, DeepFace, native Python virtual environment, offline wheelhouse bundle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Fix native setup and launcher paths
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `run_rtsp.sh`
|
||||||
|
- Modify: `setup_native_venv.sh`
|
||||||
|
- Modify: `README_NATIVE.md`
|
||||||
|
|
||||||
|
**Step 1: Update `run_rtsp.sh`**
|
||||||
|
|
||||||
|
Remove the `DEEPFACE_HOME` override. Create `"$HOME/.deepface/weights"` and copy bundled `.h5` files from `"$PROJECT_ROOT/weights/deepface"` into that directory before starting the Python process.
|
||||||
|
|
||||||
|
**Step 2: Update `setup_native_venv.sh`**
|
||||||
|
|
||||||
|
After dependency installation, create `"$HOME/.deepface/weights"` and copy bundled `.h5` files into it so the environment is ready before the first run.
|
||||||
|
|
||||||
|
**Step 3: Update native documentation**
|
||||||
|
|
||||||
|
Explain that bundled weights are staged into `~/.deepface/weights` automatically and that the project path itself can move without breaking the weight lookup.
|
||||||
|
|
||||||
|
### Task 2: Sync and verify on the Ubuntu target
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: remote copies of the files above under `/home/x/people/people_flow_project`
|
||||||
|
|
||||||
|
**Step 1: Sync the changed files to `192.168.5.154`**
|
||||||
|
|
||||||
|
Copy the updated launcher, setup script, and documentation.
|
||||||
|
|
||||||
|
**Step 2: Stage bundled weights into the target user's home directory**
|
||||||
|
|
||||||
|
Run the updated setup logic or equivalent copy command and verify `~/.deepface/weights` contains the expected `.h5` files.
|
||||||
|
|
||||||
|
**Step 3: Restart RTSP and inspect logs**
|
||||||
|
|
||||||
|
Restart the RTSP job and confirm the log no longer shows downloads from `deepface_models/releases`.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
Skip commit unless explicitly requested by the user.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Lightweight Native Bundle Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-08
|
||||||
|
|
||||||
|
**Goal:** Deliver a lightweight native deployment bundle for Ubuntu 24.04 x86_64 that includes project code, required weights, a single editable RTSP run script, and a small setup path on the target host without bundling a full Python environment in the archive.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Target host: `xiaozheng@192.168.5.154`
|
||||||
|
- Target path: `/home/x/people/people_flow_project`
|
||||||
|
- Bundle contents:
|
||||||
|
- project code
|
||||||
|
- YOLO weight
|
||||||
|
- DeepFace weights
|
||||||
|
- one editable run script
|
||||||
|
- setup and usage documentation
|
||||||
|
- Exclude the virtual environment from the compressed bundle to keep size down.
|
||||||
|
|
||||||
|
## Deployment Model
|
||||||
|
|
||||||
|
The target host already has:
|
||||||
|
|
||||||
|
- Ubuntu 24.04 x86_64
|
||||||
|
- Python 3.12
|
||||||
|
- Docker available, but Docker is intentionally not used here
|
||||||
|
- NVIDIA driver and CUDA-capable GPU
|
||||||
|
|
||||||
|
The bundle will therefore rely on:
|
||||||
|
|
||||||
|
1. a project-local `.venv` created on the target host
|
||||||
|
2. host driver compatibility for GPU wheels
|
||||||
|
3. project-relative weight paths so no external downloads are needed
|
||||||
|
|
||||||
|
## User Editing Surface
|
||||||
|
|
||||||
|
The main operator interface is a single shell script:
|
||||||
|
|
||||||
|
- `run_rtsp.sh`
|
||||||
|
|
||||||
|
The user edits only:
|
||||||
|
|
||||||
|
- `RTSP_URL`
|
||||||
|
- `OUTPUT_DIR`
|
||||||
|
|
||||||
|
The script activates `.venv`, points to the native x86 config, and runs the RTSP pipeline.
|
||||||
|
|
||||||
|
## Config Strategy
|
||||||
|
|
||||||
|
Add a dedicated native x86 config file with:
|
||||||
|
|
||||||
|
- `yolo.model_path` pointing to the local `weights/yolo11n.pt`
|
||||||
|
- RTSP timing settings
|
||||||
|
- output defaults for RTSP mode
|
||||||
|
|
||||||
|
This avoids modifying the existing Jetson-oriented config and keeps host deployment deterministic.
|
||||||
|
|
||||||
|
## Setup Strategy
|
||||||
|
|
||||||
|
Provide a small setup script that:
|
||||||
|
|
||||||
|
- creates `.venv`
|
||||||
|
- upgrades pip/setuptools/wheel
|
||||||
|
- installs CUDA-enabled PyTorch wheels
|
||||||
|
- installs TensorFlow, `tf-keras`, and application dependencies
|
||||||
|
|
||||||
|
The setup script keeps the archive light while still making the target directory self-contained after one install step.
|
||||||
|
|
||||||
|
## Bundle Output
|
||||||
|
|
||||||
|
On the target host, create a compressed archive such as:
|
||||||
|
|
||||||
|
- `/home/x/people/people_flow_project_native_bundle_2026-04-08.tar.gz`
|
||||||
|
|
||||||
|
The archive will exclude `.venv` so it stays close to the size of code plus weights.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- The target host contains a runnable native project directory
|
||||||
|
- `run_rtsp.sh` is the only file the operator needs to edit for RTSP URL and output directory
|
||||||
|
- All required weights are present locally
|
||||||
|
- The lightweight tarball is created successfully
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Lightweight Native Bundle Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Produce a lightweight native deployment bundle for Ubuntu 24.04 x86_64 with code, weights, one editable RTSP run script, and a local venv setup path.
|
||||||
|
|
||||||
|
**Architecture:** Keep all code and weights inside the project directory, add one native config and two helper scripts, then create the venv on the target host instead of bundling it into the archive.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, venv, PyTorch GPU wheels, TensorFlow, DeepFace, shell scripts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add native deployment files
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/configs/native_x86_config.yaml`
|
||||||
|
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/run_rtsp.sh`
|
||||||
|
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/setup_native_venv.sh`
|
||||||
|
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/README_NATIVE.md`
|
||||||
|
|
||||||
|
**Step 1: Add a native x86 config**
|
||||||
|
|
||||||
|
- Point YOLO to the local project weight path.
|
||||||
|
- Keep RTSP behavior aligned with the current project.
|
||||||
|
|
||||||
|
**Step 2: Add a single editable RTSP launcher**
|
||||||
|
|
||||||
|
- Put `RTSP_URL` and `OUTPUT_DIR` at the top of the file.
|
||||||
|
- Run the project with `.venv/bin/python`.
|
||||||
|
|
||||||
|
**Step 3: Add a setup script**
|
||||||
|
|
||||||
|
- Create `.venv`
|
||||||
|
- Install GPU-enabled PyTorch
|
||||||
|
- Install TensorFlow and project requirements
|
||||||
|
|
||||||
|
### Task 2: Deploy to the target host
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No code changes required
|
||||||
|
|
||||||
|
**Step 1: Sync the updated project**
|
||||||
|
|
||||||
|
- Replace the target project directory while preserving weights if needed.
|
||||||
|
|
||||||
|
**Step 2: Ensure weights are in project-relative paths**
|
||||||
|
|
||||||
|
- Verify YOLO and DeepFace weights under `weights/`.
|
||||||
|
|
||||||
|
### Task 3: Validate and bundle
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No code changes required
|
||||||
|
|
||||||
|
**Step 1: Run setup on the target host**
|
||||||
|
|
||||||
|
- Execute the setup script.
|
||||||
|
|
||||||
|
**Step 2: Validate the RTSP CLI**
|
||||||
|
|
||||||
|
- Run `./.venv/bin/python main.py rtsp --help`.
|
||||||
|
|
||||||
|
**Step 3: Create the lightweight tarball**
|
||||||
|
|
||||||
|
- Exclude `.venv`
|
||||||
|
- Keep code, scripts, configs, docs, and weights
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Offline Wheelhouse Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-08
|
||||||
|
|
||||||
|
**Goal:** Add an offline Python dependency bundle for Ubuntu 24.04 x86_64 with Python 3.12 and NVIDIA GPU support so the project can be installed on similar machines without re-downloading PyTorch, TensorFlow, and application wheels.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Target platform: Ubuntu 24.04 x86_64
|
||||||
|
- Python version: 3.12
|
||||||
|
- GPU runtime: NVIDIA, using CUDA-enabled PyTorch wheels
|
||||||
|
- Bundle type: project code + weights + `wheelhouse/`
|
||||||
|
- Setup behavior: prefer offline wheels when present, fall back to network otherwise
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Add a dedicated wheelhouse build script that downloads:
|
||||||
|
|
||||||
|
- `pip`, `setuptools`, `wheel`
|
||||||
|
- `numpy<2`
|
||||||
|
- CUDA-enabled `torch` and `torchvision`
|
||||||
|
- `tensorflow[and-cuda]==2.16.1`
|
||||||
|
- `tf-keras==2.16.0`
|
||||||
|
- project requirements and their transitive dependencies
|
||||||
|
|
||||||
|
Store the wheels inside `wheelhouse/` under the project root.
|
||||||
|
|
||||||
|
Update the native setup script so it:
|
||||||
|
|
||||||
|
1. creates `.venv`
|
||||||
|
2. upgrades installer tooling from `wheelhouse/` when available
|
||||||
|
3. installs PyTorch and TensorFlow from local wheels when available
|
||||||
|
4. installs project requirements from local wheels when available
|
||||||
|
5. falls back to online indexes only if the wheelhouse is missing
|
||||||
|
|
||||||
|
## Bundle Layout
|
||||||
|
|
||||||
|
- `weights/`
|
||||||
|
- `wheelhouse/`
|
||||||
|
- `setup_native_venv.sh`
|
||||||
|
- `build_wheelhouse.sh`
|
||||||
|
- `run_rtsp.sh`
|
||||||
|
|
||||||
|
## Tradeoff
|
||||||
|
|
||||||
|
This increases the lightweight bundle size, but it removes repeat dependency downloads on future hosts. The user explicitly asked for an offline dependency pack, so this is the right tradeoff now.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Offline Wheelhouse Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add a reusable offline wheelhouse for the native x86 bundle and make setup prefer local wheels.
|
||||||
|
|
||||||
|
**Architecture:** Keep the native bundle layout, add one build script that downloads all required wheels into `wheelhouse/`, and update the setup script to install from `wheelhouse/` first.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, pip download, wheelhouse, shell scripts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add offline dependency metadata and scripts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/requirements-native.txt`
|
||||||
|
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/build_wheelhouse.sh`
|
||||||
|
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/setup_native_venv.sh`
|
||||||
|
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/README_NATIVE.md`
|
||||||
|
|
||||||
|
**Step 1: Add a native requirements file**
|
||||||
|
|
||||||
|
- Pin `numpy<2`
|
||||||
|
- Include app-level dependencies used by native setup
|
||||||
|
|
||||||
|
**Step 2: Add a wheelhouse build script**
|
||||||
|
|
||||||
|
- Download installer tools, PyTorch CUDA wheels, TensorFlow wheels, and project wheels
|
||||||
|
- Write everything into `wheelhouse/`
|
||||||
|
|
||||||
|
**Step 3: Make setup prefer offline wheels**
|
||||||
|
|
||||||
|
- Use `--no-index --find-links wheelhouse` when local wheels are available
|
||||||
|
- Fall back to online install otherwise
|
||||||
|
|
||||||
|
### Task 2: Sync and build the wheelhouse on the target host
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No code changes required
|
||||||
|
|
||||||
|
**Step 1: Sync project changes to `192.168.5.154`**
|
||||||
|
|
||||||
|
- Preserve existing weights
|
||||||
|
|
||||||
|
**Step 2: Run `build_wheelhouse.sh`**
|
||||||
|
|
||||||
|
- Populate `/home/x/people/people_flow_project/wheelhouse`
|
||||||
|
|
||||||
|
**Step 3: Validate setup behavior**
|
||||||
|
|
||||||
|
- Confirm `setup_native_venv.sh` recognizes local wheelhouse
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# RTSP Heartbeat Logging Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-08
|
||||||
|
|
||||||
|
**Goal:** Add periodic heartbeat logs to the RTSP pipeline so operators can confirm the stream is still being processed during long 30-minute windows.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Keep the existing RTSP counting behavior unchanged.
|
||||||
|
- Print one heartbeat line every 60 seconds while the RTSP loop is running.
|
||||||
|
- Include the current demographic counts in the heartbeat output.
|
||||||
|
- Do not change JSON payload structure or window timing.
|
||||||
|
|
||||||
|
## Heartbeat Format
|
||||||
|
|
||||||
|
Each heartbeat line should report:
|
||||||
|
|
||||||
|
- runtime seconds
|
||||||
|
- current window index
|
||||||
|
- current window frame count
|
||||||
|
- total people in the active window
|
||||||
|
- age counts
|
||||||
|
- gender counts
|
||||||
|
- unknown attributes
|
||||||
|
- last processed timestamp
|
||||||
|
|
||||||
|
This output is intended for `tail -f` style monitoring and should remain single-line and compact.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Reuse the existing live stats helper to avoid recomputing counting rules in a second place. The RTSP loop already knows when each sampled frame is processed, so it can track the last successful processing timestamp and emit a heartbeat when 60 seconds have elapsed since the last log.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# RTSP Heartbeat Logging Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add one-line RTSP heartbeat logs every 60 seconds so operators can monitor progress during long windows.
|
||||||
|
|
||||||
|
**Architecture:** Extend the RTSP loop with lightweight heartbeat state. Reuse the existing live stats builder and print one compact log line every 60 seconds after sampled frames are processed.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, dataclasses, OpenCV, existing people-flow pipeline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add heartbeat state and log output
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/src/people_flow/pipeline.py`
|
||||||
|
|
||||||
|
**Step 1: Track heartbeat timing**
|
||||||
|
|
||||||
|
- Store the process start time.
|
||||||
|
- Store the next heartbeat deadline.
|
||||||
|
- Store the last successful processed timestamp.
|
||||||
|
|
||||||
|
**Step 2: Print one-line heartbeat logs**
|
||||||
|
|
||||||
|
- Reuse current live stats.
|
||||||
|
- Include runtime, window index, frame count, totals, demographics, unknown count, and last processed timestamp.
|
||||||
|
|
||||||
|
**Step 3: Keep the logging cadence stable**
|
||||||
|
|
||||||
|
- Emit at most one heartbeat per 60 seconds.
|
||||||
|
- Do not log on every frame.
|
||||||
|
|
||||||
|
### Task 2: Validate and synchronize
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No additional files required
|
||||||
|
|
||||||
|
**Step 1: Run compile checks**
|
||||||
|
|
||||||
|
Run: `python3 -m compileall main.py src`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 2: Sync to remote host**
|
||||||
|
|
||||||
|
- Replace the remote project with the updated local copy.
|
||||||
|
- Keep the existing remote backup intact.
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# RTSP Windowed People Flow Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-08
|
||||||
|
|
||||||
|
**Goal:** Extend the existing people-flow project with an RTSP mode that samples one frame per second from a live stream, computes people-flow and demographics, and writes a JSON summary every 30 minutes while preserving the existing offline video and batch modes.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Keep the existing `video` and `batch` commands unchanged.
|
||||||
|
- Add a new `rtsp` command for continuous live-stream processing.
|
||||||
|
- Sample one frame per second based on wall-clock time instead of processing every decoded frame.
|
||||||
|
- Maintain a 30-minute independent counting window.
|
||||||
|
- Write one timestamped JSON file per finished window.
|
||||||
|
- Refresh a `latest.json` file on every window flush.
|
||||||
|
- Do not save annotated RTSP video by default.
|
||||||
|
- Back up the current project before implementation.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
The current codebase already has reusable counting and attribute aggregation logic. The least risky change is to keep the offline pipeline as-is and add a dedicated RTSP processing path that reuses the same `LineCrossCounter` and `AttributeAggregator` components.
|
||||||
|
|
||||||
|
The RTSP path will:
|
||||||
|
|
||||||
|
1. Open an RTSP stream with OpenCV.
|
||||||
|
2. Read frames continuously.
|
||||||
|
3. Run inference only when at least one second has elapsed since the last processed frame.
|
||||||
|
4. Accumulate counts inside the current 30-minute window.
|
||||||
|
5. Flush a window summary to JSON when the window boundary is reached.
|
||||||
|
6. Reset all per-window state and continue into the next window.
|
||||||
|
7. Retry the stream connection when the RTSP source drops.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Command Layer
|
||||||
|
|
||||||
|
- `main.py` adds an `rtsp` subcommand with an `--input` RTSP URL.
|
||||||
|
- Existing global arguments such as `--config`, `--output-dir`, `--line`, and `--device` remain shared.
|
||||||
|
- RTSP mode disables video writing by default unless explicitly enabled in config later.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Add a new RTSP config section with:
|
||||||
|
|
||||||
|
- `sample_interval_seconds`
|
||||||
|
- `window_seconds`
|
||||||
|
- `reconnect_delay_seconds`
|
||||||
|
- `stream_open_timeout_seconds`
|
||||||
|
- `idle_sleep_seconds`
|
||||||
|
- `output_subdir`
|
||||||
|
|
||||||
|
This keeps timing and output behavior configurable without changing code.
|
||||||
|
|
||||||
|
### Processing Loop
|
||||||
|
|
||||||
|
Each processed frame will:
|
||||||
|
|
||||||
|
1. Pass through YOLO tracking.
|
||||||
|
2. Extract `person` track observations.
|
||||||
|
3. Optionally run DeepFace sampling on eligible tracks.
|
||||||
|
4. Update the line-cross counter.
|
||||||
|
5. Check whether the active 30-minute window should be flushed.
|
||||||
|
|
||||||
|
Skipped frames are decoded only to keep the stream current; they do not go through YOLO or DeepFace.
|
||||||
|
|
||||||
|
### Window Boundaries
|
||||||
|
|
||||||
|
Each window starts when the RTSP pipeline starts or right after the previous flush. The summary payload includes:
|
||||||
|
|
||||||
|
- `source_type`
|
||||||
|
- `source`
|
||||||
|
- `window_index`
|
||||||
|
- `window_start`
|
||||||
|
- `window_end`
|
||||||
|
- `window_duration_seconds`
|
||||||
|
- `total_people`
|
||||||
|
- `age_counts`
|
||||||
|
- `gender_counts`
|
||||||
|
- `unknown_attributes`
|
||||||
|
- `tracks`
|
||||||
|
|
||||||
|
After flushing:
|
||||||
|
|
||||||
|
- The timestamped JSON is written under `windows/`.
|
||||||
|
- `latest.json` is overwritten with the same payload.
|
||||||
|
- The counting and attribute state is reset.
|
||||||
|
|
||||||
|
## Output Layout
|
||||||
|
|
||||||
|
For `--output-dir /path/output`, the RTSP outputs live under:
|
||||||
|
|
||||||
|
- `/path/output/rtsp_stream/`
|
||||||
|
- `/path/output/rtsp_stream/latest.json`
|
||||||
|
- `/path/output/rtsp_stream/windows/stats_YYYY-MM-DD_HH-MM-SS.json`
|
||||||
|
|
||||||
|
The timestamp in the filename is the window end time.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- If the RTSP stream cannot be opened, retry after a configurable delay.
|
||||||
|
- If frame reads fail mid-stream, release the capture and reconnect.
|
||||||
|
- If DeepFace analysis fails on a crop, treat that sample as unknown and keep running.
|
||||||
|
- If a window has zero crossings, still write a valid JSON payload with zero counts so downstream consumers can distinguish inactivity from pipeline failure.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- `video` mode still writes annotated video and a final JSON after full processing.
|
||||||
|
- `batch` mode still writes a final CSV summary.
|
||||||
|
- Existing config keys remain valid.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- Validate CLI parsing for the new `rtsp` command.
|
||||||
|
- Validate config loading with the new RTSP section.
|
||||||
|
- Validate that RTSP mode writes windowed JSON payloads and refreshes `latest.json`.
|
||||||
|
- Validate that 30-minute windows reset counts instead of accumulating indefinitely.
|
||||||
|
- Keep offline mode behavior intact by running `--help` and Python compile checks.
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# RTSP Windowed People Flow Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add an RTSP mode that samples one frame per second, emits independent 30-minute JSON summaries, and preserves the existing offline video and batch workflows.
|
||||||
|
|
||||||
|
**Architecture:** Keep the existing offline pipeline untouched and add a dedicated RTSP pipeline path that reuses the counting and attribute aggregation components. Introduce a small RTSP configuration model and window-summary writer so the stream loop can reconnect, flush windowed JSON files, and reset state cleanly.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, OpenCV, Ultralytics YOLO, DeepFace, PyYAML, dataclasses
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add RTSP configuration models
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/src/people_flow/models.py`
|
||||||
|
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/src/people_flow/config.py`
|
||||||
|
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/configs/default_config.yaml`
|
||||||
|
|
||||||
|
**Step 1: Add an RTSP config dataclass**
|
||||||
|
|
||||||
|
- Add a dataclass with interval, window duration, reconnect delay, idle sleep, and output subdirectory fields.
|
||||||
|
- Attach it to `AppConfig`.
|
||||||
|
|
||||||
|
**Step 2: Load RTSP config from YAML**
|
||||||
|
|
||||||
|
- Update config loading to parse the new section.
|
||||||
|
- Keep backward compatibility when the section is absent.
|
||||||
|
|
||||||
|
**Step 3: Set sensible defaults in YAML**
|
||||||
|
|
||||||
|
- Add `sample_interval_seconds: 1`
|
||||||
|
- Add `window_seconds: 1800`
|
||||||
|
- Add reconnect and idle sleep defaults.
|
||||||
|
|
||||||
|
**Step 4: Run a compile check**
|
||||||
|
|
||||||
|
Run: `python3 -m compileall main.py src`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Task 2: Add the RTSP CLI entrypoint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/main.py`
|
||||||
|
|
||||||
|
**Step 1: Add a new `rtsp` subcommand**
|
||||||
|
|
||||||
|
- Accept `--input` as the RTSP URL.
|
||||||
|
- Reuse global config and output arguments.
|
||||||
|
|
||||||
|
**Step 2: Wire the command to the pipeline**
|
||||||
|
|
||||||
|
- Call a new `process_rtsp()` method.
|
||||||
|
- Print the output directory and latest JSON path once the command starts.
|
||||||
|
|
||||||
|
**Step 3: Verify CLI help**
|
||||||
|
|
||||||
|
Run: `python3 main.py rtsp --help`
|
||||||
|
Expected: PASS and shows the RTSP input argument.
|
||||||
|
|
||||||
|
### Task 3: Implement the RTSP processing loop
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/src/people_flow/pipeline.py`
|
||||||
|
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/src/people_flow/io_utils.py`
|
||||||
|
|
||||||
|
**Step 1: Add RTSP output helpers**
|
||||||
|
|
||||||
|
- Add a helper that creates `/rtsp_stream/windows`.
|
||||||
|
- Add a helper that writes a timestamped JSON file and refreshes `latest.json`.
|
||||||
|
|
||||||
|
**Step 2: Add RTSP window summary generation**
|
||||||
|
|
||||||
|
- Reuse the existing summary-building logic, but parameterize it with `source`, `window_start`, and `window_end`.
|
||||||
|
- Keep the same count keys and track payload structure.
|
||||||
|
|
||||||
|
**Step 3: Add `process_rtsp()`**
|
||||||
|
|
||||||
|
- Open the RTSP stream with OpenCV.
|
||||||
|
- Reconnect on open/read failures after a delay.
|
||||||
|
- Sample one frame per second based on wall-clock time.
|
||||||
|
- Reuse YOLO tracking, crossing detection, and DeepFace aggregation on sampled frames only.
|
||||||
|
- Flush a JSON summary every 30 minutes.
|
||||||
|
- Reset counting and attribute state after each flush.
|
||||||
|
|
||||||
|
**Step 4: Keep long-running behavior explicit**
|
||||||
|
|
||||||
|
- Do not save annotated RTSP video by default.
|
||||||
|
- Ensure zero-count windows still emit JSON.
|
||||||
|
|
||||||
|
### Task 4: Preserve offline behavior
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/src/people_flow/pipeline.py`
|
||||||
|
|
||||||
|
**Step 1: Refactor only shared summary code**
|
||||||
|
|
||||||
|
- Extract helper methods where useful.
|
||||||
|
- Do not change the existing `video`/`batch` outputs or file naming.
|
||||||
|
|
||||||
|
**Step 2: Re-run offline CLI smoke tests**
|
||||||
|
|
||||||
|
Run: `python3 main.py --help`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
Run: `python3 main.py video --help`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
Run: `python3 main.py batch --help`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Task 5: Update docs and validate
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/README.md`
|
||||||
|
|
||||||
|
**Step 1: Document the new RTSP mode**
|
||||||
|
|
||||||
|
- Add example commands.
|
||||||
|
- Explain the 1 FPS sampling and 30-minute window JSON behavior.
|
||||||
|
|
||||||
|
**Step 2: Run final validation**
|
||||||
|
|
||||||
|
Run: `python3 -m compileall main.py src`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
Run: `python3 main.py rtsp --help`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
Run: `python3 main.py --help`
|
||||||
|
Expected: PASS
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# x86 Docker Migration Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-08
|
||||||
|
|
||||||
|
**Goal:** Package the RTSP people-flow project for direct use on an Ubuntu 24.04 x86_64 host with an NVIDIA RTX 3080 by using Docker, bundled project files, and host-side model weights.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Target host: `xiaozheng@192.168.5.154`
|
||||||
|
- Target path: `/home/x/people`
|
||||||
|
- Runtime model: Docker with NVIDIA runtime
|
||||||
|
- Input source: RTSP
|
||||||
|
- Output: JSON window summaries under a mounted host directory
|
||||||
|
- Include required model weights on the target host
|
||||||
|
|
||||||
|
## Why Docker
|
||||||
|
|
||||||
|
The existing remote runtime was built on Jetson ARM64 and cannot be reused on an x86_64 RTX 3080 machine. The target host only has Python 3.12 installed, and a native port would need additional interpreter and CUDA-specific package work. Docker is the most reliable path because it isolates Python dependencies, preserves a reproducible runtime, and matches the user requirement of direct use on a new CUDA-capable machine.
|
||||||
|
|
||||||
|
## Packaging Strategy
|
||||||
|
|
||||||
|
### Host Layout
|
||||||
|
|
||||||
|
The target host will contain:
|
||||||
|
|
||||||
|
- `/home/x/people/people_flow_project/`
|
||||||
|
- `/home/x/people/people_flow_project/weights/yolo11n.pt`
|
||||||
|
- `/home/x/people/people_flow_project/weights/deepface/age_model_weights.h5`
|
||||||
|
- `/home/x/people/people_flow_project/weights/deepface/gender_model_weights.h5`
|
||||||
|
- `/home/x/people/people_flow_project/weights/deepface/retinaface.h5`
|
||||||
|
- `/home/x/people/output/`
|
||||||
|
|
||||||
|
### Container Layout
|
||||||
|
|
||||||
|
The container will:
|
||||||
|
|
||||||
|
- run on Python 3.12
|
||||||
|
- install GPU-enabled PyTorch wheels
|
||||||
|
- install the application dependencies
|
||||||
|
- read YOLO and DeepFace weights from deterministic in-container paths
|
||||||
|
- write outputs to a mounted host output directory
|
||||||
|
|
||||||
|
The project source will be copied into the image at build time. The host-side `weights/` directory will also be part of the build context so the final image does not need to download weights on first start.
|
||||||
|
|
||||||
|
## Runtime Contract
|
||||||
|
|
||||||
|
The image is intended to be built once on the target host and then started with a single `docker run` command using `--gpus all`.
|
||||||
|
|
||||||
|
The container command will remain the existing CLI:
|
||||||
|
|
||||||
|
`python main.py --config ... --output-dir ... --device cuda:0 rtsp --input ...`
|
||||||
|
|
||||||
|
## System Adaptation
|
||||||
|
|
||||||
|
The target host already has:
|
||||||
|
|
||||||
|
- Ubuntu 24.04
|
||||||
|
- Docker installed
|
||||||
|
- NVIDIA runtime registered in Docker
|
||||||
|
|
||||||
|
The adaptation work is therefore limited to:
|
||||||
|
|
||||||
|
- adding the project’s Docker packaging files
|
||||||
|
- transferring project code and model weights
|
||||||
|
- building the image on the target host
|
||||||
|
- validating the container entrypoint and GPU runtime path
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- The target GPU is currently heavily occupied by another process, so a full inference validation may need to avoid competing for memory.
|
||||||
|
- DeepFace and TensorFlow increase image size and build time.
|
||||||
|
- Network access is required during image build unless a wheel cache is prepared separately.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- The target host contains the project and all required weights under `/home/x/people`
|
||||||
|
- `docker build` completes successfully
|
||||||
|
- The container can run `main.py rtsp --help`
|
||||||
|
- The final run command is documented for direct RTSP use
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# x86 Docker Migration Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Make the RTSP people-flow project directly usable on Ubuntu 24.04 x86_64 with an RTX 3080 by transferring code and weights and building a Docker image on the target host.
|
||||||
|
|
||||||
|
**Architecture:** Use a Docker-based runtime for Python 3.12, GPU-enabled PyTorch, DeepFace, and the existing project CLI. Keep weights in a deterministic project directory and bake them into the image during build so runtime startup does not trigger downloads.
|
||||||
|
|
||||||
|
**Tech Stack:** Docker, NVIDIA Container Runtime, Python 3.12, PyTorch, Ultralytics, DeepFace
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add Docker packaging files
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/Dockerfile`
|
||||||
|
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/docker-compose.yml`
|
||||||
|
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/.dockerignore`
|
||||||
|
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/scripts/run_rtsp_docker.sh`
|
||||||
|
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/README.md`
|
||||||
|
|
||||||
|
**Step 1: Define the image build**
|
||||||
|
|
||||||
|
- Base the image on Python 3.12.
|
||||||
|
- Install required OS packages for OpenCV and ffmpeg.
|
||||||
|
- Install GPU-enabled PyTorch and project dependencies.
|
||||||
|
- Copy project source and weights into the image.
|
||||||
|
|
||||||
|
**Step 2: Add a Docker run wrapper**
|
||||||
|
|
||||||
|
- Provide a shell script that accepts RTSP URL and output directory.
|
||||||
|
- Use `--gpus all`.
|
||||||
|
|
||||||
|
**Step 3: Update the README**
|
||||||
|
|
||||||
|
- Document the Docker build and run commands.
|
||||||
|
- Document where weights must live if the host directory is rebuilt.
|
||||||
|
|
||||||
|
### Task 2: Prepare the project tree for weights
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/weights/.gitkeep`
|
||||||
|
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/weights/deepface/.gitkeep`
|
||||||
|
|
||||||
|
**Step 1: Create weight directories**
|
||||||
|
|
||||||
|
- Reserve stable paths for YOLO and DeepFace weights.
|
||||||
|
|
||||||
|
### Task 3: Transfer the project and weights to the target host
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No code changes required
|
||||||
|
|
||||||
|
**Step 1: Copy the project**
|
||||||
|
|
||||||
|
- Transfer the project directory to `/home/x/people/people_flow_project`.
|
||||||
|
|
||||||
|
**Step 2: Copy YOLO and DeepFace weights**
|
||||||
|
|
||||||
|
- Place YOLO and DeepFace weights into the target project `weights/` tree.
|
||||||
|
|
||||||
|
### Task 4: Build and validate on the target host
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No code changes required
|
||||||
|
|
||||||
|
**Step 1: Build the image**
|
||||||
|
|
||||||
|
- Run `docker build` under `/home/x/people/people_flow_project`.
|
||||||
|
|
||||||
|
**Step 2: Validate the CLI**
|
||||||
|
|
||||||
|
- Run the container with `python main.py rtsp --help`.
|
||||||
|
|
||||||
|
**Step 3: Provide the final RTSP run command**
|
||||||
|
|
||||||
|
- Document the exact `docker run` invocation for the target host.
|
||||||
136
managed/people_flow_project/main.py
Normal file
136
managed/people_flow_project/main.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="People-flow counting with YOLO tracking and DeepFace demographics."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
default="configs/default_config.yaml",
|
||||||
|
help="Path to the YAML config file.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output-dir",
|
||||||
|
default=None,
|
||||||
|
help="Directory for generated artifacts.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--line",
|
||||||
|
help="Override counting line as x1,y1,x2,y2.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--line-mode",
|
||||||
|
choices=["normalized", "pixel"],
|
||||||
|
help="Coordinate mode for --line.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--device",
|
||||||
|
help="Override inference device, for example cuda:0 or cpu.",
|
||||||
|
)
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
video_parser = subparsers.add_parser("video", help="Process one video.")
|
||||||
|
video_parser.add_argument("--input", required=True, help="Path to the video file.")
|
||||||
|
video_parser.add_argument(
|
||||||
|
"--skip-video-save",
|
||||||
|
action="store_true",
|
||||||
|
help="Do not write the annotated video.",
|
||||||
|
)
|
||||||
|
|
||||||
|
batch_parser = subparsers.add_parser("batch", help="Process a directory of videos.")
|
||||||
|
batch_parser.add_argument(
|
||||||
|
"--input-dir",
|
||||||
|
required=True,
|
||||||
|
help="Directory scanned recursively for videos.",
|
||||||
|
)
|
||||||
|
batch_parser.add_argument(
|
||||||
|
"--pattern",
|
||||||
|
default="*.mp4",
|
||||||
|
help="Glob pattern used during recursive discovery.",
|
||||||
|
)
|
||||||
|
batch_parser.add_argument(
|
||||||
|
"--skip-video-save",
|
||||||
|
action="store_true",
|
||||||
|
help="Do not write annotated videos.",
|
||||||
|
)
|
||||||
|
|
||||||
|
rtsp_parser = subparsers.add_parser("rtsp", help="Process a live RTSP stream.")
|
||||||
|
rtsp_parser.add_argument("--input", help="RTSP URL.")
|
||||||
|
|
||||||
|
manage_api_parser = subparsers.add_parser("manage-api", help="Start the management API.")
|
||||||
|
manage_api_parser.add_argument("--host", default="0.0.0.0", help="Host for the management API.")
|
||||||
|
manage_api_parser.add_argument("--port", type=int, default=18082, help="Port for the management API.")
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def build_config(args: argparse.Namespace):
|
||||||
|
from src.people_flow.config import load_config, merge_cli_overrides
|
||||||
|
|
||||||
|
save_video = None
|
||||||
|
if hasattr(args, "skip_video_save"):
|
||||||
|
save_video = not args.skip_video_save
|
||||||
|
|
||||||
|
config = load_config(Path(args.config))
|
||||||
|
return merge_cli_overrides(
|
||||||
|
config=config,
|
||||||
|
line=args.line,
|
||||||
|
line_mode=args.line_mode,
|
||||||
|
device=args.device,
|
||||||
|
save_video=save_video,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "manage-api":
|
||||||
|
from src.people_flow.manage_api import run_manage_api
|
||||||
|
|
||||||
|
run_manage_api(args.config, host=args.host, port=args.port)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
config = build_config(args)
|
||||||
|
from src.people_flow.pipeline import PeopleFlowPipeline, discover_videos
|
||||||
|
|
||||||
|
output_root = Path(args.output_dir or config.runtime.output_dir)
|
||||||
|
pipeline = PeopleFlowPipeline(config=config, output_root=output_root)
|
||||||
|
|
||||||
|
if args.command == "rtsp":
|
||||||
|
paths = pipeline.get_rtsp_output_paths()
|
||||||
|
print(f"rtsp_output_dir={paths['root']}", flush=True)
|
||||||
|
print(f"latest_json={paths['latest_json']}", flush=True)
|
||||||
|
source = args.input or config.runtime.rtsp_url
|
||||||
|
if not source:
|
||||||
|
raise SystemExit("RTSP source is required. Pass --input or set runtime.rtsp_url in the config.")
|
||||||
|
pipeline.process_rtsp(source)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.command == "video":
|
||||||
|
result = pipeline.process_video(Path(args.input))
|
||||||
|
print(f"processed_video={result['video_name']}")
|
||||||
|
print(f"total_people={result['total_people']}")
|
||||||
|
print(f"unknown_attributes={result['unknown_attributes']}")
|
||||||
|
print(f"json={result['json_path']}")
|
||||||
|
if result.get("video_output_path"):
|
||||||
|
print(f"annotated_video={result['video_output_path']}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
videos = discover_videos(Path(args.input_dir), pattern=args.pattern)
|
||||||
|
if not videos:
|
||||||
|
raise SystemExit(f"No videos found under {args.input_dir} with pattern {args.pattern}")
|
||||||
|
|
||||||
|
summary = pipeline.process_batch(videos)
|
||||||
|
print(f"videos_processed={len(summary['videos'])}")
|
||||||
|
print(f"csv={summary['csv_path']}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
11
managed/people_flow_project/pyproject.toml
Normal file
11
managed/people_flow_project/pyproject.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[project]
|
||||||
|
name = "people-flow-project"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Street video people-flow counting with YOLO tracking and face-based age/gender estimation"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10,<3.13"
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
7
managed/people_flow_project/requirements-docker.txt
Normal file
7
managed/people_flow_project/requirements-docker.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
flask>=3.1.0
|
||||||
|
ultralytics>=8.3.0
|
||||||
|
opencv-python-headless>=4.10.0
|
||||||
|
deepface>=0.0.93
|
||||||
|
pyyaml>=6.0.2
|
||||||
|
pandas>=2.2.3
|
||||||
|
numpy<2
|
||||||
8
managed/people_flow_project/requirements-native.txt
Normal file
8
managed/people_flow_project/requirements-native.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
flask>=3.1.0
|
||||||
|
numpy<2
|
||||||
|
ultralytics==8.4.35
|
||||||
|
lap>=0.5.12
|
||||||
|
opencv-python==4.11.0.86
|
||||||
|
deepface==0.0.99
|
||||||
|
pyyaml==6.0.3
|
||||||
|
pandas==3.0.2
|
||||||
6
managed/people_flow_project/requirements.txt
Normal file
6
managed/people_flow_project/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
ultralytics>=8.3.0
|
||||||
|
opencv-python>=4.10.0
|
||||||
|
deepface>=0.0.93
|
||||||
|
pyyaml>=6.0.2
|
||||||
|
pandas>=2.2.3
|
||||||
|
numpy>=1.26.0
|
||||||
6
managed/people_flow_project/run_rtsp.sh
Executable file
6
managed/people_flow_project/run_rtsp.sh
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
exec "$SCRIPT_DIR/scripts/run.sh" "$@"
|
||||||
40
managed/people_flow_project/scripts/docker-entrypoint.sh
Executable file
40
managed/people_flow_project/scripts/docker-entrypoint.sh
Executable file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PROJECT_DIR="/opt/people-flow"
|
||||||
|
CONFIG_TEMPLATE="${PROJECT_DIR}/config/config.example.yaml"
|
||||||
|
CONFIG_PATH="${CONFIG_PATH:-${PROJECT_DIR}/config/local.yaml}"
|
||||||
|
OUTPUT_DIR="${OUTPUT_DIR:-${PROJECT_DIR}/outputs}"
|
||||||
|
RTSP_URL="${RTSP_URL:-}"
|
||||||
|
API_HOST="${API_HOST:-0.0.0.0}"
|
||||||
|
API_PORT="${API_PORT:-18082}"
|
||||||
|
|
||||||
|
mkdir -p "${OUTPUT_DIR}" "$(dirname "${CONFIG_PATH}")"
|
||||||
|
|
||||||
|
if [ ! -f "${CONFIG_PATH}" ]; then
|
||||||
|
cp "${CONFIG_TEMPLATE}" "${CONFIG_PATH}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
python - "$CONFIG_PATH" "$RTSP_URL" "$OUTPUT_DIR" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
config_path = Path(sys.argv[1])
|
||||||
|
rtsp_url = sys.argv[2]
|
||||||
|
output_dir = sys.argv[3]
|
||||||
|
|
||||||
|
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||||
|
runtime = raw.setdefault("runtime", {})
|
||||||
|
if rtsp_url:
|
||||||
|
runtime["rtsp_url"] = rtsp_url
|
||||||
|
runtime["output_dir"] = output_dir
|
||||||
|
yolo = raw.setdefault("yolo", {})
|
||||||
|
yolo.setdefault("model_path", "weights/yolo11n.pt")
|
||||||
|
config_path.write_text(
|
||||||
|
yaml.safe_dump(raw, allow_unicode=True, sort_keys=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
|
||||||
|
exec python main.py --config "${CONFIG_PATH}" manage-api --host "${API_HOST}" --port "${API_PORT}"
|
||||||
60
managed/people_flow_project/scripts/install.sh
Executable file
60
managed/people_flow_project/scripts/install.sh
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
SETUP_SCRIPT="${PROJECT_DIR}/setup_native_venv.sh"
|
||||||
|
RUN_SCRIPT="${PROJECT_DIR}/scripts/run.sh"
|
||||||
|
INSTALL_SERVICE_SCRIPT="${PROJECT_DIR}/scripts/install_service.sh"
|
||||||
|
PROJECT_USER="${SUDO_USER:-$(id -un)}"
|
||||||
|
|
||||||
|
run_privileged() {
|
||||||
|
if [[ "$(id -u)" -eq 0 ]]; then
|
||||||
|
"$@"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sudo "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_project_user() {
|
||||||
|
if [[ "$(id -u)" -eq 0 && -n "${SUDO_USER:-}" ]]; then
|
||||||
|
sudo -u "${PROJECT_USER}" -H "$@"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_system_package() {
|
||||||
|
local command_name="$1"
|
||||||
|
local package_name="$2"
|
||||||
|
if command -v "${command_name}" >/dev/null 2>&1; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing missing package: ${package_name}"
|
||||||
|
run_privileged apt-get -o Acquire::ForceIPv4=true update
|
||||||
|
run_privileged apt-get -o Acquire::ForceIPv4=true install -y "${package_name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_system_package ffmpeg ffmpeg
|
||||||
|
|
||||||
|
if [[ ! -d "/usr/lib/python3.12/venv" && ! -d "/usr/lib/python3.12/ensurepip" ]]; then
|
||||||
|
echo "Installing missing package: python3.12-venv"
|
||||||
|
run_privileged apt-get -o Acquire::ForceIPv4=true update
|
||||||
|
run_privileged apt-get -o Acquire::ForceIPv4=true install -y python3.12-venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v nvidia-smi >/dev/null 2>&1; then
|
||||||
|
echo "nvidia-smi is required but not installed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_project_user env PYTHON_BIN="${PYTHON_BIN:-python3.12}" bash "${SETUP_SCRIPT}"
|
||||||
|
run_project_user bash "${RUN_SCRIPT}" --prepare-only
|
||||||
|
bash "${INSTALL_SERVICE_SCRIPT}"
|
||||||
|
run_privileged systemctl enable --now people-flow.service
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
Offline install complete.
|
||||||
|
Service started and enabled on boot: people-flow.service
|
||||||
|
Runtime log: ${PROJECT_DIR}/outputs/rtsp_run.log
|
||||||
|
EOF
|
||||||
33
managed/people_flow_project/scripts/install_service.sh
Executable file
33
managed/people_flow_project/scripts/install_service.sh
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
TEMPLATE_PATH="${PROJECT_DIR}/deploy/people-flow.service.tpl"
|
||||||
|
CONFIG_PATH="${CONFIG_PATH:-${PROJECT_DIR}/config/local.yaml}"
|
||||||
|
SERVICE_NAME="${SERVICE_NAME:-people-flow.service}"
|
||||||
|
OUTPUT_PATH="${PROJECT_DIR}/deploy/${SERVICE_NAME}"
|
||||||
|
RUN_USER="${RUN_USER:-${SUDO_USER:-$(id -un)}}"
|
||||||
|
RUN_GROUP="${RUN_GROUP:-$(id -gn "${RUN_USER}")}"
|
||||||
|
|
||||||
|
if [[ ! -f "${TEMPLATE_PATH}" ]]; then
|
||||||
|
echo "Missing service template: ${TEMPLATE_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${CONFIG_PATH}" ]]; then
|
||||||
|
echo "Missing config file: ${CONFIG_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sed \
|
||||||
|
-e "s|__PROJECT_DIR__|${PROJECT_DIR}|g" \
|
||||||
|
-e "s|__CONFIG_PATH__|${CONFIG_PATH}|g" \
|
||||||
|
-e "s|__RUN_USER__|${RUN_USER}|g" \
|
||||||
|
-e "s|__RUN_GROUP__|${RUN_GROUP}|g" \
|
||||||
|
"${TEMPLATE_PATH}" > "${OUTPUT_PATH}"
|
||||||
|
|
||||||
|
sudo cp "${OUTPUT_PATH}" "/etc/systemd/system/${SERVICE_NAME}"
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
echo "Service installed to /etc/systemd/system/${SERVICE_NAME}"
|
||||||
|
echo "Enable and start it with: sudo systemctl enable --now ${SERVICE_NAME}"
|
||||||
41
managed/people_flow_project/scripts/run.sh
Executable file
41
managed/people_flow_project/scripts/run.sh
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
VENV_PYTHON="${PROJECT_DIR}/.venv/bin/python"
|
||||||
|
CONFIG_TEMPLATE="${PROJECT_DIR}/config/config.example.yaml"
|
||||||
|
CONFIG_PATH="${PROJECT_DIR}/config/local.yaml"
|
||||||
|
RTSP_URL="${RTSP_URL:-rtsp://user:password@camera-ip:554/h264/ch1/main/av_stream}"
|
||||||
|
OUTPUT_DIR="${OUTPUT_DIR:-${PROJECT_DIR}/outputs}"
|
||||||
|
PREPARE_ONLY=0
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--prepare-only" ]]; then
|
||||||
|
PREPARE_ONLY=1
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -x "${VENV_PYTHON}" ]]; then
|
||||||
|
echo "Virtual environment is missing. Run scripts/install.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${RTSP_URL}" == "rtsp://user:password@camera-ip:554/h264/ch1/main/av_stream" ]]; then
|
||||||
|
echo "Please edit scripts/run.sh and set RTSP_URL before starting." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${OUTPUT_DIR}" "${PROJECT_DIR}/config"
|
||||||
|
|
||||||
|
cp "${CONFIG_TEMPLATE}" "${CONFIG_PATH}"
|
||||||
|
sed -i.bak \
|
||||||
|
-e "s|^ rtsp_url: .*| rtsp_url: \"${RTSP_URL}\"|" \
|
||||||
|
-e "s|^ output_dir: .*| output_dir: \"${OUTPUT_DIR}\"|" \
|
||||||
|
"${CONFIG_PATH}"
|
||||||
|
rm -f "${CONFIG_PATH}.bak"
|
||||||
|
|
||||||
|
if [[ "${PREPARE_ONLY}" -eq 1 ]]; then
|
||||||
|
echo "Prepared config at ${CONFIG_PATH}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "${VENV_PYTHON}" "${PROJECT_DIR}/main.py" --config "${CONFIG_PATH}" rtsp "$@"
|
||||||
26
managed/people_flow_project/scripts/run_rtsp_docker.sh
Normal file
26
managed/people_flow_project/scripts/run_rtsp_docker.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Usage: $0 <rtsp_url> <host_output_dir>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RTSP_URL="$1"
|
||||||
|
HOST_OUTPUT_DIR="$2"
|
||||||
|
|
||||||
|
mkdir -p "$HOST_OUTPUT_DIR"
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name people-flow-rtsp \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--network host \
|
||||||
|
--gpus all \
|
||||||
|
--shm-size 1g \
|
||||||
|
-v "$HOST_OUTPUT_DIR:/opt/people-flow/output" \
|
||||||
|
people-flow-rtsp:x86-cuda \
|
||||||
|
--config /opt/people-flow/configs/docker_x86_config.yaml \
|
||||||
|
--output-dir /opt/people-flow/output \
|
||||||
|
--device cuda:0 \
|
||||||
|
rtsp \
|
||||||
|
--input "$RTSP_URL"
|
||||||
38
managed/people_flow_project/setup_native_venv.sh
Executable file
38
managed/people_flow_project/setup_native_venv.sh
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$SCRIPT_DIR"
|
||||||
|
WHEELHOUSE_DIR="$PROJECT_ROOT/wheelhouse"
|
||||||
|
DEEPFACE_SOURCE_DIR="$PROJECT_ROOT/weights/deepface"
|
||||||
|
DEEPFACE_TARGET_DIR="${HOME}/.deepface/weights"
|
||||||
|
PYTHON_BIN="${PYTHON_BIN:-python3.12}"
|
||||||
|
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
"$PYTHON_BIN" -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
if [[ -d "$WHEELHOUSE_DIR" ]] && find "$WHEELHOUSE_DIR" -maxdepth 1 -name '*.whl' | grep -q .; then
|
||||||
|
python -m pip install --no-index --find-links "$WHEELHOUSE_DIR" --upgrade pip setuptools wheel
|
||||||
|
pip install --no-index --find-links "$WHEELHOUSE_DIR" "numpy<2"
|
||||||
|
pip install --no-index --find-links "$WHEELHOUSE_DIR" torch torchvision
|
||||||
|
pip install --no-index --find-links "$WHEELHOUSE_DIR" "tensorflow[and-cuda]==2.16.1" "tf-keras==2.16.0"
|
||||||
|
pip install --no-index --find-links "$WHEELHOUSE_DIR" -r requirements-native.txt
|
||||||
|
else
|
||||||
|
python -m pip install --upgrade pip setuptools wheel
|
||||||
|
pip install "numpy<2"
|
||||||
|
pip install --index-url https://download.pytorch.org/whl/cu126 torch torchvision
|
||||||
|
pip install "tensorflow[and-cuda]==2.16.1" "tf-keras==2.16.0"
|
||||||
|
pip install -r requirements-native.txt
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DEEPFACE_TARGET_DIR"
|
||||||
|
if find "$DEEPFACE_SOURCE_DIR" -maxdepth 1 -name '*.h5' | grep -q .; then
|
||||||
|
cp -f "$DEEPFACE_SOURCE_DIR"/*.h5 "$DEEPFACE_TARGET_DIR"/
|
||||||
|
else
|
||||||
|
echo "Warning: missing bundled DeepFace weights under $DEEPFACE_SOURCE_DIR"
|
||||||
|
echo "Attribute analysis will stay unavailable until the .h5 files are provided."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "venv_ready=$PROJECT_ROOT/.venv"
|
||||||
1
managed/people_flow_project/src/people_flow/__init__.py
Normal file
1
managed/people_flow_project/src/people_flow/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""People flow analysis package."""
|
||||||
141
managed/people_flow_project/src/people_flow/attributes.py
Normal file
141
managed/people_flow_project/src/people_flow/attributes.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from statistics import median
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .models import AttributeConfig, AttributeVote, TrackAttributeSummary, TrackObservation
|
||||||
|
|
||||||
|
|
||||||
|
def age_to_bucket(age: int) -> str:
|
||||||
|
if age < 18:
|
||||||
|
return "minor"
|
||||||
|
if age < 60:
|
||||||
|
return "adult"
|
||||||
|
return "senior"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_gender(raw_gender: str | None) -> str | None:
|
||||||
|
if not raw_gender:
|
||||||
|
return None
|
||||||
|
lowered = raw_gender.strip().lower()
|
||||||
|
if lowered in {"man", "male"}:
|
||||||
|
return "male"
|
||||||
|
if lowered in {"woman", "female"}:
|
||||||
|
return "female"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AttributeAggregator:
|
||||||
|
def __init__(self, config: AttributeConfig) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.votes: dict[int, list[AttributeVote]] = defaultdict(list)
|
||||||
|
self.samples_taken: dict[int, int] = defaultdict(int)
|
||||||
|
self.last_sampled_frame: dict[int, int] = {}
|
||||||
|
self._deepface = self._load_deepface() if config.enabled else None
|
||||||
|
|
||||||
|
def _load_deepface(self) -> Any:
|
||||||
|
try:
|
||||||
|
from deepface import DeepFace
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"DeepFace is not installed. Install dependencies with `pip install -r requirements.txt`."
|
||||||
|
) from exc
|
||||||
|
return DeepFace
|
||||||
|
|
||||||
|
def maybe_collect(self, frame: np.ndarray, frame_index: int, track: TrackObservation) -> None:
|
||||||
|
if self._deepface is None:
|
||||||
|
return
|
||||||
|
if self.samples_taken[track.track_id] >= self.config.max_samples_per_track:
|
||||||
|
return
|
||||||
|
last_frame = self.last_sampled_frame.get(track.track_id)
|
||||||
|
if last_frame is not None and frame_index - last_frame < self.config.sample_every_n_frames:
|
||||||
|
return
|
||||||
|
|
||||||
|
x1, y1, x2, y2 = track.bbox
|
||||||
|
width = x2 - x1
|
||||||
|
height = y2 - y1
|
||||||
|
if width < self.config.min_person_box_width or height < self.config.min_person_box_height:
|
||||||
|
return
|
||||||
|
|
||||||
|
crop = self._crop_person(frame, track.bbox)
|
||||||
|
if crop.size == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
vote = self._analyze_crop(crop)
|
||||||
|
self.last_sampled_frame[track.track_id] = frame_index
|
||||||
|
if vote is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.samples_taken[track.track_id] += 1
|
||||||
|
self.votes[track.track_id].append(vote)
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.votes.clear()
|
||||||
|
self.samples_taken.clear()
|
||||||
|
self.last_sampled_frame.clear()
|
||||||
|
|
||||||
|
def _crop_person(self, frame: np.ndarray, bbox: tuple[int, int, int, int]) -> np.ndarray:
|
||||||
|
x1, y1, x2, y2 = bbox
|
||||||
|
height, width = frame.shape[:2]
|
||||||
|
pad_x = int((x2 - x1) * self.config.person_crop_padding)
|
||||||
|
pad_y = int((y2 - y1) * self.config.person_crop_padding)
|
||||||
|
left = max(0, x1 - pad_x)
|
||||||
|
top = max(0, y1 - pad_y)
|
||||||
|
right = min(width, x2 + pad_x)
|
||||||
|
bottom = min(height, y2 + pad_y)
|
||||||
|
return frame[top:bottom, left:right]
|
||||||
|
|
||||||
|
def _analyze_crop(self, crop: np.ndarray) -> AttributeVote | None:
|
||||||
|
rgb_crop = cv2.cvtColor(crop, cv2.COLOR_BGR2RGB)
|
||||||
|
try:
|
||||||
|
analysis = self._deepface.analyze(
|
||||||
|
img_path=rgb_crop,
|
||||||
|
actions=["age", "gender"],
|
||||||
|
detector_backend=self.config.detector_backend,
|
||||||
|
enforce_detection=self.config.enforce_detection,
|
||||||
|
silent=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(analysis, list):
|
||||||
|
if not analysis:
|
||||||
|
return None
|
||||||
|
analysis = analysis[0]
|
||||||
|
|
||||||
|
age_value = analysis.get("age")
|
||||||
|
gender_value = normalize_gender(analysis.get("dominant_gender"))
|
||||||
|
if age_value is None or gender_value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
age_int = int(round(float(age_value)))
|
||||||
|
return AttributeVote(
|
||||||
|
age=age_int,
|
||||||
|
age_bucket=age_to_bucket(age_int),
|
||||||
|
gender=gender_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
def summarize_track(self, track_id: int) -> TrackAttributeSummary | None:
|
||||||
|
votes = self.votes.get(track_id, [])
|
||||||
|
if not votes:
|
||||||
|
return None
|
||||||
|
|
||||||
|
age_bucket_counts = Counter(vote.age_bucket for vote in votes)
|
||||||
|
gender_counts = Counter(vote.gender for vote in votes)
|
||||||
|
if not age_bucket_counts or not gender_counts:
|
||||||
|
return None
|
||||||
|
|
||||||
|
age_bucket = age_bucket_counts.most_common(1)[0][0]
|
||||||
|
gender = gender_counts.most_common(1)[0][0]
|
||||||
|
age_value = int(round(median(vote.age for vote in votes)))
|
||||||
|
return TrackAttributeSummary(
|
||||||
|
track_id=track_id,
|
||||||
|
age=age_value,
|
||||||
|
age_bucket=age_bucket,
|
||||||
|
gender=gender,
|
||||||
|
samples_used=len(votes),
|
||||||
|
)
|
||||||
99
managed/people_flow_project/src/people_flow/config.py
Normal file
99
managed/people_flow_project/src/people_flow/config.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
AppConfig,
|
||||||
|
AttributeConfig,
|
||||||
|
CountingConfig,
|
||||||
|
OutputConfig,
|
||||||
|
RtspConfig,
|
||||||
|
RuntimeConfig,
|
||||||
|
YoloConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_yaml(config_path: Path) -> dict:
|
||||||
|
if not config_path.exists():
|
||||||
|
raise FileNotFoundError(f"Config file not found: {config_path}")
|
||||||
|
with config_path.open("r", encoding="utf-8") as handle:
|
||||||
|
loaded = yaml.safe_load(handle) or {}
|
||||||
|
if not isinstance(loaded, dict):
|
||||||
|
raise ValueError(f"Config file must contain a mapping: {config_path}")
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
def load_config_document(config_path: Path) -> dict:
|
||||||
|
return _read_yaml(config_path)
|
||||||
|
|
||||||
|
|
||||||
|
def save_config_document(config_path: Path, payload: dict) -> None:
|
||||||
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temp_path = config_path.with_suffix(config_path.suffix + ".tmp")
|
||||||
|
temp_path.write_text(
|
||||||
|
yaml.safe_dump(payload, allow_unicode=True, sort_keys=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
temp_path.replace(config_path)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_project_root(config_path: Path) -> Path:
|
||||||
|
return config_path.expanduser().resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_project_path(project_root: Path, raw_path: str | Path) -> Path:
|
||||||
|
path = Path(raw_path)
|
||||||
|
if path.is_absolute():
|
||||||
|
return path.resolve()
|
||||||
|
return (project_root.resolve() / path).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: Path) -> AppConfig:
|
||||||
|
data = _read_yaml(config_path)
|
||||||
|
config = AppConfig(
|
||||||
|
yolo=YoloConfig(**data.get("yolo", {})),
|
||||||
|
counting=CountingConfig(**_normalize_counting_config(data.get("counting", {}))),
|
||||||
|
attributes=AttributeConfig(**data.get("attributes", {})),
|
||||||
|
output=OutputConfig(**data.get("output", {})),
|
||||||
|
rtsp=RtspConfig(**data.get("rtsp", {})),
|
||||||
|
runtime=RuntimeConfig(**data.get("runtime", {})),
|
||||||
|
config_path=config_path.resolve(),
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_counting_config(data: dict) -> dict:
|
||||||
|
normalized = dict(data)
|
||||||
|
line = normalized.get("line")
|
||||||
|
if line is not None:
|
||||||
|
normalized["line"] = tuple(float(value) for value in line)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def parse_line_override(raw_line: str) -> tuple[float, float, float, float]:
|
||||||
|
parts = [part.strip() for part in raw_line.split(",")]
|
||||||
|
if len(parts) != 4:
|
||||||
|
raise ValueError("--line must contain exactly four comma-separated values")
|
||||||
|
return tuple(float(part) for part in parts) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def merge_cli_overrides(
|
||||||
|
config: AppConfig,
|
||||||
|
line: str | None,
|
||||||
|
line_mode: str | None,
|
||||||
|
device: str | None,
|
||||||
|
save_video: bool | None,
|
||||||
|
) -> AppConfig:
|
||||||
|
updated = config
|
||||||
|
if line:
|
||||||
|
updated.counting = replace(updated.counting, line=parse_line_override(line))
|
||||||
|
if line_mode:
|
||||||
|
updated.counting = replace(updated.counting, line_mode=line_mode)
|
||||||
|
if device:
|
||||||
|
updated.yolo = replace(updated.yolo, device=device)
|
||||||
|
if save_video is not None:
|
||||||
|
updated.output = replace(updated.output, save_video=save_video)
|
||||||
|
return updated
|
||||||
52
managed/people_flow_project/src/people_flow/counting.py
Normal file
52
managed/people_flow_project/src/people_flow/counting.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .models import CountingConfig, CrossingEvent, TrackObservation
|
||||||
|
|
||||||
|
|
||||||
|
def _line_side(
|
||||||
|
point: tuple[float, float], line: tuple[float, float, float, float]
|
||||||
|
) -> float:
|
||||||
|
px, py = point
|
||||||
|
x1, y1, x2, y2 = line
|
||||||
|
return (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1)
|
||||||
|
|
||||||
|
|
||||||
|
class LineCrossCounter:
|
||||||
|
def __init__(self, line: tuple[float, float, float, float], config: CountingConfig) -> None:
|
||||||
|
self.line = line
|
||||||
|
self.config = config
|
||||||
|
self.previous_side: dict[int, float] = {}
|
||||||
|
self.counted_ids: set[int] = set()
|
||||||
|
self.crossings: list[CrossingEvent] = []
|
||||||
|
|
||||||
|
def update(self, observations: list[TrackObservation]) -> list[CrossingEvent]:
|
||||||
|
events: list[CrossingEvent] = []
|
||||||
|
for observation in observations:
|
||||||
|
side = _line_side(observation.center, self.line)
|
||||||
|
previous = self.previous_side.get(observation.track_id)
|
||||||
|
self.previous_side[observation.track_id] = side
|
||||||
|
|
||||||
|
if observation.track_id in self.counted_ids:
|
||||||
|
continue
|
||||||
|
if previous is None:
|
||||||
|
continue
|
||||||
|
if abs(previous) <= self.config.crossing_tolerance or abs(side) <= self.config.crossing_tolerance:
|
||||||
|
continue
|
||||||
|
if previous * side >= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
direction = "negative_to_positive" if previous < 0 < side else "positive_to_negative"
|
||||||
|
event = CrossingEvent(track_id=observation.track_id, direction=direction)
|
||||||
|
self.counted_ids.add(observation.track_id)
|
||||||
|
self.crossings.append(event)
|
||||||
|
events.append(event)
|
||||||
|
return events
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.previous_side.clear()
|
||||||
|
self.counted_ids.clear()
|
||||||
|
self.crossings.clear()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_people(self) -> int:
|
||||||
|
return len(self.counted_ids)
|
||||||
95
managed/people_flow_project/src/people_flow/io_utils.py
Normal file
95
managed/people_flow_project/src/people_flow/io_utils.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
from .models import TrackObservation
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dir(path: Path) -> Path:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def make_video_writer(path: Path, width: int, height: int, fps: float) -> cv2.VideoWriter:
|
||||||
|
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||||||
|
return cv2.VideoWriter(str(path), fourcc, fps if fps > 0 else 25.0, (width, height))
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path: Path, payload: dict) -> None:
|
||||||
|
with path.open("w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, ensure_ascii=True, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def write_csv(path: Path, rows: list[dict]) -> None:
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
dataframe = pd.DataFrame(rows)
|
||||||
|
dataframe.to_csv(path, index=False)
|
||||||
|
|
||||||
|
|
||||||
|
def write_window_json(windows_dir: Path, latest_path: Path, payload: dict, window_end: datetime) -> Path:
|
||||||
|
ensure_dir(windows_dir)
|
||||||
|
ensure_dir(latest_path.parent)
|
||||||
|
target = windows_dir / f"stats_{window_end.strftime('%Y-%m-%d_%H-%M-%S')}.json"
|
||||||
|
write_json(target, payload)
|
||||||
|
write_json(latest_path, payload)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def draw_line(frame, line: tuple[float, float, float, float]) -> None:
|
||||||
|
x1, y1, x2, y2 = (int(value) for value in line)
|
||||||
|
cv2.line(frame, (x1, y1), (x2, y2), (0, 255, 255), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_tracks(
|
||||||
|
frame,
|
||||||
|
observations: list[TrackObservation],
|
||||||
|
counted_ids: set[int],
|
||||||
|
draw_labels: bool,
|
||||||
|
) -> None:
|
||||||
|
for observation in observations:
|
||||||
|
x1, y1, x2, y2 = observation.bbox
|
||||||
|
color = (0, 200, 0) if observation.track_id in counted_ids else (255, 140, 0)
|
||||||
|
cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2)
|
||||||
|
if draw_labels:
|
||||||
|
label = f"id={observation.track_id} conf={observation.confidence:.2f}"
|
||||||
|
cv2.putText(
|
||||||
|
frame,
|
||||||
|
label,
|
||||||
|
(x1, max(20, y1 - 6)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.5,
|
||||||
|
color,
|
||||||
|
1,
|
||||||
|
cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_stats(frame, stats: dict) -> None:
|
||||||
|
lines = [
|
||||||
|
f"total_people: {stats['total_people']}",
|
||||||
|
f"minor: {stats['age_counts']['minor']}",
|
||||||
|
f"adult: {stats['age_counts']['adult']}",
|
||||||
|
f"senior: {stats['age_counts']['senior']}",
|
||||||
|
f"male: {stats['gender_counts']['male']}",
|
||||||
|
f"female: {stats['gender_counts']['female']}",
|
||||||
|
f"unknown_attributes: {stats['unknown_attributes']}",
|
||||||
|
]
|
||||||
|
x = 12
|
||||||
|
y = 24
|
||||||
|
for text in lines:
|
||||||
|
cv2.putText(
|
||||||
|
frame,
|
||||||
|
text,
|
||||||
|
(x, y),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.65,
|
||||||
|
(255, 255, 255),
|
||||||
|
2,
|
||||||
|
cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
y += 24
|
||||||
389
managed/people_flow_project/src/people_flow/manage_api.py
Normal file
389
managed/people_flow_project/src/people_flow/manage_api.py
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request, send_file
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
load_config,
|
||||||
|
load_config_document,
|
||||||
|
resolve_project_path,
|
||||||
|
resolve_project_root,
|
||||||
|
save_config_document,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_TYPE = "people_flow_project"
|
||||||
|
DEFAULT_MANAGE_PORT = 18082
|
||||||
|
MAX_PREVIEW_LINES = 2000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ManageContext:
|
||||||
|
config_path: Path
|
||||||
|
project_root: Path
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_path: str | Path) -> Flask:
|
||||||
|
resolved_config = Path(config_path).expanduser().resolve()
|
||||||
|
ctx = ManageContext(
|
||||||
|
config_path=resolved_config,
|
||||||
|
project_root=resolve_project_root(resolved_config),
|
||||||
|
)
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["MANAGE_CONTEXT"] = ctx
|
||||||
|
|
||||||
|
@app.get("/api/manage/health")
|
||||||
|
def get_health():
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"project_type": PROJECT_TYPE,
|
||||||
|
"version": "dev",
|
||||||
|
"runtime_status": "running",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/api/manage/config")
|
||||||
|
def get_config():
|
||||||
|
return jsonify(_config_payload(ctx))
|
||||||
|
|
||||||
|
@app.put("/api/manage/config")
|
||||||
|
def update_config():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
rtsp_url = payload.get("rtsp_url")
|
||||||
|
if not isinstance(rtsp_url, str) or not rtsp_url.strip():
|
||||||
|
return jsonify({"error": "rtsp_url is required"}), 400
|
||||||
|
|
||||||
|
raw = load_config_document(ctx.config_path)
|
||||||
|
runtime = raw.setdefault("runtime", {})
|
||||||
|
runtime["rtsp_url"] = rtsp_url.strip()
|
||||||
|
save_config_document(ctx.config_path, raw)
|
||||||
|
return jsonify(_config_payload(ctx))
|
||||||
|
|
||||||
|
@app.get("/api/manage/summary")
|
||||||
|
def get_summary():
|
||||||
|
return jsonify(_build_summary(ctx))
|
||||||
|
|
||||||
|
@app.get("/api/manage/windows")
|
||||||
|
def get_windows():
|
||||||
|
page = max(_int_arg("page", 1), 1)
|
||||||
|
page_size = max(_int_arg("page_size", 24), 1)
|
||||||
|
limit = request.args.get("limit")
|
||||||
|
|
||||||
|
items = list(_load_window_stats(ctx))
|
||||||
|
if limit is not None:
|
||||||
|
items = items[: max(_int_value(limit), 0)]
|
||||||
|
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
end = start + page_size
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"items": items[start:end],
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total": len(items),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/api/manage/files")
|
||||||
|
def get_files():
|
||||||
|
return jsonify({"files": _list_result_files(ctx)})
|
||||||
|
|
||||||
|
@app.get("/api/manage/files/preview")
|
||||||
|
def preview_file():
|
||||||
|
target = _resolve_sandbox_file(ctx, request.args.get("path", ""))
|
||||||
|
lines = _tail_lines(target, _bounded_preview_lines(request.args.get("lines")))
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"path": _relative_path(ctx, target),
|
||||||
|
"lines": lines,
|
||||||
|
"count": len(lines),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/api/manage/files/download")
|
||||||
|
def download_file():
|
||||||
|
target = _resolve_sandbox_file(ctx, request.args.get("path", ""))
|
||||||
|
return send_file(target, as_attachment=True, download_name=target.name)
|
||||||
|
|
||||||
|
@app.errorhandler(ValueError)
|
||||||
|
def handle_value_error(error: ValueError):
|
||||||
|
return jsonify({"error": str(error)}), 400
|
||||||
|
|
||||||
|
@app.errorhandler(FileNotFoundError)
|
||||||
|
def handle_missing_file(error: FileNotFoundError):
|
||||||
|
return jsonify({"error": str(error)}), 404
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def run_manage_api(
|
||||||
|
config_path: str | Path,
|
||||||
|
host: str = "0.0.0.0",
|
||||||
|
port: int = DEFAULT_MANAGE_PORT,
|
||||||
|
) -> None:
|
||||||
|
app = create_app(config_path)
|
||||||
|
app.run(host=host, port=port)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> ArgumentParser:
|
||||||
|
parser = ArgumentParser(description="People flow management API")
|
||||||
|
parser.add_argument("--config", required=True, help="Path to YAML config file")
|
||||||
|
parser.add_argument("--host", default="0.0.0.0", help="Host for the management API")
|
||||||
|
parser.add_argument("--port", type=int, default=DEFAULT_MANAGE_PORT, help="Port for the management API")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = parse_args()
|
||||||
|
args = parser.parse_args()
|
||||||
|
run_manage_api(args.config, host=args.host, port=args.port)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _config_payload(ctx: ManageContext) -> dict:
|
||||||
|
config = load_config(ctx.config_path)
|
||||||
|
output_root = resolve_project_path(ctx.project_root, config.runtime.output_dir)
|
||||||
|
return {
|
||||||
|
"project_type": PROJECT_TYPE,
|
||||||
|
"config_path": str(ctx.config_path),
|
||||||
|
"runtime": {
|
||||||
|
"rtsp_url": config.runtime.rtsp_url,
|
||||||
|
"output_dir": str(output_root),
|
||||||
|
},
|
||||||
|
"rtsp": {
|
||||||
|
"output_subdir": config.rtsp.output_subdir,
|
||||||
|
"window_seconds": config.rtsp.window_seconds,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_summary(ctx: ManageContext) -> dict:
|
||||||
|
summary_path, payload = _load_summary_payload(ctx)
|
||||||
|
all_window_stats = _load_window_stats(ctx)
|
||||||
|
if payload is None:
|
||||||
|
latest_json = _latest_json_path(ctx)
|
||||||
|
return {
|
||||||
|
"result_type": PROJECT_TYPE,
|
||||||
|
"headline": "No RTSP summary output yet",
|
||||||
|
"metrics": {
|
||||||
|
"latest_path": str(latest_json),
|
||||||
|
"recent_window_stats": all_window_stats[:24],
|
||||||
|
"all_window_stats": all_window_stats,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks = payload.get("tracks", [])
|
||||||
|
direction_counts: dict[str, int] = {}
|
||||||
|
if isinstance(tracks, list):
|
||||||
|
for item in tracks:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
direction = _string_value(item.get("direction"))
|
||||||
|
if not direction:
|
||||||
|
continue
|
||||||
|
direction_counts[direction] = direction_counts.get(direction, 0) + 1
|
||||||
|
|
||||||
|
total_people = _int_value(payload.get("total_people"))
|
||||||
|
window_end = _string_value(payload.get("window_end"))
|
||||||
|
return {
|
||||||
|
"result_type": PROJECT_TYPE,
|
||||||
|
"headline": f"Latest window counted {total_people} people",
|
||||||
|
"last_result_time": window_end,
|
||||||
|
"metrics": {
|
||||||
|
"summary_path": str(summary_path) if summary_path else "",
|
||||||
|
"window_start": _string_value(payload.get("window_start")),
|
||||||
|
"window_end": window_end,
|
||||||
|
"total_people": total_people,
|
||||||
|
"direction_counts": direction_counts,
|
||||||
|
"age_counts": _map_string_int(payload.get("age_counts")),
|
||||||
|
"gender_counts": _map_string_int(payload.get("gender_counts")),
|
||||||
|
"unknown_attributes": _int_value(payload.get("unknown_attributes")),
|
||||||
|
"recent_window_stats": all_window_stats[:24],
|
||||||
|
"all_window_stats": all_window_stats,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_summary_payload(ctx: ManageContext) -> tuple[Path | None, dict | None]:
|
||||||
|
candidates: list[Path] = []
|
||||||
|
latest_json = _latest_json_path(ctx)
|
||||||
|
if latest_json.exists():
|
||||||
|
candidates.append(latest_json)
|
||||||
|
|
||||||
|
window_files = _window_files(ctx)
|
||||||
|
if window_files:
|
||||||
|
candidates.extend(window_files[:1])
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
try:
|
||||||
|
payload = json.loads(candidate.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(f"invalid summary json: {candidate}") from exc
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
return candidate, payload
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_window_stats(ctx: ManageContext) -> list[dict]:
|
||||||
|
stats: list[dict] = []
|
||||||
|
for path in _window_files(ctx):
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
stats.append(
|
||||||
|
{
|
||||||
|
"window_start": _string_value(payload.get("window_start")),
|
||||||
|
"window_end": _string_value(payload.get("window_end")),
|
||||||
|
"total_people": _int_value(payload.get("total_people")),
|
||||||
|
"age_counts": _map_string_int(payload.get("age_counts")),
|
||||||
|
"gender_counts": _map_string_int(payload.get("gender_counts")),
|
||||||
|
"unknown_attributes": _int_value(payload.get("unknown_attributes")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
stats.sort(key=lambda item: item["window_end"], reverse=True)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def _list_result_files(ctx: ManageContext) -> list[dict]:
|
||||||
|
files: list[dict] = []
|
||||||
|
for path, label in (
|
||||||
|
(_latest_json_path(ctx), "Latest Summary"),
|
||||||
|
(_runtime_log_path(ctx), "Runtime Log"),
|
||||||
|
):
|
||||||
|
if path.exists() and path.is_file():
|
||||||
|
files.append(_build_result_file(ctx, path, label))
|
||||||
|
|
||||||
|
for path in _window_files(ctx):
|
||||||
|
if path.exists() and path.is_file():
|
||||||
|
files.append(_build_result_file(ctx, path, "Window Summary"))
|
||||||
|
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def _build_result_file(ctx: ManageContext, path: Path, label: str) -> dict:
|
||||||
|
info = path.stat()
|
||||||
|
return {
|
||||||
|
"path": _relative_path(ctx, path),
|
||||||
|
"name": path.name,
|
||||||
|
"label": label,
|
||||||
|
"kind": path.suffix.lstrip(".").lower(),
|
||||||
|
"size": info.st_size,
|
||||||
|
"modified_at": datetime.fromtimestamp(info.st_mtime).astimezone().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _output_root(ctx: ManageContext) -> Path:
|
||||||
|
config = load_config(ctx.config_path)
|
||||||
|
return resolve_project_path(ctx.project_root, config.runtime.output_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def _rtsp_output_root(ctx: ManageContext) -> Path:
|
||||||
|
config = load_config(ctx.config_path)
|
||||||
|
return _output_root(ctx) / config.rtsp.output_subdir
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_json_path(ctx: ManageContext) -> Path:
|
||||||
|
return _rtsp_output_root(ctx) / "latest.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_dir(ctx: ManageContext) -> Path:
|
||||||
|
return _rtsp_output_root(ctx) / "windows"
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_log_path(ctx: ManageContext) -> Path:
|
||||||
|
return _output_root(ctx) / "rtsp_run.log"
|
||||||
|
|
||||||
|
|
||||||
|
def _window_files(ctx: ManageContext) -> list[Path]:
|
||||||
|
windows_dir = _windows_dir(ctx)
|
||||||
|
if not windows_dir.exists():
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
[path for path in windows_dir.iterdir() if path.is_file()],
|
||||||
|
key=lambda path: path.name,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_sandbox_file(ctx: ManageContext, raw_path: str) -> Path:
|
||||||
|
relative = raw_path.strip().lstrip("/")
|
||||||
|
if not relative:
|
||||||
|
raise ValueError("path is required")
|
||||||
|
|
||||||
|
target = (ctx.project_root / relative).resolve()
|
||||||
|
project_root = ctx.project_root.resolve()
|
||||||
|
if target != project_root and project_root not in target.parents:
|
||||||
|
raise ValueError("invalid file path")
|
||||||
|
if not target.exists() or not target.is_file():
|
||||||
|
raise FileNotFoundError(relative)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_path(ctx: ManageContext, target: Path) -> str:
|
||||||
|
return target.resolve().relative_to(ctx.project_root.resolve()).as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def _tail_lines(path: Path, line_count: int) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
for raw_line in handle:
|
||||||
|
lines.append(raw_line.rstrip("\n"))
|
||||||
|
if len(lines) > line_count:
|
||||||
|
lines = lines[1:]
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_preview_lines(raw_value: str | None) -> int:
|
||||||
|
if raw_value is None:
|
||||||
|
return 200
|
||||||
|
value = _int_value(raw_value)
|
||||||
|
if value <= 0:
|
||||||
|
return 200
|
||||||
|
return min(value, MAX_PREVIEW_LINES)
|
||||||
|
|
||||||
|
|
||||||
|
def _int_arg(name: str, default: int) -> int:
|
||||||
|
value = request.args.get(name)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return _int_value(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _string_value(value) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _int_value(value) -> int:
|
||||||
|
if value is None:
|
||||||
|
return 0
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
if isinstance(value, float):
|
||||||
|
return int(value)
|
||||||
|
try:
|
||||||
|
return int(str(value).strip())
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _map_string_int(value) -> dict[str, int]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {}
|
||||||
|
return {str(key): _int_value(raw) for key, raw in value.items()}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
105
managed/people_flow_project/src/people_flow/models.py
Normal file
105
managed/people_flow_project/src/people_flow/models.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class YoloConfig:
|
||||||
|
model_path: str = "yolo11n.pt"
|
||||||
|
tracker: str = "botsort.yaml"
|
||||||
|
conf: float = 0.35
|
||||||
|
iou: float = 0.5
|
||||||
|
imgsz: int = 1280
|
||||||
|
device: str = "cuda:0"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CountingConfig:
|
||||||
|
line: tuple[float, float, float, float] = (0.1, 0.55, 0.9, 0.55)
|
||||||
|
line_mode: str = "normalized"
|
||||||
|
crossing_tolerance: float = 12.0
|
||||||
|
|
||||||
|
def to_pixel_line(self, width: int, height: int) -> tuple[float, float, float, float]:
|
||||||
|
x1, y1, x2, y2 = self.line
|
||||||
|
if self.line_mode == "pixel":
|
||||||
|
return x1, y1, x2, y2
|
||||||
|
return x1 * width, y1 * height, x2 * width, y2 * height
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AttributeConfig:
|
||||||
|
enabled: bool = True
|
||||||
|
sample_every_n_frames: int = 12
|
||||||
|
max_samples_per_track: int = 5
|
||||||
|
min_person_box_width: int = 80
|
||||||
|
min_person_box_height: int = 160
|
||||||
|
person_crop_padding: float = 0.15
|
||||||
|
detector_backend: str = "retinaface"
|
||||||
|
enforce_detection: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OutputConfig:
|
||||||
|
save_video: bool = True
|
||||||
|
save_json: bool = True
|
||||||
|
save_csv: bool = True
|
||||||
|
draw_boxes: bool = True
|
||||||
|
draw_labels: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RtspConfig:
|
||||||
|
sample_interval_seconds: float = 1.0
|
||||||
|
window_seconds: int = 1800
|
||||||
|
reconnect_delay_seconds: float = 5.0
|
||||||
|
stream_open_timeout_seconds: float = 10.0
|
||||||
|
idle_sleep_seconds: float = 0.05
|
||||||
|
output_subdir: str = "rtsp_stream"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RuntimeConfig:
|
||||||
|
rtsp_url: str = "rtsp://user:password@camera-ip:554/h264/ch1/main/av_stream"
|
||||||
|
output_dir: str = "outputs"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppConfig:
|
||||||
|
yolo: YoloConfig = field(default_factory=YoloConfig)
|
||||||
|
counting: CountingConfig = field(default_factory=CountingConfig)
|
||||||
|
attributes: AttributeConfig = field(default_factory=AttributeConfig)
|
||||||
|
output: OutputConfig = field(default_factory=OutputConfig)
|
||||||
|
rtsp: RtspConfig = field(default_factory=RtspConfig)
|
||||||
|
runtime: RuntimeConfig = field(default_factory=RuntimeConfig)
|
||||||
|
config_path: Path | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrackObservation:
|
||||||
|
track_id: int
|
||||||
|
bbox: tuple[int, int, int, int]
|
||||||
|
confidence: float
|
||||||
|
center: tuple[float, float]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CrossingEvent:
|
||||||
|
track_id: int
|
||||||
|
direction: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AttributeVote:
|
||||||
|
age: int
|
||||||
|
age_bucket: str
|
||||||
|
gender: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrackAttributeSummary:
|
||||||
|
track_id: int
|
||||||
|
age: int
|
||||||
|
age_bucket: str
|
||||||
|
gender: str
|
||||||
|
samples_used: int
|
||||||
445
managed/people_flow_project/src/people_flow/pipeline.py
Normal file
445
managed/people_flow_project/src/people_flow/pipeline.py
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
from .attributes import AttributeAggregator
|
||||||
|
from .counting import LineCrossCounter
|
||||||
|
from .io_utils import (
|
||||||
|
draw_line,
|
||||||
|
draw_stats,
|
||||||
|
draw_tracks,
|
||||||
|
ensure_dir,
|
||||||
|
make_video_writer,
|
||||||
|
write_csv,
|
||||||
|
write_json,
|
||||||
|
write_window_json,
|
||||||
|
)
|
||||||
|
from .models import AppConfig
|
||||||
|
from .tracking import extract_person_tracks
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORTED_EXTENSIONS = {".mp4", ".mov", ".mkv", ".avi"}
|
||||||
|
|
||||||
|
|
||||||
|
def discover_videos(root: Path, pattern: str = "*.mp4") -> list[Path]:
|
||||||
|
if not root.exists():
|
||||||
|
raise FileNotFoundError(f"Input directory not found: {root}")
|
||||||
|
videos = [
|
||||||
|
path
|
||||||
|
for path in root.rglob(pattern)
|
||||||
|
if path.is_file() and path.suffix.lower() in SUPPORTED_EXTENSIONS
|
||||||
|
]
|
||||||
|
return sorted(videos)
|
||||||
|
|
||||||
|
|
||||||
|
class PeopleFlowPipeline:
|
||||||
|
def __init__(self, config: AppConfig, output_root: Path) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.output_root = ensure_dir(output_root)
|
||||||
|
self.model = self._load_model()
|
||||||
|
|
||||||
|
def _load_model(self) -> Any:
|
||||||
|
try:
|
||||||
|
from ultralytics import YOLO
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Ultralytics is not installed. Install dependencies with `pip install -r requirements.txt`."
|
||||||
|
) from exc
|
||||||
|
return YOLO(self.config.yolo.model_path)
|
||||||
|
|
||||||
|
def get_rtsp_output_paths(self) -> dict[str, Path]:
|
||||||
|
root = ensure_dir(self.output_root / self.config.rtsp.output_subdir)
|
||||||
|
windows = ensure_dir(root / "windows")
|
||||||
|
latest_json = root / "latest.json"
|
||||||
|
return {"root": root, "windows": windows, "latest_json": latest_json}
|
||||||
|
|
||||||
|
def process_batch(self, videos: list[Path]) -> dict:
|
||||||
|
rows: list[dict] = []
|
||||||
|
for video_path in videos:
|
||||||
|
rows.append(self.process_video(video_path))
|
||||||
|
|
||||||
|
csv_path = self.output_root / "batch_summary.csv"
|
||||||
|
if self.config.output.save_csv:
|
||||||
|
csv_rows = [
|
||||||
|
{
|
||||||
|
"video_name": row["video_name"],
|
||||||
|
"video_path": row["video_path"],
|
||||||
|
"total_people": row["total_people"],
|
||||||
|
"minor": row["age_counts"]["minor"],
|
||||||
|
"adult": row["age_counts"]["adult"],
|
||||||
|
"senior": row["age_counts"]["senior"],
|
||||||
|
"male": row["gender_counts"]["male"],
|
||||||
|
"female": row["gender_counts"]["female"],
|
||||||
|
"unknown_attributes": row["unknown_attributes"],
|
||||||
|
"json_path": row["json_path"],
|
||||||
|
"video_output_path": row.get("video_output_path"),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
write_csv(csv_path, csv_rows)
|
||||||
|
|
||||||
|
return {"videos": rows, "csv_path": str(csv_path)}
|
||||||
|
|
||||||
|
def process_video(self, video_path: Path) -> dict:
|
||||||
|
if not video_path.exists():
|
||||||
|
raise FileNotFoundError(f"Video file not found: {video_path}")
|
||||||
|
|
||||||
|
capture = cv2.VideoCapture(str(video_path))
|
||||||
|
if not capture.isOpened():
|
||||||
|
raise RuntimeError(f"Failed to open video: {video_path}")
|
||||||
|
|
||||||
|
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
|
||||||
|
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
|
||||||
|
fps = float(capture.get(cv2.CAP_PROP_FPS) or 25.0)
|
||||||
|
pixel_line = self.config.counting.to_pixel_line(width=width, height=height)
|
||||||
|
|
||||||
|
video_output_dir = ensure_dir(self.output_root / video_path.stem)
|
||||||
|
video_output_path = video_output_dir / f"{video_path.stem}.annotated.mp4"
|
||||||
|
json_path = video_output_dir / f"{video_path.stem}.json"
|
||||||
|
|
||||||
|
writer = None
|
||||||
|
if self.config.output.save_video:
|
||||||
|
writer = make_video_writer(video_output_path, width=width, height=height, fps=fps)
|
||||||
|
|
||||||
|
counter = LineCrossCounter(pixel_line, self.config.counting)
|
||||||
|
attributes = AttributeAggregator(self.config.attributes)
|
||||||
|
|
||||||
|
frame_index = 0
|
||||||
|
while True:
|
||||||
|
ok, frame = capture.read()
|
||||||
|
if not ok:
|
||||||
|
break
|
||||||
|
|
||||||
|
observations = self._track_frame(frame)
|
||||||
|
|
||||||
|
for observation in observations:
|
||||||
|
attributes.maybe_collect(frame=frame, frame_index=frame_index, track=observation)
|
||||||
|
|
||||||
|
counter.update(observations)
|
||||||
|
|
||||||
|
if writer is not None:
|
||||||
|
frame_stats = self._build_live_stats(counter, attributes)
|
||||||
|
annotated = frame.copy()
|
||||||
|
draw_line(annotated, pixel_line)
|
||||||
|
if self.config.output.draw_boxes:
|
||||||
|
draw_tracks(
|
||||||
|
annotated,
|
||||||
|
observations=observations,
|
||||||
|
counted_ids=counter.counted_ids,
|
||||||
|
draw_labels=self.config.output.draw_labels,
|
||||||
|
)
|
||||||
|
draw_stats(annotated, frame_stats)
|
||||||
|
writer.write(annotated)
|
||||||
|
|
||||||
|
frame_index += 1
|
||||||
|
|
||||||
|
capture.release()
|
||||||
|
if writer is not None:
|
||||||
|
writer.release()
|
||||||
|
|
||||||
|
summary = self._finalize_summary(video_path, counter, attributes, json_path)
|
||||||
|
if not self.config.output.save_video:
|
||||||
|
summary["video_output_path"] = None
|
||||||
|
else:
|
||||||
|
summary["video_output_path"] = str(video_output_path)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
def process_rtsp(self, source: str) -> dict:
|
||||||
|
rtsp_paths = self.get_rtsp_output_paths()
|
||||||
|
sample_interval = max(float(self.config.rtsp.sample_interval_seconds), 0.01)
|
||||||
|
window_seconds = max(int(self.config.rtsp.window_seconds), 1)
|
||||||
|
reconnect_delay = max(float(self.config.rtsp.reconnect_delay_seconds), 0.1)
|
||||||
|
open_timeout_seconds = max(float(self.config.rtsp.stream_open_timeout_seconds), 1.0)
|
||||||
|
idle_sleep = max(float(self.config.rtsp.idle_sleep_seconds), 0.0)
|
||||||
|
|
||||||
|
window_index = 0
|
||||||
|
process_started_at = datetime.now().astimezone()
|
||||||
|
window_start = datetime.now().astimezone()
|
||||||
|
window_end = window_start + timedelta(seconds=window_seconds)
|
||||||
|
last_processed_at = 0.0
|
||||||
|
last_processed_wall_time: datetime | None = None
|
||||||
|
next_heartbeat_at = time.monotonic() + 60.0
|
||||||
|
frame_index = 0
|
||||||
|
capture = None
|
||||||
|
pixel_line = None
|
||||||
|
counter = None
|
||||||
|
attributes = AttributeAggregator(self.config.attributes)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
while now >= window_end:
|
||||||
|
payload = self._build_rtsp_summary(
|
||||||
|
source=source,
|
||||||
|
window_index=window_index,
|
||||||
|
window_start=window_start,
|
||||||
|
window_end=window_end,
|
||||||
|
counter=counter,
|
||||||
|
attributes=attributes,
|
||||||
|
)
|
||||||
|
json_path = write_window_json(
|
||||||
|
rtsp_paths["windows"],
|
||||||
|
rtsp_paths["latest_json"],
|
||||||
|
payload,
|
||||||
|
window_end,
|
||||||
|
)
|
||||||
|
print(f"window_json={json_path}", flush=True)
|
||||||
|
print(f"window_total_people={payload['total_people']}", flush=True)
|
||||||
|
window_index += 1
|
||||||
|
window_start = window_end
|
||||||
|
window_end = window_start + timedelta(seconds=window_seconds)
|
||||||
|
if counter is not None:
|
||||||
|
counter.reset()
|
||||||
|
attributes.reset()
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
|
||||||
|
if capture is None or not capture.isOpened():
|
||||||
|
capture = self._open_rtsp_capture(source, open_timeout_seconds)
|
||||||
|
if capture is None:
|
||||||
|
time.sleep(reconnect_delay)
|
||||||
|
continue
|
||||||
|
|
||||||
|
ok, frame = capture.read()
|
||||||
|
if not ok or frame is None:
|
||||||
|
capture.release()
|
||||||
|
capture = None
|
||||||
|
time.sleep(reconnect_delay)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if pixel_line is None:
|
||||||
|
height, width = frame.shape[:2]
|
||||||
|
pixel_line = self.config.counting.to_pixel_line(width=width, height=height)
|
||||||
|
counter = LineCrossCounter(pixel_line, self.config.counting)
|
||||||
|
|
||||||
|
current_time = time.monotonic()
|
||||||
|
if current_time - last_processed_at < sample_interval:
|
||||||
|
if idle_sleep > 0:
|
||||||
|
time.sleep(idle_sleep)
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_processed_at = current_time
|
||||||
|
observations = self._track_frame(frame)
|
||||||
|
for observation in observations:
|
||||||
|
attributes.maybe_collect(frame=frame, frame_index=frame_index, track=observation)
|
||||||
|
if counter is not None:
|
||||||
|
counter.update(observations)
|
||||||
|
if current_time >= next_heartbeat_at:
|
||||||
|
self._print_rtsp_heartbeat(
|
||||||
|
process_started_at=process_started_at,
|
||||||
|
window_index=window_index,
|
||||||
|
frame_index=frame_index + 1,
|
||||||
|
counter=counter,
|
||||||
|
attributes=attributes,
|
||||||
|
last_processed_wall_time=now,
|
||||||
|
)
|
||||||
|
next_heartbeat_at = current_time + 60.0
|
||||||
|
last_processed_wall_time = now
|
||||||
|
frame_index += 1
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if capture is not None:
|
||||||
|
capture.release()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rtsp_output_dir": str(rtsp_paths["root"]),
|
||||||
|
"latest_json": str(rtsp_paths["latest_json"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _track_frame(self, frame) -> list:
|
||||||
|
results = self.model.track(
|
||||||
|
frame,
|
||||||
|
persist=True,
|
||||||
|
tracker=self.config.yolo.tracker,
|
||||||
|
conf=self.config.yolo.conf,
|
||||||
|
iou=self.config.yolo.iou,
|
||||||
|
imgsz=self.config.yolo.imgsz,
|
||||||
|
device=self.config.yolo.device,
|
||||||
|
verbose=False,
|
||||||
|
classes=[0],
|
||||||
|
)
|
||||||
|
result = results[0] if isinstance(results, list) else results
|
||||||
|
return extract_person_tracks(result)
|
||||||
|
|
||||||
|
def _open_rtsp_capture(self, source: str, timeout_seconds: float):
|
||||||
|
capture = cv2.VideoCapture()
|
||||||
|
open_timeout = getattr(cv2, "CAP_PROP_OPEN_TIMEOUT_MSEC", None)
|
||||||
|
read_timeout = getattr(cv2, "CAP_PROP_READ_TIMEOUT_MSEC", None)
|
||||||
|
if open_timeout is not None:
|
||||||
|
capture.set(open_timeout, timeout_seconds * 1000.0)
|
||||||
|
if read_timeout is not None:
|
||||||
|
capture.set(read_timeout, timeout_seconds * 1000.0)
|
||||||
|
buffersize = getattr(cv2, "CAP_PROP_BUFFERSIZE", None)
|
||||||
|
if buffersize is not None:
|
||||||
|
capture.set(buffersize, 1)
|
||||||
|
capture.open(source)
|
||||||
|
if capture.isOpened():
|
||||||
|
return capture
|
||||||
|
capture.release()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _build_live_stats(self, counter: LineCrossCounter, attributes: AttributeAggregator) -> dict:
|
||||||
|
age_counts = {"minor": 0, "adult": 0, "senior": 0}
|
||||||
|
gender_counts = {"male": 0, "female": 0}
|
||||||
|
unknown_attributes = 0
|
||||||
|
|
||||||
|
for track_id in counter.counted_ids:
|
||||||
|
summary = attributes.summarize_track(track_id)
|
||||||
|
if summary is None:
|
||||||
|
unknown_attributes += 1
|
||||||
|
continue
|
||||||
|
age_counts[summary.age_bucket] += 1
|
||||||
|
gender_counts[summary.gender] += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_people": counter.total_people,
|
||||||
|
"age_counts": age_counts,
|
||||||
|
"gender_counts": gender_counts,
|
||||||
|
"unknown_attributes": unknown_attributes,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _print_rtsp_heartbeat(
|
||||||
|
self,
|
||||||
|
process_started_at: datetime,
|
||||||
|
window_index: int,
|
||||||
|
frame_index: int,
|
||||||
|
counter: LineCrossCounter,
|
||||||
|
attributes: AttributeAggregator,
|
||||||
|
last_processed_wall_time: datetime | None,
|
||||||
|
) -> None:
|
||||||
|
stats = self._build_live_stats(counter, attributes)
|
||||||
|
runtime_seconds = int((datetime.now().astimezone() - process_started_at).total_seconds())
|
||||||
|
last_processed = (
|
||||||
|
last_processed_wall_time.isoformat(timespec="seconds")
|
||||||
|
if last_processed_wall_time is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"heartbeat "
|
||||||
|
f"runtime_seconds={runtime_seconds} "
|
||||||
|
f"window_index={window_index} "
|
||||||
|
f"window_frames={frame_index} "
|
||||||
|
f"total_people={stats['total_people']} "
|
||||||
|
f"minor={stats['age_counts']['minor']} "
|
||||||
|
f"adult={stats['age_counts']['adult']} "
|
||||||
|
f"senior={stats['age_counts']['senior']} "
|
||||||
|
f"male={stats['gender_counts']['male']} "
|
||||||
|
f"female={stats['gender_counts']['female']} "
|
||||||
|
f"unknown_attributes={stats['unknown_attributes']} "
|
||||||
|
f"last_processed_at={last_processed}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _collect_track_summaries(
|
||||||
|
self,
|
||||||
|
counter: LineCrossCounter | None,
|
||||||
|
attributes: AttributeAggregator,
|
||||||
|
) -> tuple[dict[str, int], dict[str, int], int, list[dict]]:
|
||||||
|
age_counts = {"minor": 0, "adult": 0, "senior": 0}
|
||||||
|
gender_counts = {"male": 0, "female": 0}
|
||||||
|
unknown_attributes = 0
|
||||||
|
track_summaries: list[dict] = []
|
||||||
|
|
||||||
|
if counter is None:
|
||||||
|
return age_counts, gender_counts, unknown_attributes, track_summaries
|
||||||
|
|
||||||
|
for event in counter.crossings:
|
||||||
|
summary = attributes.summarize_track(event.track_id)
|
||||||
|
if summary is None:
|
||||||
|
unknown_attributes += 1
|
||||||
|
track_summaries.append(
|
||||||
|
{
|
||||||
|
"track_id": event.track_id,
|
||||||
|
"direction": event.direction,
|
||||||
|
"age": None,
|
||||||
|
"age_bucket": None,
|
||||||
|
"gender": None,
|
||||||
|
"samples_used": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
age_counts[summary.age_bucket] += 1
|
||||||
|
gender_counts[summary.gender] += 1
|
||||||
|
track_summaries.append(
|
||||||
|
{
|
||||||
|
"track_id": summary.track_id,
|
||||||
|
"direction": event.direction,
|
||||||
|
"age": summary.age,
|
||||||
|
"age_bucket": summary.age_bucket,
|
||||||
|
"gender": summary.gender,
|
||||||
|
"samples_used": summary.samples_used,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return age_counts, gender_counts, unknown_attributes, track_summaries
|
||||||
|
|
||||||
|
def _build_rtsp_summary(
|
||||||
|
self,
|
||||||
|
source: str,
|
||||||
|
window_index: int,
|
||||||
|
window_start: datetime,
|
||||||
|
window_end: datetime,
|
||||||
|
counter: LineCrossCounter | None,
|
||||||
|
attributes: AttributeAggregator,
|
||||||
|
) -> dict:
|
||||||
|
age_counts, gender_counts, unknown_attributes, track_summaries = self._collect_track_summaries(
|
||||||
|
counter,
|
||||||
|
attributes,
|
||||||
|
)
|
||||||
|
total_people = 0 if counter is None else counter.total_people
|
||||||
|
return {
|
||||||
|
"source_type": "rtsp",
|
||||||
|
"source": source,
|
||||||
|
"window_index": window_index,
|
||||||
|
"window_start": window_start.isoformat(),
|
||||||
|
"window_end": window_end.isoformat(),
|
||||||
|
"window_duration_seconds": int((window_end - window_start).total_seconds()),
|
||||||
|
"config_path": str(self.config.config_path) if self.config.config_path else None,
|
||||||
|
"line": {
|
||||||
|
"coordinates": list(self.config.counting.line),
|
||||||
|
"mode": self.config.counting.line_mode,
|
||||||
|
},
|
||||||
|
"total_people": total_people,
|
||||||
|
"age_counts": age_counts,
|
||||||
|
"gender_counts": gender_counts,
|
||||||
|
"unknown_attributes": unknown_attributes,
|
||||||
|
"tracks": track_summaries,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _finalize_summary(
|
||||||
|
self,
|
||||||
|
video_path: Path,
|
||||||
|
counter: LineCrossCounter,
|
||||||
|
attributes: AttributeAggregator,
|
||||||
|
json_path: Path,
|
||||||
|
) -> dict:
|
||||||
|
age_counts, gender_counts, unknown_attributes, track_summaries = self._collect_track_summaries(
|
||||||
|
counter,
|
||||||
|
attributes,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"video_name": video_path.name,
|
||||||
|
"video_path": str(video_path),
|
||||||
|
"config_path": str(self.config.config_path) if self.config.config_path else None,
|
||||||
|
"line": {
|
||||||
|
"coordinates": list(self.config.counting.line),
|
||||||
|
"mode": self.config.counting.line_mode,
|
||||||
|
},
|
||||||
|
"total_people": counter.total_people,
|
||||||
|
"age_counts": age_counts,
|
||||||
|
"gender_counts": gender_counts,
|
||||||
|
"unknown_attributes": unknown_attributes,
|
||||||
|
"tracks": track_summaries,
|
||||||
|
}
|
||||||
|
if self.config.output.save_json:
|
||||||
|
write_json(json_path, payload)
|
||||||
|
|
||||||
|
payload["json_path"] = str(json_path)
|
||||||
|
return payload
|
||||||
35
managed/people_flow_project/src/people_flow/tracking.py
Normal file
35
managed/people_flow_project/src/people_flow/tracking.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .models import TrackObservation
|
||||||
|
|
||||||
|
|
||||||
|
def extract_person_tracks(result: Any) -> list[TrackObservation]:
|
||||||
|
boxes = getattr(result, "boxes", None)
|
||||||
|
if boxes is None:
|
||||||
|
return []
|
||||||
|
if getattr(boxes, "id", None) is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
xyxy = boxes.xyxy.int().cpu().tolist()
|
||||||
|
ids = boxes.id.int().cpu().tolist()
|
||||||
|
confs = boxes.conf.cpu().tolist()
|
||||||
|
classes = boxes.cls.int().cpu().tolist()
|
||||||
|
|
||||||
|
observations: list[TrackObservation] = []
|
||||||
|
for bbox, track_id, confidence, class_id in zip(xyxy, ids, confs, classes, strict=False):
|
||||||
|
if int(class_id) != 0:
|
||||||
|
continue
|
||||||
|
x1, y1, x2, y2 = bbox
|
||||||
|
center_x = (x1 + x2) / 2.0
|
||||||
|
center_y = (y1 + y2) / 2.0
|
||||||
|
observations.append(
|
||||||
|
TrackObservation(
|
||||||
|
track_id=int(track_id),
|
||||||
|
bbox=(int(x1), int(y1), int(x2), int(y2)),
|
||||||
|
confidence=float(confidence),
|
||||||
|
center=(center_x, center_y),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return observations
|
||||||
164
managed/people_flow_project/tests/test_manage_api.py
Normal file
164
managed/people_flow_project/tests/test_manage_api.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from src.people_flow.manage_api import create_app
|
||||||
|
|
||||||
|
|
||||||
|
def build_client(project_root: Path):
|
||||||
|
config_path = project_root / "config" / "local.yaml"
|
||||||
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
config_path.write_text(
|
||||||
|
"runtime:\n"
|
||||||
|
" rtsp_url: rtsp://before-update\n"
|
||||||
|
" output_dir: outputs\n"
|
||||||
|
"rtsp:\n"
|
||||||
|
" output_subdir: rtsp_stream\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
rtsp_dir = project_root / "outputs" / "rtsp_stream"
|
||||||
|
windows_dir = rtsp_dir / "windows"
|
||||||
|
windows_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
latest_payload = {
|
||||||
|
"source_type": "rtsp",
|
||||||
|
"window_start": "2026-04-16T09:30:00+08:00",
|
||||||
|
"window_end": "2026-04-16T10:00:00+08:00",
|
||||||
|
"total_people": 7,
|
||||||
|
"age_counts": {"minor": 1, "adult": 5, "senior": 1},
|
||||||
|
"gender_counts": {"male": 4, "female": 3},
|
||||||
|
"unknown_attributes": 2,
|
||||||
|
"tracks": [
|
||||||
|
{"track_id": 1, "direction": "in"},
|
||||||
|
{"track_id": 2, "direction": "out"},
|
||||||
|
{"track_id": 3, "direction": "in"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
(rtsp_dir / "latest.json").write_text(
|
||||||
|
json.dumps(latest_payload),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(windows_dir / "stats_2026-04-16_09-00-00.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"window_start": "2026-04-16T09:00:00+08:00",
|
||||||
|
"window_end": "2026-04-16T09:30:00+08:00",
|
||||||
|
"total_people": 5,
|
||||||
|
"age_counts": {"minor": 0, "adult": 4, "senior": 1},
|
||||||
|
"gender_counts": {"male": 2, "female": 3},
|
||||||
|
"unknown_attributes": 1,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(windows_dir / "stats_2026-04-16_09-30-00.json").write_text(
|
||||||
|
json.dumps(latest_payload),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(project_root / "outputs" / "rtsp_run.log").write_text("rtsp ok\n", encoding="utf-8")
|
||||||
|
|
||||||
|
app = create_app(config_path)
|
||||||
|
app.testing = True
|
||||||
|
return app.test_client(), config_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_manage_health(tmp_path: Path):
|
||||||
|
client, _ = build_client(tmp_path)
|
||||||
|
|
||||||
|
response = client.get("/api/manage/health")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json["status"] == "ok"
|
||||||
|
assert response.json["project_type"] == "people_flow_project"
|
||||||
|
assert response.json["runtime_status"] == "running"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_manage_config(tmp_path: Path):
|
||||||
|
client, config_path = build_client(tmp_path)
|
||||||
|
|
||||||
|
response = client.get("/api/manage/config")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json["config_path"] == str(config_path)
|
||||||
|
assert response.json["runtime"]["rtsp_url"] == "rtsp://before-update"
|
||||||
|
assert response.json["rtsp"]["output_subdir"] == "rtsp_stream"
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_manage_config_updates_rtsp_url(tmp_path: Path):
|
||||||
|
client, config_path = build_client(tmp_path)
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
"/api/manage/config",
|
||||||
|
json={"rtsp_url": "rtsp://after-update"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json["runtime"]["rtsp_url"] == "rtsp://after-update"
|
||||||
|
|
||||||
|
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||||
|
assert saved["runtime"]["rtsp_url"] == "rtsp://after-update"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_manage_summary(tmp_path: Path):
|
||||||
|
client, _ = build_client(tmp_path)
|
||||||
|
|
||||||
|
response = client.get("/api/manage/summary")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json["result_type"] == "people_flow_project"
|
||||||
|
assert response.json["last_result_time"] == "2026-04-16T10:00:00+08:00"
|
||||||
|
assert response.json["metrics"]["total_people"] == 7
|
||||||
|
assert response.json["metrics"]["direction_counts"] == {"in": 2, "out": 1}
|
||||||
|
assert response.json["metrics"]["recent_window_stats"][0]["window_end"] == "2026-04-16T10:00:00+08:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_manage_windows(tmp_path: Path):
|
||||||
|
client, _ = build_client(tmp_path)
|
||||||
|
|
||||||
|
response = client.get("/api/manage/windows?page=1&page_size=1")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json["total"] == 2
|
||||||
|
assert response.json["page"] == 1
|
||||||
|
assert response.json["page_size"] == 1
|
||||||
|
assert response.json["items"][0]["window_end"] == "2026-04-16T10:00:00+08:00"
|
||||||
|
assert response.json["items"][0]["total_people"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_manage_files(tmp_path: Path):
|
||||||
|
client, _ = build_client(tmp_path)
|
||||||
|
|
||||||
|
response = client.get("/api/manage/files")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert {item["path"] for item in response.json["files"]} == {
|
||||||
|
"outputs/rtsp_run.log",
|
||||||
|
"outputs/rtsp_stream/latest.json",
|
||||||
|
"outputs/rtsp_stream/windows/stats_2026-04-16_09-00-00.json",
|
||||||
|
"outputs/rtsp_stream/windows/stats_2026-04-16_09-30-00.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_manage_files_preview(tmp_path: Path):
|
||||||
|
client, _ = build_client(tmp_path)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/manage/files/preview?path=outputs/rtsp_stream/latest.json&lines=1"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json["path"] == "outputs/rtsp_stream/latest.json"
|
||||||
|
assert response.json["count"] == 1
|
||||||
|
assert "total_people" in response.json["lines"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_manage_files_download(tmp_path: Path):
|
||||||
|
client, _ = build_client(tmp_path)
|
||||||
|
|
||||||
|
response = client.get("/api/manage/files/download?path=outputs/rtsp_run.log")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == b"rtsp ok\n"
|
||||||
1
managed/people_flow_project/weights/.gitkeep
Normal file
1
managed/people_flow_project/weights/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
managed/people_flow_project/weights/deepface/.gitkeep
Normal file
1
managed/people_flow_project/weights/deepface/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
11
managed/store_dwell_alert/.dockerignore
Normal file
11
managed/store_dwell_alert/.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.DS_Store
|
||||||
|
.git
|
||||||
|
.pytest_cache
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
dist
|
||||||
|
logs/*.jsonl
|
||||||
|
logs/*.log
|
||||||
|
config/*.local.yaml
|
||||||
|
config/local.yaml
|
||||||
|
wheelhouse
|
||||||
11
managed/store_dwell_alert/.gitignore
vendored
Normal file
11
managed/store_dwell_alert/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.DS_Store
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
config/*.local.yaml
|
||||||
|
config/local.yaml
|
||||||
|
logs/*.jsonl
|
||||||
|
logs/*.log
|
||||||
|
dist/
|
||||||
|
wheelhouse/
|
||||||
|
weights/*.pt
|
||||||
39
managed/store_dwell_alert/Dockerfile
Normal file
39
managed/store_dwell_alert/Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libgomp1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt /app/requirements.txt
|
||||||
|
RUN python -m pip install --upgrade pip setuptools wheel \
|
||||||
|
&& python -m pip install --extra-index-url https://download.pytorch.org/whl/cpu \
|
||||||
|
"torch==2.6.0+cpu" "torchvision==0.21.0+cpu" \
|
||||||
|
&& python -m pip install -r /app/requirements.txt
|
||||||
|
|
||||||
|
COPY app /app/app
|
||||||
|
COPY config /app/config
|
||||||
|
COPY data /app/data
|
||||||
|
COPY scripts/docker-entrypoint.sh /app/scripts/docker-entrypoint.sh
|
||||||
|
COPY weights /app/weights
|
||||||
|
COPY README.md README_zh.md /app/
|
||||||
|
|
||||||
|
RUN test -f /app/weights/yolo11n.pt \
|
||||||
|
&& chmod +x /app/scripts/docker-entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 18081
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18081/api/manage/health', timeout=3).read()" || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"]
|
||||||
161
managed/store_dwell_alert/README.md
Normal file
161
managed/store_dwell_alert/README.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Store Dwell Alert on 192.168.5.108
|
||||||
|
|
||||||
|
Local planning project for a single-camera RTSP long-stay alert system running on `192.168.5.108`.
|
||||||
|
|
||||||
|
Documents:
|
||||||
|
- `docs/plans/2026-04-15-store-dwell-alert-design.md`
|
||||||
|
- `docs/plans/2026-04-15-store-dwell-alert.md`
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Single RTSP store camera
|
||||||
|
- `YOLO11n` person detection
|
||||||
|
- Full-frame tracking and lightweight re-association
|
||||||
|
- Staff whitelist exclusion
|
||||||
|
- Local JSONL event sink
|
||||||
|
- Half-hour dwell reports
|
||||||
|
- Offline bundle packaging for `.108`-class Ubuntu hosts
|
||||||
|
- Chinese operations guide: `README_zh.md`
|
||||||
|
|
||||||
|
Runtime:
|
||||||
|
- Default config path: `config/108.local.yaml`
|
||||||
|
- Event sink path: `logs/events.jsonl`
|
||||||
|
- Continuous mode: `python -m app.main`
|
||||||
|
- Single-frame smoke test: `python -m app.main --once`
|
||||||
|
- Management API mode: `python -m app.manage_api --config config/local.yaml --port 18081`
|
||||||
|
|
||||||
|
Staff gallery:
|
||||||
|
- Directory: `data/staff_gallery/`
|
||||||
|
- Supported formats:
|
||||||
|
- `*.json` signature files
|
||||||
|
- `*.jpg`, `*.jpeg`, `*.png`, `*.bmp`, `*.webp` sample crops
|
||||||
|
- JSON example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"staff_id": "staff_001",
|
||||||
|
"signature": [0.182, 0.244, 0.317]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Offline bundle:
|
||||||
|
- Target host assumption: Ubuntu 24.04, Python 3.12, NVIDIA GPU compatible with the `.108` runtime stack
|
||||||
|
- Bundle script: `bash scripts/package_bundle.sh`
|
||||||
|
- Bundle output: `dist/store_dwell_alert_bundle_<date>.tar.gz`
|
||||||
|
- Bundle prerequisites before packaging:
|
||||||
|
- place Linux wheels under `wheelhouse/`
|
||||||
|
- place `yolo11n.pt` under `weights/yolo11n.pt`
|
||||||
|
|
||||||
|
Offline install on target host:
|
||||||
|
1. Extract `store_dwell_alert_bundle_<date>.tar.gz`
|
||||||
|
2. Edit `scripts/run.sh` and set `RTSP_URL`
|
||||||
|
3. Run `sudo bash scripts/install.sh`
|
||||||
|
4. The installer enables and starts `store-dwell-alert.service`
|
||||||
|
|
||||||
|
Optional service install:
|
||||||
|
1. Run `bash scripts/install_service.sh`
|
||||||
|
2. Start with `sudo systemctl start store-dwell-alert.service`
|
||||||
|
|
||||||
|
Docker image:
|
||||||
|
- Required runtime weight in build context: `weights/yolo11n.pt`
|
||||||
|
- Build: `docker build -t store-dwell-alert:test .`
|
||||||
|
- Compose: `docker compose up --build store-dwell-alert`
|
||||||
|
- Health: `http://127.0.0.1:18081/api/manage/health`
|
||||||
|
- Default container entrypoint seeds `config/local.yaml` from `config/config.example.yaml` and then starts the management API
|
||||||
|
- Persisted host paths:
|
||||||
|
- `./config -> /app/config`
|
||||||
|
- `./data -> /app/data`
|
||||||
|
- `./logs -> /app/logs`
|
||||||
|
|
||||||
|
## 中文离线部署说明
|
||||||
|
|
||||||
|
适用环境:
|
||||||
|
- `Ubuntu 24.04`
|
||||||
|
- `Python 3.12`
|
||||||
|
- 已安装 NVIDIA 驱动,`nvidia-smi` 可用
|
||||||
|
- 已安装 `ffmpeg`
|
||||||
|
|
||||||
|
离线包准备:
|
||||||
|
- 成品包示例:`store_dwell_alert_bundle_2026-04-16.tar.gz`
|
||||||
|
- 包内已经包含:
|
||||||
|
- 项目源码
|
||||||
|
- `wheelhouse/` 离线依赖
|
||||||
|
- `weights/yolo11n.pt`
|
||||||
|
- `scripts/install.sh`
|
||||||
|
- `scripts/run.sh`
|
||||||
|
- `scripts/install_service.sh`
|
||||||
|
|
||||||
|
部署步骤:
|
||||||
|
1. 把离线包传到目标机器
|
||||||
|
2. 解压:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -xzf store_dwell_alert_bundle_2026-04-16.tar.gz
|
||||||
|
cd store_dwell_alert_bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 执行离线安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 编辑运行脚本,至少改这几个变量:
|
||||||
|
- `RTSP_URL`
|
||||||
|
- `CAMERA_ID`
|
||||||
|
- `EVENT_SINK_PATH`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vim scripts/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 手工启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
常见输出位置:
|
||||||
|
- 事件结果:`logs/events.jsonl`
|
||||||
|
- 本地配置:`config/local.yaml`
|
||||||
|
- 店员图库:`data/staff_gallery/`
|
||||||
|
|
||||||
|
如果需要做店员排除:
|
||||||
|
- 把店员样本图放到 `data/staff_gallery/`
|
||||||
|
- 或者放入对应的 `*.json` 特征文件
|
||||||
|
|
||||||
|
配置完成后的常驻运行:
|
||||||
|
1. 安装服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/install_service.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 启动服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start store-dwell-alert.service
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 查看状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status store-dwell-alert.service
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 停止服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop store-dwell-alert.service
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 开机自启:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable store-dwell-alert.service
|
||||||
|
```
|
||||||
|
|
||||||
|
排查建议:
|
||||||
|
- `scripts/install.sh` 失败时,先确认 `python3.12`、`ffmpeg`、`nvidia-smi` 都存在
|
||||||
|
- `scripts/run.sh` 失败时,先确认 `RTSP_URL` 已改成真实地址
|
||||||
|
- 如果程序启动了但没有结果,先看 `logs/events.jsonl` 是否生成
|
||||||
|
- 如果识别不到店员,先检查 `data/staff_gallery/` 是否有有效样本
|
||||||
73
managed/store_dwell_alert/README_zh.md
Normal file
73
managed/store_dwell_alert/README_zh.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# 店内停留报警项目中文说明
|
||||||
|
|
||||||
|
这个项目用于单路店内 RTSP 摄像头的人体检测、停留计时和本地 JSON 事件输出。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 基于 `YOLO11n` 的店内人员检测与跟踪
|
||||||
|
- 停留时长统计
|
||||||
|
- 短暂离开后重关联
|
||||||
|
- 店员白名单排除
|
||||||
|
- 半小时统计
|
||||||
|
- 本地 `JSONL` 事件输出
|
||||||
|
- 安装完成后自动启动
|
||||||
|
- 开机自动启动
|
||||||
|
|
||||||
|
## 目标机器
|
||||||
|
|
||||||
|
- `Ubuntu 24.04`
|
||||||
|
- `Python 3.12`
|
||||||
|
- NVIDIA 显卡可用
|
||||||
|
- `nvidia-smi` 可正常执行
|
||||||
|
|
||||||
|
## 安装前需要修改
|
||||||
|
|
||||||
|
先编辑 `scripts/run.sh`,至少改这几个变量:
|
||||||
|
|
||||||
|
- `RTSP_URL`
|
||||||
|
- `CAMERA_ID`
|
||||||
|
- `EVENT_SINK_PATH`
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
在项目根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash scripts/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
安装脚本会自动:
|
||||||
|
|
||||||
|
- 检查并安装 `ffmpeg`
|
||||||
|
- 检查并安装 `python3.12-venv`
|
||||||
|
- 创建 `.venv`
|
||||||
|
- 离线安装 `wheelhouse/` 中的依赖
|
||||||
|
- 生成 `config/local.yaml`
|
||||||
|
- 安装 `systemd` 服务
|
||||||
|
- 自动启动服务
|
||||||
|
- 设置开机自启
|
||||||
|
|
||||||
|
## 服务管理
|
||||||
|
|
||||||
|
服务名:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
store-dwell-alert.service
|
||||||
|
```
|
||||||
|
|
||||||
|
常用命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status store-dwell-alert.service
|
||||||
|
sudo systemctl restart store-dwell-alert.service
|
||||||
|
sudo systemctl stop store-dwell-alert.service
|
||||||
|
sudo systemctl start store-dwell-alert.service
|
||||||
|
sudo systemctl disable store-dwell-alert.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## 输出位置
|
||||||
|
|
||||||
|
- 运行日志:`logs/runtime.log`
|
||||||
|
- 事件结果:`logs/events.jsonl`
|
||||||
|
- 本地配置:`config/local.yaml`
|
||||||
|
- 店员图库:`data/staff_gallery/`
|
||||||
1
managed/store_dwell_alert/app/__init__.py
Normal file
1
managed/store_dwell_alert/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Store dwell alert application package."""
|
||||||
103
managed/store_dwell_alert/app/config.py
Normal file
103
managed/store_dwell_alert/app/config.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Thresholds:
|
||||||
|
min_people: int = 5
|
||||||
|
min_dwell_seconds: int = 600
|
||||||
|
pause_timeout_seconds: int = 300
|
||||||
|
alert_cooldown_seconds: int = 600
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class StreamConfig:
|
||||||
|
rtsp_url: str = ""
|
||||||
|
sample_fps: float = 2.0
|
||||||
|
reconnect_backoff_seconds: float = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class StaffConfig:
|
||||||
|
gallery_dir: str = "data/staff_gallery"
|
||||||
|
min_hits: int = 3
|
||||||
|
similarity_threshold: float = 0.9
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class WebhookConfig:
|
||||||
|
alert_url: str = ""
|
||||||
|
report_url: str = ""
|
||||||
|
timeout_seconds: float = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class EventSinkConfig:
|
||||||
|
path: str = "logs/events.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AppConfig:
|
||||||
|
camera_id: str
|
||||||
|
timezone: str = "Asia/Shanghai"
|
||||||
|
thresholds: Thresholds = field(default_factory=Thresholds)
|
||||||
|
stream: StreamConfig = field(default_factory=StreamConfig)
|
||||||
|
staff: StaffConfig = field(default_factory=StaffConfig)
|
||||||
|
event_sink: EventSinkConfig = field(default_factory=EventSinkConfig)
|
||||||
|
webhook: WebhookConfig = field(default_factory=WebhookConfig)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_section(raw: dict, key: str, cls):
|
||||||
|
return cls(**raw.get(key, {}))
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_config_path(config_path: str | Path | None = None) -> Path:
|
||||||
|
if config_path is not None:
|
||||||
|
return Path(config_path).expanduser().resolve()
|
||||||
|
project_root = Path(__file__).resolve().parent.parent
|
||||||
|
local_path = project_root / "config" / "108.local.yaml"
|
||||||
|
if local_path.exists():
|
||||||
|
return local_path
|
||||||
|
return project_root / "config" / "config.example.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_project_root(config_path: str | Path) -> Path:
|
||||||
|
return Path(config_path).expanduser().resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_project_path(project_root: str | Path, raw_path: str | Path) -> Path:
|
||||||
|
path = Path(raw_path)
|
||||||
|
if path.is_absolute():
|
||||||
|
return path.resolve()
|
||||||
|
return (Path(project_root).resolve() / path).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def load_config_document(path: Path) -> dict:
|
||||||
|
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path) -> AppConfig:
|
||||||
|
raw = load_config_document(path)
|
||||||
|
return AppConfig(
|
||||||
|
camera_id=raw["camera_id"],
|
||||||
|
timezone=raw.get("timezone", "Asia/Shanghai"),
|
||||||
|
thresholds=_load_section(raw, "thresholds", Thresholds),
|
||||||
|
stream=_load_section(raw, "stream", StreamConfig),
|
||||||
|
staff=_load_section(raw, "staff", StaffConfig),
|
||||||
|
event_sink=_load_section(raw, "event_sink", EventSinkConfig),
|
||||||
|
webhook=_load_section(raw, "webhook", WebhookConfig),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_config_document(path: Path, raw: dict) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temp_path = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
temp_path.write_text(
|
||||||
|
yaml.safe_dump(raw, allow_unicode=True, sort_keys=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
temp_path.replace(path)
|
||||||
148
managed/store_dwell_alert/app/main.py
Normal file
148
managed/store_dwell_alert/app/main.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
from app.config import (
|
||||||
|
AppConfig,
|
||||||
|
load_config,
|
||||||
|
resolve_config_path,
|
||||||
|
resolve_project_path,
|
||||||
|
resolve_project_root,
|
||||||
|
)
|
||||||
|
from app.modules.detector_tracker import YOLOTrackerAdapter
|
||||||
|
from app.modules.dwell_engine import DwellEngine
|
||||||
|
from app.modules.identity_resolver import IdentityResolver
|
||||||
|
from app.modules.notifier import append_json_event
|
||||||
|
from app.modules.staff_filter import StaffMatcher, load_staff_gallery
|
||||||
|
from app.modules.stream_reader import RTSPFrameReader
|
||||||
|
|
||||||
|
|
||||||
|
def build_app(config_path: str | Path | None = None) -> dict:
|
||||||
|
resolved_config_path = resolve_config_path(config_path)
|
||||||
|
config = load_config(resolved_config_path)
|
||||||
|
project_root = resolve_project_root(resolved_config_path)
|
||||||
|
event_sink_path = resolve_project_path(project_root, config.event_sink.path)
|
||||||
|
gallery_dir = resolve_project_path(project_root, config.staff.gallery_dir)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"config": config,
|
||||||
|
"config_path": resolved_config_path,
|
||||||
|
"stream_reader": RTSPFrameReader(
|
||||||
|
rtsp_url=config.stream.rtsp_url,
|
||||||
|
sample_fps=config.stream.sample_fps,
|
||||||
|
reconnect_backoff_seconds=config.stream.reconnect_backoff_seconds,
|
||||||
|
),
|
||||||
|
"tracker": YOLOTrackerAdapter(),
|
||||||
|
"identity_resolver": IdentityResolver(
|
||||||
|
pause_timeout_seconds=config.thresholds.pause_timeout_seconds,
|
||||||
|
),
|
||||||
|
"staff_matcher": StaffMatcher(
|
||||||
|
gallery=load_staff_gallery(gallery_dir),
|
||||||
|
similarity_threshold=config.staff.similarity_threshold,
|
||||||
|
min_hits=config.staff.min_hits,
|
||||||
|
),
|
||||||
|
"dwell_engine": DwellEngine(
|
||||||
|
camera_id=config.camera_id,
|
||||||
|
min_people=config.thresholds.min_people,
|
||||||
|
min_dwell_seconds=config.thresholds.min_dwell_seconds,
|
||||||
|
pause_timeout_seconds=config.thresholds.pause_timeout_seconds,
|
||||||
|
alert_cooldown_seconds=config.thresholds.alert_cooldown_seconds,
|
||||||
|
),
|
||||||
|
"notifier": append_json_event,
|
||||||
|
"event_sink_path": event_sink_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def process_observations(
|
||||||
|
app: dict,
|
||||||
|
observations: list[dict],
|
||||||
|
when,
|
||||||
|
) -> list[dict]:
|
||||||
|
events = app["dwell_engine"].process_observations(observations, when)
|
||||||
|
for event in events:
|
||||||
|
app["notifier"](app["event_sink_path"], event)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def process_frame(app: dict, frame, when: datetime | None = None) -> list[dict]:
|
||||||
|
observation_time = when or datetime.now().astimezone()
|
||||||
|
tracks = app["tracker"].track(frame)
|
||||||
|
observations = app["identity_resolver"].resolve(tracks, observation_time)
|
||||||
|
observations = app["staff_matcher"].classify(observations)
|
||||||
|
return process_observations(app, observations, observation_time)
|
||||||
|
|
||||||
|
|
||||||
|
def run_once(app: dict) -> list[dict]:
|
||||||
|
frame = app["stream_reader"].read()
|
||||||
|
if frame is None:
|
||||||
|
return []
|
||||||
|
return process_frame(app, frame)
|
||||||
|
|
||||||
|
|
||||||
|
def run_forever(app: dict, max_frames: int | None = None) -> int:
|
||||||
|
processed_frames = 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
frame = app["stream_reader"].read()
|
||||||
|
if frame is None:
|
||||||
|
sleep(0.2)
|
||||||
|
continue
|
||||||
|
process_frame(app, frame)
|
||||||
|
processed_frames += 1
|
||||||
|
if max_frames is not None and processed_frames >= max_frames:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
app["stream_reader"].close()
|
||||||
|
return processed_frames
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> ArgumentParser:
|
||||||
|
parser = ArgumentParser(description="Store dwell alert service bootstrap")
|
||||||
|
parser.add_argument("--config", help="Path to YAML config file")
|
||||||
|
parser.add_argument("--once", action="store_true", help="Read and process one frame")
|
||||||
|
parser.add_argument(
|
||||||
|
"--manage-api",
|
||||||
|
action="store_true",
|
||||||
|
help="Start the management API instead of the RTSP worker loop",
|
||||||
|
)
|
||||||
|
parser.add_argument("--host", default="0.0.0.0", help="Host for the management API")
|
||||||
|
parser.add_argument("--port", type=int, default=18081, help="Port for the management API")
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-frames",
|
||||||
|
type=int,
|
||||||
|
help="Maximum frames to process before exiting",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = parse_args()
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.manage_api:
|
||||||
|
from app.manage_api import run_manage_api
|
||||||
|
|
||||||
|
run_manage_api(args.config, host=args.host, port=args.port)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
app = build_app(args.config)
|
||||||
|
config: AppConfig = app["config"]
|
||||||
|
print(f"Loaded config from {app['config_path']}")
|
||||||
|
print(f"Camera: {config.camera_id}")
|
||||||
|
print(f"RTSP: {config.stream.rtsp_url}")
|
||||||
|
print(f"Event sink: {app['event_sink_path']}")
|
||||||
|
print(f"Loaded {len(app['staff_matcher'].gallery)} staff gallery embedding(s)")
|
||||||
|
if args.once:
|
||||||
|
events = run_once(app)
|
||||||
|
print(f"Generated {len(events)} event(s)")
|
||||||
|
app["stream_reader"].close()
|
||||||
|
return 0
|
||||||
|
processed_frames = run_forever(app, max_frames=args.max_frames)
|
||||||
|
print(f"Processed {processed_frames} frame(s)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
394
managed/store_dwell_alert/app/manage_api.py
Normal file
394
managed/store_dwell_alert/app/manage_api.py
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request, send_file
|
||||||
|
|
||||||
|
from app.config import (
|
||||||
|
load_config,
|
||||||
|
load_config_document,
|
||||||
|
resolve_config_path,
|
||||||
|
resolve_project_path,
|
||||||
|
resolve_project_root,
|
||||||
|
save_config_document,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_TYPE = "store_dwell_alert"
|
||||||
|
DEFAULT_MANAGE_PORT = 18081
|
||||||
|
MAX_PREVIEW_LINES = 2000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ManageContext:
|
||||||
|
config_path: Path
|
||||||
|
project_root: Path
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_path: str | Path | None = None) -> Flask:
|
||||||
|
resolved_config = resolve_config_path(config_path)
|
||||||
|
ctx = ManageContext(
|
||||||
|
config_path=resolved_config,
|
||||||
|
project_root=resolve_project_root(resolved_config),
|
||||||
|
)
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["MANAGE_CONTEXT"] = ctx
|
||||||
|
|
||||||
|
@app.get("/api/manage/health")
|
||||||
|
def get_health():
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"project_type": PROJECT_TYPE,
|
||||||
|
"version": "dev",
|
||||||
|
"runtime_status": "running",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/api/manage/config")
|
||||||
|
def get_config():
|
||||||
|
return jsonify(_config_payload(ctx))
|
||||||
|
|
||||||
|
@app.put("/api/manage/config")
|
||||||
|
def update_config():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
rtsp_url = payload.get("rtsp_url")
|
||||||
|
if not isinstance(rtsp_url, str) or not rtsp_url.strip():
|
||||||
|
return jsonify({"error": "rtsp_url is required"}), 400
|
||||||
|
|
||||||
|
raw = load_config_document(ctx.config_path)
|
||||||
|
stream = raw.setdefault("stream", {})
|
||||||
|
stream["rtsp_url"] = rtsp_url.strip()
|
||||||
|
save_config_document(ctx.config_path, raw)
|
||||||
|
return jsonify(_config_payload(ctx))
|
||||||
|
|
||||||
|
@app.get("/api/manage/summary")
|
||||||
|
def get_summary():
|
||||||
|
return jsonify(_build_summary(ctx))
|
||||||
|
|
||||||
|
@app.get("/api/manage/windows")
|
||||||
|
def get_windows():
|
||||||
|
page = max(_int_arg("page", 1), 1)
|
||||||
|
page_size = max(_int_arg("page_size", 24), 1)
|
||||||
|
limit = request.args.get("limit")
|
||||||
|
|
||||||
|
items = list(_load_window_stats(ctx))
|
||||||
|
if limit is not None:
|
||||||
|
items = items[: max(_int_value(limit), 0)]
|
||||||
|
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
end = start + page_size
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"items": items[start:end],
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total": len(items),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/api/manage/files")
|
||||||
|
def get_files():
|
||||||
|
return jsonify({"files": _list_result_files(ctx)})
|
||||||
|
|
||||||
|
@app.get("/api/manage/files/preview")
|
||||||
|
def preview_file():
|
||||||
|
target = _resolve_sandbox_file(ctx, request.args.get("path", ""))
|
||||||
|
lines = _tail_lines(target, _bounded_preview_lines(request.args.get("lines")))
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"path": _relative_path(ctx, target),
|
||||||
|
"lines": lines,
|
||||||
|
"count": len(lines),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/api/manage/files/download")
|
||||||
|
def download_file():
|
||||||
|
target = _resolve_sandbox_file(ctx, request.args.get("path", ""))
|
||||||
|
return send_file(target, as_attachment=True, download_name=target.name)
|
||||||
|
|
||||||
|
@app.errorhandler(ValueError)
|
||||||
|
def handle_value_error(error: ValueError):
|
||||||
|
return jsonify({"error": str(error)}), 400
|
||||||
|
|
||||||
|
@app.errorhandler(FileNotFoundError)
|
||||||
|
def handle_missing_file(error: FileNotFoundError):
|
||||||
|
return jsonify({"error": str(error)}), 404
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def run_manage_api(
|
||||||
|
config_path: str | Path | None = None,
|
||||||
|
host: str = "0.0.0.0",
|
||||||
|
port: int = DEFAULT_MANAGE_PORT,
|
||||||
|
) -> None:
|
||||||
|
app = create_app(config_path)
|
||||||
|
app.run(host=host, port=port)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> ArgumentParser:
|
||||||
|
parser = ArgumentParser(description="Store dwell alert management API")
|
||||||
|
parser.add_argument("--config", help="Path to YAML config file")
|
||||||
|
parser.add_argument("--host", default="0.0.0.0", help="Host for the management API")
|
||||||
|
parser.add_argument("--port", type=int, default=DEFAULT_MANAGE_PORT, help="Port for the management API")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = parse_args()
|
||||||
|
args = parser.parse_args()
|
||||||
|
run_manage_api(args.config, host=args.host, port=args.port)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _config_payload(ctx: ManageContext) -> dict:
|
||||||
|
config = load_config(ctx.config_path)
|
||||||
|
event_sink_path = resolve_project_path(ctx.project_root, config.event_sink.path)
|
||||||
|
return {
|
||||||
|
"project_type": PROJECT_TYPE,
|
||||||
|
"config_path": str(ctx.config_path),
|
||||||
|
"camera_id": config.camera_id,
|
||||||
|
"timezone": config.timezone,
|
||||||
|
"stream": {
|
||||||
|
"rtsp_url": config.stream.rtsp_url,
|
||||||
|
"sample_fps": config.stream.sample_fps,
|
||||||
|
"reconnect_backoff_seconds": config.stream.reconnect_backoff_seconds,
|
||||||
|
},
|
||||||
|
"event_sink": {
|
||||||
|
"path": str(event_sink_path),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_summary(ctx: ManageContext) -> dict:
|
||||||
|
events_path = _events_path(ctx)
|
||||||
|
if not events_path.exists():
|
||||||
|
return {
|
||||||
|
"result_type": PROJECT_TYPE,
|
||||||
|
"headline": "No event output yet",
|
||||||
|
"metrics": {
|
||||||
|
"events_path": str(events_path),
|
||||||
|
"recent_window_stats": [],
|
||||||
|
"all_window_stats": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
alert_count = 0
|
||||||
|
last_alert_time = ""
|
||||||
|
last_report_time = ""
|
||||||
|
active_count = 0
|
||||||
|
longest_dwell_seconds = 0
|
||||||
|
window_stats: list[dict] = []
|
||||||
|
|
||||||
|
with events_path.open("r", encoding="utf-8") as handle:
|
||||||
|
for raw_line in handle:
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if payload.get("event") == "long_stay_alert":
|
||||||
|
alert_count += 1
|
||||||
|
last_alert_time = _string_value(payload.get("ts"))
|
||||||
|
elif payload.get("event") == "half_hour_report":
|
||||||
|
last_report_time = _string_value(payload.get("window_end"))
|
||||||
|
active_count = _int_value(payload.get("active_customer_count"))
|
||||||
|
stat = _build_window_stat(payload)
|
||||||
|
window_stats.append(stat)
|
||||||
|
longest_dwell_seconds = max(
|
||||||
|
longest_dwell_seconds,
|
||||||
|
stat["max_wait_seconds"],
|
||||||
|
)
|
||||||
|
|
||||||
|
window_stats.sort(
|
||||||
|
key=lambda item: _parse_timestamp(item["window_end"]),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
headline = "No alerts or reports yet"
|
||||||
|
if last_report_time:
|
||||||
|
headline = (
|
||||||
|
"Latest report shows "
|
||||||
|
f"{active_count} active customers, longest dwell {longest_dwell_seconds}s"
|
||||||
|
)
|
||||||
|
elif alert_count > 0:
|
||||||
|
headline = f"Observed {alert_count} alert(s), latest alert at {last_alert_time}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"result_type": PROJECT_TYPE,
|
||||||
|
"headline": headline,
|
||||||
|
"last_result_time": _latest_timestamp(last_alert_time, last_report_time),
|
||||||
|
"metrics": {
|
||||||
|
"alert_count": alert_count,
|
||||||
|
"last_alert_time": last_alert_time,
|
||||||
|
"last_half_hour_report_time": last_report_time,
|
||||||
|
"active_customer_count": active_count,
|
||||||
|
"longest_dwell_seconds": longest_dwell_seconds,
|
||||||
|
"events_path": str(events_path),
|
||||||
|
"recent_window_stats": window_stats[:24],
|
||||||
|
"all_window_stats": window_stats,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_window_stats(ctx: ManageContext) -> list[dict]:
|
||||||
|
return list(_build_summary(ctx)["metrics"]["all_window_stats"])
|
||||||
|
|
||||||
|
|
||||||
|
def _list_result_files(ctx: ManageContext) -> list[dict]:
|
||||||
|
files: list[dict] = []
|
||||||
|
for path, label in (
|
||||||
|
(_events_path(ctx), "Events JSONL"),
|
||||||
|
(ctx.project_root / "logs" / "runtime.log", "Runtime Log"),
|
||||||
|
):
|
||||||
|
if not path.exists() or not path.is_file():
|
||||||
|
continue
|
||||||
|
info = path.stat()
|
||||||
|
files.append(
|
||||||
|
{
|
||||||
|
"path": _relative_path(ctx, path),
|
||||||
|
"name": path.name,
|
||||||
|
"label": label,
|
||||||
|
"kind": path.suffix.lstrip(".").lower(),
|
||||||
|
"size": info.st_size,
|
||||||
|
"modified_at": datetime.fromtimestamp(info.st_mtime).astimezone().isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
files.sort(key=lambda item: item["path"])
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def _events_path(ctx: ManageContext) -> Path:
|
||||||
|
config = load_config(ctx.config_path)
|
||||||
|
return resolve_project_path(ctx.project_root, config.event_sink.path)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_window_stat(payload: dict) -> dict:
|
||||||
|
active_wait_seconds = _int_list(payload.get("active_customers"), "dwell_seconds")
|
||||||
|
closed_wait_seconds = _int_list(
|
||||||
|
payload.get("closed_customers"),
|
||||||
|
"final_dwell_seconds",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"window_start": _string_value(payload.get("window_start")),
|
||||||
|
"window_end": _string_value(payload.get("window_end")),
|
||||||
|
"active_customer_count": _int_value(payload.get("active_customer_count")),
|
||||||
|
"active_wait_seconds": active_wait_seconds,
|
||||||
|
"closed_wait_seconds": closed_wait_seconds,
|
||||||
|
"max_wait_seconds": max(
|
||||||
|
max(active_wait_seconds, default=0),
|
||||||
|
max(closed_wait_seconds, default=0),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_sandbox_file(ctx: ManageContext, raw_path: str) -> Path:
|
||||||
|
relative = raw_path.strip().lstrip("/")
|
||||||
|
if not relative:
|
||||||
|
raise ValueError("path is required")
|
||||||
|
|
||||||
|
target = (ctx.project_root / relative).resolve()
|
||||||
|
project_root = ctx.project_root.resolve()
|
||||||
|
if target != project_root and project_root not in target.parents:
|
||||||
|
raise ValueError("invalid file path")
|
||||||
|
if not target.exists() or not target.is_file():
|
||||||
|
raise FileNotFoundError(relative)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_path(ctx: ManageContext, target: Path) -> str:
|
||||||
|
return target.resolve().relative_to(ctx.project_root.resolve()).as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def _tail_lines(path: Path, line_count: int) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
for raw_line in handle:
|
||||||
|
lines.append(raw_line.rstrip("\n"))
|
||||||
|
if len(lines) > line_count:
|
||||||
|
lines = lines[1:]
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_preview_lines(raw_value: str | None) -> int:
|
||||||
|
if raw_value is None:
|
||||||
|
return 200
|
||||||
|
value = _int_value(raw_value)
|
||||||
|
if value <= 0:
|
||||||
|
return 200
|
||||||
|
return min(value, MAX_PREVIEW_LINES)
|
||||||
|
|
||||||
|
|
||||||
|
def _int_arg(name: str, default: int) -> int:
|
||||||
|
value = request.args.get(name)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return _int_value(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _string_value(value) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _int_value(value) -> int:
|
||||||
|
if value is None:
|
||||||
|
return 0
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
if isinstance(value, float):
|
||||||
|
return int(value)
|
||||||
|
try:
|
||||||
|
return int(str(value).strip())
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _int_list(value, field: str) -> list[int]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
values: list[int] = []
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
values.append(_int_value(item.get(field)))
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_timestamp(*values: str) -> str:
|
||||||
|
latest_raw = ""
|
||||||
|
latest_at: datetime | None = None
|
||||||
|
for value in values:
|
||||||
|
if not value.strip():
|
||||||
|
continue
|
||||||
|
parsed = _parse_timestamp(value)
|
||||||
|
if parsed is None:
|
||||||
|
if not latest_raw:
|
||||||
|
latest_raw = value
|
||||||
|
continue
|
||||||
|
if latest_at is None or parsed > latest_at:
|
||||||
|
latest_at = parsed
|
||||||
|
latest_raw = value
|
||||||
|
return latest_raw
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timestamp(value: str) -> datetime:
|
||||||
|
parsed = datetime.fromisoformat(value)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return parsed.replace(tzinfo=datetime.now().astimezone().tzinfo)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
1
managed/store_dwell_alert/app/modules/__init__.py
Normal file
1
managed/store_dwell_alert/app/modules/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Runtime modules for the store dwell alert service."""
|
||||||
89
managed/store_dwell_alert/app/modules/detector_tracker.py
Normal file
89
managed/store_dwell_alert/app/modules/detector_tracker.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.modules.identity_resolver import build_color_signature
|
||||||
|
|
||||||
|
|
||||||
|
def filter_person_detections(detections: list[dict]) -> list[dict]:
|
||||||
|
return [item for item in detections if item["class_name"] == "person"]
|
||||||
|
|
||||||
|
|
||||||
|
def extract_tracked_people(results: list) -> list[dict]:
|
||||||
|
tracked_people: list[dict] = []
|
||||||
|
for result in results:
|
||||||
|
boxes = getattr(result, "boxes", None)
|
||||||
|
if boxes is None:
|
||||||
|
continue
|
||||||
|
for box in boxes:
|
||||||
|
class_name = result.names[int(box.cls[0])]
|
||||||
|
if class_name != "person":
|
||||||
|
continue
|
||||||
|
tracked_people.append(
|
||||||
|
{
|
||||||
|
"track_id": int(box.id[0]) if getattr(box, "id", None) is not None else None,
|
||||||
|
"class_name": class_name,
|
||||||
|
"confidence": float(box.conf[0]),
|
||||||
|
"xyxy": [float(value) for value in box.xyxy[0]],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return tracked_people
|
||||||
|
|
||||||
|
|
||||||
|
def attach_track_signatures(frame, tracked_people: list[dict]) -> list[dict]:
|
||||||
|
if frame is None:
|
||||||
|
return tracked_people
|
||||||
|
|
||||||
|
frame_height = len(frame)
|
||||||
|
frame_width = len(frame[0]) if frame_height else 0
|
||||||
|
enriched: list[dict] = []
|
||||||
|
for item in tracked_people:
|
||||||
|
x1, y1, x2, y2 = [int(value) for value in item["xyxy"]]
|
||||||
|
x1 = max(0, min(frame_width, x1))
|
||||||
|
x2 = max(0, min(frame_width, x2))
|
||||||
|
y1 = max(0, min(frame_height, y1))
|
||||||
|
y2 = max(0, min(frame_height, y2))
|
||||||
|
if y2 > y1 and x2 > x1:
|
||||||
|
try:
|
||||||
|
crop = frame[y1:y2, x1:x2]
|
||||||
|
except TypeError:
|
||||||
|
crop = [row[x1:x2] for row in frame[y1:y2]]
|
||||||
|
else:
|
||||||
|
crop = None
|
||||||
|
enriched.append({**item, "signature": build_color_signature(crop)})
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
|
class YOLOTrackerAdapter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model_name: str = "yolo11n.pt",
|
||||||
|
conf: float = 0.25,
|
||||||
|
tracker: str = "botsort.yaml",
|
||||||
|
model_factory=None,
|
||||||
|
) -> None:
|
||||||
|
self.model_name = model_name
|
||||||
|
self.conf = conf
|
||||||
|
self.tracker = tracker
|
||||||
|
self.model_factory = model_factory
|
||||||
|
self.model = None
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
|
if self.model_factory is None:
|
||||||
|
try:
|
||||||
|
from ultralytics import YOLO # type: ignore
|
||||||
|
except ImportError as exc: # pragma: no cover - depends on runtime deps
|
||||||
|
raise RuntimeError("ultralytics is required for YOLO tracking") from exc
|
||||||
|
self.model_factory = YOLO
|
||||||
|
self.model = self.model_factory(self.model_name)
|
||||||
|
|
||||||
|
def track(self, frame) -> list[dict]:
|
||||||
|
if self.model is None:
|
||||||
|
self.load()
|
||||||
|
results = self.model.track(
|
||||||
|
frame,
|
||||||
|
persist=True,
|
||||||
|
classes=[0],
|
||||||
|
verbose=False,
|
||||||
|
conf=self.conf,
|
||||||
|
tracker=self.tracker,
|
||||||
|
)
|
||||||
|
return attach_track_signatures(frame, extract_tracked_people(results))
|
||||||
232
managed/store_dwell_alert/app/modules/dwell_engine.py
Normal file
232
managed/store_dwell_alert/app/modules/dwell_engine.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.modules.reporter import floor_half_hour, previous_half_hour_window
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class DwellSession:
|
||||||
|
person_id: str
|
||||||
|
session_id: str
|
||||||
|
entered_at: datetime
|
||||||
|
role: str = "customer"
|
||||||
|
state: str = "active"
|
||||||
|
accumulated_dwell_seconds: int = 0
|
||||||
|
active_started_at: datetime | None = None
|
||||||
|
last_seen_at: datetime | None = None
|
||||||
|
pause_started_at: datetime | None = None
|
||||||
|
closed_at: datetime | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.active_started_at is None:
|
||||||
|
self.active_started_at = self.entered_at
|
||||||
|
if self.last_seen_at is None:
|
||||||
|
self.last_seen_at = self.entered_at
|
||||||
|
|
||||||
|
def dwell_seconds(self, when: datetime | None = None) -> int:
|
||||||
|
if self.state == "active" and self.active_started_at is not None:
|
||||||
|
current_time = when or self.last_seen_at or self.entered_at
|
||||||
|
return self.accumulated_dwell_seconds + max(
|
||||||
|
0,
|
||||||
|
int((current_time - self.active_started_at).total_seconds()),
|
||||||
|
)
|
||||||
|
return self.accumulated_dwell_seconds
|
||||||
|
|
||||||
|
def mark_seen(self, when: datetime) -> None:
|
||||||
|
if self.state == "paused":
|
||||||
|
self.active_started_at = when
|
||||||
|
self.pause_started_at = None
|
||||||
|
elif self.active_started_at is None:
|
||||||
|
self.active_started_at = when
|
||||||
|
self.last_seen_at = when
|
||||||
|
self.state = "active"
|
||||||
|
|
||||||
|
def pause(self, when: datetime) -> None:
|
||||||
|
if self.state != "active" or self.active_started_at is None:
|
||||||
|
return
|
||||||
|
self.accumulated_dwell_seconds += max(
|
||||||
|
0,
|
||||||
|
int((when - self.active_started_at).total_seconds()),
|
||||||
|
)
|
||||||
|
self.pause_started_at = when
|
||||||
|
self.last_seen_at = when
|
||||||
|
self.active_started_at = None
|
||||||
|
self.state = "paused"
|
||||||
|
|
||||||
|
def close_if_expired(self, when: datetime, pause_timeout_seconds: int) -> bool:
|
||||||
|
if self.pause_started_at is None:
|
||||||
|
return False
|
||||||
|
if int((when - self.pause_started_at).total_seconds()) <= pause_timeout_seconds:
|
||||||
|
return False
|
||||||
|
self.closed_at = when
|
||||||
|
self.state = "closed"
|
||||||
|
return True
|
||||||
|
|
||||||
|
def as_event_dict(self, when: datetime | None = None) -> dict:
|
||||||
|
return {
|
||||||
|
"person_id": self.person_id,
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"role": self.role,
|
||||||
|
"status": self.state,
|
||||||
|
"dwell_seconds": self.dwell_seconds(when),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DwellEngine:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
camera_id: str,
|
||||||
|
min_people: int,
|
||||||
|
min_dwell_seconds: int,
|
||||||
|
pause_timeout_seconds: int,
|
||||||
|
alert_cooldown_seconds: int,
|
||||||
|
) -> None:
|
||||||
|
self.camera_id = camera_id
|
||||||
|
self.min_people = min_people
|
||||||
|
self.min_dwell_seconds = min_dwell_seconds
|
||||||
|
self.pause_timeout_seconds = pause_timeout_seconds
|
||||||
|
self.alert_cooldown_seconds = alert_cooldown_seconds
|
||||||
|
self.sessions: dict[str, DwellSession] = {}
|
||||||
|
self.closed_sessions: list[DwellSession] = []
|
||||||
|
self.session_counts: dict[str, int] = {}
|
||||||
|
self.alert_rearmed = True
|
||||||
|
self.last_alert_at: datetime | None = None
|
||||||
|
self.last_report_boundary: datetime | None = None
|
||||||
|
|
||||||
|
def _next_session_id(self, person_id: str) -> str:
|
||||||
|
next_index = self.session_counts.get(person_id, 0) + 1
|
||||||
|
self.session_counts[person_id] = next_index
|
||||||
|
return f"{person_id}-s{next_index}"
|
||||||
|
|
||||||
|
def _create_session(self, person_id: str, role: str, when: datetime) -> DwellSession:
|
||||||
|
session = DwellSession(
|
||||||
|
person_id=person_id,
|
||||||
|
session_id=self._next_session_id(person_id),
|
||||||
|
entered_at=when,
|
||||||
|
role=role,
|
||||||
|
)
|
||||||
|
self.sessions[person_id] = session
|
||||||
|
return session
|
||||||
|
|
||||||
|
def process_observations(self, observations: list[dict], when: datetime) -> list[dict]:
|
||||||
|
events: list[dict] = []
|
||||||
|
seen_people: set[str] = set()
|
||||||
|
|
||||||
|
for observation in observations:
|
||||||
|
person_id = observation["person_id"]
|
||||||
|
role = observation.get("role", "customer")
|
||||||
|
seen_people.add(person_id)
|
||||||
|
|
||||||
|
session = self.sessions.get(person_id)
|
||||||
|
if session is None:
|
||||||
|
session = self._create_session(person_id, role, when)
|
||||||
|
else:
|
||||||
|
session.role = role
|
||||||
|
session.mark_seen(when)
|
||||||
|
|
||||||
|
for person_id, session in list(self.sessions.items()):
|
||||||
|
if person_id in seen_people:
|
||||||
|
continue
|
||||||
|
if session.state == "active":
|
||||||
|
session.pause(when)
|
||||||
|
if session.close_if_expired(when, self.pause_timeout_seconds):
|
||||||
|
self.closed_sessions.append(session)
|
||||||
|
del self.sessions[person_id]
|
||||||
|
|
||||||
|
alert_event = self._build_alert_event(when)
|
||||||
|
if alert_event is not None:
|
||||||
|
events.append(alert_event)
|
||||||
|
|
||||||
|
report_event = self._build_half_hour_report(when)
|
||||||
|
if report_event is not None:
|
||||||
|
events.append(report_event)
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
def _active_customer_sessions(self, when: datetime) -> list[DwellSession]:
|
||||||
|
return [
|
||||||
|
session
|
||||||
|
for session in self.sessions.values()
|
||||||
|
if session.role == "customer"
|
||||||
|
and session.state == "active"
|
||||||
|
and session.dwell_seconds(when) >= self.min_dwell_seconds
|
||||||
|
]
|
||||||
|
|
||||||
|
def _build_alert_event(self, when: datetime) -> dict | None:
|
||||||
|
long_stay_sessions = self._active_customer_sessions(when)
|
||||||
|
if len(long_stay_sessions) < self.min_people:
|
||||||
|
self.alert_rearmed = True
|
||||||
|
return None
|
||||||
|
if not self.alert_rearmed:
|
||||||
|
return None
|
||||||
|
self.alert_rearmed = False
|
||||||
|
self.last_alert_at = when
|
||||||
|
return {
|
||||||
|
"event": "long_stay_alert",
|
||||||
|
"camera_id": self.camera_id,
|
||||||
|
"ts": when.isoformat(),
|
||||||
|
"threshold": {
|
||||||
|
"min_people": self.min_people,
|
||||||
|
"min_dwell_seconds": self.min_dwell_seconds,
|
||||||
|
},
|
||||||
|
"active_long_stay_count": len(long_stay_sessions),
|
||||||
|
"people": [
|
||||||
|
session.as_event_dict(when)
|
||||||
|
for session in sorted(
|
||||||
|
long_stay_sessions,
|
||||||
|
key=lambda item: item.dwell_seconds(when),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_half_hour_report(self, when: datetime) -> dict | None:
|
||||||
|
boundary = floor_half_hour(when)
|
||||||
|
if boundary == when and self.last_report_boundary == boundary:
|
||||||
|
return
|
||||||
|
if boundary == self.last_report_boundary:
|
||||||
|
return None
|
||||||
|
if when < boundary:
|
||||||
|
return None
|
||||||
|
|
||||||
|
window_start, window_end = previous_half_hour_window(when)
|
||||||
|
active_customers = [
|
||||||
|
session.as_event_dict(when)
|
||||||
|
for session in self.sessions.values()
|
||||||
|
if session.role == "customer" and session.state == "active"
|
||||||
|
]
|
||||||
|
closed_customers = [
|
||||||
|
{
|
||||||
|
"person_id": session.person_id,
|
||||||
|
"session_id": session.session_id,
|
||||||
|
"final_dwell_seconds": session.dwell_seconds(window_end),
|
||||||
|
}
|
||||||
|
for session in self.closed_sessions
|
||||||
|
if session.role == "customer"
|
||||||
|
and session.closed_at is not None
|
||||||
|
and window_start < session.closed_at <= window_end
|
||||||
|
]
|
||||||
|
staff_seen_count = sum(1 for session in self.sessions.values() if session.role == "staff")
|
||||||
|
self.last_report_boundary = boundary
|
||||||
|
return {
|
||||||
|
"event": "half_hour_report",
|
||||||
|
"camera_id": self.camera_id,
|
||||||
|
"window_start": window_start.isoformat(),
|
||||||
|
"window_end": window_end.isoformat(),
|
||||||
|
"active_customer_count": len(active_customers),
|
||||||
|
"active_customers": active_customers,
|
||||||
|
"closed_customers": closed_customers,
|
||||||
|
"staff_seen_count": staff_seen_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def long_stay_count(sessions: list[dict], min_dwell_seconds: int) -> int:
|
||||||
|
return sum(
|
||||||
|
1
|
||||||
|
for item in sessions
|
||||||
|
if item["role"] == "customer"
|
||||||
|
and item["state"] == "active"
|
||||||
|
and item["dwell_seconds"] >= min_dwell_seconds
|
||||||
|
)
|
||||||
173
managed/store_dwell_alert/app/modules/identity_resolver.py
Normal file
173
managed/store_dwell_alert/app/modules/identity_resolver.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from math import sqrt
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
def choose_reentry_match(
|
||||||
|
paused_people: list[dict],
|
||||||
|
now_ts: int,
|
||||||
|
pause_timeout_seconds: int,
|
||||||
|
min_similarity: float,
|
||||||
|
) -> str | None:
|
||||||
|
valid = [
|
||||||
|
item
|
||||||
|
for item in paused_people
|
||||||
|
if now_ts - item["paused_at"] <= pause_timeout_seconds
|
||||||
|
and item["similarity"] >= min_similarity
|
||||||
|
]
|
||||||
|
if not valid:
|
||||||
|
return None
|
||||||
|
valid.sort(key=lambda item: (item["similarity"], item["paused_at"]), reverse=True)
|
||||||
|
return valid[0]["person_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _average(values: Iterable[float]) -> float:
|
||||||
|
values = list(values)
|
||||||
|
if not values:
|
||||||
|
return 0.0
|
||||||
|
return sum(values) / len(values)
|
||||||
|
|
||||||
|
|
||||||
|
def build_color_signature(crop) -> list[float]:
|
||||||
|
if crop is None:
|
||||||
|
return [0.0, 0.0, 0.0]
|
||||||
|
height = len(crop)
|
||||||
|
if height == 0:
|
||||||
|
return [0.0, 0.0, 0.0]
|
||||||
|
width = len(crop[0])
|
||||||
|
if width == 0:
|
||||||
|
return [0.0, 0.0, 0.0]
|
||||||
|
|
||||||
|
blue_values = []
|
||||||
|
green_values = []
|
||||||
|
red_values = []
|
||||||
|
for row in crop:
|
||||||
|
for pixel in row:
|
||||||
|
blue_values.append(float(pixel[0]))
|
||||||
|
green_values.append(float(pixel[1]))
|
||||||
|
red_values.append(float(pixel[2]))
|
||||||
|
return [
|
||||||
|
round(_average(blue_values) / 255.0, 4),
|
||||||
|
round(_average(green_values) / 255.0, 4),
|
||||||
|
round(_average(red_values) / 255.0, 4),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def signature_similarity(left: list[float], right: list[float]) -> float:
|
||||||
|
if not left or not right:
|
||||||
|
return 0.0
|
||||||
|
distance = sqrt(sum((left[idx] - right[idx]) ** 2 for idx in range(min(len(left), len(right)))))
|
||||||
|
return max(0.0, 1.0 - distance)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ActiveIdentity:
|
||||||
|
person_id: str
|
||||||
|
track_id: int
|
||||||
|
signature: list[float]
|
||||||
|
last_seen_at: datetime
|
||||||
|
role: str = "customer"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PausedIdentity:
|
||||||
|
person_id: str
|
||||||
|
signature: list[float]
|
||||||
|
paused_at: datetime
|
||||||
|
role: str = "customer"
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityResolver:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
pause_timeout_seconds: int,
|
||||||
|
reentry_similarity_threshold: float = 0.92,
|
||||||
|
) -> None:
|
||||||
|
self.pause_timeout_seconds = pause_timeout_seconds
|
||||||
|
self.reentry_similarity_threshold = reentry_similarity_threshold
|
||||||
|
self.active_by_track: dict[int, ActiveIdentity] = {}
|
||||||
|
self.paused_by_person: dict[str, PausedIdentity] = {}
|
||||||
|
self.person_counter = 0
|
||||||
|
|
||||||
|
def _next_person_id(self) -> str:
|
||||||
|
self.person_counter += 1
|
||||||
|
return f"cust_{self.person_counter:05d}"
|
||||||
|
|
||||||
|
def _expire_paused(self, when: datetime) -> None:
|
||||||
|
expired = [
|
||||||
|
person_id
|
||||||
|
for person_id, paused in self.paused_by_person.items()
|
||||||
|
if int((when - paused.paused_at).total_seconds()) > self.pause_timeout_seconds
|
||||||
|
]
|
||||||
|
for person_id in expired:
|
||||||
|
del self.paused_by_person[person_id]
|
||||||
|
|
||||||
|
def _match_paused(self, signature: list[float], when: datetime) -> str | None:
|
||||||
|
self._expire_paused(when)
|
||||||
|
best_person_id = None
|
||||||
|
best_similarity = 0.0
|
||||||
|
for person_id, paused in self.paused_by_person.items():
|
||||||
|
similarity = signature_similarity(signature, paused.signature)
|
||||||
|
if similarity < self.reentry_similarity_threshold:
|
||||||
|
continue
|
||||||
|
if similarity > best_similarity:
|
||||||
|
best_person_id = person_id
|
||||||
|
best_similarity = similarity
|
||||||
|
if best_person_id is not None:
|
||||||
|
del self.paused_by_person[best_person_id]
|
||||||
|
return best_person_id
|
||||||
|
|
||||||
|
def resolve(self, tracks: list[dict], when: datetime) -> list[dict]:
|
||||||
|
current_track_ids = {
|
||||||
|
track["track_id"]
|
||||||
|
for track in tracks
|
||||||
|
if track.get("track_id") is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
disappeared_track_ids = [
|
||||||
|
track_id
|
||||||
|
for track_id in self.active_by_track
|
||||||
|
if track_id not in current_track_ids
|
||||||
|
]
|
||||||
|
for track_id in disappeared_track_ids:
|
||||||
|
active = self.active_by_track.pop(track_id)
|
||||||
|
self.paused_by_person[active.person_id] = PausedIdentity(
|
||||||
|
person_id=active.person_id,
|
||||||
|
signature=active.signature,
|
||||||
|
paused_at=when,
|
||||||
|
role=active.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
observations: list[dict] = []
|
||||||
|
for track in tracks:
|
||||||
|
track_id = track.get("track_id")
|
||||||
|
if track_id is None:
|
||||||
|
continue
|
||||||
|
signature = track.get("signature", [0.0, 0.0, 0.0])
|
||||||
|
active = self.active_by_track.get(track_id)
|
||||||
|
if active is None:
|
||||||
|
person_id = self._match_paused(signature, when) or self._next_person_id()
|
||||||
|
active = ActiveIdentity(
|
||||||
|
person_id=person_id,
|
||||||
|
track_id=track_id,
|
||||||
|
signature=signature,
|
||||||
|
last_seen_at=when,
|
||||||
|
role=track.get("role", "customer"),
|
||||||
|
)
|
||||||
|
self.active_by_track[track_id] = active
|
||||||
|
else:
|
||||||
|
active.signature = signature
|
||||||
|
active.last_seen_at = when
|
||||||
|
|
||||||
|
observations.append(
|
||||||
|
{
|
||||||
|
"person_id": active.person_id,
|
||||||
|
"track_id": track_id,
|
||||||
|
"role": active.role,
|
||||||
|
"signature": signature,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return observations
|
||||||
20
managed/store_dwell_alert/app/modules/notifier.py
Normal file
20
managed/store_dwell_alert/app/modules/notifier.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib import request
|
||||||
|
|
||||||
|
|
||||||
|
def build_json_request(url: str, payload: dict, timeout_seconds: float = 5.0) -> request.Request:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = request.Request(url=url, data=data, method="POST")
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
req.timeout_seconds = timeout_seconds
|
||||||
|
return req
|
||||||
|
|
||||||
|
|
||||||
|
def append_json_event(path: str | Path, payload: dict) -> None:
|
||||||
|
output_path = Path(path)
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with output_path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
||||||
19
managed/store_dwell_alert/app/modules/reporter.py
Normal file
19
managed/store_dwell_alert/app/modules/reporter.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
def should_emit_half_hour_report(ts: str) -> bool:
|
||||||
|
dt = datetime.fromisoformat(ts)
|
||||||
|
return dt.minute in {0, 30} and dt.second == 0
|
||||||
|
|
||||||
|
|
||||||
|
def floor_half_hour(dt: datetime) -> datetime:
|
||||||
|
minute = 0 if dt.minute < 30 else 30
|
||||||
|
return dt.replace(minute=minute, second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def previous_half_hour_window(dt: datetime) -> tuple[datetime, datetime]:
|
||||||
|
window_end = floor_half_hour(dt)
|
||||||
|
window_start = window_end - timedelta(minutes=30)
|
||||||
|
return window_start, window_end
|
||||||
123
managed/store_dwell_alert/app/modules/staff_filter.py
Normal file
123
managed/store_dwell_alert/app/modules/staff_filter.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.modules.identity_resolver import build_color_signature, signature_similarity
|
||||||
|
|
||||||
|
|
||||||
|
def staff_vote(matches: list[bool], min_hits: int) -> bool:
|
||||||
|
return sum(1 for item in matches if item) >= min_hits
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class StaffEmbedding:
|
||||||
|
staff_id: str
|
||||||
|
signature: list[float]
|
||||||
|
source: str
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_signature(signature: list[float]) -> list[float]:
|
||||||
|
if len(signature) < 3:
|
||||||
|
return [0.0, 0.0, 0.0]
|
||||||
|
return [round(float(value), 4) for value in signature[:3]]
|
||||||
|
|
||||||
|
|
||||||
|
def load_staff_gallery(gallery_dir: str | Path) -> list[StaffEmbedding]:
|
||||||
|
path = Path(gallery_dir)
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
embeddings: list[StaffEmbedding] = []
|
||||||
|
for json_path in sorted(path.glob("*.json")):
|
||||||
|
raw = json.loads(json_path.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
raw = [raw]
|
||||||
|
for item in raw:
|
||||||
|
staff_id = item.get("staff_id") or json_path.stem
|
||||||
|
signature = _normalize_signature(item.get("signature", []))
|
||||||
|
embeddings.append(
|
||||||
|
StaffEmbedding(
|
||||||
|
staff_id=staff_id,
|
||||||
|
signature=signature,
|
||||||
|
source=str(json_path),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
image_paths = []
|
||||||
|
for pattern in ("*.jpg", "*.jpeg", "*.png", "*.bmp", "*.webp"):
|
||||||
|
image_paths.extend(sorted(path.glob(pattern)))
|
||||||
|
|
||||||
|
if not image_paths:
|
||||||
|
return embeddings
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cv2 # type: ignore
|
||||||
|
except ImportError: # pragma: no cover - runtime dependency
|
||||||
|
return embeddings
|
||||||
|
|
||||||
|
for image_path in image_paths:
|
||||||
|
image = cv2.imread(str(image_path))
|
||||||
|
if image is None:
|
||||||
|
continue
|
||||||
|
staff_id = image_path.stem.split("_")[0]
|
||||||
|
embeddings.append(
|
||||||
|
StaffEmbedding(
|
||||||
|
staff_id=staff_id,
|
||||||
|
signature=build_color_signature(image),
|
||||||
|
source=str(image_path),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return embeddings
|
||||||
|
|
||||||
|
|
||||||
|
class StaffMatcher:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
gallery: list[StaffEmbedding],
|
||||||
|
similarity_threshold: float,
|
||||||
|
min_hits: int,
|
||||||
|
vote_window: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.gallery = gallery
|
||||||
|
self.similarity_threshold = similarity_threshold
|
||||||
|
self.min_hits = min_hits
|
||||||
|
self.vote_window = vote_window or max(5, min_hits)
|
||||||
|
self.votes: dict[str, deque[bool]] = defaultdict(
|
||||||
|
lambda: deque(maxlen=self.vote_window)
|
||||||
|
)
|
||||||
|
|
||||||
|
def match_signature(self, signature: list[float]) -> StaffEmbedding | None:
|
||||||
|
best_match = None
|
||||||
|
best_similarity = 0.0
|
||||||
|
for embedding in self.gallery:
|
||||||
|
similarity = signature_similarity(signature, embedding.signature)
|
||||||
|
if similarity < self.similarity_threshold:
|
||||||
|
continue
|
||||||
|
if similarity > best_similarity:
|
||||||
|
best_match = embedding
|
||||||
|
best_similarity = similarity
|
||||||
|
return best_match
|
||||||
|
|
||||||
|
def classify(self, observations: list[dict]) -> list[dict]:
|
||||||
|
classified: list[dict] = []
|
||||||
|
for observation in observations:
|
||||||
|
person_id = observation["person_id"]
|
||||||
|
signature = observation.get("signature", [0.0, 0.0, 0.0])
|
||||||
|
embedding = self.match_signature(signature)
|
||||||
|
vote_history = self.votes[person_id]
|
||||||
|
vote_history.append(embedding is not None)
|
||||||
|
role = "staff" if staff_vote(list(vote_history), self.min_hits) else "customer"
|
||||||
|
classified.append(
|
||||||
|
{
|
||||||
|
**observation,
|
||||||
|
"role": role,
|
||||||
|
"staff_id": embedding.staff_id if embedding is not None else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return classified
|
||||||
|
|
||||||
|
def forget(self, person_id: str) -> None:
|
||||||
|
self.votes.pop(person_id, None)
|
||||||
79
managed/store_dwell_alert/app/modules/stream_reader.py
Normal file
79
managed/store_dwell_alert/app/modules/stream_reader.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from time import monotonic, sleep
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class StreamHealth:
|
||||||
|
max_failures: int
|
||||||
|
failures: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_disconnected(self) -> bool:
|
||||||
|
return self.failures >= self.max_failures
|
||||||
|
|
||||||
|
def record_failure(self) -> None:
|
||||||
|
self.failures += 1
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.failures = 0
|
||||||
|
|
||||||
|
|
||||||
|
class RTSPFrameReader:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
rtsp_url: str,
|
||||||
|
sample_fps: float,
|
||||||
|
reconnect_backoff_seconds: float,
|
||||||
|
capture_factory: Callable[[str], Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.rtsp_url = rtsp_url
|
||||||
|
self.sample_fps = sample_fps
|
||||||
|
self.reconnect_backoff_seconds = reconnect_backoff_seconds
|
||||||
|
self.capture_factory = capture_factory
|
||||||
|
self.health = StreamHealth(max_failures=3)
|
||||||
|
self.capture = None
|
||||||
|
self.last_read_at: float | None = None
|
||||||
|
|
||||||
|
def open(self) -> None:
|
||||||
|
if self.capture_factory is None:
|
||||||
|
try:
|
||||||
|
import cv2 # type: ignore
|
||||||
|
except ImportError as exc: # pragma: no cover - depends on runtime deps
|
||||||
|
raise RuntimeError("opencv-python is required for RTSP reading") from exc
|
||||||
|
self.capture_factory = cv2.VideoCapture
|
||||||
|
self.capture = self.capture_factory(self.rtsp_url)
|
||||||
|
self.health.reset()
|
||||||
|
|
||||||
|
def _throttle(self) -> None:
|
||||||
|
if self.sample_fps <= 0:
|
||||||
|
return
|
||||||
|
interval = 1.0 / self.sample_fps
|
||||||
|
if self.last_read_at is None:
|
||||||
|
return
|
||||||
|
remaining = interval - (monotonic() - self.last_read_at)
|
||||||
|
if remaining > 0:
|
||||||
|
sleep(remaining)
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
if self.capture is None:
|
||||||
|
self.open()
|
||||||
|
self._throttle()
|
||||||
|
ok, frame = self.capture.read()
|
||||||
|
if not ok:
|
||||||
|
self.health.record_failure()
|
||||||
|
if self.health.is_disconnected:
|
||||||
|
self.close()
|
||||||
|
sleep(self.reconnect_backoff_seconds)
|
||||||
|
return None
|
||||||
|
self.health.reset()
|
||||||
|
self.last_read_at = monotonic()
|
||||||
|
return frame
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self.capture is not None and hasattr(self.capture, "release"):
|
||||||
|
self.capture.release()
|
||||||
|
self.capture = None
|
||||||
|
self.last_read_at = None
|
||||||
23
managed/store_dwell_alert/app/schemas.py
Normal file
23
managed/store_dwell_alert/app/schemas.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PersonIdentity:
|
||||||
|
person_id: str
|
||||||
|
role: str = "customer"
|
||||||
|
track_id: str | None = None
|
||||||
|
state: str = "active"
|
||||||
|
dwell_seconds: int = 0
|
||||||
|
last_seen_ts: int = 0
|
||||||
|
pause_start_ts: int | None = None
|
||||||
|
embedding: list[float] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AlertEvent:
|
||||||
|
event: str
|
||||||
|
camera_id: str
|
||||||
|
ts: str
|
||||||
|
payload: dict
|
||||||
26
managed/store_dwell_alert/config/config.example.yaml
Normal file
26
managed/store_dwell_alert/config/config.example.yaml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
camera_id: store_cam_01
|
||||||
|
timezone: Asia/Shanghai
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
min_people: 5
|
||||||
|
min_dwell_seconds: 600
|
||||||
|
pause_timeout_seconds: 300
|
||||||
|
alert_cooldown_seconds: 600
|
||||||
|
|
||||||
|
stream:
|
||||||
|
rtsp_url: rtsp://user:password@camera-ip:554/h264/ch1/main/av_stream
|
||||||
|
sample_fps: 2.0
|
||||||
|
reconnect_backoff_seconds: 5.0
|
||||||
|
|
||||||
|
staff:
|
||||||
|
gallery_dir: data/staff_gallery
|
||||||
|
min_hits: 3
|
||||||
|
similarity_threshold: 0.9
|
||||||
|
|
||||||
|
event_sink:
|
||||||
|
path: logs/events.jsonl
|
||||||
|
|
||||||
|
webhook:
|
||||||
|
alert_url: ""
|
||||||
|
report_url: ""
|
||||||
|
timeout_seconds: 5.0
|
||||||
1
managed/store_dwell_alert/data/runtime/.gitkeep
Normal file
1
managed/store_dwell_alert/data/runtime/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user