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> */ 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> $gray * @return array> */ 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> */ 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> */ 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> $energy * @param array> $rarity * @return array> */ 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> $matrix * @return array> */ 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> $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> $rarity * @param array> $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> $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); } }