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)資料,不含任何真實使用者資料。






09 / ARCHITECTURE DIAGRAMS
以下架構圖皆由專案原始碼推導繪製,每個節點都可回溯到實際檔案。圖較寬,可左右捲動,或點擊開啟原尺寸 SVG。
NEXT
耐人尋味 FoodSelector 美食選擇器