This commit is contained in:
Your Name
2026-10-30 13:41:35 +08:00
parent 28ba9b97f2
commit e2fc30e1f7
69 changed files with 8702 additions and 252 deletions
@@ -4,6 +4,8 @@ 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
{
@@ -21,7 +23,7 @@ class ClinicalImageTextService
$indices = self::indices($images);
if ($indices === null) return self::failure([], 'invalid_request');
$fallback = self::failure($indices, 'unavailable');
$handles = []; $paths = []; $directory = ''; $process = null; $closed = false;
$handles = []; $paths = []; $directory = ''; $process = null; $closed = false; $reuse = null;
try {
self::reap();
if ($shouldAbort !== null && $shouldAbort()) return self::failure($indices, 'cancelled');
@@ -57,6 +59,16 @@ class ClinicalImageTextService
$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');
@@ -111,10 +123,12 @@ class ClinicalImageTextService
$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.
@@ -181,6 +195,23 @@ class ClinicalImageTextService
}
}
/** 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)