257 lines
16 KiB
PHP
257 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service;
|
|
|
|
require_once __DIR__ . '/ClinicalImageTextCache.php';
|
|
|
|
/** Optional local OCR evidence. It never replaces or decides the visual AI request. */
|
|
class ClinicalImageTextService
|
|
{
|
|
private const OUTPUT_LIMIT = 524288;
|
|
private const STDERR_LIMIT = 65536;
|
|
private const FILE_LIMIT = 20971520;
|
|
private const PIXEL_LIMIT = 40000000;
|
|
|
|
/** @var array<int,resource> Extremely rare OS termination failures are never passed to blocking proc_close. */
|
|
private static array $unreaped = [];
|
|
|
|
public static function read(array $images, string $allowedRoot, float $deadline, ?callable $shouldAbort, array $config): array
|
|
{
|
|
if (empty($config['enabled'])) return ['status' => 'disabled', 'images' => []];
|
|
$indices = self::indices($images);
|
|
if ($indices === null) return self::failure([], 'invalid_request');
|
|
$fallback = self::failure($indices, 'unavailable');
|
|
$handles = []; $paths = []; $directory = ''; $process = null; $closed = false; $reuse = null;
|
|
try {
|
|
self::reap();
|
|
if ($shouldAbort !== null && $shouldAbort()) return self::failure($indices, 'cancelled');
|
|
$timeout = (float) ($config['timeout_seconds'] ?? 10);
|
|
if (!is_finite($deadline) || !is_finite($timeout) || $timeout <= 0) return self::failure($indices, 'invalid_config');
|
|
$end = min($deadline, microtime(true) + min(60.0, $timeout));
|
|
if ($end <= microtime(true)) return self::failure($indices, 'deadline');
|
|
$threads = $config['threads'] ?? 1;
|
|
$detection = $config['detection_limit'] ?? 640;
|
|
$textLimit = $config['max_text_bytes'] ?? 131072;
|
|
if (!in_array($threads, [1, 2], true) || !in_array($detection, [640, 736], true)
|
|
|| !is_int($textLimit) || $textLimit < 1 || $textLimit > 262144) return self::failure($indices, 'invalid_config');
|
|
if (!self::localPath($allowedRoot) || !self::localPath((string) ($config['python'] ?? ''))) return self::failure($indices, 'invalid_source');
|
|
$python = realpath((string) ($config['python'] ?? ''));
|
|
$script = realpath(dirname(__DIR__, 3) . '/scripts/clinical_image_ocr.py');
|
|
$root = realpath($allowedRoot);
|
|
if ($root === false || !is_dir($root)) return self::failure($indices, 'invalid_source');
|
|
if ($python === false || !is_file($python) || $script === false
|
|
|| (PHP_OS_FAMILY !== 'Windows' && !is_executable($python))) return self::failure($indices, 'runtime_unavailable');
|
|
if (!function_exists('proc_open')) return self::failure($indices, 'runtime_unavailable');
|
|
$sources = [];
|
|
foreach ($images as $image) {
|
|
$value = $image['path'] ?? null;
|
|
if (!is_string($value) || !self::localPath($value)) return self::failure($indices, 'invalid_source');
|
|
$path = realpath($value);
|
|
if ($path === false || !self::inside($path, $root) || !is_file($path)) return self::failure($indices, 'invalid_source');
|
|
$size = filesize($path);
|
|
$geometry = @getimagesize($path);
|
|
if ($size === false || $size < 1 || $size > self::FILE_LIMIT || $geometry === false
|
|
|| $geometry[0] < 1 || $geometry[1] < 1 || $geometry[0] > intdiv(self::PIXEL_LIMIT, $geometry[1])) {
|
|
return self::failure($indices, 'image_limits');
|
|
}
|
|
$sources[] = ['index' => $image['index'], 'path' => $path];
|
|
}
|
|
if ($sources === []) return ['status' => 'completed', 'images' => []];
|
|
$reuse = ClinicalImageTextCache::acquire($root, $sources, $config, $python, $script, $end, $shouldAbort);
|
|
if (isset($config['_cache_scope']) && $shouldAbort !== null && $shouldAbort()) return self::failure($indices, 'cancelled');
|
|
if ($reuse !== null) {
|
|
$candidate = $reuse->cached($sources, $end, $shouldAbort);
|
|
$cached = self::validateOutput($candidate, $indices, $textLimit);
|
|
if ($shouldAbort !== null && $shouldAbort()) return self::failure($indices, 'cancelled');
|
|
if (microtime(true) >= $end) return self::failure($indices, 'deadline');
|
|
if ($cached !== null && array_column($candidate['images'], 'index') === $indices
|
|
&& ($cached['status'] === 'completed' || ($cached['reason'] ?? '') === 'unreadable_sources')) return $cached;
|
|
}
|
|
if (microtime(true) >= $end) return self::failure($indices, 'deadline');
|
|
$directory = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'clinical-ocr-' . bin2hex(random_bytes(16));
|
|
if (!@mkdir($directory, 0700)) return self::failure($indices, 'runtime_unavailable');
|
|
foreach (['input', 'output', 'error'] as $name) {
|
|
$path = $directory . DIRECTORY_SEPARATOR . $name;
|
|
$handle = @fopen($path, 'x+b');
|
|
if ($handle === false) return self::failure($indices, 'runtime_unavailable');
|
|
@chmod($path, 0600);
|
|
$paths[] = $path; $handles[] = $handle;
|
|
}
|
|
$request = json_encode(['allowed_root' => $root, 'images' => $sources,
|
|
'deadline_ms' => max(0, (int) floor(($end - microtime(true)) * 1000)),
|
|
'threads' => $threads, 'detection_limit' => $detection, 'max_text_bytes' => $textLimit], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
|
if (strlen($request) > 1048576 || fwrite($handles[0], $request) !== strlen($request)) return self::failure($indices, 'invalid_request');
|
|
fflush($handles[0]); rewind($handles[0]);
|
|
$environment = ['PYTHONNOUSERSITE' => '1', 'PYTHONDONTWRITEBYTECODE' => '1', 'PYTHONIOENCODING' => 'utf-8'];
|
|
foreach (['SystemRoot', 'WINDIR', 'PATH', 'TEMP', 'TMP'] as $key) {
|
|
$value = getenv($key);
|
|
if (is_string($value)) $environment[$key] = $value;
|
|
}
|
|
foreach (['OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', 'NUMEXPR_NUM_THREADS'] as $key) $environment[$key] = (string) $threads;
|
|
$process = @proc_open([$python, $script], [0 => $handles[0], 1 => $handles[1], 2 => $handles[2]],
|
|
$pipes, null, $environment, ['bypass_shell' => true, 'suppress_errors' => true]);
|
|
if (!is_resource($process)) return self::failure($indices, 'spawn_failed');
|
|
$forced = ''; $exitCode = -1;
|
|
while (true) {
|
|
$state = proc_get_status($process);
|
|
if (self::size($handles[1]) > self::OUTPUT_LIMIT || self::size($handles[2]) > self::STDERR_LIMIT) $forced = 'output_limit';
|
|
elseif ($shouldAbort !== null && $shouldAbort()) $forced = 'cancelled';
|
|
elseif (microtime(true) >= $end) $forced = 'deadline';
|
|
if (!$state['running']) { $exitCode = (int) $state['exitcode']; break; }
|
|
if ($forced !== '') break;
|
|
usleep(50000);
|
|
}
|
|
if ($forced !== '') {
|
|
$closed = self::terminate($process);
|
|
if (!$closed) { self::$unreaped[] = $process; $process = null; return self::failure($indices, 'termination_failed'); }
|
|
} else {
|
|
// This call only reaps a child already observed stopped; never wait on a live native inference.
|
|
@proc_close($process); $closed = true;
|
|
}
|
|
if ($forced === 'output_limit') return self::failure($indices, 'output_limit');
|
|
if ($forced === '' && $exitCode !== 0) return self::failure($indices, 'process_failed');
|
|
if (self::size($handles[1]) > self::OUTPUT_LIMIT) return self::failure($indices, 'output_limit');
|
|
rewind($handles[1]);
|
|
$raw = stream_get_contents($handles[1], self::OUTPUT_LIMIT + 1);
|
|
try { $data = is_string($raw) ? json_decode($raw, true, 64, JSON_THROW_ON_ERROR) : null; }
|
|
catch (\JsonException $error) { return self::failure($indices, $forced !== '' ? $forced : 'invalid_output'); }
|
|
$validated = self::validateOutput($data, $indices, $textLimit);
|
|
if ($validated === null) return self::failure($indices, $forced !== '' ? $forced : 'invalid_output');
|
|
if ($forced !== '') {
|
|
$validated['status'] = count(array_filter($validated['images'], static fn (array $row): bool => $row['status'] === 'processed')) > 0 ? 'partial' : 'unavailable';
|
|
$validated['reason'] = $forced;
|
|
}
|
|
if ($forced === '' && self::cacheableRecognition($data, $indices)) $reuse?->remember($validated, $sources, $end, $shouldAbort);
|
|
return $validated;
|
|
} catch (\Throwable $error) {
|
|
return $fallback;
|
|
} finally {
|
|
$reuse?->close();
|
|
if (is_resource($process) && !$closed && !self::terminate($process)) self::$unreaped[] = $process;
|
|
foreach ($handles as $handle) if (is_resource($handle)) fclose($handle);
|
|
// Windows may defer inherited handle release briefly after termination.
|
|
$cleanupEnd = microtime(true) + 0.25;
|
|
do {
|
|
foreach ($paths as $path) @unlink($path);
|
|
if ($directory === '' || @rmdir($directory) || !is_dir($directory)) break;
|
|
usleep(10000);
|
|
} while (microtime(true) < $cleanupEnd);
|
|
}
|
|
}
|
|
|
|
private static function indices(array $images): ?array
|
|
{
|
|
if (count($images) > 256 || !array_is_list($images)) return null;
|
|
$result = [];
|
|
foreach ($images as $image) {
|
|
if (!is_array($image) || !is_int($image['index'] ?? null) || $image['index'] < 1 || $image['index'] > 1000000000
|
|
|| isset($result[$image['index']])) return null;
|
|
$result[$image['index']] = true;
|
|
}
|
|
return array_keys($result);
|
|
}
|
|
|
|
private static function inside(string $path, string $root): bool
|
|
{
|
|
$prefix = rtrim($root, '/\\') . DIRECTORY_SEPARATOR;
|
|
return PHP_OS_FAMILY === 'Windows' ? str_starts_with(strtolower($path), strtolower($prefix)) : str_starts_with($path, $prefix);
|
|
}
|
|
|
|
private static function localPath(string $path): bool
|
|
{
|
|
return $path !== '' && !str_contains($path, "\0") && !str_contains($path, '://')
|
|
&& !str_starts_with($path, '//') && !str_starts_with($path, '\\\\')
|
|
&& preg_match('~^(?:[A-Za-z]:[\\\\/]|/)~', $path) === 1;
|
|
}
|
|
|
|
private static function failure(array $indices, string $reason): array
|
|
{
|
|
return ['status' => 'unavailable', 'reason' => $reason,
|
|
'images' => array_map(static fn (int $index): array => ['index' => $index, 'status' => 'unreadable', 'lines' => []], $indices)];
|
|
}
|
|
|
|
private static function size($handle): int { return (int) ((fstat($handle) ?: [])['size'] ?? 0); }
|
|
|
|
private static function terminate($process): bool
|
|
{
|
|
@proc_terminate($process, 9);
|
|
$stop = microtime(true) + 0.5;
|
|
do {
|
|
$state = @proc_get_status($process);
|
|
if (is_array($state) && !$state['running']) { @proc_close($process); return true; }
|
|
usleep(10000);
|
|
} while (microtime(true) < $stop);
|
|
return false;
|
|
}
|
|
|
|
private static function reap(): void
|
|
{
|
|
foreach (self::$unreaped as $key => $process) {
|
|
$state = @proc_get_status($process);
|
|
if (is_array($state) && !$state['running']) { @proc_close($process); unset(self::$unreaped[$key]); }
|
|
else @proc_terminate($process, 9);
|
|
}
|
|
}
|
|
|
|
/** The worker's aggregate partial/unavailable status also covers a normal image with no text.
|
|
* Check raw per-row terminal reasons before validation discards them; incomplete/error rows
|
|
* must never become reusable just because the subprocess itself exited successfully.
|
|
*/
|
|
private static function cacheableRecognition(array $data, array $indices): bool
|
|
{
|
|
if (array_column($data['images'], 'index') !== $indices) return false;
|
|
$processed = 0;
|
|
foreach ($data['images'] as $row) {
|
|
if ($row['status'] === 'processed' && !isset($row['reason'])) { $processed++; continue; }
|
|
if ($row['status'] !== 'unreadable' || ($row['reason'] ?? '') !== 'no_text') return false;
|
|
}
|
|
$status = $processed === count($indices) ? 'completed' : ($processed > 0 ? 'partial' : 'unavailable');
|
|
return $data['status'] === $status && ($status === 'completed'
|
|
? !isset($data['reason']) : ($data['reason'] ?? '') === 'unreadable_sources');
|
|
}
|
|
|
|
private static function validateOutput($data, array $indices, int $textLimit): ?array
|
|
{
|
|
if (!is_array($data) || !in_array($data['status'] ?? '', ['completed', 'partial', 'unavailable'], true)
|
|
|| !is_array($data['images'] ?? null) || !array_is_list($data['images'])) return null;
|
|
$byIndex = []; $used = 0;
|
|
foreach ($data['images'] as $row) {
|
|
$index = $row['index'] ?? null;
|
|
if (!is_int($index) || !in_array($index, $indices, true) || isset($byIndex[$index])
|
|
|| !in_array($row['status'] ?? '', ['processed', 'unreadable'], true)
|
|
|| !is_array($row['lines'] ?? null) || !array_is_list($row['lines']) || count($row['lines']) > 1024) return null;
|
|
$lines = [];
|
|
foreach ($row['lines'] as $line) {
|
|
$text = $line['text'] ?? null; $confidence = $line['confidence'] ?? null; $box = $line['box'] ?? null;
|
|
if (!is_string($text) || trim($text) === '' || preg_match('//u', $text) !== 1 || preg_match('/[\x00-\x08\x0B-\x1F]/', $text)
|
|
|| strlen($text) > 8192 || (!is_float($confidence) && !is_int($confidence)) || !is_finite((float) $confidence)
|
|
|| $confidence < 0 || $confidence > 1 || !is_array($box) || !array_is_list($box) || count($box) !== 4) return null;
|
|
foreach ($box as $point) {
|
|
if (!is_array($point) || !array_is_list($point) || count($point) !== 2) return null;
|
|
foreach ($point as $coordinate) if ((!is_int($coordinate) && !is_float($coordinate))
|
|
|| !is_finite((float) $coordinate) || abs($coordinate) > 1000000) return null;
|
|
}
|
|
$used += strlen($text);
|
|
if ($used > $textLimit) return null;
|
|
$lines[] = ['text' => $text, 'confidence' => (float) $confidence, 'box' => $box];
|
|
}
|
|
if (($row['status'] === 'unreadable' && $lines !== []) || ($row['status'] === 'processed' && $lines === [])) return null;
|
|
$byIndex[$index] = ['index' => $index, 'status' => $row['status'], 'lines' => $lines];
|
|
}
|
|
// Missing checkpoint rows are explicit unreadable sources; never infer success from overall status.
|
|
$rows = [];
|
|
foreach ($indices as $index) $rows[] = $byIndex[$index] ?? ['index' => $index, 'status' => 'unreadable', 'lines' => []];
|
|
$processed = count(array_filter($rows, static fn (array $row): bool => $row['status'] === 'processed'));
|
|
$status = $processed === count($rows) && $data['status'] === 'completed' ? 'completed' : ($processed > 0 ? 'partial' : 'unavailable');
|
|
$result = ['status' => $status, 'images' => $rows];
|
|
if ($status !== 'completed') {
|
|
$reason = $data['reason'] ?? '';
|
|
$result['reason'] = in_array($reason, ['invalid_request', 'invalid_source', 'dependency_unavailable',
|
|
'model_unavailable', 'deadline', 'unreadable_sources', 'not_processed'], true) ? $reason : 'unreadable_sources';
|
|
}
|
|
return $result;
|
|
}
|
|
}
|