跳至主要內容

15 / WORK / CASE STUDY

HelmetDetect 工地安全帽偵測平台

PHP Lumen API、獨立的 Python YOLO 推論服務與 React 儀表板組成的工地安全帽偵測平台;另做了一套不需後端的展示模式,部署在 GitHub Pages。

TYPE
FULL STACK
SCOPE
個人專案 · 2024 原型 → 2026 重構
STACK
LUMEN · FLASK · YOLO · MYSQL · REACT · VITE · TAILWIND
LINKS
LIVE

01 / PROBLEM

工地未戴安全帽的稽查,原本靠人巡、拍照、事後回報。這個系統把它變成一條可查的流程:巡檢員上傳工地影像,YOLO 模型逐人標記 helmet / nohelmet,結果落成紀錄,再由工安管理員複核違規或標為誤判,並在總覽上匯出合規率與高風險地點。專案分兩個時期:2024 年 6 月是一支能跑通「上傳 → 推論」的 PHP + Flask 原型(對外靠 ngrok、後來換 serveo 打洞);2026 年的工作是把它重整成 backend/ 與 frontend/ 的完整專案,補上 React 儀表板,並讓它能在沒有後端的情況下公開展示。

02 / CONSTRAINTS

  • YOLO 權重檔沒有進版控——.gitignore 直接排除 *.pt,所以 clone 下來的 repo,Flask 服務在 import 時就會因為找不到 helmet.pt 而炸掉。任何公開的展示都不能依賴推論。
  • 後端沒有非同步層。QUEUE_CONNECTION=sync、Console\Kernel 的 schedule() 是空的、ExampleJob 仍是未動過的骨架,全庫 grep 不到任何 dispatch()、Queue:: 或 Notification 呼叫。偵測是一次阻塞的請求內呼叫,判定違規之後不會有信、推播或 webhook——這是既有事實,不是待辦。
  • 要公開開源,但憑證曾被提交進版控:MailController 寫死 Gmail SMTP 帳號與應用程式密碼,app.py 寫死本機 MySQL 連線資訊。兩者現已改讀環境變數,且公開前已改寫 git 歷史清除既有值,因此已無法從本 repo 復原。

03 / ARCHITECTURE

三個獨立執行的東西。PHP Lumen 10 的 API 是入口:routes/web.php 共 13 條註冊(1 條根路由 + 12 個 API 端點),全域串 CorsMiddleware 與 AuthMiddleware,另以 check.permission 這個 route middleware 別名做逐端點授權。推論不在 PHP 裡:PictureController 把上傳的位元組以 multipart 打到 127.0.0.1:5000 的 Flask 服務,由 Ultralytics YOLO 讀 helmet.pt 推論,只在偵測到 nohelmet 時才把標註圖寫進 storage,並回傳 { message, detection, filename }。前端是 React 18 + Vite 6 + Tailwind v4,7 個頁面。

MySQL 由兩支 migration 建出 8 張表:業務側 users / picture / result / comment,權限側 role / user_role / action / role_action,外鍵全部 ON DELETE CASCADE。授權是 role→action 查表:種子有 5 個 action,user 角色拿 4 個,admin 多一個 picture.manage.all。前端的關鍵是單一資料轉接層(src/lib/api.js,237 行):同一組函式有 demo 與 live 兩個分支,由 VITE_API_MODE 決定——demo 讀 10 筆種子紀錄與 4 個範例場景、新的偵測寫進 localStorage;live 打 Lumen 端點,再把 picture[]+results[] 正規化成同一種扁平紀錄。GitHub Actions 先跑 vitest,再以 VITE_API_MODE=demo 建置並發到 Pages。

04 / RESPONSIBILITIES

全部由我完成,git 全歷史 27 個 commit(跨我自己的兩組身分)。2024/06 的 5 個 commit 是 Lumen + Flask/YOLO 原型;2026/07–08 的 22 個 commit 是這輪工作:目錄重整、React 儀表板與 demo/live 轉接層、34 個 vitest 測試、GitHub Pages CI,以及一串後端的正確性與資安修復。PHP 環境不在我目前的機器上,所以後端這輪是靜態閱讀原始碼後改的,沒有實際啟動驗證——這點如實記錄。

05 / CHALLENGES → SOLUTIONS

CHALLENGE

Picture / Result / Comment 三個 Eloquent 模型沿用了複數表名的預設,但 migration 建的是單數表;而且程式讀寫 picture.file_name,schema 裡的欄位卻叫 url。也就是說,那三個模型的每一次查詢都必定失敗。

SOLUTION

