PhotoMosaic: fit batch of photos to best layout instead

This commit is contained in:
Aaron van Geffen 2023-12-19 21:57:29 +01:00
parent d2fa547257
commit 553744aeb5
2 changed files with 144 additions and 38 deletions

View File

@ -145,6 +145,16 @@ class Image extends Asset
return $ratio >= 1 && $ratio <= 2; return $ratio >= 1 && $ratio <= 2;
} }
public function getType()
{
if ($this->isPortrait())
return self::TYPE_PORTRAIT;
elseif ($this->isPanorama())
return self::TYPE_PANORAMA;
else
return self::TYPE_LANDSCAPE;
}
public function getThumbnails() public function getThumbnails()
{ {
return $this->thumbnails; return $this->thumbnails;

View File

@ -14,7 +14,9 @@ class PhotoMosaic
private $processedImages = 0; private $processedImages = 0;
private $queue = []; private $queue = [];
const IMAGE_MASK_ALL = Image::TYPE_PORTRAIT | Image::TYPE_LANDSCAPE | Image::TYPE_PANORAMA;
const NUM_DAYS_CUTOFF = 7; const NUM_DAYS_CUTOFF = 7;
const NUM_BATCH_PHOTOS = 6;
public function __construct(AssetIterator $iterator) public function __construct(AssetIterator $iterator)
{ {
@ -59,18 +61,24 @@ class PhotoMosaic
return $layouts; return $layouts;
} }
private static function daysApart(Image $a, Image $b) private static function daysApart(DateTime $a, DateTime $b)
{ {
return $a->getDateCaptured()->diff($b->getDateCaptured())->days; return $a->diff($b)->days;
} }
private function fetchImage($desired_type = Image::TYPE_PORTRAIT | Image::TYPE_LANDSCAPE | Image::TYPE_PANORAMA, Image $refDateImage = null) private function fetchImage($desired_type = self::IMAGE_MASK_ALL, ?DateTime $refDate = null)
{ {
// First, check if we have what we're looking for in the queue. // First, check if we have what we're looking for in the queue.
foreach ($this->queue as $i => $image) foreach ($this->queue as $i => $image)
{ {
// Give up on the queue once the dates are too far apart
if (isset($refDate) && abs(self::daysApart($image->getDateCaptured(), $refDate)) > self::NUM_DAYS_CUTOFF)
{
break;
}
// Image has to match the desired type and be taken within a week of the reference image. // Image has to match the desired type and be taken within a week of the reference image.
if (self::matchTypeMask($image, $desired_type) && !(isset($refDateImage) && abs(self::daysApart($image, $refDateImage)) > self::NUM_DAYS_CUTOFF)) if (self::matchTypeMask($image, $desired_type))
{ {
unset($this->queue[$i]); unset($this->queue[$i]);
return $image; return $image;
@ -80,61 +88,149 @@ class PhotoMosaic
// Check whatever's next up! // Check whatever's next up!
while (($asset = $this->iterator->next()) && ($image = $asset->getImage())) while (($asset = $this->iterator->next()) && ($image = $asset->getImage()))
{ {
// Image has to match the desired type and be taken within a week of the reference image. // Give up on the recordset once dates are too far apart
if (self::matchTypeMask($image, $desired_type) && !(isset($refDateImage) && abs(self::daysApart($image, $refDateImage)) > self::NUM_DAYS_CUTOFF)) if (isset($refDate) && abs(self::daysApart($image->getDateCaptured(), $refDate)) > self::NUM_DAYS_CUTOFF)
return $image; {
else
$this->pushToQueue($image); $this->pushToQueue($image);
break;
}
// Image has to match the desired type and be taken within a week of the reference image.
if (self::matchTypeMask($image, $desired_type))
{
return $image;
}
else
{
$this->pushToQueue($image);
}
} }
return false; return false;
} }
public function fetchImages($num, $refDate = null, $spec = self::IMAGE_MASK_ALL)
{
$refDate = null;
$prevImage = true;
$images = [];
for ($i = 0; $i < $num || !$prevImage; $i++)
{
$image = $this->fetchImage($spec, $refDate);
if ($image !== false)
{
$images[] = $image;
$refDate = $image->getDateCaptured();
$prevImage = $image;
}
}
return $images;
}
public function getRow() public function getRow()
{ {
$currentImages = []; $requiredImages = array_map('count', $this->layouts);
$currentImages = $this->fetchImages(self::NUM_BATCH_PHOTOS);
$selectedLayout = null; $selectedLayout = null;
foreach ($this->layouts as $layout => $requiredImageTypes)
{
// Fetch images per the layout requirements
// TODO: pass last image back into fetchImage for date cutoff purposes
$currentImages = array_filter(array_map([$this, 'fetchImage'], $requiredImageTypes));
// Matching requirements? if (empty($currentImages))
if (count($currentImages) === count($requiredImageTypes))
{
$selectedLayout = $layout;
break;
}
// Push mismatches back into the queue
array_map([$this, 'pushToQueue'], $currentImages);
$currentImages = [];
}
if ($selectedLayout)
{
// Hurray, we've got something that works
usort($currentImages, [$this, 'orderPhotos']);
$this->processedImages += count($currentImages);
return [$currentImages, $selectedLayout];
}
else
{ {
// Ensure we have no images left in the iterator before giving up // Ensure we have no images left in the iterator before giving up
assert($this->processedImages === $this->iterator->num()); assert($this->processedImages === $this->iterator->num());
return false; return false;
} }
// Assign fitness score for each layout
$fitnessScores = $this->getScoresByLayout($currentImages);
$scoresByLayout = array_map(fn($el) => $el[0], $fitnessScores);
// Select the best-fitting layout
$bestLayouts = array_keys($scoresByLayout, max($scoresByLayout));
$bestLayout = $bestLayouts[0];
$layoutImages = $fitnessScores[$bestLayout][1];
// Push any unused back into the queue
if (count($layoutImages) < count($currentImages))
{
$diff = array_udiff($currentImages, $layoutImages, function($a, $b) {
return $a->getId() <=> $b->getId();
});
array_map([$this, 'pushToQueue'], $diff);
}
// Finally, allow tweaking image order through display priority
usort($layoutImages, [$this, 'orderPhotosByPriority']);
// Done! Return the result
$this->processedImages += count($layoutImages);
return [$layoutImages, $bestLayout];
}
public function getScoreForRow(array $images, array $specs)
{
assert(count($images) === count($specs));
$score = 0;
foreach ($images as $i => $image)
{
if (self::matchTypeMask($image, $specs[$i]))
$score += 1;
else
$score -= 10;
}
return $score;
}
public function getScoresByLayout(array $candidateImages)
{
$fitnessScores = [];
foreach ($this->layouts as $layout => $requiredImageTypes)
{
// If we don't have enough candidate images for this layout, skip it
if (count($candidateImages) < count($requiredImageTypes))
continue;
$imageSelection = [];
$remainingImages = $candidateImages;
// Try to satisfy the layout spec using the images available
foreach ($requiredImageTypes as $spec)
{
foreach ($remainingImages as $i => $candidate)
{
// Satisfied spec from selection?
if (self::matchTypeMask($candidate, $spec))
{
$imageSelection[] = $candidate;
unset($remainingImages[$i]);
continue 2;
}
}
// Unable to satisfy spec from selection
break;
}
// Have we satisfied the spec? Great, assign a score
if (count($imageSelection) === count($requiredImageTypes))
{
$score = $this->getScoreForRow($imageSelection, $requiredImageTypes);
$fitnessScores[$layout] = [$score, $imageSelection];
}
}
return $fitnessScores;
} }
private static function matchTypeMask(Image $image, $type_mask) private static function matchTypeMask(Image $image, $type_mask)
{ {
return ($type_mask & Image::TYPE_PANORAMA) && $image->isPanorama() || return $image->getType() & $type_mask;
($type_mask & Image::TYPE_LANDSCAPE) && $image->isLandscape() ||
($type_mask & Image::TYPE_PORTRAIT) && $image->isPortrait();
} }
private static function orderPhotos(Image $a, Image $b) private static function orderPhotosByPriority(Image $a, Image $b)
{ {
// Leave images of different types as-is // Leave images of different types as-is
if ($a->isLandscape() !== $b->isLandscape()) if ($a->isLandscape() !== $b->isLandscape())