152 lines
5.8 KiB
PHP
152 lines
5.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Data\Images\CropBoxData;
|
|
use App\Services\Images\Detectors\HeuristicSubjectDetector;
|
|
use App\Services\Images\Detectors\NullSubjectDetector;
|
|
use App\Services\Images\SquareThumbnailService;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Str;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class);
|
|
|
|
beforeEach(function () {
|
|
$this->imageTestRoot = storage_path('framework/testing/square-thumbnails-' . Str::lower(Str::random(10)));
|
|
File::deleteDirectory($this->imageTestRoot);
|
|
File::ensureDirectoryExists($this->imageTestRoot);
|
|
});
|
|
|
|
afterEach(function () {
|
|
File::deleteDirectory($this->imageTestRoot);
|
|
});
|
|
|
|
function squareThumbCreateImage(string $root, int $width, int $height, ?callable $drawer = null): string
|
|
{
|
|
$path = $root . DIRECTORY_SEPARATOR . Str::uuid()->toString() . '.png';
|
|
$image = imagecreatetruecolor($width, $height);
|
|
$background = imagecolorallocate($image, 22, 28, 36);
|
|
imagefilledrectangle($image, 0, 0, $width, $height, $background);
|
|
|
|
if ($drawer !== null) {
|
|
$drawer($image, $width, $height);
|
|
}
|
|
|
|
imagepng($image, $path);
|
|
imagedestroy($image);
|
|
|
|
return $path;
|
|
}
|
|
|
|
it('calculates a padded square crop and clamps it to image bounds', function () {
|
|
$service = new SquareThumbnailService(new NullSubjectDetector());
|
|
|
|
$crop = $service->calculateCropBox(1200, 800, new CropBoxData(10, 40, 260, 180), [
|
|
'padding_ratio' => 0.2,
|
|
]);
|
|
|
|
expect($crop->width)->toBe($crop->height)
|
|
->and($crop->x)->toBe(0)
|
|
->and($crop->y)->toBeGreaterThanOrEqual(0)
|
|
->and($crop->x + $crop->width)->toBeLessThanOrEqual(1200)
|
|
->and($crop->y + $crop->height)->toBeLessThanOrEqual(800);
|
|
});
|
|
|
|
it('falls back to a centered square crop when no focus box exists', function () {
|
|
$service = new SquareThumbnailService(new NullSubjectDetector());
|
|
|
|
$crop = $service->calculateCropBox(900, 500, null);
|
|
|
|
expect($crop->width)->toBe(500)
|
|
->and($crop->height)->toBe(500)
|
|
->and($crop->x)->toBe(200)
|
|
->and($crop->y)->toBe(0);
|
|
});
|
|
|
|
it('generates a valid square thumbnail when smart detection is unavailable', function () {
|
|
$source = squareThumbCreateImage($this->imageTestRoot, 640, 320, function ($image): void {
|
|
$accent = imagecolorallocate($image, 240, 200, 80);
|
|
imagefilledellipse($image, 320, 160, 180, 180, $accent);
|
|
});
|
|
$destination = $this->imageTestRoot . DIRECTORY_SEPARATOR . 'output.webp';
|
|
|
|
$service = new SquareThumbnailService(new NullSubjectDetector());
|
|
$result = $service->generateFromPath($source, $destination, ['target_size' => 256]);
|
|
$size = getimagesize($destination);
|
|
|
|
expect(File::exists($destination))->toBeTrue()
|
|
->and($result->cropMode)->toBe('center')
|
|
->and($size[0] ?? null)->toBe(256)
|
|
->and($size[1] ?? null)->toBe(256);
|
|
});
|
|
|
|
it('does not upscale tiny images when allow_upscale is disabled', function () {
|
|
$source = squareThumbCreateImage($this->imageTestRoot, 60, 40, function ($image): void {
|
|
$accent = imagecolorallocate($image, 220, 120, 120);
|
|
imagefilledrectangle($image, 4, 4, 36, 32, $accent);
|
|
});
|
|
$destination = $this->imageTestRoot . DIRECTORY_SEPARATOR . 'tiny.webp';
|
|
|
|
$service = new SquareThumbnailService(new NullSubjectDetector());
|
|
$result = $service->generateFromPath($source, $destination, [
|
|
'target_size' => 256,
|
|
'allow_upscale' => false,
|
|
]);
|
|
$size = getimagesize($destination);
|
|
|
|
expect($result->outputWidth)->toBe(40)
|
|
->and($result->outputHeight)->toBe(40)
|
|
->and($size[0] ?? null)->toBe(40)
|
|
->and($size[1] ?? null)->toBe(40);
|
|
});
|
|
|
|
it('can derive a saliency crop near the border', function () {
|
|
$source = squareThumbCreateImage($this->imageTestRoot, 900, 600, function ($image): void {
|
|
$subject = imagecolorallocate($image, 250, 240, 240);
|
|
imagefilledellipse($image, 120, 240, 180, 220, $subject);
|
|
});
|
|
$detector = new HeuristicSubjectDetector();
|
|
$size = getimagesize($source);
|
|
|
|
$result = $detector->detect($source, (int) $size[0], (int) $size[1]);
|
|
|
|
expect($result)->not->toBeNull()
|
|
->and($result?->strategy)->toBe('saliency')
|
|
->and($result?->cropBox->x)->toBeLessThan(220);
|
|
});
|
|
|
|
it('prefers a distinct subject over textured foliage', function () {
|
|
$source = squareThumbCreateImage($this->imageTestRoot, 1200, 700, function ($image): void {
|
|
$sky = imagecolorallocate($image, 165, 205, 255);
|
|
$leaf = imagecolorallocate($image, 42, 118, 34);
|
|
$leafDark = imagecolorallocate($image, 28, 88, 24);
|
|
$branch = imagecolorallocate($image, 96, 72, 52);
|
|
$fur = imagecolorallocate($image, 242, 236, 228);
|
|
$ginger = imagecolorallocate($image, 214, 152, 102);
|
|
$mouth = imagecolorallocate($image, 36, 20, 18);
|
|
|
|
imagefilledrectangle($image, 0, 0, 1200, 700, $sky);
|
|
|
|
for ($i = 0; $i < 28; $i++) {
|
|
imageline($image, rand(0, 520), rand(0, 340), rand(80, 620), rand(80, 420), $branch);
|
|
imagefilledellipse($image, rand(40, 560), rand(80, 620), rand(60, 120), rand(20, 60), $leaf);
|
|
imagefilledellipse($image, rand(40, 560), rand(80, 620), rand(40, 90), rand(16, 44), $leafDark);
|
|
}
|
|
|
|
imagefilledellipse($image, 890, 300, 420, 500, $fur);
|
|
imagefilledpolygon($image, [760, 130, 840, 10, 865, 165], 3, $ginger);
|
|
imagefilledpolygon($image, [930, 165, 1000, 10, 1070, 130], 3, $ginger);
|
|
imagefilledellipse($image, 885, 355, 150, 220, $mouth);
|
|
imagefilledellipse($image, 890, 520, 180, 130, $fur);
|
|
});
|
|
|
|
$detector = new HeuristicSubjectDetector();
|
|
$size = getimagesize($source);
|
|
|
|
$result = $detector->detect($source, (int) $size[0], (int) $size[1]);
|
|
|
|
expect($result)->not->toBeNull()
|
|
->and($result?->strategy)->toBe('saliency')
|
|
->and($result?->cropBox->x)->toBeGreaterThan(180);
|
|
}); |