15 / WORK / CASE STUDY
HelmetDetect — Construction-Site Helmet Detection
A construction-site helmet-detection platform — a PHP Lumen API, a separate Python YOLO inference service and a React dashboard, plus a backend-free demo mode deployed to GitHub Pages.
- TYPE
- FULL STACK
- SCOPE
- Personal project · 2024 prototype → 2026 rebuild
- STACK
- LUMEN · FLASK · YOLO · MYSQL · REACT · VITE · TAILWIND
- LINKS
- LIVE
01 / PROBLEM
Checking that workers wear helmets on site used to mean walking the site, taking photos and filing a report afterwards. This system turns that into a traceable flow: an inspector uploads a site image, a YOLO model marks each person helmet / nohelmet, the outcome becomes a record, and a safety manager confirms the violation or flags it as a false positive — with compliance rate and high-risk locations rolled up on an overview. The project has two eras: a June 2024 PHP + Flask prototype that could run upload → inference (exposed through an ngrok, later serveo, tunnel), and 2026 work that restructured it into a proper backend/ + frontend/ project, added the React dashboard, and made it demonstrable with no backend at all.
02 / CONSTRAINTS
- The YOLO weights are not in version control — .gitignore excludes *.pt outright — so on a fresh clone the Flask service dies at import time looking for helmet.pt. No public demo can depend on inference running.
- There is no async tier. QUEUE_CONNECTION=sync, Console\Kernel’s schedule() body is empty, ExampleJob is an untouched skeleton, and a repo-wide grep finds no dispatch(), Queue:: or Notification call anywhere. Detection is a blocking in-request call, and a violation produces no mail, push or webhook — a stated fact, not a to-do.
- It had to be open-sourced, yet credentials had been committed: a Gmail SMTP account and app password hardcoded in MailController, and local MySQL connection details in app.py. Both are now read from the environment, and the git history was rewritten before publication to purge the committed values — so they are no longer recoverable from this repo.
03 / ARCHITECTURE
Three things that run independently. A PHP Lumen 10 API is the entry point: routes/web.php holds 13 registrations (1 root route plus 12 API endpoints), with CorsMiddleware and AuthMiddleware in the global stack and a check.permission route-middleware alias doing per-endpoint authorization. Inference does not live in PHP: PictureController posts the upload bytes as multipart to a Flask service on 127.0.0.1:5000, where Ultralytics YOLO loads helmet.pt, writes an annotated image to storage only when nohelmet is found, and returns { message, detection, filename }. The frontend is React 18 + Vite 6 + Tailwind v4 across seven pages.
Two migrations create eight MySQL tables: users / picture / result / comment on the business side, role / user_role / action / role_action for RBAC, every foreign key ON DELETE CASCADE. Authorization is a role→action lookup: five seeded actions, four granted to the user role and a fifth (picture.manage.all) added for admin. The frontend’s keystone is a single data adapter (src/lib/api.js, 237 lines) where every operation has a demo and a live branch selected by VITE_API_MODE — demo reads ten seeded records and four sample scenes and writes new runs to localStorage; live calls the Lumen endpoints and normalizes picture[]+results[] into the same flat record shape. GitHub Actions runs vitest, then builds with VITE_API_MODE=demo and publishes to Pages.
04 / RESPONSIBILITIES
All of it is mine: 27 commits across the full git history (under two of my own identities). Five commits in June 2024 are the Lumen + Flask/YOLO prototype; 22 commits in July–August 2026 are this round — the directory restructure, the React dashboard and its demo/live adapter, 34 vitest tests, GitHub Pages CI, and a run of backend correctness and security fixes. PHP is not installed on my current machine, so those backend changes were made by reading the source rather than by booting the service — recorded as-is.
05 / CHALLENGES → SOLUTIONS
CHALLENGE
The Picture / Result / Comment Eloquent models kept Laravel’s plural-table default while the migration creates singular tables — and the code read and wrote picture.file_name against a column actually named url. Every query through those three models was guaranteed to fail.
SOLUTION
Pinned $table explicitly on each model and aligned the schema with the columns the code actually touches: renamed picture.url to file_name and added result.result_file_name. Nothing about this is visible from reading a model in isolation — it surfaced only from checking every read and write line against the migration.
CHALLENGE
The upload path had two failure modes waiting for real traffic: upload() called store(), which moves the temp file, and only afterwards read the bytes via getRealPath(); and the Flask response was indexed straight as $res['detection'], so a detector that was down or answered differently surfaced to the user as an undefined index.
SOLUTION
Moved reading the bytes and the original filename ahead of store(), with an in-place comment on why the order cannot be swapped; the Flask response is now checked with ->ok() and then for the presence of the detection key, each failure returning its own 502. In the same pass checkAction() went from an implicit return to a real boolean, and the 401 message that read “token is valid” was corrected to “token invalid”.
CHALLENGE
The pre-publication pass turned up a batch of things that should never leave the machine: /user/info serialized the User model straight back, password hash included; the reset flow wrote reset_code / reset_expires_at, columns no migration creates, so it could never have worked; updateUser() overwrote account with the phone number, silently changing the login identifier; MailController held hardcoded Gmail credentials and app.py hardcoded MySQL connection details.
SOLUTION
Added $hidden on the User model for the password and reset fields, switched to the columns that actually exist (password_reset_code / password_reset_expires_at), and deleted the line overwriting account; SMTP and MySQL settings now come from environment variables with a placeholder-only .env.example, and app.py’s finally block was guarded against a connection that never opened. UserSeeder was also fixed so someone can actually sign in — it previously created only faker users whose account was a random phone number, meaning the README’s “seeded demo users” claim was not true at the time.
CHALLENGE
The project needed to be publicly demonstrable while inference simply could not run: the weights are not in the repo and the Flask service needs a machine with Python and Ultralytics. The easy move would have been to fake a model in the browser.
SOLUTION
Chose not to fake it. The data adapter gained a demo branch that resolves fixed normalized coordinates out of the seed, and the UI says so out loud: a “demo mode” pill in the header and “demo mode shows simulated results” on the upload zone. The scenes are not photographs either — SceneImage.jsx paints a worksite-ish CSS gradient and overlays the boxes, so nothing on screen impersonates a real inference result. That layer is what the tests actually cover: 34 vitest cases pin the demo branch down, including “a compliant scene must not fabricate an annotated filename” and “new ids must never collide with the seed”.
06 / SYSTEM FACTS
3
services (Lumen · Flask · React)
12
API endpoints (plus 1 root route)
8
MySQL tables
34
frontend tests (vitest, all passing)
07 / LESSONS
“A violation was detected” and “somebody will know” are two different claims. This system records helmet violations completely, queryably and reviewably — but the backend has no queue, no scheduler and no notification of any kind; its only outbound mail path is password reset. It is an inspection-record tool, not a realtime alerting system, and saying where that line sits is more useful than implying it will page someone.
A README is not evidence. This one advertised seeded demo accounts while UserSeeder produced only faker users whose account was a random phone number, so nobody could sign in; the models’ table and column names likewise disagreed with the migration. Every fix this round came from tracing which line reads which column, not from what the docs claimed — a system that reads correctly is not the same as a system that runs.
Demoing without a model is fine; pretending to have one is not. The answer was to make the simulated part an explicit product mode — a demo branch, a “demo mode” badge on screen, CSS-gradient scenes instead of photographs — and then pin that branch’s behaviour with tests. Labelling it honestly does not weaken the demo; it tells the viewer exactly what was really built: the UI, the data flow, the review workflow and the responsive layout are all real. Only the inference is not.
08 / SCREENS
All screens below show demo (mock) data — no real user data.






09 / ARCHITECTURE DIAGRAMS
Every diagram below is derived from the project's actual source; each node traces back to a real file. They are wide — scroll sideways, or click to open the full-size SVG.
NEXT
FoodSelector — Food Picker