409 lines
14 KiB
PHP
409 lines
14 KiB
PHP
<?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);
|
|
}
|
|
} |