QBX Camera Network
A Flock-style citywide ALPR + CCTV surveillance network for QBX / Qbox servers. Built for law-enforcement roleplay: automated plate readers, live camera feeds, BOLO matching, vehicle tracking and a proper police tablet UI.
Everything that matters is server authoritative. Clients report what a camera saw; the server re-validates the vehicle, the position, the plate and the permissions before anything is stored.
Table of contents
- Features
- Dependencies
- Installation
- Commands
- Permissions
- Configuration guide
- The map
- Following a vehicle
- Live operations
- Facial recognition
- Camera sensors
- Placing cameras in game
- Portable cameras
- How detection actually works
- Read accuracy model
- Database
- Exports
- Performance notes
- Security notes
- Troubleshooting
- File structure
Features
Camera network
- Centralised registry that merges config.lua cameras with cameras placed in game.
- Grid-based streaming: a client only ever receives cameras in the cells around it.
- Four camera types: alpr, cctv, traffic, portable, each with its own defaults.
- Enable/disable, online / offline / maintenance status, random hardware failure,
weather-driven signal loss.
ALPR - Interval scanning (never per-frame), only for cameras this client has claimed. - One reporter per camera, server assigned, so a busy intersection costs the same as an empty one. - Captures plate, model, make, class, colour (full name + simplified family), secondary colour, heading, direction of travel, speed, camera, coordinates and time. - Duplicate suppression per camera+plate, client side and again server side. - Direction filters so a camera can watch one side of the road only.
BOLOs
- Plate, partial plate, vehicle description or person.
- Priorities map to alert levels; a stolen flag always escalates to CRITICAL.
- In-memory active cache so the detection hot path never queries MySQL.
- Hit counters, expiry, close and delete with a full audit trail.
Police tablet (NUI) - Dashboard, camera map with pan/zoom, ALPR search, vehicle history, vehicle tracking, BOLO board, camera management, alert feed and audit log. - Dark tactical interface with blue command accents and amber alerts. No cartoon animations.
Live CCTV - Real scripted camera at the camera position, with pan / tilt / zoom limits per camera. - Scanlines, grain, vignette, night mode, signal loss, camera cycling. - Lock on to a person or vehicle and follow them with a tracking box. - On demand plate reads, evidence stills, operator notes, saved PTZ presets. - Camera to camera handoff: follow a vehicle across the network. - Push the feed you are watching to another officer's tablet.
Facial recognition - Candidate based. Ranked possible matches with a confidence score, never a confirmed identity. - Only matches people already in the records database. Covered faces return nothing. - Processing takes real time and every request is audited.
Camera sensors - Gunfire, crowds forming and vehicles left standing raise alerts with no operator watching.
Evidence
- Every detection stores a snapshot payload used to build a camera-style evidence panel.
- Optional real screenshot capture via screenshot-basic to an endpoint you own
(disabled by default; no third-party image hosts are ever contacted).
Dependencies
| Resource | Required | Notes |
|---|---|---|
| ox_lib | Yes | Callbacks, notifications, dialogs, text UI |
| ox_target | Yes | Terminals and camera props |
| oxmysql | Yes | Database layer |
| qbx_core | Yes* | Framework bridge (jobs, grades, duty) |
| ox_inventory | Optional | Portable camera item (falls back to qb inventory) |
| screenshot-basic | Optional | Real snapshot capture, off by default |
| ps-dispatch / cd_dispatch | Optional | Bridge for BOLO hits |
* Only server/permissions.lua touches the framework. Swap the three bridge
functions at the top of that file to run on anything else.
qb-target is not used anywhere.
Installation
-
Drop the resource in.
resources/[qbx]/qbx_camera_network/ -
Import the database.
bash mysql -u <user> -p <database> < sql/install.sqlThe file is idempotent - it usesCREATE TABLE IF NOT EXISTSand guarded inserts, so re-running it will not duplicate anything. The example BOLOs and detections at the bottom exist so the tablet is not empty on first boot; delete that block if you do not want them.
Already running an earlier version? You do not have to do anything: the
resource creates any missing table itself at boot with
CREATE TABLE IF NOT EXISTS, which never alters or drops existing data. The
console tells you what it created. sql/update_1.1.sql is there if you would
rather apply it by hand, and Config.Database.autoCreateTables = false turns
the automatic behaviour off (the resource then refuses to start on a missing
table instead of running with one).
-
Start it after its dependencies.
cfg ensure oxmysql ensure ox_lib ensure ox_target ensure qbx_core ensure qbx_camera_network -
Set your jobs and ranks in
config.lua->Config.JobsandConfig.Permissions. The defaults assumepolice,bcsoandsaspwith grade namescadet / officer / sergeant / lieutenant / chief. -
(Optional) Add the portable camera item. - ox_inventory - add to
data/items.lua:lua ['portable_camera'] = { label = 'Portable ALPR Camera', weight = 1200, stack = false, close = true, description = 'Tripod mounted ALPR camera for temporary deployment', client = { event = 'otm_camera_network:useportable' } },and register the usable item server side (see below). - qb-inventory - the commentedINSERTat the bottom ofsql/install.sql.
To make the item usable, add this to any server file of your own:
lua
-- ox_inventory
exports.ox_inventory:RegisterUsableItem('portable_camera', function(source)
TriggerClientEvent('otm:portable:use', source)
end)
and client side:
lua
RegisterNetEvent('otm:portable:use', function()
ExecuteCommand('portablecamera')
end)
Or simply let officers use /portablecamera and set
Config.PortableCameras.requireItem = false.
- Verify. Join the server as a police character and run
/cameras. The console printsQBX Camera Network v1.0.0 started.on boot andCamera network online: N cameras.
Commands
| Command | Who | What |
|---|---|---|
/cameras |
Police (accessTablet) |
Opens the tablet |
/cameranetwork |
Police | Same as above |
/placecamera |
manageCameras |
Enter camera placement mode |
/portablecamera |
deployPortable |
Deploy a portable camera |
/cancelplacement |
Anyone placing | Abort placement |
/cameradebug |
debug |
Toggle the debug overlay |
/alprstats |
Console / ACE admin | Print network statistics |
/camerastatus <id> <status> |
Console / ACE admin | Force a camera online/offline/maintenance |
/camerareload |
Console / ACE admin | Rebuild the registry from config + database |
A keybind can be enabled with Config.UI.keybind = 'F7'.
HUD. The game HUD and minimap are hidden while a feed or the tablet is on screen - a surveillance feed showing the operator's own health bar and radar breaks the illusion.
Most QBX servers draw their HUD in NUI, which the game's own hide native cannot
touch, so each event in Config.UI.hudEvents is fired with false when a feed
or the tablet opens and true when it closes. jg-hud is wired up already -
it registers jg-hud:client:toggle-hud taking exactly that boolean, and no edit
to jg-hud is needed:
Config.UI.hudEvents = { 'jg-hud:client:toggle-hud' }
Firing the event rather than calling jg-hud's toggleHud export is deliberate:
TriggerEvent with no handler is a no-op, so nothing breaks if jg-hud is not
running. For a different HUD, add its own toggle event to the list, or have it
listen for otm:hud:visible, which is always fired with the same boolean.
Two details that matter when an external HUD is wired in:
- The resource stops touching
DisplayRadaritself and lets the HUD own it. jg-hud only shows the minimap on foot when the player has asked for it, and forcing the radar back on would override that preference. - The hide is re-fired every
Config.UI.hudReassertIntervalms (5s default) while a feed is up, because another resource can turn its HUD back on underneath you - jg-hud's own pause-menu watcher does exactly that.
Turn the whole behaviour off per surface with Config.CCTV.hideHud and
Config.UI.hideHud.
Tablet animation. While the tablet or a live feed is open the officer plays a
tablet-holding animation with a prop_cs_tablet in hand, so other players see
them working a device rather than staring into space. It is upper body only, so
they can still walk while reading, and it restarts itself if a ragdoll or another
task interrupts it. Everything is in Config.UI.emote - dictionary, animation,
prop model, attachment bone and offsets - because prop alignment varies between
ped models. Set enabled = false to turn it off, or prop = false to keep the
animation without the tablet model.
If Config.UI.item is set (e.g. 'police_tablet'), officers need that item to open
the tablet unless they are standing within 3.5 m of one of Config.Terminals -
the server checks the distance itself, the client cannot claim to be at a terminal.
Permissions
Permissions are per job, per action. A player passes if any of these is true:
- their grade name is listed for that action,
- a listed entry is a number and their grade level is greater or equal,
'*'is listed for that action,- they hold the ACE permission
otm.cameras.admin, orotm.cameras.<action>.
Config.Permissions = {
police = {
accessTablet = { 'cadet', 'officer', 'sergeant', 'lieutenant', 'chief' },
searchALPR = { 'officer', 'sergeant', 'lieutenant', 'chief' },
trackVehicle = { 'officer', 'sergeant', 'lieutenant', 'chief' },
viewLiveCameras = { 'officer', 'sergeant', 'lieutenant', 'chief' },
createBolo = { 'officer', 'sergeant', 'lieutenant', 'chief' },
editBolo = { 'sergeant', 'lieutenant', 'chief' },
deleteBolo = { 'lieutenant', 'chief' },
deployPortable = { 'sergeant', 'lieutenant', 'chief' },
manageCameras = { 'lieutenant', 'chief' },
deleteRecords = { 'chief' },
facialRecognition = { 'sergeant', 'lieutenant', 'chief' },
viewLogs = { 'lieutenant', 'chief' },
debug = { 'chief' },
},
}
ACE example (server.cfg):
add_ace group.admin otm.cameras.admin allow
Config.RequireOnDuty = true means off-duty officers cannot use anything.
Configuration guide
The whole of config.lua is documented inline. The values worth knowing:
| Setting | Default | What it does |
|---|---|---|
Config.ALPR.scanInterval |
500 |
Milliseconds between scan passes on a claimed camera |
Config.ALPR.duplicateCooldown |
15000 |
Same plate + same camera is not recorded again inside this window |
Config.ALPR.detectionDistance |
35.0 |
Default capture radius |
Config.ALPR.accuracy |
95 |
Base optical quality of an average camera |
Config.ALPR.includeNpcTraffic |
false |
false keeps the database to player vehicles only |
Config.ALPR.npcSampleRate |
12 |
If NPC traffic is on, only this % of NPC vehicles is recorded |
Config.Network.cellSize / cellRadius |
400 / 1 |
How much of the network is streamed to a client |
Config.Network.claimDistance |
120.0 |
How close a client must be to scan for a camera |
Config.Network.batchInterval |
2000 |
Detections are sent to the server in one batch on this interval |
Config.Database.writeInterval |
3000 |
Batched insert interval |
Config.Database.retentionDays |
30 |
Detections older than this are deleted |
Config.Search.maxResults |
200 |
Hard cap on rows any query can return |
Config.Search.maxWindowHours |
720 |
A search can never reach further back than this |
Config.Search.cooldown |
900 |
Minimum milliseconds between two searches by one officer |
Config.Search.ratePerMinute |
25 |
Searches allowed per officer per minute |
Config.Tracking.windows |
{1,6,24,168} |
Tracking windows offered in the tablet |
Config.UI.map.style |
'schematic' |
'schematic' or 'image' - see The map |
Config.Failure.randomFailureChance |
0.6 |
% chance per check that a camera drops offline |
Config.Debug |
false |
Debug overlay. Keep it false on a live server |
Adding cameras in config.lua
{
id = 'ALPR_001',
name = 'Strawberry Ave / Innocence Blvd',
type = 'alpr',
zone = 'Strawberry',
coords = vector3(291.53, -1810.42, 30.24),
rotation = vector3(-8.0, 0.0, 180.0),
heading = 180.0,
detectionRadius = 35.0,
captureDirections = { 'Northbound', 'Southbound' }, -- optional
enabled = true,
},
Only id, name, type and coords are required; everything else falls back to
Config.CameraTypes[type]. The shipped config includes 33 example cameras across
Los Santos and Blaine County - treat those coordinates as a starting point and adjust
them with the in-game placement tool.
Alerts
Each level (INFO, WARNING, HIGH, CRITICAL) configures its own sound,
notification, duration, blip behaviour and whether it forces the tablet open.
| Level | Fired when |
|---|---|
| INFO | Ordinary detection. Only feeds the tablet ticker, no notification |
| WARNING | Vehicle of interest: description-only BOLO, or a partial plate read that matched |
| HIGH | Confirmed BOLO plate match |
| CRITICAL | Stolen vehicle, or any BOLO with the stolen flag |
The map
The tablet ships with the Oulsen satellite render already installed and
calibrated (web/assets/satmap.webp, 2560 x 3840 WebP with transparency,
about 1.9 MB). Camera dots land on the right streets with no setup.
The world bounds were not guessed. They were solved from the map pack's own postal coordinate list by fitting the printed postal numbers to their real world positions, then verified against all 947 of them:
Config.UI.map = {
style = 'image',
image = 'assets/satmap.webp',
imageOpacity = 1.0,
minX = -4138.4, maxX = 4860.5,
minY = -5098.6, maxY = 8399.6,
allowCalibration = true,
}
The render is 0.9103 pixels per world unit and covers 8998 x 13498 world units.
Using a different render. Drop your file into web/assets/, point
Config.UI.map.image at it, then open the tablet, go to the Camera Map and press
Calibrate:
- Pick a step size (250 to get close, 10 to finish).
- Nudge the image with the arrow pad or your arrow keys until the camera dots sit
on the streets they belong to.
ALPR_008at Mission Row PD andCCTV_001at Legion Square are good reference points. - Stretch the width and height until dots at opposite ends of the map line up.
- Press Copy and paste the block into
Config.UIinconfig.lua.
Calibration saves to your own browser straight away so you can check your work;
pasting it into config.lua applies it for the whole department.
The vector fallback. Set Config.UI.map.style = 'schematic' and the tablet
draws its own basemap instead - coastline, the Alamo Sea, the urban footprint and
district labels, no image needed. It is also the automatic fallback if the image
is missing or fails to load, and the tablet says so when that happens.
Distribution note. The satellite render is a copy of the map pack you already
run on your server, embedded here so the tablet does not depend on that resource
being present. If you would rather not ship a second copy, delete
web/assets/satmap.webp and switch style back to 'schematic'.
Following a vehicle
Short answer: yes, and it is one click from any search result.
An ALPR search returns individual reads - one row per camera the vehicle passed.
Every row has a Track button, and the results header shows a summary of what
you are looking at (48 reads · 3 plates · 11 cameras · 20:41:22 to 21:07:55).
Pressing Track opens the Vehicle Track section for that plate and gives you:
- the vehicle description from the most confident read,
- a chronological timeline of every sighting - time, camera, location, direction of travel, speed and read quality,
- a red banner if the plate is on an active BOLO,
- Plot on map - draws the route across the Camera Map as a dashed line with numbered, timestamped nodes, oldest to newest,
- Mark on GPS - drops numbered blips in the world with a GPS route to the most recent sighting, so an officer can drive it,
- All reads - back to the full search, filtered to that plate.
The tracking window is selectable (1 hour, 6 hours, 24 hours, 7 days by default,
set in Config.Tracking.windows).
You can also reach it from a detection card, a BOLO, or an alert - anywhere a plate appears there is a Track action.
What it will not do. Tracking only ever shows camera sightings. If a vehicle took a route with no cameras on it, that leg simply does not exist in the record - the map draws a straight line between two known points, and that line is not a claim about the road taken. There is no live GPS, no interpolation and no gap filling. That is deliberate: it keeps the tool honest and keeps investigations built on evidence an officer can point at.
Live operations
Everything below happens inside the live feed. Nothing needs the mouse - the feed keeps full camera control while you work.
| Key | Action |
|---|---|
MOUSE |
Pan / tilt (within the camera's limits) |
SCROLL |
Zoom |
E |
Lock on to whatever is in the centre of frame |
X |
Release the lock |
F |
Run facial recognition on a locked person |
R |
Run a plate read on a locked vehicle |
SPACE |
Capture an evidence still |
T |
Write an operator note into the camera log |
TAB |
Follow the subject to the suggested next camera |
1 - 4 |
Recall a saved pan/tilt/zoom preset |
CTRL + 1 - 4 |
Save the current position as a preset (supervisors) |
Q |
Push this feed to another on-duty officer |
G |
Night mode |
← → |
Cycle to the previous / next camera |
BACKSPACE |
Disconnect |
The game HUD and minimap are hidden for the whole session and come back when you disconnect. Switching between cameras does not flicker them.
The feed also closes itself if the camera goes offline, if you move past
Config.CCTV.terminalLeash, or if you die while watching.
Locking on. Centre the camera on someone and press E. The system picks the
closest thing to the middle of frame that the camera actually has line of sight to,
draws a tracking box around it, and opens a panel describing what an operator could
genuinely make out:
- a person: sex, on foot or in a vehicle, stationary / walking / running / sprinting, distance from the lens, WEAPON VISIBLE if they are armed, and whether the face is covered
- a vehicle: colour and model, direction and speed, occupant count, distance, and how legible the plate is from here
The lock survives a camera change, so you can hand off and keep following.
Plate checks. R on a locked vehicle runs a read there and then instead of
waiting for the automatic scan. It uses the same accuracy model, so a plate at 80m
in fog still comes back partial. The result shows BOLO status immediately and the
read is stored as a normal detection flagged manual - it will appear in searches
and in that vehicle's travel history.
A manual read is a deliberate act by an operator, so it is not bound by the restrictions the automatic scanner works under. It reads any vehicle in frame - parked, empty, engine off, nobody near it - on CCTV cameras as well as plate readers, and ignores the camera's capture direction filter. The automatic scanner still only records moving, player driven traffic, which is what keeps the database to vehicles that matter.
Handoff. While a moving vehicle is locked, the server works out which camera
lies ahead along its heading and offers it: TAB Follow to CCTV_007 · 180m. One key
and you are on the next camera with the subject still locked. Search cone and
distance are set by Config.LiveOps.handoffDistance and handoffAngle.
Presets. Supervisors can store up to four pan/tilt/zoom positions per camera with
CTRL + a number, named as they go ("Rear entrance", "Forecourt"). Anyone with live
view access can recall them. Presets are stored on the camera and persist.
Evidence and notes. SPACE captures a still and returns a reference like
CCTV_003-1757298412, which goes in the audit log with whatever was locked on at the
time. T opens a note box - use it for the observations that make a case later.
Pushing a feed. Q lists on-duty officers. Pick one and they get a prompt to
open the same camera. They need live camera access in their own right; the server
checks that, not the sender.
Facial recognition
The camera never tells an officer who someone is.
Point the camera at a person, lock on, press F. After a processing delay the system
returns a ranked list of possible matches with a confidence score each. The true
subject is in that list only if they are in the records database, and at low image
quality they will not necessarily be at the top. It is a lead, not an identification,
and the panel says so.
What defeats it
- A covered face. Masks and full-face helmets return "Facial features obscured" and nothing else. This is what gives masks mechanical weight in your criminal RP.
- Not being in the records database. NPCs never match. Whether ordinary
citizens match is your choice - see
recordGateQuerybelow. - A bad image. Distance, night, weather, camera optics and which way the
subject is facing all feed the same confidence model the plate readers use.
Below
minConfidencethe system reports the image as unusable.
How the confidence maps to the result
| Confidence | What the officer gets |
|---|---|
Below minConfidence (45) |
"Image quality insufficient". No names at all |
45 to strongConfidence (82) |
Candidates returned, true subject mixed among plausible others |
Above strongConfidence (82) |
True subject clearly on top, decoys well below |
Who counts as "on record"
-- Resolves an identity. Receives a citizen id.
recordQuery = "SELECT `citizenid`, `charinfo` FROM `players` WHERE `citizenid` = ? LIMIT 1",
-- Optional second gate. Empty = anyone with a character can be matched, which
-- is the equivalent of searching DMV photos. Point it at your MDT and only
-- people with a police record can be matched at all.
recordGateQuery = '', -- e.g. "SELECT 1 FROM `mdt_profiles` WHERE `citizenid` = ? LIMIT 1"
Setting recordGateQuery to your MDT is the stricter, better-for-RP option: a clean
skin genuinely cannot be identified from a camera.
Mask detection cannot be faked by the officer. The watching client is never asked whether the subject is masked - it has an obvious incentive to lie. The server tries its own check first, then asks the subject's own client, and if neither answers it treats the face as covered. It fails closed on purpose.
Everything is audited. Every request lands in otm_recognition_requests with the
officer, the camera, the outcome and the candidate list they were shown - including
requests that found nothing. Supervisors can see who ran recognition on whom.
Requires the facialRecognition permission, which defaults to sergeant and above.
Camera sensors
Cameras raise alerts on their own, with nobody watching. They run on the same claim system as the plate readers, so a camera with no player nearby senses nothing - the same honest limitation the rest of the network has.
| Sensor | Fires when | Default level |
|---|---|---|
| Gunfire | A weapon is discharged within 70m of the camera | CRITICAL |
| Crowd | 5+ players on foot gather within 30m for 12 seconds | WARNING |
| Standing vehicle | An empty vehicle sits in view for 5 minutes | INFO |
Each has its own radius, threshold, sustain time and cooldown in Config.Sensors,
and each can be turned off independently. Alerts arrive through the same feed as
BOLO hits, so they blip, notify and log exactly like everything else.
Gunfire reports are validated server-side: the reporting client must hold the camera's claim, be near it, and reference a real ped who is actually holding a weapon.
Placing cameras in game
/placecamera(needsmanageCameras).- Pick the camera type in the dialog.
- Look where the camera should go - the preview follows your crosshair.
Arrow Left/Rightrotate,Arrow Up/Downtilt,Scrollheight,Shift + Scrollfine height. The panel shows the live facing as you turn it (Facing: Northbound (2deg)), and the purple line is where the camera will actually look.Eto place,Backspaceto cancel.- Name it, set the zone, capture radius and capture directions, then save.
The camera is written to otm_cameras, added to the live registry immediately and
broadcast to nearby clients. The server checks that you are within 45 m of the point
you claim to be placing at.
Cameras from config.lua can also be edited from the tablet - editing one writes a
database row that overrides the config entry from then on.
If the prop faces the wrong way
The prop_cctv_cam_* models are built with the lens pointing the opposite way to
the entity's forward axis, so every camera type carries a yawOffset that turns
the prop to match:
Config.CameraTypes.alpr.yawOffset = 180.0
heading is always the direction the camera looks - the live feed, the
capture direction filter, the handoff bearing and the placement line all use it
directly. yawOffset only rotates the visible model on top of that. If you swap
in a prop that faces the other way, set it to 0.0 for that type; the line and
the feed do not change.
Portable cameras
/portablecamera(needsdeployPortable).- Consumes
Config.PortableCameras.itemifrequireItemis true, and returns it on pickup. - Lifetime defaults to 120 minutes, after which the server removes it automatically.
- Limits: 2 per officer, 25 server wide, minimum 25 m apart.
- They join the network like any other camera and generate ALPR detections.
- Retrieve one by targeting it (ox_target -> Retrieve portable camera).
How detection actually works
client (nearest player to a camera)
| claims the camera from the server, one client per camera
| every 500 ms: GetGamePool('CVehicle') -> vehicles inside the capture radius
| filters: class, speed, direction, line of sight, cooldown, NPC policy
| computes read confidence from distance, night, weather, speed, angle, plate condition
| queues the detection
| every 2 s: sends the batch
v
server
| rate limit -> camera exists -> camera is capturing -> client holds the claim
| client is within 220 m of the camera
| netId resolves to a real vehicle entity that still exists
| the vehicle is genuinely inside the capture radius (server side coords)
| the plate is re-read server side and must match what the client claimed
| the model hash must match
| masked/partial reads must be derivable from the real plate
| duplicate suppression across every reporter
v
record -> BOLO match -> alert level -> batched insert (or immediate insert on a hit)
Realism rule: a detection only exists if a vehicle actually drove past a configured, enabled, online camera. Tracking shows those sightings and nothing else. There is no GPS, no magic pings and no gap filling.
Read accuracy model
Config.ALPR.accuracy is not a random chance to corrupt plates. The system never
replaces a plate with a different plate. Instead each read gets a confidence score:
confidence = camera quality
- distance penalty (further across the capture radius = worse)
- night penalty (unless the camera has night vision)
- weather penalty (fog, rain, snow, thunder ...)
- speed penalty (above ~50 mph)
- approach angle (side-on plates read worse than head-on)
- plate condition (deterministic per plate, so a car always reads the same)
- body damage
confidence >= plateReadThreshold(62): full read, plate stored exactly.- Between the thresholds: partial read. The characters the camera did resolve are
correct; the rest are stored as
*(e.g.4K*8*21). It is flaggedpartialand can only ever raise a WARNING, never a confirmed hit. confidence < plateFailThreshold(34): stored asUNREADABLE- the vehicle was seen, the plate was not.
Searching for a partial read works: * is translated to the SQL single-character
wildcard, so ABC123 still finds AB*123.
Database
Six tables, all created by sql/install.sql.
| Table | Purpose |
|---|---|
otm_cameras |
Cameras placed in game (config cameras are not stored) |
otm_alpr_detections |
Every plate read. The hot table |
otm_bolos |
BOLO records |
otm_camera_logs |
Audit log of every sensitive action |
otm_alpr_alerts |
Alert history for the dashboard |
otm_recognition_requests |
Every facial recognition request and its outcome |
Indexes on otm_alpr_detections:
idx_plate_time (plate, detected_at) -- plate history / tracking
idx_time (detected_at) -- retention + recent feeds
idx_camera_time (camera_id, detected_at) -- per camera statistics
idx_colour_time (colour_family, detected_at)
idx_model_time (model, detected_at)
idx_flags (flags(16), detected_at) -- hits only searches
Writes:
- Ordinary detections are buffered and flushed as one prepared batch every 3 s.
- Detections that produced an alert are inserted immediately so the alert can link to them.
- Logs are batched too.
- Retention deletes run hourly in bounded chunks (LIMIT 5000) so they never lock the table.
At roughly 200 reads/minute you are looking at ~290k rows/day; with the 30 day default
retention the table stays around 8-9M rows, which the indexes above handle comfortably.
Lower retentionDays, turn off includeNpcTraffic, or raise duplicateCooldown if
you want it smaller.
Exports
Server
-- Is a plate on an active BOLO or flagged stolen?
local wanted, info = exports.otm_camera_network:IsPlateWanted('ABC123')
-- info = { level = 'CRITICAL', reason = '...', id = 12, stolen = true }
-- Camera sightings for a plate
local history = exports.otm_camera_network:GetPlateHistory('ABC123', 24)
-- history.rows = { { cameraId, cameraName, coords, direction, timestamp, ... }, ... }
-- The camera network
local cameras = exports.otm_camera_network:GetCameras()
local camera = exports.otm_camera_network:GetCamera('ALPR_001')
exports.otm_camera_network:SetCameraStatus('ALPR_001', 'maintenance')
-- BOLOs
local bolos = exports.otm_camera_network:GetActiveBolos()
exports.otm_camera_network:AddBolo({
plate = 'ABC123',
model = 'Dominator',
colour = 'Black',
reason = 'Stolen during a carjacking',
priority = 'critical',
stolen = true,
expiryHours = 24,
creatorName = 'MDT',
})
-- Statistics
local stats = exports.otm_camera_network:GetNetworkStats()
Client
exports.otm_camera_network:IsTabletOpen()
exports.otm_camera_network:IsLiveFeedOpen()
exports.otm_camera_network:CloseLiveFeed()
exports.otm_camera_network:GetStreamedCameras()
exports.otm_camera_network:OpenTablet()
Events you can listen to
-- client
RegisterNetEvent('otm:alert', function(alert) end) -- ALPR alert for police
RegisterNetEvent('otm:alpr:stream', function(detection) end) -- live ticker (tablet open only)
Performance notes
What this resource deliberately does not do:
- No per-frame vehicle scanning. Scanning is on an interval and only when this client holds a claim.
- No duplicated work. The server hands each camera to exactly one nearby client.
- No full network replication. Clients receive the 3x3 grid cells around them.
- No per-detection database write. Writes are batched.
- No per-detection BOLO query. Active BOLOs live in memory.
- No unbounded queries. Every search is time-bounded, paginated and capped.
- No prop spam. Props exist only within
Config.Props.renderDistance(90 m default).
Cost profile with 300 cameras and 60 players: idle clients run one 4 s region loop and one 5-8 s claim loop; a client standing at a camera runs a 500 ms pass over the vehicle pool. Server side, a detection is a table build, a cache lookup and an append to a write queue.
Tuning for a large server:
Config.ALPR.scanInterval = 750 -- less frequent passes
Config.Network.batchInterval = 3000 -- fewer network events
Config.Database.writeInterval = 5000 -- fewer, larger inserts
Config.ALPR.duplicateCooldown = 30000 -- fewer rows per vehicle
Config.Props.renderDistance = 60.0 -- fewer objects
Security notes
- Nothing a client sends is trusted. See the flow above.
- Every tablet action goes through
OTM.Perms.Guard(source, action, rateLimitKey). - Rate limits per player per action are in
Config.Security.rateLimits. - Repeated limit violations create a strike; at the threshold the player is logged (or kicked, configurable).
- Camera creation, editing, deletion, BOLO changes, live views, searches and record
purges are all written to
otm_camera_logswith the officer identity. - Players cannot spoof detections: the reporting client must hold the server-issued claim, be near the camera, and reference a real networked vehicle whose server-side position and plate match the report.
- SQL is fully parameterised. The only interpolated values are integers the server itself has floored and clamped.
Troubleshooting
"MISSING DATABASE TABLES" or "COULD NOT CREATE" in the console
The resource tries to create missing tables itself at boot. If it says it could
not, the database user oxmysql connects with has no CREATE grant - either grant
it, or import sql/install.sql by hand.
"Table ... doesn't exist" from a query at runtime This should no longer be possible: the boot check now covers every table the resource queries, and creates what is missing. If you do see it, the resource started before the table existed - restart it.
The tablet says "You are not authorised"
Check Config.Jobs / Config.Permissions against the job name and grade name in your
qbx jobs config, and remember Config.RequireOnDuty.
No detections appear
- Is the camera enabled and status = 'online'?
- Are you driving faster than Config.ALPR.minSpeed (1.5 m/s)?
- Config.ALPR.includeNpcTraffic is false by default, so NPC traffic is ignored.
- Turn on /cameradebug: claimed cameras turn green and show their capture radius.
Live feed shows empty streets
The camera is far from the player. The resource forces streaming around the camera
(SetFocusPosAndVel + a load scene), but entities that are not networked near the
viewer can still be missing. Give it a second after connecting.
Cameras do not appear as props
Config.Props.enabled must be true and you must be within renderDistance. Props are
client-side only and are never networked.
Plates come back with * characters
That is a partial read - the camera could not resolve every character. Raise the camera
quality, reduce the capture radius, or lower Config.ALPR.plateReadThreshold.
File structure
otm_camera_network/
├── fxmanifest.lua
├── config.lua -- everything configurable, fully commented
├── README.md
│
├── shared/
│ └── utils.lua -- maths, plates, colours, confidence model
│
├── client/
│ ├── main.lua -- state, region streaming, alerts, track blips
│ ├── cameras.lua -- props, blips, proximity queries
│ ├── alpr.lua -- claims, scanning, batching
│ ├── cctv.lua -- live camera, pan/tilt/zoom, effects
│ ├── liveops.lua -- tagging, recognition, plate checks, handoff, presets
│ ├── sensors.lua -- gunfire, crowd and standing vehicle detection
│ ├── placement.lua -- placement mode
│ ├── interactions.lua -- ox_target terminals and camera props
│ ├── nui.lua -- tablet bridge
│ └── debug.lua -- debug overlay
│
├── server/
│ ├── database.lua -- batched writes, retention, schema check
│ ├── permissions.lua -- framework bridge, permissions, rate limits
│ ├── cameras.lua -- registry, streaming, claims, CRUD, portables
│ ├── bolos.lua -- BOLO cache, matching, CRUD
│ ├── alerts.lua -- alert routing and history
│ ├── alpr.lua -- report validation, manual checks, search, dashboard
│ ├── recognition.lua -- facial recognition candidate engine
│ ├── sensors.lua -- sensor validation and alerting
│ └── main.lua -- callbacks, commands, exports
│
├── sql/
│ ├── install.sql -- schema, indexes and example data
│ └── update_1.1.sql -- upgrade for servers already running 1.0
│
└── web/
├── index.html
├── style.css
└── app.js
Credits
Built for QBX. ox_lib, ox_target and oxmysql by Overextended. QBX / Qbox by the Qbox project.