forked from Public/pics
		
	PhotoMosaic: fit batch of photos to best layout instead
This commit is contained in:
		
							parent
							
								
									d2fa547257
								
							
						
					
					
						commit
						553744aeb5
					
				| @ -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; | ||||||
|  | |||||||
| @ -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()) | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user