Files
zyt/server/app/common/service/ClinicalImageTextService.php
T
2026-10-23 13:57:11 +08:00

226 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\service;
/** 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;
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' => []];
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;
}
return $validated;
} catch (\Throwable $error) {
return $fallback;
} finally {
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);
}
}
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;
}
}