Skip to main content

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.

Safety overview: KPI tiles for total detections, violating and compliant scenes and people detected, a compliance donut, a high-risk-location bar chart and annotated recent-detection thumbnails
Safety overview — the 50% compliance rate is computed live from the ten seeded records, not hard-coded; the “demo mode” pill top-right is the app labelling itself, because nothing is behind it.
Helmet detection page: the annotated frame with per-person confidence on the left, an upload zone and four sample scenes on the right, and a completion toast at the bottom
Detection result — green HELMET boxes, red NO-HELMET boxes, per-person confidence. The boxes come from fixed seed coordinates: the YOLO service is not running, and the upload zone says so in plain words.
Detection history: a grid of nine cards with annotated thumbnails, above them a keyword search and all/violation/compliant filters
Detection history — keyword search plus violation/compliance filters. The 08/29 card at top-left is not seed data: it is the record produced by actually clicking “run detection” during capture, persisted to localStorage.
Detection detail: an enlarged annotated frame, a file/location info card, per-person result rows and a review panel with confirm-violation and false-positive actions
Detection detail — per-person results and the review panel. Confirm/false-positive maps to the backend’s comment.confirm column; the table and CommentController both exist, but routes/web.php registers no route for them.
Admin console: KPI tiles for inspectors, total violations, compliance rate and pending reviews, above a cross-inspector detection table with status badges
The admin’s cross-inspector table, filterable by violation and pending-review. Two inspectors is the entire seeded roster — not a truncated view.
The safety overview at 390px: KPI tiles in two columns, charts stacked full width, and a fixed bottom navigation bar
The same page at 390px: KPIs fold to two columns, charts stack full width, and below Tailwind’s md breakpoint the sidebar becomes a fixed bottom tab bar — one component tree, two information densities.

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.

Sequence diagram of POST /picture/upload across the SPA, middleware, PictureController, storage, MySQL, Flask and YOLO
The system’s single most important call: one POST walks RBAC lookup → file store → picture insert → an HTTP hop into Flask → YOLO inference → result insert, all synchronously. There is no queue anywhere on that path (QUEUE_CONNECTION=sync), so the request blocks until inference returns — and a confirmed violation notifies nobody.
Service and layer architecture: the React SPA, Lumen’s front controller, middleware, routes, controllers and Eloquent, MySQL, plus the separate Flask + YOLO service and SMTP
Three runtimes and the protocols between them: React SPA → Lumen (global CORS/auth middleware, 13 route registrations) → Flask/YOLO on 127.0.0.1:5000. One box is reserved for defined-but-unrouted controllers — ResultController, CommentController, UserController and ExampleController all exist on disk, and routes/web.php wires up none of them.
JWT authentication and role→action authorization flow: the CORS short circuit, the global pass-through, guarded versus unguarded routes, the role-permission lookup and every 401/403 branch
What authorization actually looks like: only the five /picture/* routes carry check.permission. Of the other seven, three re-check identity inside the controller and four check nothing at all — login, register, and both password-reset routes. The figure draws that asymmetry rather than a tidy single gate.
MySQL ER diagram: the four business tables users, picture, result and comment, the four RBAC tables role, user_role, action and role_action, and their foreign keys
The eight tables the two migrations create — users/picture/result/comment on the business side, role/user_role/action/role_action for RBAC, every foreign key ON DELETE CASCADE. Worth noticing: result.result_file_name is populated only on a violation, so a compliant photo leaves no annotated file. The “store only bad news” decision is visible in the column itself.
Password-reset sequence: issuing the code, writing the code and expiry onto the users row, sending it over SMTP, and the reset lookup with its expiry check
The only outbound mail path in the entire backend — no violation alert travels this way or any other. The figure also marks two behaviours read off the source (the Lumen app was never booted here — no PHP runtime): an unknown user still gets HTTP 200 with the status buried in the body, and the reset step matches the six-character code across all users without binding it back to whoever requested it.

NEXT

FoodSelector — Food Picker