223 lines
6.3 KiB
PHP
223 lines
6.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Ocr;
|
|
|
|
use App\Models\Photo;
|
|
use App\Services\PhotoPreview;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
class PhotoOcrPreparer
|
|
{
|
|
/** @var array<string, ?string> */
|
|
private static array $bins = [];
|
|
|
|
public function __construct(private readonly PhotoPreview $preview) {}
|
|
|
|
/**
|
|
* Write a resized JPEG for Tesseract. Caller must unlink the returned path.
|
|
*/
|
|
public function jpegPath(Photo $photo): ?string
|
|
{
|
|
$abs = Storage::disk('local')->path((string) $photo->path);
|
|
if (! is_file($abs)) {
|
|
return null;
|
|
}
|
|
|
|
$jpeg = $this->tempJpeg();
|
|
if ($jpeg === null) {
|
|
return null;
|
|
}
|
|
|
|
$head = (string) @file_get_contents($abs, false, null, 0, 64);
|
|
if ($this->preview->headLooksHeic($head) || $this->looksHeicPath($abs)) {
|
|
$payload = $this->preview->payload(
|
|
$abs,
|
|
(string) ($photo->device?->device_id ?: $photo->device_id),
|
|
(string) $photo->sha256,
|
|
);
|
|
$bytes = (string) ($payload['bytes'] ?? '');
|
|
unset($payload);
|
|
if ($bytes === '' || ! $this->isJpeg($bytes)) {
|
|
unset($bytes);
|
|
@unlink($jpeg);
|
|
|
|
return null;
|
|
}
|
|
file_put_contents($jpeg, $bytes);
|
|
unset($bytes);
|
|
} elseif (! @copy($abs, $jpeg)) {
|
|
@unlink($jpeg);
|
|
|
|
return null;
|
|
}
|
|
|
|
$this->resize($jpeg);
|
|
|
|
return is_file($jpeg) ? $jpeg : null;
|
|
}
|
|
|
|
private function resize(string $path): void
|
|
{
|
|
$max = (int) config('coruna.ocr.max_edge', 1280);
|
|
if ($max < 400) {
|
|
return;
|
|
}
|
|
$info = @getimagesize($path);
|
|
if (is_array($info) && (int) ($info[0] ?? 0) <= $max && (int) ($info[1] ?? 0) <= $max) {
|
|
return;
|
|
}
|
|
// Magick/sips first: GD truecolor of a 12MP iPhone shot is ~48MB and
|
|
// fatals the 128M queue worker before this method can fall through.
|
|
if ($this->resizeWithMagick($path, $max)) {
|
|
return;
|
|
}
|
|
if (PHP_OS_FAMILY === 'Darwin' && is_executable('/usr/bin/sips')) {
|
|
$result = Process::timeout(30)->run(['/usr/bin/sips', '-Z', (string) $max, $path]);
|
|
if ($result->successful()) {
|
|
return;
|
|
}
|
|
}
|
|
$this->resizeWithGd($path, $max);
|
|
}
|
|
|
|
private function resizeWithMagick(string $path, int $max): bool
|
|
{
|
|
foreach ($this->magickCandidates() as $bin) {
|
|
try {
|
|
$result = Process::timeout(30)->run([$bin, $path, '-resize', $max.'x'.$max.'>', $path]);
|
|
} catch (\Throwable) {
|
|
continue;
|
|
}
|
|
if ($result->successful() && is_file($path)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/** @return list<string> */
|
|
private function magickCandidates(): array
|
|
{
|
|
$bins = [];
|
|
foreach ([
|
|
'/usr/bin/magick',
|
|
'/usr/local/bin/magick',
|
|
'/usr/bin/convert',
|
|
'/usr/local/bin/convert',
|
|
$this->which('magick'),
|
|
$this->which('convert'),
|
|
] as $bin) {
|
|
if (is_string($bin) && $bin !== '' && ! in_array($bin, $bins, true)) {
|
|
$bins[] = $bin;
|
|
}
|
|
}
|
|
|
|
return $bins;
|
|
}
|
|
|
|
private function resizeWithGd(string $path, int $max): bool
|
|
{
|
|
if (! function_exists('imagecreatefromjpeg') || ! function_exists('imagejpeg')) {
|
|
return false;
|
|
}
|
|
$info = @getimagesize($path);
|
|
$width = (int) ($info[0] ?? 0);
|
|
$height = (int) ($info[1] ?? 0);
|
|
if ($width < 1 || $height < 1 || ! $this->gdFitsMemory($width, $height)) {
|
|
return false;
|
|
}
|
|
$src = @imagecreatefromjpeg($path);
|
|
if ($src === false) {
|
|
return false;
|
|
}
|
|
$width = imagesx($src);
|
|
$height = imagesy($src);
|
|
$edge = max($width, $height);
|
|
if ($edge <= $max) {
|
|
imagedestroy($src);
|
|
|
|
return true;
|
|
}
|
|
$scale = $max / $edge;
|
|
$newW = max(1, (int) round($width * $scale));
|
|
$newH = max(1, (int) round($height * $scale));
|
|
$dst = imagecreatetruecolor($newW, $newH);
|
|
if ($dst === false) {
|
|
imagedestroy($src);
|
|
|
|
return false;
|
|
}
|
|
imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $width, $height);
|
|
imagedestroy($src);
|
|
$ok = imagejpeg($dst, $path, 85);
|
|
imagedestroy($dst);
|
|
|
|
return $ok;
|
|
}
|
|
|
|
private function gdFitsMemory(int $width, int $height): bool
|
|
{
|
|
$limit = $this->phpMemoryBytes();
|
|
if ($limit <= 0) {
|
|
return true;
|
|
}
|
|
$need = ($width * $height * 4) + (16 * 1024 * 1024);
|
|
|
|
return (memory_get_usage(true) + $need) < $limit;
|
|
}
|
|
|
|
private function phpMemoryBytes(): int
|
|
{
|
|
$raw = strtolower(trim((string) ini_get('memory_limit')));
|
|
if ($raw === '' || $raw === '-1') {
|
|
return 0;
|
|
}
|
|
$n = (int) $raw;
|
|
|
|
return match (substr($raw, -1)) {
|
|
'g' => $n * 1024 * 1024 * 1024,
|
|
'm' => $n * 1024 * 1024,
|
|
'k' => $n * 1024,
|
|
default => (int) $raw,
|
|
};
|
|
}
|
|
|
|
private function tempJpeg(): ?string
|
|
{
|
|
$tmp = tempnam(sys_get_temp_dir(), 'ocr_');
|
|
if ($tmp === false) {
|
|
return null;
|
|
}
|
|
$jpeg = $tmp.'.jpg';
|
|
@unlink($tmp);
|
|
|
|
return $jpeg;
|
|
}
|
|
|
|
private function looksHeicPath(string $path): bool
|
|
{
|
|
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
|
|
|
return in_array($ext, ['heic', 'heif'], true);
|
|
}
|
|
|
|
private function isJpeg(string $bytes): bool
|
|
{
|
|
return strlen($bytes) >= 3 && substr($bytes, 0, 2) === "\xFF\xD8";
|
|
}
|
|
|
|
private function which(string $bin): ?string
|
|
{
|
|
if (array_key_exists($bin, self::$bins)) {
|
|
return self::$bins[$bin];
|
|
}
|
|
$result = Process::timeout(3)->run(['which', $bin]);
|
|
$path = trim($result->output());
|
|
self::$bins[$bin] = $result->successful() && $path !== '' ? $path : null;
|
|
|
|
return self::$bins[$bin];
|
|
}
|
|
}
|