在每個模型上明確釘死 $table,並把 schema 對齊程式實際讀寫的欄位:picture.url 改名為 file_name、補上 result.result_file_name。這類錯誤靜態看程式碼看不出來,是從「哪一行讀哪一個欄位」逐條比對 migration 才浮現的。

CHALLENGE

上傳流程有兩個會在真實流量下爆掉的地方:upload() 先呼叫 store() 把暫存檔搬走,之後才用 getRealPath() 讀位元組;而 Flask 的回應直接以 $res['detection'] 取用,推論服務一旦掛掉或回傳別的東西,使用者拿到的是 undefined index。

SOLUTION

把讀取位元組與原始檔名提前到 store() 之前,並在原地留下註解說明為什麼順序不能換;Flask 回應改成先檢查 ->ok() 再檢查 detection 欄位存在,兩種失敗各自回 502。同時把 checkAction() 從隱含回傳改成明確回傳布林,並修掉 401 訊息把 "token invalid" 寫成 "token is valid" 的錯字。

CHALLENGE

準備公開時的檢查翻出一批不該外流的東西:/user/info 直接把 User 模型序列化回去、密碼雜湊跟著出去;忘記密碼流程寫的是 reset_code / reset_expires_at 這兩個 migration 根本沒建的欄位,所以整條流程不可能成功;updateUser() 會把 account 覆寫成電話號碼,登入識別直接被改掉;MailController 有寫死的 Gmail 帳密,app.py 有寫死的 MySQL 連線資訊。

SOLUTION

在 User 模型上設 $hidden 擋掉 password 與重設欄位、改用真正存在的 password_reset_code / password_reset_expires_at、刪掉那行覆寫 account 的賦值;SMTP 與 MySQL 連線資訊全部改讀環境變數並附 .env.example(只有佔位值),app.py 的 finally 區塊也補上連線從未建立時的保護。另外把 UserSeeder 補成能真的登入的樣子——原本只產 faker 使用者、account 是隨機電話,README 宣稱的「種子示範帳號」在當時並不成立。

CHALLENGE

要讓這個專案能公開展示,但推論根本跑不起來:權重檔不在 repo 裡,Flask 服務也需要一台會裝 Python 與 Ultralytics 的機器。最省事的做法是在瀏覽器裡假裝有模型。

SOLUTION

選擇不假裝。資料轉接層加一個 demo 分支,讀的是 seed 裡寫死的正規化座標,並在畫面上把這件事講清楚:標題列掛「展示模式」標籤、上傳區直接寫「展示模式會以模擬結果呈現」。場景圖也不用照片——SceneImage.jsx 以 CSS 漸層畫出工地感的底,再把框疊上去,所以畫面上不會出現任何冒充成真實推論結果的東西。真正被測試涵蓋的是這一層:34 個 vitest 案例把 demo 分支釘住,包含「合規的場景不得捏造出標註圖檔名」與「新紀錄的 id 不得和種子撞號」。

06 / SYSTEM FACTS

3

服務(Lumen · Flask · React)

12

API 端點(另 1 條根路由)

8

MySQL 資料表

34

前端測試(vitest 實跑通過)

07 / LESSONS

「偵測到違規」和「有人會知道」是兩件事。這套系統把安全帽違規完整記錄、可查、可複核,但整個後端沒有任何佇列、排程或通知——唯一一條對外送信的路徑是忘記密碼。它是一個稽核紀錄工具,不是即時告警系統;把這條界線講清楚,比暗示它會叫人有用得多。

README 不是證據。這個 repo 的 README 寫著有種子示範帳號,而 UserSeeder 只產隨機電話當帳號的 faker 使用者,誰都登不進去;模型的表名、欄位名同樣和 migration 對不上。這輪所有修復的依據都是「哪一行讀哪一個欄位」,而不是文件怎麼說——文件說得通的系統,不代表跑得起來。

沒有模型也要能展示,但不能假裝有模型。做法是把假的部分做成產品的一個明確模式:demo 分支、畫面上的「展示模式」標籤、CSS 漸層而非照片的場景圖,再用測試把這個分支的行為釘住。誠實標示不會讓 demo 變弱——它讓看的人知道哪些是真的做出來的:UI、資料流、複核流程與響應式版型全是真的,只有推論不是。

08 / SCREENS

以下畫面皆為示範(mock)資料,不含任何真實使用者資料。

