624 lines
21 KiB
PHP
624 lines
21 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
require_once WD . '/modules/licenseplaterecognizer/licenseplaterecognizer_c.php';
|
|
|
|
use Exception;
|
|
use interfaces\licenseplaterecognizer_i;
|
|
use licenseplaterecognizer\helpers\licenseplaterecognizer_info;
|
|
use licenseplaterecognizer\licenseplaterecognizer_c;
|
|
|
|
class licenseplaterecognizer implements licenseplaterecognizer_i
|
|
{
|
|
private const DEFAULT_API_URL = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk';
|
|
private const PLATE_READER_CONFIG_JSON = '{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0}';
|
|
private const RESULT_CACHE_CONTEXT = '{"config":{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0},"regions":"dk,de,se,no"}';
|
|
private const PLATE_READER_REGIONS = 'dk,de,se,no';
|
|
private const DEFAULT_UPLOAD_FILE_NAME = 'license-plate.jpg';
|
|
private const RUNTIME_CONFIG_CACHE_TTL_SECONDS = 15;
|
|
private const RUNTIME_CONFIG_REDIS_CACHE_KEY = 'licenseplaterecognizer:runtime_config:v1';
|
|
private const RESULT_CACHE_TTL_SECONDS = 10;
|
|
private const RESULT_CACHE_REDIS_KEY_PREFIX = 'licenseplaterecognizer:result:v1:';
|
|
private const PLATE_READER_CONNECT_TIMEOUT_MS = 1000;
|
|
private const PLATE_READER_TOTAL_TIMEOUT_MS = 4500;
|
|
/**
|
|
* @var array<string, float>
|
|
*/
|
|
private array $last_timings = [];
|
|
|
|
/**
|
|
* The configuration of the module
|
|
* @var licenseplaterecognizer_c
|
|
*/
|
|
public licenseplaterecognizer_c $config;
|
|
/**
|
|
* API URL
|
|
* @var string
|
|
*/
|
|
private string $api_url;
|
|
|
|
/**
|
|
* @var array{enabled: bool, api_key: string}|null
|
|
*/
|
|
private ?array $runtime_config = null;
|
|
|
|
/**
|
|
* @var array{values: array{enabled: bool, api_key: string}, cached_at: float}|null
|
|
*/
|
|
private static ?array $runtime_config_cache = null;
|
|
|
|
public function __construct(bool $load_config = true, ?string $api_url = null)
|
|
{
|
|
$this->api_url = self::normalizeApiUrl($api_url ?? self::configuredApiUrl());
|
|
|
|
if ($load_config) {
|
|
$this->config = new licenseplaterecognizer_c();
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function requireModuleEnabled(): void
|
|
{
|
|
if (!$this->runtimeConfig()['enabled']) {
|
|
throw new Exception('licenseplaterecognizer module is not enabled.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
* @throws Exception If the module is not enabled or if there is an error in the API request
|
|
* @throws Exception If the API response cannot be parsed
|
|
*/
|
|
public function licenseplaterecognizer(string $base64_image): array
|
|
{
|
|
return $this->recognizePlate(
|
|
fn () => $this->buildPlateReaderPayload($base64_image),
|
|
fn () => $this->buildResultCacheKeyFromUploadString($base64_image)
|
|
);
|
|
}
|
|
|
|
public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array
|
|
{
|
|
return $this->recognizePlate(
|
|
fn () => $this->buildPlateReaderPayloadFromUpload(
|
|
$this->buildUploadValueFromBytes($image_data, $mime_type)
|
|
),
|
|
fn () => $this->buildResultCacheKeyFromBytes($image_data)
|
|
);
|
|
}
|
|
|
|
public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array
|
|
{
|
|
return $this->recognizePlate(
|
|
fn () => $this->buildPlateReaderPayloadFromUpload(
|
|
$this->buildUploadValueFromBytes($image_data, $mime_type)
|
|
)
|
|
);
|
|
}
|
|
|
|
public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array
|
|
{
|
|
return $this->recognizePlate(
|
|
fn () => $this->buildPlateReaderPayloadFromUpload(
|
|
$this->buildUploadValueFromFile($image_path, $mime_type)
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function recognizePlate(callable $payload_factory, ?callable $result_cache_key_factory = null): array
|
|
{
|
|
$started_at = microtime(true);
|
|
$this->last_timings = [];
|
|
$result_cache = null;
|
|
$result_cache_key = null;
|
|
|
|
try {
|
|
$config_started_at = microtime(true);
|
|
$runtime_config = $this->runtimeConfig();
|
|
if (!$runtime_config['enabled']) {
|
|
throw new Exception('licenseplaterecognizer module is not enabled.');
|
|
}
|
|
$api_key = $runtime_config['api_key'];
|
|
$this->last_timings['config'] = $this->elapsedMs($config_started_at);
|
|
|
|
if ($result_cache_key_factory !== null) {
|
|
$cache_started_at = microtime(true);
|
|
try {
|
|
$result_cache = $this->resultCacheStore();
|
|
if ($result_cache !== null) {
|
|
$result_cache_key = $result_cache_key_factory();
|
|
if ($result_cache_key !== null) {
|
|
$cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key);
|
|
if ($cached_result !== null) {
|
|
$this->last_timings['cache_hit'] = 1;
|
|
return $cached_result;
|
|
}
|
|
}
|
|
}
|
|
$this->last_timings['cache_miss'] = 1;
|
|
} finally {
|
|
$this->last_timings['cache'] = $this->elapsedMs($cache_started_at);
|
|
}
|
|
}
|
|
|
|
$payload_started_at = microtime(true);
|
|
$data = $payload_factory();
|
|
$this->last_timings['payload'] = $this->elapsedMs($payload_started_at);
|
|
|
|
$ch = curl_init($this->api_url . '/v1/plate-reader/');
|
|
if (!$ch instanceof \CurlHandle) {
|
|
throw new Exception('Error initializing API request.');
|
|
}
|
|
|
|
$curl_options = [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => $data,
|
|
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
|
|
CURLOPT_CONNECTTIMEOUT_MS => self::PLATE_READER_CONNECT_TIMEOUT_MS,
|
|
CURLOPT_TIMEOUT_MS => self::PLATE_READER_TOTAL_TIMEOUT_MS,
|
|
CURLOPT_NOSIGNAL => true,
|
|
CURLOPT_NOPROGRESS => false,
|
|
CURLOPT_XFERINFOFUNCTION => self::clientDisconnectAbortCallback(),
|
|
CURLOPT_HTTPHEADER => [
|
|
"Authorization: Token " . $api_key,
|
|
'Expect:',
|
|
],
|
|
];
|
|
if (defined('CURLOPT_TCP_NODELAY')) {
|
|
$curl_options[(int)constant('CURLOPT_TCP_NODELAY')] = true;
|
|
}
|
|
curl_setopt_array($ch, $curl_options);
|
|
|
|
// Submit the POST request and close cURL session handle
|
|
$upstream_started_at = microtime(true);
|
|
$result = curl_exec($ch);
|
|
$this->last_timings['upstream'] = $this->elapsedMs($upstream_started_at);
|
|
$this->recordCurlTimings($ch);
|
|
curl_close($ch);
|
|
|
|
// Print the response from the server
|
|
if ($result === false) {
|
|
throw new Exception('Error in API request.');
|
|
}
|
|
|
|
$parse_started_at = microtime(true);
|
|
$response_data = json_decode($result, true);
|
|
$this->last_timings['parse'] = $this->elapsedMs($parse_started_at);
|
|
$this->recordResponseTimings($response_data);
|
|
|
|
if (isset($response_data['results']) && count($response_data['results']) > 0) {
|
|
$recognized_result = [
|
|
'success' => true,
|
|
'license_plate_number' => $response_data['results'][0]['plate'] ?? null,
|
|
'confidence' => $response_data['results'][0]['score'] ?? null,
|
|
];
|
|
|
|
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
|
|
|
|
return $recognized_result;
|
|
}
|
|
|
|
$recognized_result = [
|
|
'success' => false,
|
|
'message' => 'No license plate detected.',
|
|
];
|
|
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
|
|
|
|
return $recognized_result;
|
|
} finally {
|
|
$this->last_timings['total'] = $this->elapsedMs($started_at);
|
|
}
|
|
}
|
|
|
|
private static function clientDisconnectAbortCallback(): callable
|
|
{
|
|
return static function (): int {
|
|
return connection_aborted() ? 1 : 0;
|
|
};
|
|
}
|
|
|
|
public function getLastTimings(): array
|
|
{
|
|
return $this->last_timings;
|
|
}
|
|
|
|
private function elapsedMs(float $started_at): float
|
|
{
|
|
return (microtime(true) - $started_at) * 1000;
|
|
}
|
|
|
|
private static function configuredApiUrl(): string
|
|
{
|
|
$configured = getenv('PLATE_RECOGNIZER_API_URL');
|
|
if ($configured === false || trim((string)$configured) === '') {
|
|
$configured = $_ENV['PLATE_RECOGNIZER_API_URL'] ?? $_SERVER['PLATE_RECOGNIZER_API_URL'] ?? self::DEFAULT_API_URL;
|
|
}
|
|
|
|
return (string)$configured;
|
|
}
|
|
|
|
public static function configuredApiBaseUrl(): string
|
|
{
|
|
return self::normalizeApiUrl(self::configuredApiUrl());
|
|
}
|
|
|
|
private static function normalizeApiUrl(string $api_url): string
|
|
{
|
|
$api_url = trim($api_url);
|
|
if ($api_url === '') {
|
|
return self::DEFAULT_API_URL;
|
|
}
|
|
|
|
return rtrim($api_url, '/');
|
|
}
|
|
|
|
private function recordCurlTimings(\CurlHandle $curl_handle): void
|
|
{
|
|
$mapping = [
|
|
CURLINFO_NAMELOOKUP_TIME => 'upstream_dns',
|
|
CURLINFO_CONNECT_TIME => 'upstream_connect',
|
|
CURLINFO_APPCONNECT_TIME => 'upstream_tls',
|
|
CURLINFO_PRETRANSFER_TIME => 'upstream_pretransfer',
|
|
CURLINFO_STARTTRANSFER_TIME => 'upstream_ttfb',
|
|
CURLINFO_TOTAL_TIME => 'upstream_total',
|
|
];
|
|
|
|
foreach ($mapping as $curl_info_option => $timing_key) {
|
|
$value = curl_getinfo($curl_handle, $curl_info_option);
|
|
if (!is_numeric($value)) {
|
|
continue;
|
|
}
|
|
|
|
$this->last_timings[$timing_key] = max(0, (float)$value * 1000);
|
|
}
|
|
}
|
|
|
|
private function recordResponseTimings(mixed $response_data): void
|
|
{
|
|
if (!is_array($response_data) || !isset($response_data['processing_time']) || !is_numeric($response_data['processing_time'])) {
|
|
return;
|
|
}
|
|
|
|
$this->last_timings['upstream_processing'] = max(0, (float)$response_data['processing_time']);
|
|
}
|
|
|
|
private function buildResultCacheKeyFromUploadString(string $base64_image): string
|
|
{
|
|
$base64_image = trim($base64_image);
|
|
if (preg_match('/^data:image\/[a-zA-Z0-9.+-]+;base64,(.*)$/s', $base64_image, $matches) === 1) {
|
|
$image_data = base64_decode((string)$matches[1], true);
|
|
if (is_string($image_data)) {
|
|
return $this->buildResultCacheKeyFromBytes($image_data);
|
|
}
|
|
}
|
|
|
|
return $this->buildResultCacheKeyFromBytes($base64_image);
|
|
}
|
|
|
|
private function buildResultCacheKeyFromBytes(string $image_data): string
|
|
{
|
|
$context = hash_init('sha256');
|
|
hash_update($context, $this->resultCacheContext());
|
|
hash_update($context, "\0");
|
|
hash_update($context, $image_data);
|
|
|
|
return self::RESULT_CACHE_REDIS_KEY_PREFIX . hash_final($context);
|
|
}
|
|
|
|
private function resultCacheContext(): string
|
|
{
|
|
return self::RESULT_CACHE_CONTEXT;
|
|
}
|
|
|
|
protected function resultCacheStore(): ?object
|
|
{
|
|
return $this->runtimeConfigCacheStore();
|
|
}
|
|
|
|
private function readRecognitionResultCache(?object $cache, ?string $key): ?array
|
|
{
|
|
if ($cache === null || $key === null || !method_exists($cache, 'get')) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$cached = $cache->get($key);
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
|
|
if (!is_string($cached) || trim($cached) === '') {
|
|
return null;
|
|
}
|
|
|
|
$decoded = json_decode($cached, true);
|
|
if (!is_array($decoded) || !array_key_exists('success', $decoded)) {
|
|
return null;
|
|
}
|
|
|
|
return $decoded;
|
|
}
|
|
|
|
private function writeRecognitionResultCache(?object $cache, ?string $key, array $result): void
|
|
{
|
|
if ($cache === null || $key === null || !method_exists($cache, 'setEx')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$encoded = json_encode($result, JSON_UNESCAPED_SLASHES);
|
|
if (is_string($encoded)) {
|
|
$cache->setEx($key, $encoded, self::RESULT_CACHE_TTL_SECONDS);
|
|
}
|
|
} catch (\Throwable) {
|
|
// Scanner result cache is best-effort; Plate Recognizer remains the source of truth.
|
|
}
|
|
}
|
|
|
|
protected function buildPlateReaderPayload(string $base64_image): array
|
|
{
|
|
return $this->buildPlateReaderPayloadFromUpload($this->buildUploadValue($base64_image));
|
|
}
|
|
|
|
protected function buildPlateReaderPayloadFromUpload(string|\CURLFile|\CURLStringFile $upload): array
|
|
{
|
|
return [
|
|
'upload' => $upload,
|
|
'config' => self::PLATE_READER_CONFIG_JSON,
|
|
'regions' => self::PLATE_READER_REGIONS,
|
|
];
|
|
}
|
|
|
|
private function buildUploadValue(string $base64_image): string|\CURLStringFile
|
|
{
|
|
$base64_image = trim($base64_image);
|
|
if (preg_match('/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.*)$/s', $base64_image, $matches) !== 1) {
|
|
return $base64_image;
|
|
}
|
|
|
|
$image_data = base64_decode((string)$matches[2], true);
|
|
if ($image_data === false || !class_exists(\CURLStringFile::class)) {
|
|
return (string)$matches[2];
|
|
}
|
|
|
|
return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, (string)$matches[1]);
|
|
}
|
|
|
|
private function buildUploadValueFromBytes(string $image_data, string $mime_type): string|\CURLStringFile
|
|
{
|
|
$mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg';
|
|
if (!str_starts_with($mime_type, 'image/')) {
|
|
$mime_type = 'image/jpeg';
|
|
}
|
|
|
|
if (!class_exists(\CURLStringFile::class)) {
|
|
return $image_data;
|
|
}
|
|
|
|
return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, $mime_type);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function buildUploadValueFromFile(string $image_path, string $mime_type): \CURLFile
|
|
{
|
|
$image_path = trim($image_path);
|
|
$mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg';
|
|
if (!str_starts_with($mime_type, 'image/')) {
|
|
$mime_type = 'image/jpeg';
|
|
}
|
|
|
|
if ($image_path === '' || !is_file($image_path) || !class_exists(\CURLFile::class)) {
|
|
throw new Exception('Image upload file is invalid.');
|
|
}
|
|
|
|
return new \CURLFile($image_path, $mime_type, self::DEFAULT_UPLOAD_FILE_NAME);
|
|
}
|
|
|
|
protected function runtimeConfig(): array
|
|
{
|
|
if ($this->runtime_config !== null) {
|
|
return $this->runtime_config;
|
|
}
|
|
|
|
if ($this->shouldUseSharedRuntimeConfigCache()) {
|
|
$cached_config = self::getSharedRuntimeConfigCache();
|
|
if ($cached_config !== null) {
|
|
$this->runtime_config = $cached_config;
|
|
|
|
return $this->runtime_config;
|
|
}
|
|
|
|
$cached_config = $this->readRuntimeConfigCacheStore();
|
|
if ($cached_config !== null) {
|
|
self::$runtime_config_cache = [
|
|
'values' => $cached_config,
|
|
'cached_at' => microtime(true),
|
|
];
|
|
$this->runtime_config = $cached_config;
|
|
|
|
return $this->runtime_config;
|
|
}
|
|
}
|
|
|
|
$values = $this->readRuntimeModuleConfig();
|
|
|
|
$this->runtime_config = [
|
|
'enabled' => $this->parseModuleConfigBool($values['enabled'] ?? false),
|
|
'api_key' => (string)($values['api_key'] ?? ''),
|
|
];
|
|
|
|
if ($this->shouldUseSharedRuntimeConfigCache()) {
|
|
self::$runtime_config_cache = [
|
|
'values' => $this->runtime_config,
|
|
'cached_at' => microtime(true),
|
|
];
|
|
$this->writeRuntimeConfigCacheStore($this->runtime_config);
|
|
}
|
|
|
|
return $this->runtime_config;
|
|
}
|
|
|
|
protected function shouldUseSharedRuntimeConfigCache(): bool
|
|
{
|
|
return static::class === self::class;
|
|
}
|
|
|
|
private static function getSharedRuntimeConfigCache(): ?array
|
|
{
|
|
if (self::$runtime_config_cache === null) {
|
|
return null;
|
|
}
|
|
|
|
$cache_age_seconds = microtime(true) - self::$runtime_config_cache['cached_at'];
|
|
if ($cache_age_seconds > self::RUNTIME_CONFIG_CACHE_TTL_SECONDS) {
|
|
self::$runtime_config_cache = null;
|
|
|
|
return null;
|
|
}
|
|
|
|
return self::$runtime_config_cache['values'];
|
|
}
|
|
|
|
protected function runtimeConfigCacheStore(): ?object
|
|
{
|
|
return defined('redis') ? constant('redis') : null;
|
|
}
|
|
|
|
private function readRuntimeConfigCacheStore(): ?array
|
|
{
|
|
$cache = $this->runtimeConfigCacheStore();
|
|
if ($cache === null || !method_exists($cache, 'get')) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$cached = $cache->get(self::RUNTIME_CONFIG_REDIS_CACHE_KEY);
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
|
|
if (!is_string($cached) || trim($cached) === '') {
|
|
return null;
|
|
}
|
|
|
|
$decoded = json_decode($cached, true);
|
|
if (!is_array($decoded)) {
|
|
return null;
|
|
}
|
|
|
|
if (!array_key_exists('enabled', $decoded) || !array_key_exists('api_key', $decoded)) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'enabled' => $this->parseModuleConfigBool($decoded['enabled']),
|
|
'api_key' => (string)$decoded['api_key'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{enabled: bool, api_key: string} $config
|
|
*/
|
|
private function writeRuntimeConfigCacheStore(array $config): void
|
|
{
|
|
$cache = $this->runtimeConfigCacheStore();
|
|
if ($cache === null || !method_exists($cache, 'setEx')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$encoded = json_encode($config, JSON_UNESCAPED_SLASHES);
|
|
if (is_string($encoded)) {
|
|
$cache->setEx(self::RUNTIME_CONFIG_REDIS_CACHE_KEY, $encoded, self::RUNTIME_CONFIG_CACHE_TTL_SECONDS);
|
|
}
|
|
} catch (\Throwable) {
|
|
// Scanner config cache is best-effort; DB remains the source of truth.
|
|
}
|
|
}
|
|
|
|
private function parseModuleConfigBool(mixed $value): bool
|
|
{
|
|
if (is_bool($value)) {
|
|
return $value;
|
|
}
|
|
|
|
if (is_numeric($value)) {
|
|
return (int)$value === 1;
|
|
}
|
|
|
|
return strtolower(trim((string)$value)) === 'true';
|
|
}
|
|
|
|
protected function readRuntimeModuleConfig(): array
|
|
{
|
|
global $db;
|
|
|
|
if ($db instanceof db) {
|
|
$module = $db->escape_string('licenseplaterecognizer');
|
|
$result = $db->query("SELECT variable, value FROM module_config WHERE module = '$module' AND variable IN ('enabled', 'api_key')");
|
|
$values = [];
|
|
|
|
if ($result instanceof \mysqli_result) {
|
|
while ($row = $result->fetch_assoc()) {
|
|
$variable = (string)($row['variable'] ?? '');
|
|
if ($variable !== '') {
|
|
$values[$variable] = (string)($row['value'] ?? '');
|
|
}
|
|
}
|
|
}
|
|
|
|
return $values;
|
|
}
|
|
|
|
if (!isset($this->config)) {
|
|
$this->config = new licenseplaterecognizer_c();
|
|
}
|
|
|
|
return [
|
|
'enabled' => (string)$this->config->enabled->getVariableValue(),
|
|
'api_key' => (string)$this->config->api_key->getVariableValue(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
* @throws Exception If the module is not enabled or if there is an error in the API request
|
|
* @throws Exception If the API response cannot be parsed
|
|
*/
|
|
public function get_usage(): licenseplaterecognizer_info
|
|
{
|
|
$this->requireModuleEnabled();
|
|
$api_key = $this->runtimeConfig()['api_key'];
|
|
$curl = curl_init();
|
|
curl_setopt_array($curl, array(
|
|
CURLOPT_URL => $this->api_url . '/info/',
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_ENCODING => '',
|
|
CURLOPT_MAXREDIRS => 10,
|
|
CURLOPT_TIMEOUT => 0,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
|
|
CURLOPT_CUSTOMREQUEST => 'GET',
|
|
CURLOPT_HTTPHEADER => [
|
|
'Authorization: Token ' . $api_key,
|
|
],
|
|
));
|
|
$response = curl_exec($curl);
|
|
curl_close($curl);
|
|
$response_data = json_decode($response, true);
|
|
if ($response_data === null) {
|
|
throw new Exception('Error parsing API response.');
|
|
}
|
|
return new licenseplaterecognizer_info($response_data);
|
|
}
|
|
}
|