This is my rule for performing licence plate recognition and saving them into an SQLite database as well as keeping the snapshot images for 30 days.
Yes there are other ready-made systems out there (e.g. Plate-Minder) that does it better. This is just to demonstrate what’s possible with jruby scripting along with its ecosystem of available libraries.
# frozen_string_literal: true
require "personal"
require "json"
require "active_support/json"
require "net/http"
gemfile do
source "https://rubygems.org"
gem "image_voodoo"
gem "jdbc-sqlite3"
gem "sequel"
end
PLATE_PATH = OpenHAB::Core.config_folder / "scripts/plates"
PLATE_FILE = PLATE_PATH.parent / "plates.txt"
PLATE_DB = PLATE_PATH.parent / "plates.db"
OPENALPR_URI = URI("http://192.168.1.10:5000/detect")
# Add more cameras here
CAMERA_IDS = { Street_Camera => 0 }.freeze
DB = Sequel.jdbc("sqlite:#{PLATE_DB}")
DB.create_table? :captures do
primary_key :capture_id
DateTime :timestamp, default: Sequel::CURRENT_TIMESTAMP, index: true
Integer :camera_id, default: 0, index: true
String :plate, size: 10, index: true
String :plates # All the plates in the snapshots
String :snapshot_file
end
DB.create_table? :plates do
String :plate, size: 10, primary_key: true
String :notes
end
# This table is to manually correct some common mistakes of familiar plates, e.g. 338ZZZ -> 388ZZZ
DB.create_table? :plate_corrections do
String :raw_plate, size: 10, primary_key: true
String :plate, size: 10
end
PLATES = DB[:plates]
CAPTURES = DB[:captures]
PLATE_CORRECTIONS = DB[:plate_corrections]
DB.alter_table(:captures) { add_column :plates, String } unless CAPTURES.columns.include? :plates
#
# Holds a snapshot image with timestamp
#
class Snapshot
attr_reader :image, :timestamp, :source
@@camera_mutex = Mutex.new
# Initialize with an image object
def initialize(image, source)
@timestamp = Time.now
@image = image
@source = source
end
# take a snapshot from the given camera
def self.capture(camera)
url = camera.metadata["snapshot_url"].value
# logger.info "Capture url: #{url}"
image = @@camera_mutex.synchronize { Net::HTTP.get(URI(url)) }
Snapshot.new(image, camera)
end
# if necessary, calls lpr to recognize the plate
def plate
@plate ||= lpr(@image) || ""
end
# Return true if we have a valid plate
def plate?
!plate.empty?
end
def to_s
"#{timestamp} #{@plate}"
end
end
#
# Submit the given image to openalpr-http-wrapper service and return the JSON data
#
# @param [Binary] image the binary jpg image data to process
#
# @return [Hash] the returned json data as a Ruby hash, or nil if unsuccessful
#
def openalpr(image)
uri = OPENALPR_URI
req = Net::HTTP::Post.new(uri)
req.set_form([["upload", image, { filename: "image.jpg" }]], "multipart/form-data")
res = Net::HTTP.start(uri.hostname, uri.port, read_timeout: 2000) { |http| http.request(req) }
JSON.parse(res.body) if res.is_a? Net::HTTPSuccess
end
#
# Crop the given image
#
# @param [Image] image the binary image data
# @param [Array] rect rectangle to crop, an array with 4 elements: left, top, right, bottom
#
# @return [Image] The cropped image
#
def crop_image(image, rect)
ImageVoodoo.with_bytes(image).with_crop(*rect).bytes("jpg")
end
#
# Perform the LPR with the given image and
# return the plate with the highest confidence
#
# This will also crop the bottom of the image to remove
# The timestamp that's baked in by the camera
#
# @param [Binary] image the binary jpg image data to process
#
# @return [String] The recognised plate or nil
#
def lpr(image)
retries = 0
begin
# crop the timestamp on the lower part of the image
image = crop_image(image, [0, 0, 1920, 1000])
result = openalpr(image)
result&.dig("results")&.sort_by { |res| res["confidence"] }&.last&.dig("plate")
rescue => e
retry if (retries += 1) < 3
logger.error "1 #{e.message}"
nil
end
end
# { camera_id => array of Snapshot }
@snapshots = Hash.new { |hash, key| hash[key] = [] }
@snapshot_mutex = Mutex.new
@processing_queue = []
@processing_mutex = Mutex.new
#
# Save the given snapshot
# Append the plate to a text file
#
# @param [Snapshot] snapshot The snapshot object to save
#
def save_snapshot(best, snapshots)
time_str = best.timestamp.strftime("%Y-%m-%d %k:%M:%S.%L")
lplate = best.plate
filename = "#{time_str}-#{lplate}.jpg"
other_plates = snapshots.map(&:plate).grep_v(lplate)
logger.info { "Saving recognised plate: #{time_str}: #{lplate} - #{other_plates}" }
File.write(PLATE_FILE, "#{time_str} #{lplate} - #{other_plates}\n", mode: "a") unless lplate.empty?
File.write(PLATE_PATH / filename, best.image)
CAPTURES.insert timestamp: best.timestamp,
plate: best.plate,
plates: other_plates.join(","),
snapshot_file: filename,
camera_id: CAMERA_IDS[best.source]
end
#
# Given an array of snapshots objects
# Apply LPR and return the longest plate number
#
# @param [Array] snapshots An array of snapshot objects
#
# @return [Snapshot] One snapshot class containing the recognised plate number
#
def best_snapshot(snapshots)
snapshots.group_by(&:plate).max_by { |_plate, snaps| snaps.length }&.pop&.first
end
#
# Process everything in the processing queue and empty it
#
def process_queue
snapshots = nil
@processing_mutex.synchronize do
snapshots = @processing_queue
@processing_queue = []
end
snapshots = snapshots.select(&:plate?) # throw away snapshots without recognised plates
best = best_snapshot(snapshots)
save_snapshot(best, snapshots) if best
end
#
# Add a set of snapshots to the processing queue
# And start a re-settable timer to process the queue shortly
# This will process multiple triggers of the snapshots
# to remove duplicates of the same car triggering multiple times
#
# @param [Array] snaps An array of Snapshot objects
#
def add_snapshots(snaps)
@processing_mutex.synchronize { @processing_queue.concat snaps }
after(3.seconds, id: :process_queue) { process_queue }
end
#
# Get multiple snapshots 50ms apart and save them into
# the processing queue
#
# @param [GenericItem] camera The Camera item (Semantic Equipment)
# @param [Integer] count how many snapshots to take
# @param [Duration] interval the interval of the snapshots
# @param [Block] The block to be invoked once all the snapshots have been taken, passing the snapshots array
#
#
def initiate_lpr(camera, num_snapshots: 3, interval: 50.ms)
after(0.ms) do |timer|
next if timer.cancelled?
snaps = nil
@snapshot_mutex.synchronize do
last_snapshot = @snapshots[camera].length >= num_snapshots - 1
timer.reschedule(interval) unless last_snapshot
logger.debug do
"Getting snapshot: #{@snapshots[camera].length + 1} #{Time.now.strftime("%Y-%m-%d %k:%M:%S.%L")}"
end
@snapshots[camera] << Snapshot.capture(camera)
snaps = @snapshots.delete(camera) if last_snapshot
rescue => e
logger.error "2 #{e.message}"
timer.cancel
end
add_snapshots(snaps) if snaps
end
end
#
# Check whether a vehicle is detected
# The logic here highly depends on the type of camera in use and
# what data it provides. This particular implementation is for Dahua cameras
#
# @param [Hash] data The JSON data from ipcameras binding
#
# @return [Boolean] true if the data indicates that a vehicle may be detected
#
def vehicle_detected?(data)
return false unless data
data["Action"] == "Start" || data["RegionName"] || data.dig("Object", "ObjectType") == "Vehicle"
end
rule "LPR: Capture" do
updated Street_Camera_EventData
run do |event|
if logger.debug? && event.respond_to?(:state)
logger.debug("Street Camera: #{Street_Camera_LastMotionType.state} #{Street_Camera_EventData.state}")
end
json = event.state.to_s
data = JSON.parse(json)
camera = event.item.equipment
next unless vehicle_detected?(data)
initiate_lpr(camera)
end
end
#
# Return the file age in days
#
# @param [String] filename
#
# @return [Float] Number of days since the file was created
#
def file_age(filename)
(Time.now - File.ctime(filename)) / 86_400
end
rule "LPR: Clean up old snapshots" do
every :day, at: "3am"
# on_load
run do
count = Dir.glob(PLATE_PATH / "*.jpg").select { |f| file_age(f) > 30 }.map { |f| File.delete(f) }.count
logger.info("Deleted #{count} old LPR snapshot images ") if count.positive?
end
end
# uncomment to manually trigger it for testing
# initiate_lpr(Street_Camera)