forked from Public/pics
		
	
		
			
				
	
	
		
			259 lines
		
	
	
		
			6.9 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			259 lines
		
	
	
		
			6.9 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
<?php
 | 
						|
/*****************************************************************************
 | 
						|
 * PhotoMosaic.php
 | 
						|
 * Contains the photo mosaic model, an iterator to create tiled photo galleries.
 | 
						|
 *
 | 
						|
 * Kabuki CMS (C) 2013-2015, Aaron van Geffen
 | 
						|
 *****************************************************************************/
 | 
						|
 | 
						|
class PhotoMosaic
 | 
						|
{
 | 
						|
	private $descending;
 | 
						|
	private AssetIterator $iterator;
 | 
						|
	private $layouts;
 | 
						|
	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)
 | 
						|
	{
 | 
						|
		$this->iterator = $iterator;
 | 
						|
		$this->layouts = $this->availableLayouts();
 | 
						|
		$this->descending = $iterator->isDescending();
 | 
						|
	}
 | 
						|
 | 
						|
	public function __destruct()
 | 
						|
	{
 | 
						|
		$this->iterator->clean();
 | 
						|
	}
 | 
						|
 | 
						|
	private function availableLayouts()
 | 
						|
	{
 | 
						|
		static $layouts = [
 | 
						|
			// Single panorama
 | 
						|
			'panorama' => [Image::TYPE_PANORAMA],
 | 
						|
 | 
						|
			// A whopping six landscapes?
 | 
						|
			'sixLandscapes' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE,
 | 
						|
								Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],
 | 
						|
 | 
						|
			// Big-small juxtapositions
 | 
						|
			'sidePortrait' => [Image::TYPE_PORTRAIT, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE,
 | 
						|
													 Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],
 | 
						|
			'sideLandscape' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],
 | 
						|
 | 
						|
			// Single row of three
 | 
						|
			'threeLandscapes' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],
 | 
						|
			'threePortraits' => [Image::TYPE_PORTRAIT, Image::TYPE_PORTRAIT, Image::TYPE_PORTRAIT],
 | 
						|
 | 
						|
			// Dual layouts
 | 
						|
			'dualLandscapes' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],
 | 
						|
			'dualPortraits' => [Image::TYPE_PORTRAIT, Image::TYPE_PORTRAIT],
 | 
						|
			'dualMixed' => [Image::TYPE_LANDSCAPE, Image::TYPE_PORTRAIT],
 | 
						|
 | 
						|
			// Fallback layouts
 | 
						|
			'singleLandscape' => [Image::TYPE_LANDSCAPE],
 | 
						|
			'singlePortrait' => [Image::TYPE_PORTRAIT],
 | 
						|
		];
 | 
						|
 | 
						|
		return $layouts;
 | 
						|
	}
 | 
						|
 | 
						|
	private static function daysApart(DateTime $a, DateTime $b)
 | 
						|
	{
 | 
						|
		return $a->diff($b)->days;
 | 
						|
	}
 | 
						|
 | 
						|
	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))
 | 
						|
			{
 | 
						|
				unset($this->queue[$i]);
 | 
						|
				return $image;
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		// Check whatever's next up!
 | 
						|
		while (($asset = $this->iterator->next()) && ($image = $asset->getImage()))
 | 
						|
		{
 | 
						|
			// 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()
 | 
						|
	{
 | 
						|
		$requiredImages = array_map('count', $this->layouts);
 | 
						|
		$currentImages = $this->fetchImages(self::NUM_BATCH_PHOTOS);
 | 
						|
		$selectedLayout = null;
 | 
						|
 | 
						|
		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 $image->getType() & $type_mask;
 | 
						|
	}
 | 
						|
 | 
						|
	private static function orderPhotosByPriority(Image $a, Image $b)
 | 
						|
	{
 | 
						|
		// Leave images of different types as-is
 | 
						|
		if ($a->isLandscape() !== $b->isLandscape())
 | 
						|
			return 0;
 | 
						|
 | 
						|
		// Otherwise, show images of highest priority first
 | 
						|
		$priority_diff = $a->getPriority() - $b->getPriority();
 | 
						|
		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)
 | 
						|
	{
 | 
						|
		$this->queue[] = $image;
 | 
						|
		$this->orderQueueByDate();
 | 
						|
	}
 | 
						|
}
 |