FIELD MANUAL // Police tools

QBX Camera Network

Documentation from the supplied script archive. Check your included resource files for the version you install.

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

  1. Features
  2. Dependencies
  3. Installation
  4. Commands
  5. Permissions
  6. Configuration guide
  7. The map
  8. Following a vehicle
  9. Live operations
  10. Facial recognition
  11. Camera sensors
  12. Placing cameras in game
  13. Portable cameras
  14. How detection actually works
  15. Read accuracy model
  16. Database
  17. Exports
  18. Performance notes
  19. Security notes
  20. Troubleshooting
  21. 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

  1. Drop the resource in. resources/[qbx]/qbx_camera_network/

  2. Import the database. bash mysql -u <user> -p <database> < sql/install.sql The file is idempotent - it uses CREATE TABLE IF NOT EXISTS and 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).

  1. Start it after its dependencies. cfg ensure oxmysql ensure ox_lib ensure ox_target ensure qbx_core ensure qbx_camera_network

  2. Set your jobs and ranks in config.lua -> Config.Jobs and Config.Permissions. The defaults assume police, bcso and sasp with grade names cadet / officer / sergeant / lieutenant / chief.

  3. (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 commented INSERT at the bottom of sql/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.

  1. Verify. Join the server as a police character and run /cameras. The console prints QBX Camera Network v1.0.0 started. on boot and Camera 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 DisplayRadar itself 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.hudReassertInterval ms (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, or otm.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:

  1. Pick a step size (250 to get close, 10 to finish).
  2. Nudge the image with the arrow pad or your arrow keys until the camera dots sit on the streets they belong to. ALPR_008 at Mission Row PD and CCTV_001 at Legion Square are good reference points.
  3. Stretch the width and height until dots at opposite ends of the map line up.
  4. Press Copy and paste the block into Config.UI in config.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 recordGateQuery below.
  • 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 minConfidence the 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

  1. /placecamera (needs manageCameras).
  2. Pick the camera type in the dialog.
  3. Look where the camera should go - the preview follows your crosshair.
  4. Arrow Left/Right rotate, Arrow Up/Down tilt, Scroll height, Shift + Scroll fine 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.
  5. E to place, Backspace to cancel.
  6. 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 (needs deployPortable).
  • Consumes Config.PortableCameras.item if requireItem is 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 flagged partial and can only ever raise a WARNING, never a confirmed hit.
  • confidence < plateFailThreshold (34): stored as UNREADABLE - 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_logs with 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.