來源: https://codeantenna.com/a/jRxHFeO0F9
# 手把手 Golang 實現靜態圖像與視頻流人臉識別
[實時音視頻互動應用開發教程](https://codeantenna.com/tag/%E5%AE%9E%E6%97%B6%E9%9F%B3%E8%A7%86%E9%A2%91%E4%BA%92%E5%8A%A8%E5%BA%94%E7%94%A8%E5%BC%80%E5%8F%91%E6%95%99%E7%A8%8B "【實時音視頻互動應用開發教程】標簽搜索")[技術干貨](https://codeantenna.com/tag/%E6%8A%80%E6%9C%AF%E5%B9%B2%E8%B4%A7 "【技術干貨】標簽搜索")[音視頻](https://codeantenna.com/tag/%E9%9F%B3%E8%A7%86%E9%A2%91 "【音視頻】標簽搜索")[人臉識別](https://codeantenna.com/tag/%E4%BA%BA%E8%84%B8%E8%AF%86%E5%88%AB "【人臉識別】標簽搜索")
* * *
說起人臉識別,大家首先想到的實現方式應該是 Python 去做相關的處理,因為相關的機器學習框架,庫都已經封裝得比較好了。但是我們今天討論的實現方式換成 Golang,利用 Golang 去做靜態圖像和視頻流人臉識別的相應處理。
# 靜態圖像人臉識別
首先我們來進行靜態的人臉識別,Golang 這邊相較于 Python 社區來說相對少一些,不過依然有一些優秀的庫可以供我們使用。今天我們用到的就是[go-face](https://github.com/Kagami/go-face)這個庫。該庫利用[dlib](http://dlib.net/)去實現人臉識別,一個很受歡迎的機器學習工具集,它可以說是人臉識別中使用最多的軟件包之一。在產學界有廣泛應用,涵蓋了機器人學,嵌入式設備,移動設備等等。在它官網的文檔中提到在 Wild 基準測試中識別標記面部的準確度達到驚人的 99.4%,這也說明為什么它能得到廣泛的應用。
在我們開始碼代碼之前,首先需要安裝 dlib。Windows 平臺相對麻煩一些,具體在官網有安裝方案,這里我介紹兩個平臺。
### Ubuntu 18.10+, Debian sid
最新版本的 Ubuntu 和 Debian 都提供合適的 dlib 包,所以只需要運行。
~~~
# Ubuntu
sudo apt-get install libdlib-dev libblas-dev liblapack-dev libjpeg-turbo8-dev
# Debian
sudo apt-get install libdlib-dev libblas-dev liblapack-dev libjpeg62-turbo-dev
~~~
### macOS
確保安裝了[Homebrew](https://brew.sh/)。
~~~
brew install dlib
~~~
## 創建項目及準備工作
在 GOPATH 的 src 目錄下,創建項目文件,命令如下。
~~~
sudo makedir go-face-test
# 創建 main.go
sudo touch main.go
~~~
然后進入該目錄下,生成 mod 文件。
~~~
sudo go mod init
~~~
調用該命令后,在 go-face-test 目錄下應該已經生成了**go.mod**文件。
該庫需要三個模型**shape\_predictor\_5\_face\_landmarks.dat**,**mmod\_human\_face\_detector.dat**和**dlib\_face\_recognition\_resnet\_model\_v1.dat**,在 go-face-test 目錄下下載相應的測試數據。
~~~
git clone https://github.com/Kagami/go-face-testdata testdata
~~~
最終的項目結構應該如圖。

## 代碼實現
首先,我們利用代碼檢查環境是否正常。初始化識別器,釋放資源。
~~~
package main
import (
"fmt"
"github.com/Kagami/go-face"
)
const dataDir = "testdata"
// testdata 目錄下兩個對應的文件夾目錄
const (
modelDir = dataDir + "/models"
imagesDir = dataDir + "/images"
)
func main() {
fmt.Println("Face Recognition...")
// 初始化識別器
rec, err := face.NewRecognizer(modelDir)
if err != nil {
fmt.Println("Cannot INItialize recognizer")
}
defer rec.Close()
fmt.Println("Recognizer Initialized")
}
~~~
編譯然后運行代碼。
~~~
sudo go run main.go
~~~
應該得到下面輸出。
~~~
Face Recognition...
Recognizer Initialized
~~~
到這一步,我們已經成功的設置好了需要的一切。
## 檢測圖片中人臉數量
首先準備一張林俊杰的照片,放到任意目錄下,為了演示方便,我放在了**main.go**同級目錄下。

如你所見,現在什么都沒有,只有一張圖片,接下來我們要讓計算機計算圖片中的人臉數量。
~~~
package main
import (
"fmt"
"log"
"github.com/Kagami/go-face"
)
const dataDir = "testdata"
// testdata 目錄下兩個對應的文件夾目錄
const (
modelDir = dataDir + "/models"
imagesDir = dataDir + "/images"
)
func main() {
fmt.Println("Face Recognition...")
// 初始化識別器
rec, err := face.NewRecognizer(modelDir)
if err != nil {
fmt.Println("Cannot INItialize recognizer")
}
defer rec.Close()
fmt.Println("Recognizer Initialized")
// 調用該方法,傳入路徑。返回面部數量和任何錯誤
faces, err := rec.RecognizeFile("linjunjie.jpeg")
if err != nil {
log.Fatalf("無法識別: %v", err)
}
// 打印人臉數量
fmt.Println("圖片人臉數量: ", len(faces))
}
~~~
核心代碼其實就是一行,go-face 封裝進行識別的方法,傳入相應路徑的圖片文件,執行代碼后結果如下。
~~~
Face Recognition...
Recognizer Initialized
圖片人臉數量: 1
~~~
現在笨笨的計算機已經會數人臉數量了。那…如果一張照片里面有多人準不準呢,我們試試看,準備一張多人合照圖片。

heyin.jpeg
我們將第 31 行代碼換成如下即可。
~~~
faces, err := rec.RecognizeFile("heyin.jpeg")
~~~
運行后的結果應該打印 (**圖片人臉數量: 6**),接下來正式看展我們的人臉識別。
## 人臉識別
首先我們準備一張合照,這里依然沿用上面的**heyin.jpeg**。
整個處理過程大致分為以下幾步。
1.將合影中人物映射到唯一 ID, 然后將唯一 ID 和對應人物相關聯。
~~~
var samples []face.Descriptor
var peoples []int32
for i, f := range faces {
samples = append(samples, f.Descriptor)
// 每張臉唯一 id
peoples = append(peoples, int32(i))
}
// Pass samples to the recognizer.
rec.SetSamples(samples, peoples)
~~~
2.接下來我們封裝一個人臉識別的方法,傳入識別器和照片路徑,打印對應人物 ID,人物名字。
~~~
func RecognizePeople(rec *face.Recognizer, file string) {
people, err := rec.RecognizeSingleFile(file)
if err != nil {
log.Fatalf("無法識別: %v", err)
}
if people == nil {
log.Fatalf("圖片上不是一張臉")
}
peopleID := rec.Classify(people.Descriptor)
if peopleID < 0 {
log.Fatalf("無法區分")
}
fmt.Println(peopleID)
fmt.Println(labels[peopleID])
}
~~~
3.最后我們傳入想要識別的圖片,目前傳入了 3 張圖片,感興趣的小伙伴可以傳入其他圖片嘗試。

jay.jpeg

linjunjie.jpeg

taozhe.jpeg
4.調用三次。
~~~
RecognizePeople(rec, "jay.jpeg")
RecognizePeople(rec, "linjunjie.jpeg")
RecognizePeople(rec, "taozhe.jpeg")
~~~
代碼如下
~~~
package main
import (
"fmt"
"log"
"github.com/Kagami/go-face"
)
const dataDir = "testdata"
// testdata 目錄下兩個對應的文件夾目錄
const (
modelDir = dataDir + "/models"
imagesDir = dataDir + "/images"
)
// 圖片中的人名
var labels = []string{
"蕭敬騰",
"周杰倫",
"unknow",
"王力宏",
"陶喆",
"林俊杰",
}
func main() {
fmt.Println("Face Recognition...")
// 初始化識別器
rec, err := face.NewRecognizer(modelDir)
if err != nil {
fmt.Println("Cannot INItialize recognizer")
}
defer rec.Close()
fmt.Println("Recognizer Initialized")
// 調用該方法,傳入路徑。返回面部數量和任何錯誤
faces, err := rec.RecognizeFile("heyin.jpeg")
if err != nil {
log.Fatalf("無法識別: %v", err)
}
// 打印人臉數量
fmt.Println("圖片人臉數量: ", len(faces))
var samples []face.Descriptor
var peoples []int32
for i, f := range faces {
samples = append(samples, f.Descriptor)
// 每張臉唯一 id
peoples = append(peoples, int32(i))
}
// 傳入樣例到識別器
rec.SetSamples(samples, peoples)
RecognizePeople(rec, "jay.jpeg")
RecognizePeople(rec, "linjunjie.jpeg")
RecognizePeople(rec, "taozhe.jpeg")
}
func RecognizePeople(rec *face.Recognizer, file string) {
people, err := rec.RecognizeSingleFile(file)
if err != nil {
log.Fatalf("無法識別: %v", err)
}
if people == nil {
log.Fatalf("圖片上不是一張臉")
}
peopleID := rec.Classify(people.Descriptor)
if peopleID < 0 {
log.Fatalf("無法區分")
}
fmt.Println(peopleID)
fmt.Println(labels[peopleID])
}
~~~
## 運行結果
最后我們運行代碼。
~~~
go build main.go
./main
~~~
結果如下
~~~
圖片人臉數量: 6
1
周杰倫
5
林俊杰
4
陶喆
~~~
恭喜你,你已經成功的識別出這三張圖片是誰了,到這一步,靜態的圖像人臉識別已經完成了。
## 靜態人臉識別總結
到這一步我們已經可以成功的利用 Go 實現了靜態人臉識別。將其運用到項目中也不是不可,不過它有諸多局限,使用的場景較為單一,只能用在例如用戶上傳人臉身份識別,單一人臉識別等場景;圖片格式較為單一,暫時不支持 PNG 格式等缺點。
# 視頻流人臉識別
## 背景
靜態的人臉識別應用場景較為局限,不能夠放到比較重要的環境中,例如金融,保險,安防等領域,存在偽造等可能。而且單純的靜態人臉識別,意義不大。動態的視頻流擁有更加廣闊的應用空間,充分應用在智能安防,手勢識別,美顏等領域。5G 時代,眾多業務將圍繞視頻這一塊展開,如何將視頻業務與核心業務實現解耦,聲網的**RTE**組件做得不錯,作為 RTE-PaaS 的開創者,聲網已經有較多的技術積累,通過 RTE 組件的形式有很多好處。
**RTE 優點**
1.應用無關性
可以在不同的項目間共享,實現復用,避免多次開發的重復性工作
2.平臺無關性
廣泛應用于操作系統,編程語言及各領域
3.豐富的三方模塊
能夠提供例如白板教學,視頻美顏,鑒黃等眾多模塊供開發者使用
## 代碼實現
這里我們來實現一下視頻流的相關人臉識別,之前的靜態識別就是為了動態視頻流人臉識別做鋪墊。我們來說一下視頻流的人臉識別的實現思路,靜態的圖像人臉識別已經完成,而視頻是多幀的連續,我們只需要抽取片段捕獲關鍵幀,識別出人像,人后輸出對應關聯的人名。
### 準備工作
這里我們用到的是[gocv](https://gocv.io/getting-started/macos/)(底層使用 OpenCV),這里我們暫時略過具體的安裝流程,按照官方文檔安裝即可。
1.設置視頻捕捉的設備,一般來說默認 0
~~~
// set to use a video capture device 0
deviceID := 0
// open webcam
webcam, err := gocv.OpenVideoCapture(deviceID)
if err != nil {
fmt.Println(err)
return
}
defer webcam.Close()
~~~
2.打開展示窗口
~~~
// open display window
window := gocv.NewWindow("Face Detect")
defer window.Close()
~~~
3.準備圖像矩陣,檢測到人臉時顯示矩形框的配置
~~~
// prepare image matrix
img := gocv.NewMat()
defer img.Close()
// color for the rect when faces detected
blue := color.RGBA{0, 0, 255, 0}
~~~
4.加載人臉識別分類器,用一個死循環,里面加上我們的相關識別服務
~~~
for {
if ok := webcam.Read(&img); !ok {
fmt.Printf("cannot read device %v\n", deviceID)
return
}
if img.Empty() {
continue
}
// detect faces
rects := classifier.DetectMultiScale(img)
fmt.Printf("found %d faces\n", len(rects))
// draw a rectangle around each face on the original image
for _, r := range rects {
gocv.Rectangle(&img, r, blue, 3)
imgFace := img.Region(r)
buff, err:=gocv.IMEncode(".jpg",imgFace)
if err != nil {
fmt.Println("encoding to jpg err:%v", err)
break
}
RecognizePeopleFromMemory(rec, buff)
}
// show the image in the window, and wait 1 millisecond
window.IMShow(img)
window.WaitKey(1)
}
~~~
其中有幾個步驟需要將一下,目前來說**gocv.IMEncode**只支持將捕獲到的圖片轉成**PNG**,**JPG**,**GIF**三種格式。轉換后的字節流放在內存中,然后將字節流傳入我們的人臉識別函數中即可。
~~~
// RecognizeSingle returns face if it's the only face on the image or
// nil otherwise. Only JPEG format is currently supported. Thread-safe.
func (rec *Recognizer) RecognizeSingle(imgData []byte) (face *Face, err error) {
faces, err := rec.recognize(0, imgData, 1)
if err != nil || len(faces) != 1 {
return
}
face = &faces[0]
return
}
~~~
**注意事項**
> 由于 go-face 只支持 JPEG 的格式,所以我們捕捉的幀只能轉換成 JPG 格式
然后簡單的封裝一個字符流的識別函數。這里需要說明一下,之所以將 log.Fatal 換成了 log.Println 的原因是在視頻流級別的識別中可能會出現沒有人臉的情況,這個時候程序應當是正常運行的,不能退出。
~~~
func RecognizePeopleFromMemory(rec *face.Recognizer, img []byte) {
people, err := rec.RecognizeSingle(img)
if err != nil {
log.Println("無法識別: %v", err)
return
}
if people == nil {
log.Println("圖片上不是一張臉")
return
}
peopleID := rec.Classify(people.Descriptor)
if peopleID < 0 {
log.Println("無法區分")
return
}
fmt.Println(peopleID)
fmt.Println(labels[peopleID])
}
~~~
最后完整代碼如下
~~~
package main
import (
"fmt"
"image/color"
"log"
"github.com/Kagami/go-face"
"gocv.io/x/gocv"
)
const dataDir = "testdata"
// testdata 目錄下兩個對應的文件夾目錄
const (
modelDir = dataDir + "/models"
imagesDir = dataDir + "/images"
)
// 圖片中的人名
var labels = []string{
"蕭敬騰",
"周杰倫",
"unknow",
"王力宏",
"陶喆",
"林俊杰",
}
func main() {
// 初始化識別器
rec, err := face.NewRecognizer(modelDir)
if err != nil {
fmt.Println("Cannot INItialize recognizer")
}
defer rec.Close()
fmt.Println("Recognizer Initialized")
// 調用該方法,傳入路徑。返回面部數量和任何錯誤
faces, err := rec.RecognizeFile("heyin.jpeg")
if err != nil {
log.Fatalf("無法識別: %v", err)
}
// 打印人臉數量
fmt.Println("圖片人臉數量: ", len(faces))
var samples []face.Descriptor
var peoples []int32
for i, f := range faces {
samples = append(samples, f.Descriptor)
// 每張臉唯一 id
peoples = append(peoples, int32(i))
}
// Pass samples to the recognizer.
rec.SetSamples(samples, peoples)
RecognizePeople(rec, "jay.jpeg")
RecognizePeople(rec, "linjunjie.jpeg")
RecognizePeople(rec, "taozhe.jpeg")
// set to use a video capture device 0
deviceID := 0
// open webcam
webcam, err := gocv.OpenVideoCapture(deviceID)
if err != nil {
fmt.Println(err)
return
}
defer webcam.Close()
// open display window
window := gocv.NewWindow("Face Detect")
defer window.Close()
// prepare image matrix
img := gocv.NewMat()
defer img.Close()
// color for the rect when faces detected
blue := color.RGBA{0, 0, 255, 0}
// load classifier to recognize faces
classifier := gocv.NewCascadeClassifier()
defer classifier.Close()
if !classifier.Load("./haarcascade_frontalface_default.xml") {
fmt.Println("Error reading cascade file: data/haarcascade_frontalface_default.xml")
return
}
fmt.Printf("start reading camera device: %v\n", deviceID)
for {
if ok := webcam.Read(&img); !ok {
fmt.Printf("cannot read device %v\n", deviceID)
return
}
if img.Empty() {
continue
}
// detect faces
rects := classifier.DetectMultiScale(img)
if len(rects) == 0 {
continue
}
fmt.Printf("found %d faces\n", len(rects))
// draw a rectangle around each face on the original image
for _, r := range rects {
gocv.Rectangle(&img, r, blue, 3)
imgFace := img.Region(r)
buff, err:=gocv.IMEncode(".jpg",imgFace)
if err != nil {
fmt.Println("encoding to jpg err:%v", err)
break
}
RecognizePeopleFromMemory(rec, buff)
}
// show the image in the window, and wait 1 millisecond
window.IMShow(img)
window.WaitKey(1)
}
}
func RecognizePeople(rec *face.Recognizer, file string) {
people, err := rec.RecognizeSingleFile(file)
if err != nil {
log.Fatalf("無法識別: %v", err)
}
if people == nil {
log.Fatalf("圖片上不是一張臉")
}
peopleID := rec.Classify(people.Descriptor)
if peopleID < 0 {
log.Fatalf("無法區分")
}
fmt.Println(peopleID)
fmt.Println(labels[peopleID])
}
func RecognizePeopleFromMemory(rec *face.Recognizer, img []byte) {
people, err := rec.RecognizeSingle(img)
if err != nil {
log.Println("無法識別: %v", err)
return
}
if people == nil {
log.Println("圖片上不是一張臉")
return
}
peopleID := rec.Classify(people.Descriptor)
if peopleID < 0 {
log.Println("無法區分")
return
}
fmt.Println(peopleID)
fmt.Println(labels[peopleID])
}
~~~
接下來我們運行代碼,應該能夠拉起攝像頭,這個時候我手持林俊杰的照片進行識別,我們可以看到左下角已經輸出對應的人名了。

## 視頻流人臉識別總結
到這一步,恭喜你,你已經能夠完成視頻流人臉識別了。但是,這里要說明一下,為了快速的實現,我們的樣本集是比較少的,識別成功率相對來說比較低。不過一個簡單的動態人臉識別已經搭好了。
# 總結
雖然我們實現了動態的人臉識別,但是在更為復雜的應用場景下難以實現相應的需求,而且存在圖片格式等限制,缺乏人臉處理的其他模塊,美顏,鑒黃等功能。不過通過第三方的 SDK,例如聲網等平臺去實現對應的需求,園區的人臉識別,視頻會議,云課堂等場景,能夠實現快速搭建,能夠幾行代碼就能夠完成相應的接入,并圍繞 RTE 等組件進行人臉識別的相關開發。為開發節約大量時間和成本,可以將開發重心轉移到更加核心的業務。
版權聲明:本文為CSDN博主「agora\_cloud」的原創文章,遵循CC 4.0 BY-SA版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:[https://blog.csdn.net/agora\_cloud/article/details/119522891](https://blog.csdn.net/agora_cloud/article/details/119522891)
- Golang
- Beego框架
- Gin框架
- gin框架介紹
- 使用Gin web框架的知名開源線上項目
- go-admin-gin
- air 熱啟動
- 完整的form表單參數驗證語法
- Go 語言入門練手項目推薦
- Golang是基于多線程模型
- golang 一些概念
- Golang程序開發注意事項
- fatal error: all goroutines are asleep - deadlock
- defer
- Golang 的內建調試器
- go部署
- golang指針重要性
- 包(golang)
- Golang框架選型比較: goframe, beego, iris和gin
- GoFrame
- golang-admin-項目
- go module的使用方法及原理
- go-admin支持多框架的后臺系統(go-admin.cn)
- docker gocv
- go-fac
- MSYS2
- 企業開發框架系統推薦
- gorm
- go-zero
- 優秀系統
- GinSkeleton(gin web 及gin 知識)
- 一次 request -> response 的生命周期概述
- 路由與路由組以及gin源碼學習
- 中間件以及gin源碼學習
- golang項目部署
- 獨立部署golang
- 代理部署golang
- 容器部署golang
- golang交叉編譯
- goravel
- kardianos+gin 項目作為windows服務運行
- go env
- 適用在Windows、Linux和macOS環境下打包Go應用程序的詳細步驟和命令
- Redis
- Dochub
- Docker部署開發go環境
- Docker部署運行go環境
- dochub說明
- Vue
- i18n
- vue3
- vue3基本知識
- element-plus 表格單選
- vue3后臺模板
- Thinkphp
- Casbin權限控制中間件
- 容器、依賴注入、門面、事件、中間件
- tp6問答
- 偽靜態
- thinkphp-queue
- think-throttle
- thinkphp隊列queue的一些使用說明,queue:work和queue:listen的區別
- ThinkPHP6之模型事件的觸發條件
- thinkphp-swoole
- save、update、insert 的區別
- Socket
- workerman
- 介紹
- 從ThinkPHP6移植到Webman的一些技術和經驗(干貨)
- swoole
- swoole介紹
- hyperf
- hf官網
- Swoft
- swoft官網
- easyswoole
- easyswoole官網地址
- EASYSWOOLE 聊天室DEMO
- socket問答
- MySQL
- 聚簇索引與非聚簇索引
- Mysql使用max獲取最大值細節
- 主從復制
- 隨機生成20萬User表的數據
- MySQL進階-----前綴索引、單例與聯合索引
- PHP
- 面向切面編程AOP
- php是單線程的一定程度上也可以看成是“多線程”
- PHP 線程,進程、并發、并行 的理解
- excel數據畫表格圖片
- php第三方包
- monolog/monolog
- league/glide
- 博客-知識網站
- php 常用bc函數
- PHP知識點的應用場景
- AOP(面向切面編程)
- 注解
- 依賴注入
- 事件機制
- phpspreadsheet導出數據和圖片到excel
- Hyperf
- mineAdmin
- 微服務
- nacos注冊服務
- simps-mqtt連接客戶端simps
- Linux
- 切換php版本
- Vim
- Laravel
- RabbitMQ
- thinkphp+rabbitmq
- 博客
- Webman框架
- 框架注意問題
- 關于內存泄漏
- 移動端自動化
- 懶人精靈
- 工具應用
- render
- gitlab Sourcetree
- ssh-agent失敗 錯誤代碼-1
- 資源網站
- Git
- wkhtmltopdf
- MSYS2 介紹
- powershell curl 使用教程
- NSSM(windows服務工具)
- MinGW64
- 知識擴展
- 對象存儲系統
- minio
- 雪花ID
- 請求body參數類型
- GraphQL
- js 深拷貝
- window 共享 centos文件夾
- 前端get/post 請求 特殊符號 “+”傳參數問題
- 什么是SCM系統?SCM系統與ERP系統有什么區別?
- nginx 日志格式統一為 json
- 特殊符號怎么打
- 收藏網址
- 收藏-golang
- 收藏-vue3
- 收藏-php
- 收藏-node
- 收藏-前端
- 規劃ITEM
- 旅游類
- 人臉識別
- dlib
- Docker&&部署
- Docker-compose
- Docker的網絡模式
- rancher
- DHorse
- Elasticsearch
- es與kibana都docke連接
- 4種數據同步到Elasticsearch方案
- GPT
- 推薦系統
- fastposter海報生成
- elasticsearch+logstash+kibana
- beego文檔系統-MinDoc
- jeecg開源平臺
- Java
- 打包部署
- spring boot
- 依賴
- Maven 相關 命令
- Gradle 相關命令
- mybatis
- mybatis.plus
- spring boot 模板引擎
- SpringBoot+Maven多模塊項目(創建、依賴、打包可執行jar包部署測試)完整流程
- Spring Cloud
- Sentinel
- nacos
- Apollo
- java推薦項目
- gradle
- Maven
- Nexus倉庫管理器
- Python
- Masonite框架
- scrapy
- Python2的pip2
- Python3 安裝 pip3
- 安全攻防
- 運維技術
- 騰訊云安全加固建議
- 免費freessl證書申請
- ruby
- homeland
- Protobuf
- GIT
- FFMPEG
- 命令說明
- 音頻
- ffmpeg合并多個MP4視頻
- NODEJS
- 開發npm包
- MongoDB
- php-docker-mongodb環境搭建
- mongo基本命令
- Docker安裝MongoDB最新版并連接
- 少兒編程官網
- UI推薦
- MQTT
- PHP連接mqtt
- EMQX服務端
- php搭建mqtt服務端