From 553744aeb502053bf9cefc9a01754a38a8e79eda Mon Sep 17 00:00:00 2001 From: Aaron van Geffen Date: Tue, 19 Dec 2023 21:57:29 +0100 Subject: [PATCH] PhotoMosaic: fit batch of photos to best layout instead --- models/Image.php | 10 +++ models/PhotoMosaic.php | 172 ++++++++++++++++++++++++++++++++--------- 2 files changed, 144 insertions(+), 38 deletions(-) diff --git a/models/Image.php b/models/Image.php index fb4d8c75..0172a9ca 100644 --- a/models/Image.php +++ b/models/Image.php @@ -145,6 +145,16 @@ class Image extends Asset 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() { return $this->thumbnails; diff --git a/models/PhotoMosaic.php b/models/PhotoMosaic.php index 949c52c7..7f6717c9 100644 --- a/models/PhotoMosaic.php +++ b/models/PhotoMosaic.php @@ -14,7 +14,9 @@ class PhotoMosaic private $processedImages = 0; private $queue = []; + const IMAGE_MASK_ALL = Image::TYPE_PORTRAIT | Image::TYPE_LANDSCAPE | Image::TYPE_PANORAMA; const NUM_DAYS_CUTOFF = 7; + const NUM_BATCH_PHOTOS = 6; public function __construct(AssetIterator $iterator) { @@ -59,18 +61,24 @@ class PhotoMosaic 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. 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. - 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]); return $image; @@ -80,61 +88,149 @@ class PhotoMosaic // Check whatever's next up! 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. - if (self::matchTypeMask($image, $desired_type) && !(isset($refDateImage) && abs(self::daysApart($image, $refDateImage)) > self::NUM_DAYS_CUTOFF)) - return $image; - else + // Give up on the recordset once dates are too far apart + if (isset($refDate) && abs(self::daysApart($image->getDateCaptured(), $refDate)) > self::NUM_DAYS_CUTOFF) + { $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; } + 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() { - $currentImages = []; + $requiredImages = array_map('count', $this->layouts); + $currentImages = $this->fetchImages(self::NUM_BATCH_PHOTOS); $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 (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 + if (empty($currentImages)) { // Ensure we have no images left in the iterator before giving up assert($this->processedImages === $this->iterator->num()); 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) { - return ($type_mask & Image::TYPE_PANORAMA) && $image->isPanorama() || - ($type_mask & Image::TYPE_LANDSCAPE) && $image->isLandscape() || - ($type_mask & Image::TYPE_PORTRAIT) && $image->isPortrait(); + return $image->getType() & $type_mask; } - private static function orderPhotos(Image $a, Image $b) + private static function orderPhotosByPriority(Image $a, Image $b) { // Leave images of different types as-is if ($a->isLandscape() !== $b->isLandscape())