工安總覽:總偵測次數、違規/合規場景與偵測到人員的 KPI 卡、合規率環圈圖、高風險地點長條圖,以及最近偵測的標註縮圖
工安總覽——合規率 50% 是由 10 筆種子紀錄即時算出的,不是寫死的數字;右上角的「展示模式」標籤是應用自己掛的,提醒背後沒有後端。
安全帽偵測頁:左側是標註完成的畫面與逐人信心值,右側為上傳區與四個範例場景,底部有偵測完成提示
偵測結果——綠框 HELMET、紅框 NO-HELMET,附逐人信心值。這些框來自 seed 的固定座標:YOLO 服務沒有啟動,上傳區也直接寫著「展示模式會以模擬結果呈現」。
偵測紀錄頁:9 張帶標註縮圖的卡片網格,上方有關鍵字搜尋與全部/違規/合規篩選
偵測紀錄——關鍵字搜尋加違規/合規篩選。左上 08/29 那筆不是種子資料,是截圖當下真的按下「執行偵測」後寫進 localStorage 的紀錄。
偵測詳情頁:放大的標註畫面、檔名與地點資訊卡、逐人辨識結果列,以及確認違規/誤判的複核面板
偵測詳情——逐人結果與複核面板。「確認違規/誤判」對應後端 comment.confirm 欄位;該資料表與 CommentController 都存在,但 routes/web.php 沒有替它註冊任何路由。
偵測管理頁:巡檢員人數、違規總數、合規率與待複核的 KPI 卡,下方是跨巡檢員的偵測表格與狀態標籤
管理端的跨巡檢員表格,可依違規/待複核過濾。只有 2 名巡檢員,是種子資料的全部,不是被截斷。
390px 寬的行動版工安總覽:KPI 卡改為兩欄、圖表整寬堆疊,畫面底部固定底部導覽列
同一頁在 390px:KPI 收成兩欄、圖表整寬堆疊,側欄在 Tailwind md 斷點以下換成固定底部 tab 導覽——同一份元件,兩種資訊密度。

09 / ARCHITECTURE DIAGRAMS

以下架構圖皆由專案原始碼推導繪製,每個節點都可回溯到實際檔案。圖較寬,可左右捲動,或點擊開啟原尺寸 SVG。

POST /picture/upload 的循序圖:SPA、中介層、PictureController、儲存層、MySQL、Flask 與 YOLO 之間的同步呼叫順序
整個系統最重要的一次呼叫:一個 POST 同步走完 RBAC 查表 → 存檔 → 寫入 picture → HTTP 打進 Flask → YOLO 推論 → 寫入 result。這條路徑上沒有任何佇列(QUEUE_CONNECTION=sync),請求會一路阻塞到推論回來;而且判定違規之後不會通知任何人。
後端服務與分層架構圖:React SPA、Lumen 的前控制器/中介層/路由/控制器/Eloquent、MySQL,以及獨立的 Flask + YOLO 服務與 SMTP
三個執行體與它們之間的協定:React SPA → Lumen(全域 Cors/Auth 中介層、13 條路由註冊)→ 127.0.0.1:5000 的 Flask/YOLO。圖裡特地留了一格「已定義但未註冊路由」:ResultController、CommentController、UserController、ExampleController 都在檔案系統裡,routes/web.php 一條都沒接。
JWT 認證與 role→action 授權流程圖:CORS 短路、全域中介層放行、有無 check.permission 的兩類路由、角色權限查表與各種 401/403 分支
授權的實際形狀:只有 5 條 /picture/* 掛了 check.permission。另外 7 條裡,3 條在 controller 內自行重查身分,4 條完全不查——login、register,以及兩條密碼重設路由。這張圖畫的是這個不對稱,而不是一道齊整的門。
MySQL 結構 ER 圖:users、picture、result、comment 四張業務表與 role、user_role、action、role_action 四張權限表及其外鍵關係
兩支 migration 建出的 8 張表——業務側 users/picture/result/comment,權限側 role/user_role/action/role_action,所有外鍵都是 ON DELETE CASCADE。值得注意的是 result.result_file_name 只有違規時才有值:合規的照片不會留下標註檔,這個「只存壞消息」的設計直接寫在欄位上。
忘記密碼流程的循序圖:發送驗證碼、寫入 users 表的驗證碼與到期時間、透過 SMTP 寄信,以及重設密碼的查詢與到期判斷
這是整個後端唯一一條對外送信的路徑——沒有任何違規通知走這裡。圖同時標出兩個從原始碼判讀出的行為(本機沒有 PHP runtime,Lumen 從未實際啟動):查無此人時 HTTP 仍回 200、狀態碼藏在 body 裡;重設時只用 6 碼驗證碼跨全表比對,沒有再綁回發起的使用者。

NEXT

耐人尋味 FoodSelector 美食選擇器