forked from Public/pics
Merge pull request 'Improve the mosaic algorithm further' (#43) from improve-mosaic into master
Reviewed-on: Public/pics#43 Reviewed-by: Roflin <d.brentjes@gmail.com>
This commit is contained in:
commit
b1c2001c06
@ -8,15 +8,18 @@
|
|||||||
|
|
||||||
class AssetIterator extends Asset
|
class AssetIterator extends Asset
|
||||||
{
|
{
|
||||||
|
private Database $db;
|
||||||
|
private $direction;
|
||||||
|
|
||||||
private $return_format;
|
private $return_format;
|
||||||
private $res_assets;
|
private $res_assets;
|
||||||
private $res_meta;
|
private $res_meta;
|
||||||
private $res_thumbs;
|
private $res_thumbs;
|
||||||
private Database $db;
|
|
||||||
|
|
||||||
protected function __construct($res_assets, $res_meta, $res_thumbs, $return_format)
|
protected function __construct($res_assets, $res_meta, $res_thumbs, $return_format, $direction)
|
||||||
{
|
{
|
||||||
$this->db = Registry::get('db');
|
$this->db = Registry::get('db');
|
||||||
|
$this->direction = $direction;
|
||||||
$this->res_assets = $res_assets;
|
$this->res_assets = $res_assets;
|
||||||
$this->res_meta = $res_meta;
|
$this->res_meta = $res_meta;
|
||||||
$this->res_thumbs = $res_thumbs;
|
$this->res_thumbs = $res_thumbs;
|
||||||
@ -174,7 +177,7 @@ class AssetIterator extends Asset
|
|||||||
'_' => '_',
|
'_' => '_',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$iterator = new self($res_assets, $res_meta, $res_thumbs, $return_format);
|
$iterator = new self($res_assets, $res_meta, $res_thumbs, $return_format, $params['direction']);
|
||||||
|
|
||||||
// Returning total count, too?
|
// Returning total count, too?
|
||||||
if ($return_count)
|
if ($return_count)
|
||||||
@ -190,4 +193,14 @@ class AssetIterator extends Asset
|
|||||||
else
|
else
|
||||||
return $iterator;
|
return $iterator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isAscending()
|
||||||
|
{
|
||||||
|
return $this->direction === 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDescending()
|
||||||
|
{
|
||||||
|
return $this->direction === 'desc';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -8,17 +8,21 @@
|
|||||||
|
|
||||||
class PhotoMosaic
|
class PhotoMosaic
|
||||||
{
|
{
|
||||||
|
private $descending;
|
||||||
private AssetIterator $iterator;
|
private AssetIterator $iterator;
|
||||||
private $layouts;
|
private $layouts;
|
||||||
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)
|
||||||
{
|
{
|
||||||
$this->iterator = $iterator;
|
$this->iterator = $iterator;
|
||||||
$this->layouts = $this->availableLayouts();
|
$this->layouts = $this->availableLayouts();
|
||||||
|
$this->descending = $iterator->isDescending();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __destruct()
|
public function __destruct()
|
||||||
@ -48,6 +52,7 @@ class PhotoMosaic
|
|||||||
// Dual layouts
|
// Dual layouts
|
||||||
'dualLandscapes' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],
|
'dualLandscapes' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],
|
||||||
'dualPortraits' => [Image::TYPE_PORTRAIT, Image::TYPE_PORTRAIT],
|
'dualPortraits' => [Image::TYPE_PORTRAIT, Image::TYPE_PORTRAIT],
|
||||||
|
'dualMixed' => [Image::TYPE_LANDSCAPE, Image::TYPE_PORTRAIT],
|
||||||
|
|
||||||
// Fallback layouts
|
// Fallback layouts
|
||||||
'singleLandscape' => [Image::TYPE_LANDSCAPE],
|
'singleLandscape' => [Image::TYPE_LANDSCAPE],
|
||||||
@ -57,18 +62,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;
|
||||||
@ -78,61 +89,153 @@ 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];
|
||||||
|
|
||||||
|
// Perfect score? Bail out early
|
||||||
|
if ($score === count($requiredImageTypes))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
@ -143,8 +246,17 @@ class PhotoMosaic
|
|||||||
return -$priority_diff;
|
return -$priority_diff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function orderQueueByDate()
|
||||||
|
{
|
||||||
|
usort($this->queue, function($a, $b) {
|
||||||
|
$score = $a->getDateCaptured() <=> $b->getDateCaptured();
|
||||||
|
return $score * ($this->descending ? -1 : 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private function pushToQueue(Image $image)
|
private function pushToQueue(Image $image)
|
||||||
{
|
{
|
||||||
$this->queue[] = $image;
|
$this->queue[] = $image;
|
||||||
|
$this->orderQueueByDate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -270,6 +270,29 @@ class PhotosIndex extends Template
|
|||||||
</div>';
|
</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function dualMixed(array $photos, $altLayout)
|
||||||
|
{
|
||||||
|
$image = array_shift($photos);
|
||||||
|
|
||||||
|
echo '
|
||||||
|
<div class="row g-5 mb-5 tile-feat-landscape',
|
||||||
|
$altLayout ? ' flex-row-reverse' : '', '">
|
||||||
|
<div class="col-md-8">';
|
||||||
|
|
||||||
|
$this->photo($image, 'landscape', static::LANDSCAPE_WIDTH, static::LANDSCAPE_HEIGHT, 'top');
|
||||||
|
|
||||||
|
echo '
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">';
|
||||||
|
|
||||||
|
$this->photo($image, 'portrait', static::PORTRAIT_WIDTH, static::PORTRAIT_HEIGHT, true);
|
||||||
|
|
||||||
|
echo '
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>';
|
||||||
|
}
|
||||||
|
|
||||||
protected function dualPortraits(array $photos, $altLayout)
|
protected function dualPortraits(array $photos, $altLayout)
|
||||||
{
|
{
|
||||||
// Recycle the row layout so portraits don't appear too large
|
// Recycle the row layout so portraits don't appear too large
|
||||||
|
Loading…
Reference in New Issue
Block a user