13 changed files with 809 additions and 1 deletions
+2 -1
View File
@@ -50,9 +50,10 @@
### Completed ### Completed
- Tag & Scanner CLI for configuration & persistent config - Tag & Scanner CLI for configuration & persistent config
- Core script (MQTT, location estimation, Store in Redis) - Core script (MQTT, location estimation, Store in Redis)
- NGSI-LD public api
- public dashboard (Node-RED)
### In Progress ### In Progress
- NGSI-LD data for public API
- Admin API with CRUD functions - Admin API with CRUD functions
### Next Steps ### Next Steps
+352
View File
@@ -0,0 +1,352 @@
[
{
"id": "f1398f7c64b1d0ea",
"type": "tab",
"label": "OfficeSense Flow",
"disabled": false,
"info": "",
"env": []
},
{
"id": "20bb112ec6e856de",
"type": "ui-table",
"z": "f1398f7c64b1d0ea",
"group": "05cf402c93866d4e",
"name": "",
"label": "",
"order": 1,
"width": 0,
"height": 0,
"maxrows": 0,
"passthru": false,
"autocols": true,
"showSearch": true,
"deselect": true,
"selectionType": "none",
"columns": [],
"mobileBreakpoint": "sm",
"mobileBreakpointType": "defaults",
"action": "replace",
"className": "",
"x": 690,
"y": 360,
"wires": [
[]
]
},
{
"id": "0a5259b54388cdfc",
"type": "inject",
"z": "f1398f7c64b1d0ea",
"name": "",
"props": [
{
"p": "payload"
}
],
"repeat": "3",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "",
"payloadType": "date",
"x": 70,
"y": 400,
"wires": [
[
"4030b9e7bcab5435",
"f37401387ca7a595"
]
]
},
{
"id": "4030b9e7bcab5435",
"type": "http request",
"z": "f1398f7c64b1d0ea",
"name": "",
"method": "GET",
"ret": "obj",
"paytoqs": "ignore",
"url": "http://192.168.1.2/livedata/entities",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [],
"x": 270,
"y": 360,
"wires": [
[
"4aee360198b34403"
]
]
},
{
"id": "f37401387ca7a595",
"type": "http request",
"z": "f1398f7c64b1d0ea",
"name": "",
"method": "GET",
"ret": "obj",
"paytoqs": "ignore",
"url": "http://192.168.1.2/livedata/entities?type=room",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [],
"x": 270,
"y": 440,
"wires": [
[
"448ba65b518d534d"
]
]
},
{
"id": "448ba65b518d534d",
"type": "function",
"z": "f1398f7c64b1d0ea",
"name": "ParseNGSI",
"func": "msg.payload = msg.payload.map(r => {\n return {\n Room: r.name?.value,\n Occupancy: r.occupancy?.value\n };\n});\n\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 490,
"y": 440,
"wires": [
[
"586c57be87bf8bc6",
"49042a0c5086d4b8"
]
]
},
{
"id": "586c57be87bf8bc6",
"type": "ui-table",
"z": "f1398f7c64b1d0ea",
"group": "a98ec11a60f808be",
"name": "",
"label": "",
"order": 1,
"width": 0,
"height": 0,
"maxrows": 0,
"passthru": false,
"autocols": true,
"showSearch": true,
"deselect": true,
"selectionType": "none",
"columns": [],
"mobileBreakpoint": "sm",
"mobileBreakpointType": "defaults",
"action": "replace",
"className": "",
"x": 690,
"y": 440,
"wires": [
[]
]
},
{
"id": "4aee360198b34403",
"type": "function",
"z": "f1398f7c64b1d0ea",
"name": "ParseNGSI",
"func": "const entities = msg.payload;\n\nconst users = entities.filter(e => e.type === \"User\");\nconst rooms = entities.filter(e => e.type === \"Room\");\n\nconst roomMap = {};\nfor (const r of rooms) {\n roomMap[r.id.replace(\"urn:ngsi-ld:Room:\", \"\")] = r;\n}\n\nmsg.payload = users.map(u => {\n\n const roomId = u.locatedIn?.object;\n const room = roomMap[roomId];\n\n return {\n Name: u.name?.value,\n Room: room ? room.name?.value : \"Unknown\",\n RSSI: u.rssi?.value,\n Auth: u.authenticationStatus?.value,\n Time: new Date(u.observedAt?.value)\n .toLocaleString(\"el-GR\", { timeZone: \"Europe/Athens\" })\n };\n});\n\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 490,
"y": 360,
"wires": [
[
"20bb112ec6e856de"
]
]
},
{
"id": "49042a0c5086d4b8",
"type": "ui-chart",
"z": "f1398f7c64b1d0ea",
"group": "a98ec11a60f808be",
"name": "",
"label": "Room Occupancy Chart",
"order": 2,
"chartType": "bar",
"category": "",
"categoryType": "none",
"xAxisLabel": "Room",
"xAxisProperty": "Room",
"xAxisPropertyType": "property",
"xAxisType": "category",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"xmin": "",
"xmax": "",
"yAxisLabel": "Occupancy",
"yAxisProperty": "Occupancy",
"yAxisPropertyType": "property",
"ymin": "0",
"ymax": "10",
"bins": 10,
"action": "replace",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"showLegend": true,
"removeOlder": 1,
"removeOlderUnit": "3600",
"removeOlderPoints": "",
"colors": [
"#0095ff",
"#ff0000",
"#ff7f0e",
"#2ca02c",
"#a347e1",
"#d62728",
"#ff9896",
"#9467bd",
"#c5b0d5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"width": 6,
"height": 8,
"className": "",
"interpolation": "linear",
"x": 750,
"y": 520,
"wires": [
[]
]
},
{
"id": "05cf402c93866d4e",
"type": "ui-group",
"name": "Users",
"page": "fc203945d260d5ec",
"width": 6,
"height": 1,
"order": 1,
"showTitle": true,
"className": "",
"visible": "true",
"disabled": "false",
"groupType": "default"
},
{
"id": "a98ec11a60f808be",
"type": "ui-group",
"name": "Rooms",
"page": "fc203945d260d5ec",
"width": 6,
"height": 1,
"order": 2,
"showTitle": true,
"className": "",
"visible": "true",
"disabled": "false",
"groupType": "default"
},
{
"id": "fc203945d260d5ec",
"type": "ui-page",
"name": "OfficeSense",
"ui": "7d0f4d8165214cca",
"path": "/officesense",
"icon": "home",
"layout": "grid",
"theme": "f49a05b173b6cac3",
"breakpoints": [
{
"name": "Default",
"px": "0",
"cols": "3"
},
{
"name": "Tablet",
"px": "576",
"cols": "6"
},
{
"name": "Small Desktop",
"px": "768",
"cols": "9"
},
{
"name": "Desktop",
"px": "1024",
"cols": "12"
}
],
"order": 1,
"className": "",
"visible": "true",
"disabled": "false"
},
{
"id": "7d0f4d8165214cca",
"type": "ui-base",
"name": "My Dashboard",
"path": "/dashboard",
"appIcon": "",
"includeClientData": true,
"acceptsClientConfig": [
"ui-notification",
"ui-control"
],
"showPathInSidebar": false,
"headerContent": "page",
"navigationStyle": "default",
"titleBarStyle": "default",
"showReconnectNotification": true,
"notificationDisplayTime": 1,
"showDisconnectNotification": true,
"allowInstall": false
},
{
"id": "f49a05b173b6cac3",
"type": "ui-theme",
"name": "Default Theme",
"colors": {
"surface": "#ffffff",
"primary": "#0094ce",
"bgPage": "#eeeeee",
"groupBg": "#ffffff",
"groupOutline": "#cccccc"
},
"sizes": {
"density": "default",
"pagePadding": "12px",
"groupGap": "12px",
"groupBorderRadius": "4px",
"widgetGap": "12px"
}
},
{
"id": "2372b8eb214a55ee",
"type": "global-config",
"env": [],
"modules": {
"@flowfuse/node-red-dashboard": "1.30.2"
}
}
]
+8
View File
@@ -0,0 +1,8 @@
# dlib compile cache
.docker-cache/
# face recognition models
models/
# executable
output/
@@ -0,0 +1,55 @@
ARG BUILDPLATFORM=linux/amd64
FROM --platform=$BUILDPLATFORM golang:1.26-bookworm AS builder
# Install dlib deps
RUN dpkg --add-architecture arm64 && \
apt-get update && apt-get install -y \
g++-aarch64-linux-gnu \
libdlib-dev:arm64 \
libjpeg-dev:arm64 \
libblas-dev:arm64 \
libopenblas-dev:arm64 \
libatlas-base-dev:arm64 \
liblapack-dev:arm64 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Compiler wrapper for arm64
RUN printf '#!/bin/bash\nexec aarch64-linux-gnu-gcc "${@//-march=native/-march=armv8-a}"\n' \
> /usr/local/bin/arm64-gcc && \
printf '#!/bin/bash\nexec aarch64-linux-gnu-g++ "${@//-march=native/-march=armv8-a}"\n' \
> /usr/local/bin/arm64-g++ && \
chmod +x /usr/local/bin/arm64-gcc /usr/local/bin/arm64-g++
# Compiler variables for cross-compile to arm64
ENV CGO_ENABLED=1 \
GOOS=linux \
GOARCH=arm64 \
CC=/usr/local/bin/arm64-gcc \
CXX=/usr/local/bin/arm64-g++ \
PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig
COPY go.mod go.sum ./
RUN go mod download
# Pre-compile all CGO deps into cache
RUN go build -v ./... 2>/dev/null || \
go list -m all && \
CGO_ENABLED=1 GOOS=linux GOARCH=arm64 \
go build -gcflags="-trimpath" -v \
$(go list -f '{{if .CgoFiles}}{{.ImportPath}}{{end}}' ./...) 2>/dev/null; exit 0
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
go mod download
COPY . .
# Build - reuse CGO cache
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
go build -o cameraSession
FROM scratch AS export
COPY --from=builder /app/cameraSession /cameraSession
@@ -0,0 +1,10 @@
all:
docker buildx build --platform linux/amd64 --cache-from type=local,src=./.docker-cache --cache-to type=local,dest=./.docker-cache --target export --output type=local,dest=./output .
models:
wget https://github.com/Kagami/go-face-testdata/raw/master/models/shape_predictor_5_face_landmarks.dat
wget https://github.com/Kagami/go-face-testdata/raw/master/models/dlib_face_recognition_resnet_model_v1.dat
wget https://github.com/Kagami/go-face-testdata/raw/master/models/mmod_human_face_detector.dat
clean:
rm -r output/
@@ -0,0 +1,163 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"os"
"sync"
"github.com/Kagami/go-face"
"github.com/blackjack/webcam"
)
type InputPacket struct {
UUID string `json:"uuid"`
Descriptor face.Descriptor `json:"descriptor"`
}
type ResultPacket struct {
UUID string `json:"uuid"`
Verified bool `json:"verified"`
}
var (
rec *face.Recognizer
mtx sync.Mutex
results = make([]ResultPacket, 0)
)
func captureCameraFrame() ([]byte, error) {
cam, err := webcam.Open("/dev/video0")
if err != nil {
return nil, err
}
defer cam.Close()
format := webcam.PixelFormat(uint32(0x47504a4d)) // MJPG
_, _, _, err = cam.SetImageFormat(format, 1280, 720)
if err != nil {
return nil, fmt.Errorf("camera format error: %v", err)
}
err = cam.StartStreaming()
if err != nil {
return nil, err
}
defer cam.StopStreaming()
for {
err = cam.WaitForFrame(5)
if err != nil {
continue
}
frame, err := cam.ReadFrame()
if err != nil {
return nil, err
}
if len(frame) != 0 {
result := make([]byte, len(frame))
copy(result, frame)
return result, nil
}
}
}
// verfication thread
func verifyFace(packet InputPacket, descriptors []face.Descriptor, wg *sync.WaitGroup) {
defer wg.Done()
const threshold = 0.35
verified := false
for _, descriptor := range descriptors {
distance := face.SquaredEuclideanDistance(packet.Descriptor, descriptor)
if distance < threshold {
verified = true
break
}
}
mtx.Lock()
results = append(results, ResultPacket{
UUID: packet.UUID,
Verified: verified,
})
mtx.Unlock()
}
func main() {
var err error
// initialize recognizer
rec, err = face.NewRecognizer("./models")
if err != nil {
log.Fatalf("failed to initialize recognizer: %v", err)
}
defer rec.Close()
// capture image
cameraImage, err := captureCameraFrame()
if err != nil {
log.Fatalf("failed to read camera: %v", err)
}
// recognize faces from camera input
faces, err := rec.RecognizeCNN(cameraImage)
if err != nil {
log.Fatalf("recognition error: %v", err)
}
if len(faces) == 0 {
fmt.Println("no face found.")
return
}
var descriptors []face.Descriptor
// extract descriptors to array
for _, f := range faces {
descriptors = append(descriptors, f.Descriptor)
}
// read stdin
scanner := bufio.NewScanner(os.Stdin)
var wg sync.WaitGroup
for scanner.Scan() {
line := scanner.Bytes()
var packet InputPacket
err := json.Unmarshal(line, &packet)
if err != nil {
log.Printf("invalid json packet: %v", err)
continue
}
wg.Add(1)
go verifyFace(packet, descriptors, &wg)
}
if err := scanner.Err(); err != nil {
log.Printf("stdin error: %v", err)
}
// wait threads to finish
wg.Wait()
// print results to stdout
output, err := json.Marshal(results)
if err != nil {
log.Fatalf("failed to marshal results: %v", err)
}
fmt.Println(string(output))
}
@@ -0,0 +1,10 @@
module OfficeSense/cameraSession
go 1.26.3
require (
github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e
github.com/blackjack/webcam v0.6.1
)
require golang.org/x/sys v0.14.0 // indirect
@@ -0,0 +1,6 @@
github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e h1:lqIUFzxaqyYqUn4MhzAvSAh4wIte/iLNcIEWxpT/qbc=
github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e/go.mod h1:9wdDJkRgo3SGTcFwbQ7elVIQhIr2bbBjecuY7VoqmPU=
github.com/blackjack/webcam v0.6.1 h1:K0T6Q0zto23U99gNAa5q/hFoye6uGcKr2aE6hFoxVoE=
github.com/blackjack/webcam v0.6.1/go.mod h1:zs+RkUZzqpFPHPiwBZ6U5B34ZXXe9i+SiHLKnnukJuI=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@@ -0,0 +1,55 @@
ARG BUILDPLATFORM=linux/amd64
FROM --platform=$BUILDPLATFORM golang:1.26-bookworm AS builder
# Install dlib deps
RUN dpkg --add-architecture arm64 && \
apt-get update && apt-get install -y \
g++-aarch64-linux-gnu \
libdlib-dev:arm64 \
libjpeg-dev:arm64 \
libblas-dev:arm64 \
libopenblas-dev:arm64 \
libatlas-base-dev:arm64 \
liblapack-dev:arm64 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Compiler wrapper for arm64
RUN printf '#!/bin/bash\nexec aarch64-linux-gnu-gcc "${@//-march=native/-march=armv8-a}"\n' \
> /usr/local/bin/arm64-gcc && \
printf '#!/bin/bash\nexec aarch64-linux-gnu-g++ "${@//-march=native/-march=armv8-a}"\n' \
> /usr/local/bin/arm64-g++ && \
chmod +x /usr/local/bin/arm64-gcc /usr/local/bin/arm64-g++
# Compiler variables for cross-compile to arm64
ENV CGO_ENABLED=1 \
GOOS=linux \
GOARCH=arm64 \
CC=/usr/local/bin/arm64-gcc \
CXX=/usr/local/bin/arm64-g++ \
PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig
COPY go.mod go.sum ./
RUN go mod download
# Pre-compile all CGO deps into cache
RUN go build -v ./... 2>/dev/null || \
go list -m all && \
CGO_ENABLED=1 GOOS=linux GOARCH=arm64 \
go build -gcflags="-trimpath" -v \
$(go list -f '{{if .CgoFiles}}{{.ImportPath}}{{end}}' ./...) 2>/dev/null; exit 0
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
go mod download
COPY . .
# Build - reuse CGO cache
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
go build -o extractEmbeddings
FROM scratch AS export
COPY --from=builder /app/extractEmbeddings /extractEmbeddings
@@ -0,0 +1,10 @@
all:
docker buildx build --platform linux/amd64 --cache-from type=local,src=./.docker-cache --cache-to type=local,dest=./.docker-cache --target export --output type=local,dest=./output .
models:
wget https://github.com/Kagami/go-face-testdata/raw/master/models/shape_predictor_5_face_landmarks.dat
wget https://github.com/Kagami/go-face-testdata/raw/master/models/dlib_face_recognition_resnet_model_v1.dat
wget https://github.com/Kagami/go-face-testdata/raw/master/models/mmod_human_face_detector.dat
clean:
rm -r output/
@@ -0,0 +1,126 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"image"
"image/jpeg"
"io"
"os"
"github.com/Kagami/go-face"
"golang.org/x/image/draw"
)
type Result struct {
Success bool `json:"success"`
Descriptor face.Descriptor `json:"descriptor"`
Error string `json:"error"`
}
var res Result
func atExit() {
resString, err := json.Marshal(res)
if err != nil {
os.Exit(1)
}
fmt.Println(string(resString))
os.Exit(0)
}
func resizeIfNeeded(img image.Image, max int) image.Image {
b := img.Bounds()
w := b.Dx()
h := b.Dy()
if w <= max && h <= max {
return img
}
var scale float64
if w > h {
scale = float64(max) / float64(w)
} else {
scale = float64(max) / float64(h)
}
newW := int(float64(w) * scale)
newH := int(float64(h) * scale)
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
draw.BiLinear.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
return dst
}
func main() {
defer atExit()
// Read input image from stdin in Base64 encoding
input, err := io.ReadAll(os.Stdin)
if err != nil {
res.Success = false
res.Error = "[STDIN]: " + err.Error()
return
}
// Decode Base64
image, err := base64.StdEncoding.DecodeString(string(input))
if err != nil {
res.Success = false
res.Error = "[BASE64]: " + err.Error()
return
}
imageJPEG, err := jpeg.Decode(bytes.NewReader(image))
if err != nil {
res.Success = false
res.Error = "[JPEG][DECODE]: " + err.Error()
return
}
imageJPEG = resizeIfNeeded(imageJPEG, 600)
var buf bytes.Buffer
err = jpeg.Encode(&buf, imageJPEG, &jpeg.Options{Quality: 90})
if err != nil {
res.Success = false
res.Error = "[JPEG][ENCODE]: " + err.Error()
return
}
image = buf.Bytes()
// Create recognizer - provide ./models dir
rec, err := face.NewRecognizer("models")
if err != nil {
res.Success = false
res.Error = "[DLIB][INIT]: " + err.Error()
return
}
defer rec.Close()
faces, err := rec.RecognizeCNN(image)
if err != nil {
res.Success = false
res.Error = "[DLIB][CNN]: " + err.Error()
return
} else if len(faces) == 0 {
res.Success = false
res.Error = "[DLIB][CNN]: No face found in image."
return
} else if len(faces) != 1 {
res.Success = false
res.Error = "[DLIB][CNN]: Multiple faces found in image."
return
}
res.Success = true
res.Descriptor = faces[0].Descriptor
}
@@ -0,0 +1,8 @@
module OfficeSense/extractEmbeddings
go 1.26.3
require (
github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e
golang.org/x/image v0.41.0
)
@@ -0,0 +1,4 @@
github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e h1:lqIUFzxaqyYqUn4MhzAvSAh4wIte/iLNcIEWxpT/qbc=
github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e/go.mod h1:9wdDJkRgo3SGTcFwbQ7elVIQhIr2bbBjecuY7VoqmPU=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=