Implement creator studio and upload updates
This commit is contained in:
347
app/Services/Images/ArtworkSquareThumbnailBackfillService.php
Normal file
347
app/Services/Images/ArtworkSquareThumbnailBackfillService.php
Normal file
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Images;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Repositories\Uploads\ArtworkFileRepository;
|
||||
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||
use App\Services\ThumbnailService;
|
||||
use App\Services\Uploads\UploadDerivativesService;
|
||||
use App\Services\Uploads\UploadStorageService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use RuntimeException;
|
||||
|
||||
final class ArtworkSquareThumbnailBackfillService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UploadDerivativesService $derivatives,
|
||||
private readonly UploadStorageService $storage,
|
||||
private readonly ArtworkFileRepository $artworkFiles,
|
||||
private readonly ArtworkCdnPurgeService $cdnPurge,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function ensureSquareThumbnail(Artwork $artwork, bool $force = false, bool $dryRun = false): array
|
||||
{
|
||||
$hash = strtolower((string) ($artwork->hash ?? ''));
|
||||
if ($hash === '') {
|
||||
throw new RuntimeException('Artwork hash is required to generate a square thumbnail.');
|
||||
}
|
||||
|
||||
$existing = DB::table('artwork_files')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('variant', 'sq')
|
||||
->first(['path']);
|
||||
|
||||
if ($existing !== null && ! $force) {
|
||||
return [
|
||||
'status' => 'skipped',
|
||||
'reason' => 'already_exists',
|
||||
'artwork_id' => $artwork->id,
|
||||
'path' => (string) ($existing->path ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$resolved = $this->resolveBestSource($artwork);
|
||||
if ($dryRun) {
|
||||
return [
|
||||
'status' => 'dry_run',
|
||||
'artwork_id' => $artwork->id,
|
||||
'source_variant' => $resolved['variant'],
|
||||
'source_path' => $resolved['source_path'],
|
||||
'object_path' => $this->storage->objectPathForVariant('sq', $hash, $hash . '.webp'),
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$asset = $this->derivatives->generateSquareDerivative($resolved['source_path'], $hash, [
|
||||
'context' => ['artwork' => $artwork],
|
||||
]);
|
||||
|
||||
$this->artworkFiles->upsert($artwork->id, 'sq', $asset['path'], $asset['mime'], $asset['size']);
|
||||
|
||||
$this->cdnPurge->purgeArtworkObjectPaths([$asset['path']], [
|
||||
'artwork_id' => $artwork->id,
|
||||
'reason' => 'square_thumbnail_regenerated',
|
||||
]);
|
||||
|
||||
if (! is_string($artwork->thumb_ext) || trim($artwork->thumb_ext) === '') {
|
||||
$artwork->forceFill(['thumb_ext' => 'webp'])->saveQuietly();
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'generated',
|
||||
'artwork_id' => $artwork->id,
|
||||
'path' => $asset['path'],
|
||||
'source_variant' => $resolved['variant'],
|
||||
'crop_mode' => $asset['result']?->cropMode,
|
||||
];
|
||||
} finally {
|
||||
if (($resolved['cleanup'] ?? false) === true) {
|
||||
File::delete($resolved['source_path']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{variant: string, source_path: string, cleanup: bool}
|
||||
*/
|
||||
private function resolveBestSource(Artwork $artwork): array
|
||||
{
|
||||
$hash = strtolower((string) ($artwork->hash ?? ''));
|
||||
$files = DB::table('artwork_files')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->pluck('path', 'variant')
|
||||
->all();
|
||||
|
||||
$variants = ['orig_image', 'orig', 'xl', 'lg', 'md', 'sm', 'xs'];
|
||||
|
||||
foreach ($variants as $variant) {
|
||||
$path = $files[$variant] ?? null;
|
||||
if (! is_string($path) || trim($path) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($variant === 'orig_image' || $variant === 'orig') {
|
||||
$filename = basename($path);
|
||||
$localPath = $this->storage->localOriginalPath($hash, $filename);
|
||||
if (is_file($localPath)) {
|
||||
return [
|
||||
'variant' => $variant,
|
||||
'source_path' => $localPath,
|
||||
'cleanup' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$temporary = $this->downloadToTempFile($path, pathinfo($path, PATHINFO_EXTENSION) ?: 'webp');
|
||||
if ($temporary !== null) {
|
||||
return [
|
||||
'variant' => $variant,
|
||||
'source_path' => $temporary,
|
||||
'cleanup' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$directSource = $this->resolveArtworkFilePathSource($artwork);
|
||||
if ($directSource !== null) {
|
||||
return $directSource;
|
||||
}
|
||||
|
||||
$canonicalDerivativeSource = $this->resolveCanonicalDerivativeSource($artwork);
|
||||
if ($canonicalDerivativeSource !== null) {
|
||||
return $canonicalDerivativeSource;
|
||||
}
|
||||
|
||||
throw new RuntimeException(sprintf('No usable source image was found for artwork %d.', (int) $artwork->id));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{variant: string, source_path: string, cleanup: bool}|null
|
||||
*/
|
||||
private function resolveArtworkFilePathSource(Artwork $artwork): ?array
|
||||
{
|
||||
$relativePath = trim((string) ($artwork->file_path ?? ''), '/');
|
||||
if ($relativePath === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->localFilePathCandidates($relativePath) as $candidate) {
|
||||
if (is_file($candidate)) {
|
||||
return [
|
||||
'variant' => 'file_path',
|
||||
'source_path' => $candidate,
|
||||
'cleanup' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$downloaded = $this->downloadUrlToTempFile($this->cdnUrlForPath($relativePath), pathinfo($relativePath, PATHINFO_EXTENSION));
|
||||
|
||||
if ($downloaded === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'variant' => 'file_path',
|
||||
'source_path' => $downloaded,
|
||||
'cleanup' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{variant: string, source_path: string, cleanup: bool}|null
|
||||
*/
|
||||
private function resolveCanonicalDerivativeSource(Artwork $artwork): ?array
|
||||
{
|
||||
$hash = strtolower((string) ($artwork->hash ?? ''));
|
||||
$thumbExt = strtolower(ltrim((string) ($artwork->thumb_ext ?? ''), '.'));
|
||||
|
||||
if ($hash === '' || $thumbExt === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['xl', 'lg', 'md', 'sm', 'xs'] as $variant) {
|
||||
$url = ThumbnailService::fromHash($hash, $thumbExt, $variant);
|
||||
if (! is_string($url) || $url === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$downloaded = $this->downloadUrlToTempFile($url, $thumbExt);
|
||||
if ($downloaded === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return [
|
||||
'variant' => $variant,
|
||||
'source_path' => $downloaded,
|
||||
'cleanup' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function localFilePathCandidates(string $relativePath): array
|
||||
{
|
||||
$normalizedPath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relativePath);
|
||||
|
||||
return array_values(array_unique([
|
||||
$normalizedPath,
|
||||
base_path($normalizedPath),
|
||||
public_path($normalizedPath),
|
||||
storage_path('app/public' . DIRECTORY_SEPARATOR . $normalizedPath),
|
||||
storage_path('app/private' . DIRECTORY_SEPARATOR . $normalizedPath),
|
||||
]));
|
||||
}
|
||||
|
||||
private function cdnUrlForPath(string $relativePath): string
|
||||
{
|
||||
return rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/') . '/' . ltrim($relativePath, '/');
|
||||
}
|
||||
|
||||
private function downloadUrlToTempFile(string $url, string $extension = ''): ?string
|
||||
{
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'timeout' => 30,
|
||||
'ignore_errors' => true,
|
||||
'header' => implode("\r\n", [
|
||||
'User-Agent: Skinbase Nova square-thumb backfill',
|
||||
'Accept: image/*,*/*;q=0.8',
|
||||
'Accept-Encoding: identity',
|
||||
'Connection: close',
|
||||
]) . "\r\n",
|
||||
],
|
||||
'ssl' => [
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$contents = @file_get_contents($url, false, $context);
|
||||
$headers = $http_response_header ?? [];
|
||||
|
||||
if (! is_string($contents) || $contents === '' || ! $this->isSuccessfulHttpResponse($url, $headers)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_string($contents) || $contents === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$resolvedExtension = trim($extension) !== ''
|
||||
? trim($extension)
|
||||
: $this->extensionFromContentType($this->contentTypeFromHeaders($headers));
|
||||
|
||||
return $this->writeTemporaryFile($contents, $resolvedExtension);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $headers
|
||||
*/
|
||||
private function isSuccessfulHttpResponse(string $url, array $headers): bool
|
||||
{
|
||||
if ($headers === [] && parse_url($url, PHP_URL_SCHEME) === 'file') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$statusLine = $headers[0] ?? '';
|
||||
if (! is_string($statusLine) || ! preg_match('/\s(\d{3})\s/', $statusLine, $matches)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$statusCode = (int) ($matches[1] ?? 0);
|
||||
|
||||
return $statusCode >= 200 && $statusCode < 300;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $headers
|
||||
*/
|
||||
private function contentTypeFromHeaders(array $headers): string
|
||||
{
|
||||
foreach ($headers as $header) {
|
||||
if (! is_string($header) || stripos($header, 'Content-Type:') !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return trim(substr($header, strlen('Content-Type:')));
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function writeTemporaryFile(string $contents, string $extension = ''): string
|
||||
{
|
||||
$temp = tempnam(sys_get_temp_dir(), 'sq-thumb-');
|
||||
if ($temp === false) {
|
||||
throw new RuntimeException('Unable to allocate a temporary file for square thumbnail generation.');
|
||||
}
|
||||
|
||||
$normalizedExtension = trim((string) $extension);
|
||||
$path = $normalizedExtension !== '' ? $temp . '.' . $normalizedExtension : $temp;
|
||||
|
||||
if ($normalizedExtension !== '') {
|
||||
rename($temp, $path);
|
||||
}
|
||||
|
||||
File::put($path, $contents);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function extensionFromContentType(string $contentType): string
|
||||
{
|
||||
$normalized = strtolower(trim(strtok($contentType, ';') ?: ''));
|
||||
|
||||
return match ($normalized) {
|
||||
'image/jpeg', 'image/jpg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
'image/gif' => 'gif',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function downloadToTempFile(string $objectPath, string $extension): ?string
|
||||
{
|
||||
$contents = $this->storage->readObject($objectPath);
|
||||
if (! is_string($contents) || $contents === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->writeTemporaryFile($contents, $extension);
|
||||
}
|
||||
}
|
||||
30
app/Services/Images/Detectors/ChainedSubjectDetector.php
Normal file
30
app/Services/Images/Detectors/ChainedSubjectDetector.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Images\Detectors;
|
||||
|
||||
use App\Contracts\Images\SubjectDetectorInterface;
|
||||
use App\Data\Images\SubjectDetectionResultData;
|
||||
|
||||
final class ChainedSubjectDetector implements SubjectDetectorInterface
|
||||
{
|
||||
/**
|
||||
* @param iterable<int, SubjectDetectorInterface> $detectors
|
||||
*/
|
||||
public function __construct(private readonly iterable $detectors)
|
||||
{
|
||||
}
|
||||
|
||||
public function detect(string $sourcePath, int $sourceWidth, int $sourceHeight, array $context = []): ?SubjectDetectionResultData
|
||||
{
|
||||
foreach ($this->detectors as $detector) {
|
||||
$result = $detector->detect($sourcePath, $sourceWidth, $sourceHeight, $context);
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
409
app/Services/Images/Detectors/HeuristicSubjectDetector.php
Normal file
409
app/Services/Images/Detectors/HeuristicSubjectDetector.php
Normal file
@@ -0,0 +1,409 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Images\Detectors;
|
||||
|
||||
use App\Contracts\Images\SubjectDetectorInterface;
|
||||
use App\Data\Images\CropBoxData;
|
||||
use App\Data\Images\SubjectDetectionResultData;
|
||||
|
||||
final class HeuristicSubjectDetector implements SubjectDetectorInterface
|
||||
{
|
||||
public function detect(string $sourcePath, int $sourceWidth, int $sourceHeight, array $context = []): ?SubjectDetectionResultData
|
||||
{
|
||||
if (! function_exists('imagecreatefromstring')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$binary = @file_get_contents($sourcePath);
|
||||
if (! is_string($binary) || $binary === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$source = @imagecreatefromstring($binary);
|
||||
if ($source === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$sampleMax = max(24, (int) config('uploads.square_thumbnails.saliency.sample_max_dimension', 96));
|
||||
$longest = max(1, max($sourceWidth, $sourceHeight));
|
||||
$scale = min(1.0, $sampleMax / $longest);
|
||||
$sampleWidth = max(8, (int) round($sourceWidth * $scale));
|
||||
$sampleHeight = max(8, (int) round($sourceHeight * $scale));
|
||||
|
||||
$sample = imagecreatetruecolor($sampleWidth, $sampleHeight);
|
||||
if ($sample === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
imagecopyresampled($sample, $source, 0, 0, 0, 0, $sampleWidth, $sampleHeight, $sourceWidth, $sourceHeight);
|
||||
$gray = $this->grayscaleMatrix($sample, $sampleWidth, $sampleHeight);
|
||||
$rarity = $this->colorRarityMatrix($sample, $sampleWidth, $sampleHeight);
|
||||
$vegetation = $this->vegetationMaskMatrix($sample, $sampleWidth, $sampleHeight);
|
||||
} finally {
|
||||
imagedestroy($sample);
|
||||
}
|
||||
|
||||
$energy = $this->energyMatrix($gray, $sampleWidth, $sampleHeight);
|
||||
$saliency = $this->combineSaliency($energy, $rarity, $sampleWidth, $sampleHeight);
|
||||
$prefix = $this->prefixMatrix($saliency, $sampleWidth, $sampleHeight);
|
||||
$vegetationPrefix = $this->prefixMatrix($vegetation, $sampleWidth, $sampleHeight);
|
||||
$totalEnergy = $prefix[$sampleHeight][$sampleWidth] ?? 0.0;
|
||||
|
||||
if ($totalEnergy < (float) config('uploads.square_thumbnails.saliency.min_total_energy', 2400.0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidate = $this->bestCandidate($prefix, $vegetationPrefix, $sampleWidth, $sampleHeight, $totalEnergy);
|
||||
$rareSubjectCandidate = $this->rareSubjectCandidate($rarity, $vegetation, $sampleWidth, $sampleHeight);
|
||||
|
||||
if ($rareSubjectCandidate !== null && ($candidate === null || $rareSubjectCandidate['score'] > ($candidate['score'] * 0.72))) {
|
||||
$candidate = $rareSubjectCandidate;
|
||||
}
|
||||
|
||||
if ($candidate === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$scaleX = $sourceWidth / max(1, $sampleWidth);
|
||||
$scaleY = $sourceHeight / max(1, $sampleHeight);
|
||||
$sideScale = max($scaleX, $scaleY);
|
||||
|
||||
$cropBox = new CropBoxData(
|
||||
x: (int) floor($candidate['x'] * $scaleX),
|
||||
y: (int) floor($candidate['y'] * $scaleY),
|
||||
width: max(1, (int) round($candidate['side'] * $sideScale)),
|
||||
height: max(1, (int) round($candidate['side'] * $sideScale)),
|
||||
);
|
||||
|
||||
$averageDensity = $totalEnergy / max(1, $sampleWidth * $sampleHeight);
|
||||
$confidence = min(1.0, max(0.15, ($candidate['density'] / max(1.0, $averageDensity)) / 4.0));
|
||||
|
||||
return new SubjectDetectionResultData(
|
||||
cropBox: $cropBox->clampToImage($sourceWidth, $sourceHeight),
|
||||
strategy: 'saliency',
|
||||
reason: 'heuristic_saliency',
|
||||
confidence: $confidence,
|
||||
meta: [
|
||||
'sample_width' => $sampleWidth,
|
||||
'sample_height' => $sampleHeight,
|
||||
'score' => $candidate['score'],
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
imagedestroy($source);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<int, int>>
|
||||
*/
|
||||
private function grayscaleMatrix($sample, int $width, int $height): array
|
||||
{
|
||||
$gray = [];
|
||||
|
||||
for ($y = 0; $y < $height; $y++) {
|
||||
$gray[$y] = [];
|
||||
for ($x = 0; $x < $width; $x++) {
|
||||
$rgb = imagecolorat($sample, $x, $y);
|
||||
$r = ($rgb >> 16) & 0xFF;
|
||||
$g = ($rgb >> 8) & 0xFF;
|
||||
$b = $rgb & 0xFF;
|
||||
$gray[$y][$x] = (int) round($r * 0.299 + $g * 0.587 + $b * 0.114);
|
||||
}
|
||||
}
|
||||
|
||||
return $gray;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<int, int>> $gray
|
||||
* @return array<int, array<int, float>>
|
||||
*/
|
||||
private function energyMatrix(array $gray, int $width, int $height): array
|
||||
{
|
||||
$energy = [];
|
||||
|
||||
for ($y = 0; $y < $height; $y++) {
|
||||
$energy[$y] = [];
|
||||
for ($x = 0; $x < $width; $x++) {
|
||||
$center = $gray[$y][$x] ?? 0;
|
||||
$right = $gray[$y][$x + 1] ?? $center;
|
||||
$down = $gray[$y + 1][$x] ?? $center;
|
||||
$diag = $gray[$y + 1][$x + 1] ?? $center;
|
||||
|
||||
$energy[$y][$x] = abs($center - $right)
|
||||
+ abs($center - $down)
|
||||
+ (abs($center - $diag) * 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
return $energy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map that highlights globally uncommon colors, which helps distinguish
|
||||
* a main subject from repetitive foliage or sky textures.
|
||||
*
|
||||
* @return array<int, array<int, float>>
|
||||
*/
|
||||
private function colorRarityMatrix($sample, int $width, int $height): array
|
||||
{
|
||||
$counts = [];
|
||||
$pixels = [];
|
||||
$totalPixels = max(1, $width * $height);
|
||||
|
||||
for ($y = 0; $y < $height; $y++) {
|
||||
$pixels[$y] = [];
|
||||
|
||||
for ($x = 0; $x < $width; $x++) {
|
||||
$rgb = imagecolorat($sample, $x, $y);
|
||||
$r = ($rgb >> 16) & 0xFF;
|
||||
$g = ($rgb >> 8) & 0xFF;
|
||||
$b = $rgb & 0xFF;
|
||||
|
||||
$bucket = (($r >> 5) << 6) | (($g >> 5) << 3) | ($b >> 5);
|
||||
$counts[$bucket] = ($counts[$bucket] ?? 0) + 1;
|
||||
$pixels[$y][$x] = [$r, $g, $b, $bucket];
|
||||
}
|
||||
}
|
||||
|
||||
$rarity = [];
|
||||
|
||||
for ($y = 0; $y < $height; $y++) {
|
||||
$rarity[$y] = [];
|
||||
|
||||
for ($x = 0; $x < $width; $x++) {
|
||||
[$r, $g, $b, $bucket] = $pixels[$y][$x];
|
||||
$bucketCount = max(1, (int) ($counts[$bucket] ?? 1));
|
||||
$baseRarity = log(($totalPixels + 1) / $bucketCount);
|
||||
$maxChannel = max($r, $g, $b);
|
||||
$minChannel = min($r, $g, $b);
|
||||
$saturation = $maxChannel - $minChannel;
|
||||
$luma = ($r * 0.299) + ($g * 0.587) + ($b * 0.114);
|
||||
|
||||
$neutralLightBoost = ($luma >= 135 && $saturation <= 95) ? 1.0 : 0.0;
|
||||
$warmBoost = ($r >= 96 && $r >= $b + 10) ? 1.0 : 0.0;
|
||||
$vegetationPenalty = ($g >= 72 && $g >= $r * 1.12 && $g >= $b * 1.08) ? 1.0 : 0.0;
|
||||
|
||||
$rarity[$y][$x] = max(0.0,
|
||||
($baseRarity * 32.0)
|
||||
+ ($saturation * 0.10)
|
||||
+ ($neutralLightBoost * 28.0)
|
||||
+ ($warmBoost * 18.0)
|
||||
- ($vegetationPenalty * 18.0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $rarity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<int, float>>
|
||||
*/
|
||||
private function vegetationMaskMatrix($sample, int $width, int $height): array
|
||||
{
|
||||
$mask = [];
|
||||
|
||||
for ($y = 0; $y < $height; $y++) {
|
||||
$mask[$y] = [];
|
||||
|
||||
for ($x = 0; $x < $width; $x++) {
|
||||
$rgb = imagecolorat($sample, $x, $y);
|
||||
$r = ($rgb >> 16) & 0xFF;
|
||||
$g = ($rgb >> 8) & 0xFF;
|
||||
$b = $rgb & 0xFF;
|
||||
|
||||
$mask[$y][$x] = ($g >= 72 && $g >= $r * 1.12 && $g >= $b * 1.08) ? 1.0 : 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
return $mask;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<int, float>> $energy
|
||||
* @param array<int, array<int, float>> $rarity
|
||||
* @return array<int, array<int, float>>
|
||||
*/
|
||||
private function combineSaliency(array $energy, array $rarity, int $width, int $height): array
|
||||
{
|
||||
$combined = [];
|
||||
|
||||
for ($y = 0; $y < $height; $y++) {
|
||||
$combined[$y] = [];
|
||||
|
||||
for ($x = 0; $x < $width; $x++) {
|
||||
$combined[$y][$x] = ($energy[$y][$x] ?? 0.0) + (($rarity[$y][$x] ?? 0.0) * 1.45);
|
||||
}
|
||||
}
|
||||
|
||||
return $combined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<int, float>> $matrix
|
||||
* @return array<int, array<int, float>>
|
||||
*/
|
||||
private function prefixMatrix(array $matrix, int $width, int $height): array
|
||||
{
|
||||
$prefix = array_fill(0, $height + 1, array_fill(0, $width + 1, 0.0));
|
||||
|
||||
for ($y = 1; $y <= $height; $y++) {
|
||||
for ($x = 1; $x <= $width; $x++) {
|
||||
$prefix[$y][$x] = $matrix[$y - 1][$x - 1]
|
||||
+ $prefix[$y - 1][$x]
|
||||
+ $prefix[$y][$x - 1]
|
||||
- $prefix[$y - 1][$x - 1];
|
||||
}
|
||||
}
|
||||
|
||||
return $prefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<int, float>> $prefix
|
||||
* @return array{x: int, y: int, side: int, density: float, score: float}|null
|
||||
*/
|
||||
private function bestCandidate(array $prefix, array $vegetationPrefix, int $sampleWidth, int $sampleHeight, float $totalEnergy): ?array
|
||||
{
|
||||
$minDimension = min($sampleWidth, $sampleHeight);
|
||||
$ratios = (array) config('uploads.square_thumbnails.saliency.window_ratios', [0.55, 0.7, 0.82, 1.0]);
|
||||
$best = null;
|
||||
|
||||
foreach ($ratios as $ratio) {
|
||||
$side = max(8, min($minDimension, (int) round($minDimension * (float) $ratio)));
|
||||
$step = max(1, (int) floor($side / 5));
|
||||
|
||||
for ($y = 0; $y <= max(0, $sampleHeight - $side); $y += $step) {
|
||||
for ($x = 0; $x <= max(0, $sampleWidth - $side); $x += $step) {
|
||||
$sum = $this->sumRegion($prefix, $x, $y, $side, $side);
|
||||
$density = $sum / max(1, $side * $side);
|
||||
$centerX = ($x + ($side / 2)) / max(1, $sampleWidth);
|
||||
$centerY = ($y + ($side / 2)) / max(1, $sampleHeight);
|
||||
$centerBias = 1.0 - min(1.0, abs($centerX - 0.5) * 1.2 + abs($centerY - 0.42) * 0.9);
|
||||
$coverage = $side / max(1, $minDimension);
|
||||
$coverageFit = 1.0 - min(1.0, abs($coverage - 0.72) / 0.45);
|
||||
$vegetationRatio = $this->sumRegion($vegetationPrefix, $x, $y, $side, $side) / max(1, $side * $side);
|
||||
$score = $density * (1.0 + max(0.0, $centerBias) * 0.18)
|
||||
+ (($sum / max(1.0, $totalEnergy)) * 4.0)
|
||||
+ (max(0.0, $coverageFit) * 2.5)
|
||||
- ($vegetationRatio * 68.0);
|
||||
|
||||
if ($best === null || $score > $best['score']) {
|
||||
$best = [
|
||||
'x' => $x,
|
||||
'y' => $y,
|
||||
'side' => $side,
|
||||
'density' => $density,
|
||||
'score' => $score,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a second candidate from rare, non-foliage pixels so a smooth subject can
|
||||
* still win even when repetitive textured leaves dominate edge energy.
|
||||
*
|
||||
* @param array<int, array<int, float>> $rarity
|
||||
* @param array<int, array<int, float>> $vegetation
|
||||
* @return array{x: int, y: int, side: int, density: float, score: float}|null
|
||||
*/
|
||||
private function rareSubjectCandidate(array $rarity, array $vegetation, int $sampleWidth, int $sampleHeight): ?array
|
||||
{
|
||||
$values = [];
|
||||
|
||||
for ($y = 0; $y < $sampleHeight; $y++) {
|
||||
for ($x = 0; $x < $sampleWidth; $x++) {
|
||||
if (($vegetation[$y][$x] ?? 0.0) >= 0.5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$values[] = (float) ($rarity[$y][$x] ?? 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
if (count($values) < 24) {
|
||||
return null;
|
||||
}
|
||||
|
||||
sort($values);
|
||||
$thresholdIndex = max(0, (int) floor((count($values) - 1) * 0.88));
|
||||
$threshold = max(48.0, (float) ($values[$thresholdIndex] ?? 0.0));
|
||||
|
||||
$weightSum = 0.0;
|
||||
$weightedX = 0.0;
|
||||
$weightedY = 0.0;
|
||||
$minX = $sampleWidth;
|
||||
$minY = $sampleHeight;
|
||||
$maxX = 0;
|
||||
$maxY = 0;
|
||||
$count = 0;
|
||||
|
||||
for ($y = 0; $y < $sampleHeight; $y++) {
|
||||
for ($x = 0; $x < $sampleWidth; $x++) {
|
||||
if (($vegetation[$y][$x] ?? 0.0) >= 0.5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$weight = (float) ($rarity[$y][$x] ?? 0.0);
|
||||
if ($weight < $threshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$weightSum += $weight;
|
||||
$weightedX += ($x + 0.5) * $weight;
|
||||
$weightedY += ($y + 0.5) * $weight;
|
||||
$minX = min($minX, $x);
|
||||
$minY = min($minY, $y);
|
||||
$maxX = max($maxX, $x);
|
||||
$maxY = max($maxY, $y);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($count < 12 || $weightSum <= 0.0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meanX = $weightedX / $weightSum;
|
||||
$meanY = $weightedY / $weightSum;
|
||||
$boxWidth = max(8, ($maxX - $minX) + 1);
|
||||
$boxHeight = max(8, ($maxY - $minY) + 1);
|
||||
$minDimension = min($sampleWidth, $sampleHeight);
|
||||
$side = max($boxWidth, $boxHeight);
|
||||
$side = max($side, (int) round($minDimension * 0.42));
|
||||
$side = min($minDimension, (int) round($side * 1.18));
|
||||
|
||||
return [
|
||||
'x' => (int) round($meanX - ($side / 2)),
|
||||
'y' => (int) round($meanY - ($side / 2)),
|
||||
'side' => max(8, $side),
|
||||
'density' => $weightSum / max(1, $count),
|
||||
'score' => ($weightSum / max(1, $count)) + ($count * 0.35),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<int, float>> $prefix
|
||||
*/
|
||||
private function sumRegion(array $prefix, int $x, int $y, int $width, int $height): float
|
||||
{
|
||||
$x2 = $x + $width;
|
||||
$y2 = $y + $height;
|
||||
|
||||
return ($prefix[$y2][$x2] ?? 0.0)
|
||||
- ($prefix[$y][$x2] ?? 0.0)
|
||||
- ($prefix[$y2][$x] ?? 0.0)
|
||||
+ ($prefix[$y][$x] ?? 0.0);
|
||||
}
|
||||
}
|
||||
16
app/Services/Images/Detectors/NullSubjectDetector.php
Normal file
16
app/Services/Images/Detectors/NullSubjectDetector.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Images\Detectors;
|
||||
|
||||
use App\Contracts\Images\SubjectDetectorInterface;
|
||||
use App\Data\Images\SubjectDetectionResultData;
|
||||
|
||||
final class NullSubjectDetector implements SubjectDetectorInterface
|
||||
{
|
||||
public function detect(string $sourcePath, int $sourceWidth, int $sourceHeight, array $context = []): ?SubjectDetectionResultData
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
142
app/Services/Images/Detectors/VisionSubjectDetector.php
Normal file
142
app/Services/Images/Detectors/VisionSubjectDetector.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Images\Detectors;
|
||||
|
||||
use App\Contracts\Images\SubjectDetectorInterface;
|
||||
use App\Data\Images\CropBoxData;
|
||||
use App\Data\Images\SubjectDetectionResultData;
|
||||
use App\Models\Artwork;
|
||||
|
||||
final class VisionSubjectDetector implements SubjectDetectorInterface
|
||||
{
|
||||
public function detect(string $sourcePath, int $sourceWidth, int $sourceHeight, array $context = []): ?SubjectDetectionResultData
|
||||
{
|
||||
$boxes = $this->extractCandidateBoxes($context, $sourceWidth, $sourceHeight);
|
||||
if ($boxes === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($boxes, static function (array $left, array $right): int {
|
||||
return $right['score'] <=> $left['score'];
|
||||
});
|
||||
|
||||
$best = $boxes[0];
|
||||
|
||||
return new SubjectDetectionResultData(
|
||||
cropBox: $best['box'],
|
||||
strategy: 'subject',
|
||||
reason: 'vision_subject_box',
|
||||
confidence: (float) $best['confidence'],
|
||||
meta: [
|
||||
'label' => $best['label'],
|
||||
'score' => $best['score'],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{box: CropBoxData, label: string, confidence: float, score: float}>
|
||||
*/
|
||||
private function extractCandidateBoxes(array $context, int $sourceWidth, int $sourceHeight): array
|
||||
{
|
||||
$boxes = [];
|
||||
$preferredLabels = collect((array) config('uploads.square_thumbnails.subject_detector.preferred_labels', []))
|
||||
->map(static fn ($label): string => mb_strtolower((string) $label))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$candidates = $context['subject_boxes'] ?? $context['vision_boxes'] ?? null;
|
||||
|
||||
if ($candidates === null && ($context['artwork'] ?? null) instanceof Artwork) {
|
||||
$candidates = $this->boxesFromArtwork($context['artwork']);
|
||||
}
|
||||
|
||||
foreach ((array) $candidates as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$box = $this->normalizeBox($row, $sourceWidth, $sourceHeight);
|
||||
if ($box === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = mb_strtolower((string) ($row['label'] ?? $row['tag'] ?? $row['name'] ?? 'subject'));
|
||||
$confidence = max(0.0, min(1.0, (float) ($row['confidence'] ?? $row['score'] ?? 0.75)));
|
||||
$areaWeight = ($box->width * $box->height) / max(1, $sourceWidth * $sourceHeight);
|
||||
$preferredBoost = in_array($label, $preferredLabels, true) ? 1.25 : 1.0;
|
||||
|
||||
$boxes[] = [
|
||||
'box' => $box,
|
||||
'label' => $label,
|
||||
'confidence' => $confidence,
|
||||
'score' => ($confidence * 0.8 + $areaWeight * 0.2) * $preferredBoost,
|
||||
];
|
||||
}
|
||||
|
||||
return $boxes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function boxesFromArtwork(Artwork $artwork): array
|
||||
{
|
||||
return collect((array) ($artwork->yolo_objects_json ?? []))
|
||||
->filter(static fn ($row): bool => is_array($row))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function normalizeBox(array $row, int $sourceWidth, int $sourceHeight): ?CropBoxData
|
||||
{
|
||||
$payload = is_array($row['box'] ?? null) ? $row['box'] : $row;
|
||||
|
||||
$left = $payload['x'] ?? $payload['left'] ?? $payload['x1'] ?? null;
|
||||
$top = $payload['y'] ?? $payload['top'] ?? $payload['y1'] ?? null;
|
||||
$width = $payload['width'] ?? null;
|
||||
$height = $payload['height'] ?? null;
|
||||
|
||||
if ($width === null && isset($payload['x2'], $payload['x1'])) {
|
||||
$width = (float) $payload['x2'] - (float) $payload['x1'];
|
||||
}
|
||||
|
||||
if ($height === null && isset($payload['y2'], $payload['y1'])) {
|
||||
$height = (float) $payload['y2'] - (float) $payload['y1'];
|
||||
}
|
||||
|
||||
if (! is_numeric($left) || ! is_numeric($top) || ! is_numeric($width) || ! is_numeric($height)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$left = (float) $left;
|
||||
$top = (float) $top;
|
||||
$width = (float) $width;
|
||||
$height = (float) $height;
|
||||
|
||||
$normalized = max(abs($left), abs($top), abs($width), abs($height)) <= 1.0;
|
||||
if ($normalized) {
|
||||
$left *= $sourceWidth;
|
||||
$top *= $sourceHeight;
|
||||
$width *= $sourceWidth;
|
||||
$height *= $sourceHeight;
|
||||
}
|
||||
|
||||
if ($width <= 1 || $height <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (new CropBoxData(
|
||||
x: (int) floor($left),
|
||||
y: (int) floor($top),
|
||||
width: (int) round($width),
|
||||
height: (int) round($height),
|
||||
))->clampToImage($sourceWidth, $sourceHeight);
|
||||
}
|
||||
}
|
||||
160
app/Services/Images/SquareThumbnailService.php
Normal file
160
app/Services/Images/SquareThumbnailService.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Images;
|
||||
|
||||
use App\Contracts\Images\SubjectDetectorInterface;
|
||||
use App\Data\Images\CropBoxData;
|
||||
use App\Data\Images\SquareThumbnailResultData;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
|
||||
final class SquareThumbnailService
|
||||
{
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
public function __construct(private readonly SubjectDetectorInterface $subjectDetector)
|
||||
{
|
||||
try {
|
||||
$this->manager = extension_loaded('gd') ? ImageManager::gd() : ImageManager::imagick();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Square thumbnail image manager configuration failed', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$this->manager = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function generateFromPath(string $sourcePath, string $destinationPath, array $options = []): SquareThumbnailResultData
|
||||
{
|
||||
$this->assertImageManagerAvailable();
|
||||
|
||||
if (! is_file($sourcePath) || ! is_readable($sourcePath)) {
|
||||
throw new RuntimeException('Square thumbnail source image is not readable.');
|
||||
}
|
||||
|
||||
$size = @getimagesize($sourcePath);
|
||||
if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) {
|
||||
throw new RuntimeException('Square thumbnail source image dimensions are invalid.');
|
||||
}
|
||||
|
||||
$sourceWidth = (int) $size[0];
|
||||
$sourceHeight = (int) $size[1];
|
||||
$config = $this->resolveOptions($options);
|
||||
$context = is_array($options['context'] ?? null) ? $options['context'] : $options;
|
||||
|
||||
$detection = null;
|
||||
if ($config['smart_crop']) {
|
||||
$detection = $this->subjectDetector->detect($sourcePath, $sourceWidth, $sourceHeight, $context);
|
||||
}
|
||||
|
||||
$cropBox = $this->calculateCropBox($sourceWidth, $sourceHeight, $detection?->cropBox, $config);
|
||||
$cropMode = $detection?->strategy ?? $config['fallback_strategy'];
|
||||
|
||||
$image = $this->manager->read($sourcePath)->crop($cropBox->width, $cropBox->height, $cropBox->x, $cropBox->y);
|
||||
$outputWidth = $config['target_width'];
|
||||
$outputHeight = $config['target_height'];
|
||||
|
||||
if ($config['allow_upscale']) {
|
||||
$image = $image->resize($config['target_width'], $config['target_height']);
|
||||
} else {
|
||||
$image = $image->resizeDown($config['target_width'], $config['target_height']);
|
||||
$outputWidth = min($config['target_width'], $cropBox->width);
|
||||
$outputHeight = min($config['target_height'], $cropBox->height);
|
||||
}
|
||||
|
||||
$encoded = (string) $image->encode(new WebpEncoder($config['quality']));
|
||||
File::ensureDirectoryExists(dirname($destinationPath));
|
||||
File::put($destinationPath, $encoded);
|
||||
|
||||
$result = new SquareThumbnailResultData(
|
||||
destinationPath: $destinationPath,
|
||||
cropBox: $cropBox,
|
||||
cropMode: $cropMode,
|
||||
sourceWidth: $sourceWidth,
|
||||
sourceHeight: $sourceHeight,
|
||||
targetWidth: $config['target_width'],
|
||||
targetHeight: $config['target_height'],
|
||||
outputWidth: $outputWidth,
|
||||
outputHeight: $outputHeight,
|
||||
detectionReason: $detection?->reason,
|
||||
meta: [
|
||||
'smart_crop' => $config['smart_crop'],
|
||||
'padding_ratio' => $config['padding_ratio'],
|
||||
'confidence' => $detection?->confidence,
|
||||
],
|
||||
);
|
||||
|
||||
if ($config['log']) {
|
||||
Log::debug('square-thumbnail-generated', $result->toArray());
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function calculateCropBox(int $sourceWidth, int $sourceHeight, ?CropBoxData $focusBox = null, array $options = []): CropBoxData
|
||||
{
|
||||
$config = $this->resolveOptions($options);
|
||||
|
||||
if ($focusBox === null) {
|
||||
return $this->safeCenterCrop($sourceWidth, $sourceHeight);
|
||||
}
|
||||
|
||||
$baseSide = max($focusBox->width, $focusBox->height);
|
||||
$side = max(1, (int) ceil($baseSide * (1 + ($config['padding_ratio'] * 2))));
|
||||
$side = min($side, min($sourceWidth, $sourceHeight));
|
||||
|
||||
$x = (int) round($focusBox->centerX() - ($side / 2));
|
||||
$y = (int) round($focusBox->centerY() - ($side / 2));
|
||||
|
||||
return (new CropBoxData($x, $y, $side, $side))->clampToImage($sourceWidth, $sourceHeight);
|
||||
}
|
||||
|
||||
private function safeCenterCrop(int $sourceWidth, int $sourceHeight): CropBoxData
|
||||
{
|
||||
$side = min($sourceWidth, $sourceHeight);
|
||||
$x = (int) floor(($sourceWidth - $side) / 2);
|
||||
$y = (int) floor(($sourceHeight - $side) / 2);
|
||||
|
||||
return new CropBoxData($x, $y, $side, $side);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
* @return array{target_width: int, target_height: int, quality: int, smart_crop: bool, padding_ratio: float, allow_upscale: bool, fallback_strategy: string, log: bool}
|
||||
*/
|
||||
private function resolveOptions(array $options): array
|
||||
{
|
||||
$config = (array) config('uploads.square_thumbnails', []);
|
||||
$targetWidth = (int) ($options['target_width'] ?? $options['target_size'] ?? $config['width'] ?? config('uploads.derivatives.sq.size', 512));
|
||||
$targetHeight = (int) ($options['target_height'] ?? $options['target_size'] ?? $config['height'] ?? $targetWidth);
|
||||
|
||||
return [
|
||||
'target_width' => max(1, $targetWidth),
|
||||
'target_height' => max(1, $targetHeight),
|
||||
'quality' => max(1, min(100, (int) ($options['quality'] ?? $config['quality'] ?? 82))),
|
||||
'smart_crop' => (bool) ($options['smart_crop'] ?? $config['smart_crop'] ?? true),
|
||||
'padding_ratio' => max(0.0, min(0.5, (float) ($options['padding_ratio'] ?? $config['padding_ratio'] ?? 0.18))),
|
||||
'allow_upscale' => (bool) ($options['allow_upscale'] ?? $config['allow_upscale'] ?? false),
|
||||
'fallback_strategy' => (string) ($options['fallback_strategy'] ?? $config['fallback_strategy'] ?? 'center'),
|
||||
'log' => (bool) ($options['log'] ?? $config['log'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
private function assertImageManagerAvailable(): void
|
||||
{
|
||||
if ($this->manager === null) {
|
||||
throw new RuntimeException('Square thumbnail generation requires Intervention Image.');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user