diff --git a/composer.json b/composer.json index 43fd765..0708187 100755 --- a/composer.json +++ b/composer.json @@ -4,8 +4,8 @@ "license": "bsd", "require": { - "xp-framework/compiler": "^8.5", - "xp-framework/imaging": "^10.1", + "xp-framework/compiler": "^8.6", + "xp-framework/imaging": "^10.2", "xp-framework/command": "^11.0", "xp-framework/networking": "^10.4", "xp-forge/web": "^3.8", @@ -17,6 +17,8 @@ "xp-forge/markdown": "^7.0", "xp-forge/yaml": "^6.0", "xp-forge/hashing": "^2.1", + "xp-forge/mongodb": "^1.6", + "xp-lang/xp-generics": "^0.7", "xp-forge/inject": "^5.4", "xp-forge/mongodb": "^2.0" }, diff --git a/src/main/handlebars/layout.handlebars b/src/main/handlebars/layout.handlebars index 79271af..afbce7a 100755 --- a/src/main/handlebars/layout.handlebars +++ b/src/main/handlebars/layout.handlebars @@ -522,6 +522,10 @@ aspect-ratio: auto; } + .image { + position: relative; + } + .image img, .image video { aspect-ratio: 15 / 10; object-fit: cover; @@ -529,6 +533,27 @@ box-shadow: .25rem .25rem 1rem rgb(0 0 0 / .2); } + .image .palette { + position: absolute; + bottom: 1rem; + left: 1rem; + display: flex; + gap: .5rem; + transform: scale(0, 0); + transition: transform ease-in-out 70ms; + } + + .image:hover .palette { + transform: scale(1, 1); + } + + .image .palette > * { + width: 2rem; + height: 2rem; + border-radius: 50%; + box-shadow: .25rem .25rem 1rem rgb(0 0 0 / .2); + } + #map { margin-top: 1rem; width: 100%; diff --git a/src/main/handlebars/partials/images.handlebars b/src/main/handlebars/partials/images.handlebars index fbefb55..719c9c8 100755 --- a/src/main/handlebars/partials/images.handlebars +++ b/src/main/handlebars/partials/images.handlebars @@ -10,6 +10,12 @@ {{title}}, {{date meta.dateTime format='d.m.Y H:i'}} {{/if}} + +
+ {{#each meta.palette}} + + {{/each}} +
{{/each}} diff --git a/src/main/php/de/thekid/dialog/Repository.php b/src/main/php/de/thekid/dialog/Repository.php index 25aae48..308fa9e 100755 --- a/src/main/php/de/thekid/dialog/Repository.php +++ b/src/main/php/de/thekid/dialog/Repository.php @@ -8,7 +8,7 @@ class Repository { private $passwords= Hashing::sha256(); - public function __construct(private Database $database) { } + public function __construct(public readonly Database $database) { } /** Authenticates a given user, returning NULL on failure */ public function authenticate(string $user, Secret $secret): ?Document { diff --git a/src/main/php/de/thekid/dialog/Scripts.php b/src/main/php/de/thekid/dialog/Scripts.php index e07fc8f..4fe04f0 100755 --- a/src/main/php/de/thekid/dialog/Scripts.php +++ b/src/main/php/de/thekid/dialog/Scripts.php @@ -12,7 +12,7 @@ * ```html * * ``` */ diff --git a/src/main/php/de/thekid/dialog/api/Entries.php b/src/main/php/de/thekid/dialog/api/Entries.php index 621a40d..b4089a2 100755 --- a/src/main/php/de/thekid/dialog/api/Entries.php +++ b/src/main/php/de/thekid/dialog/api/Entries.php @@ -2,6 +2,7 @@ use de\thekid\dialog\{Repository, Storage}; use io\File; +use text\json\Json; use util\Date; use web\rest\{Async, Delete, Entity, Put, Resource, Request, Response, Value}; @@ -57,6 +58,7 @@ public function upload(#[Value] $user, string $id, string $name, #[Request] $req foreach ($multipart->files() as $file) { yield from $file->transmit(new File($f, $file->name())); } + $meta= $req->param('meta') ?? Json::read($req->param('meta-inf', '{}')); // Fetch entry again, it might have changed in the meantime! $images= $this->repository->entry($id, published: false)['images'] ?? []; @@ -66,7 +68,7 @@ public function upload(#[Value] $user, string $id, string $name, #[Request] $req $image= [ 'name' => $name, 'modified' => time(), - 'meta' => (array)$req->param('meta') + ['dateTime' => gmdate('c')], + 'meta' => (array)$meta + ['dateTime' => gmdate('c')], 'is' => [$is => true] ]; foreach ($images as $i => $existing) { diff --git a/src/main/php/de/thekid/dialog/color/Box.php b/src/main/php/de/thekid/dialog/color/Box.php new file mode 100755 index 0000000..41aaea6 --- /dev/null +++ b/src/main/php/de/thekid/dialog/color/Box.php @@ -0,0 +1,146 @@ +colors() as $color) { + foreach ($color as $i => $component) { + if ($component < $min[$i]) $min[$i]= $component; + if ($component > $max[$i]) $max[$i]= $component; + } + } + return new self([$min[0], $max[0], $min[1], $max[1], $min[2], $max[2]], $histogram); + } + + /** Creates a copy of this box */ + public function copy(): self { + return new self($this->components, $this->histogram); + } + + /** Volume */ + public function volume(): int { + return ( + ($this->components[1] - $this->components[0] + 1) * + ($this->components[3] - $this->components[2] + 1) * + ($this->components[5] - $this->components[4] + 1) + ); + } + + /** Total color count in this box */ + public function count(): int { + [$rl, $ru, $gl, $gu, $bl, $bu]= $this->components; + + $n= 0; + if ($this->volume() > $this->histogram->size()) { + foreach ($this->histogram->colors() as $count => $c) { + if ($c[0] >= $rl && $c[0] <= $ru && $c[1] >= $gl && $c[1] <= $gu && $c[2] >= $bl && $c[2] <= $bu) { + $n+= $count; + } + } + } else { + for ($r= $rl; $r <= $ru; $r++) { + for ($g= $gl; $g <= $gu; $g++) { + for ($b= $bl; $b <= $bu; $b++) { + $n+= $this->histogram->frequency($r, $g, $b); + } + } + } + } + + return $n; + } + + /** Returns average color for this box */ + public function average(): array { + static $m= 1 << 3; + + [$rl, $ru, $gl, $gu, $bl, $bu]= $this->components; + $n= $rs= $gs= $bs= 0; + for ($r= $rl; $r <= $ru; $r++) { + for ($g= $gl; $g <= $gu; $g++) { + for ($b= $bl; $b <= $bu; $b++) { + $h= $this->histogram->frequency($r, $g, $b); + $n+= $h; + $rs+= ($h * ($r + 0.5) * $m); + $gs+= ($h * ($g + 0.5) * $m); + $bs+= ($h * ($b + 0.5) * $m); + } + } + } + + if ($n > 0) { + return [(int)($rs / $n), (int)($gs / $n), (int)($bs / $n)]; + } else { + return [ + min((int)($m * ($rl + $ru + 1) / 2), 255), + min((int)($m * ($gl + $gu + 1) / 2), 255), + min((int)($m * ($bl + $bu + 1) / 2), 255), + ]; + } + } + + /** Returns median boxes or NULL */ + public function median(): ?array { + if (1 === $this->count()) return [$this->copy(), null]; + + // Cut using longest axis + $r= $this->components[1] - $this->components[0]; + $g= $this->components[3] - $this->components[2]; + $b= $this->components[5] - $this->components[4]; + + // Rearrange colors + if ($r >= $g && $r >= $b) { + [$c1, $c2]= [0, 1]; + [$il, $iu, $jl, $ju, $kl, $ku]= $this->components; + $c= [&$i, &$j, &$k]; + } else if ($g >= $r && $g >= $b) { + [$c1, $c2]= [2, 3]; + [$jl, $ju, $il, $iu, $kl, $ku]= $this->components; + $c= [&$j, &$i, &$k]; + } else { + [$c1, $c2]= [4, 5]; + [$jl, $ju, $kl, $ku, $il, $iu]= $this->components; + $c= [&$j, &$k, &$i]; + } + + $total= 0; + $partial= []; + for ($i= $il; $i <= $iu; $i++) { + $sum= 0; + for ($j= $jl; $j <= $ju; $j++) { + for ($k= $kl; $k <= $ku; $k++) { + $sum+= $this->histogram->frequency(...$c); + } + } + $total+= $sum; + $partial[$i]= $total; + } + + for ($i= $il, $h= $total / 2; $i <= $iu; $i++) { + if ($partial[$i] <= $h) continue; + + $push= $this->copy(); + $add= $this->copy(); + + // Choose the cut plane + $l= $i - $il; + $r= $iu - $i; + $d= $l <= $r ? min($iu - 1, (int)($i + $r / 2)) : max($il, (int)($i - 1 - $l / 2)); + + while (empty($partial[$d])) $d++; + while ($partial[$d] >= $total && !empty($partial[$d - 1])) $d--; + + $push->components[$c2]= $d; + $add->components[$c1]= $d + 1; + return [$push, $add]; + } + + return null; + } +} diff --git a/src/main/php/de/thekid/dialog/color/Colors.php b/src/main/php/de/thekid/dialog/color/Colors.php new file mode 100755 index 0000000..105aa32 --- /dev/null +++ b/src/main/php/de/thekid/dialog/color/Colors.php @@ -0,0 +1,119 @@ + $queue, float $target): void { + $colors= $queue->size(); + $iteration= 0; + do { + if ($colors >= $target) return; + + $box= $queue->pop(); + if (0 === $box->count()) { + $queue->push($box); + continue; + } + + [$push, $add]= $box->median(); + $queue->push($push); + if ($add) { + $queue->push($add); + $colors++; + } + } while ($iteration++ < self::MAX_ITERATIONS); + } + + /** + * Color quantization from a given histogram + * + * @throws lang.IllegalArgumentException if no palette can be computed + */ + private function quantize(Histogram $histogram, int $colors): array { + + // Check border case, e.g. for empty images + if ($histogram->empty()) { + throw new IllegalArgumentException('Cannot quantize using an empty histogram'); + } + + $queue= new PriorityQueue(); + $queue->push(Box::from($histogram)); + + // First set of colors, sorted by population + $this->iterate( + $queue->comparing(fn($a, $b) => $a->count() <=> $b->count()), + self::FRACT_BY_POPULATIONS * $colors + ); + + // Next set - generate the median cuts using the (npix * vol) sorting. + $this->iterate( + $queue->comparing(fn($a, $b) => ($a->count() * $a->volume()) <=> ($b->count() * $b->volume())), + $colors + ); + + $colors= []; + while ($box= $queue->pop()) { + $colors[]= new Color(...$box->average()); + } + return $colors; + } + + /** + * Returns histogram for a given image. Uses complete image by default + * but may be given a 4-element array as follows: `[x, y, w, h]`. + * + * The computed histogram for an image can be recycled to derive palette + * and dominant color. + */ + public function histogram(Image $source, ?array $area= null): Histogram { + [$x, $y, $w, $h]= $area ?? [0, 0, ...$source->getDimensions()]; + + $histogram= new Histogram(); + for ($i= 0, $n= $w * $h; $i < $n; $i+= $this->quality) { + $color= $source->colorAt($x + ($i % $w), (int)($y + $i / $w)); + $histogram->add($color->red, $color->green, $color->blue); + } + return $histogram; + } + + /** + * Returns the dominant color in an image or histogram + * + * @throws lang.IllegalArgumentException if palette is empty + */ + public function dominant(Image|Histogram $source): ?Color { + return current($this->quantize( + $source instanceof Histogram ? $source : $this->histogram($source), + self::DOMINANT + )); + } + + /** + * Returns a palette with a given size for an image or histogram + * + * @throws lang.IllegalArgumentException if palette is empty + */ + public function palette(Image|Histogram $source, int $size= 10): array { + return $this->quantize($source instanceof Histogram ? $source : $this->histogram($source), $size); + } +} \ No newline at end of file diff --git a/src/main/php/de/thekid/dialog/color/Histogram.php b/src/main/php/de/thekid/dialog/color/Histogram.php new file mode 100755 index 0000000..5e77c75 --- /dev/null +++ b/src/main/php/de/thekid/dialog/color/Histogram.php @@ -0,0 +1,40 @@ + self::THRESHOLD && $g > self::THRESHOLD && $b > self::THRESHOLD) return; + + $i= (($r >> 3) << 10) | (($g >> 3) << 5) | ($b >> 3); + $this->colors[$i]= ($this->colors[$i] ?? 0) + 1; + } + + /** Returns whether this histogram is empty */ + public function empty(): bool { + return empty($this->colors); + } + + /** Returns this histogram's size */ + public function size(): int { + return sizeof($this->colors); + } + + /** Returns the frequency of a given color */ + public function frequency(int $r, int $g, int $b): int { + return $this->colors[($r << 10) | ($g << 5) | $b] ?? 0; + } + + public function colors() { + foreach ($this->colors as $i => $count) { + yield $count => [($i >> 10) & 31, ($i >> 5) & 31, $i & 31]; + } + } +} diff --git a/src/main/php/de/thekid/dialog/color/PriorityQueue.php b/src/main/php/de/thekid/dialog/color/PriorityQueue.php new file mode 100755 index 0000000..b51d03f --- /dev/null +++ b/src/main/php/de/thekid/dialog/color/PriorityQueue.php @@ -0,0 +1,33 @@ + { + private $elements= []; + private $sorted= true; + private $comparator= null; + + public function comparing(?function(E, E): int $comparator): self { + $this->comparator= $comparator; + return $this; + } + + /** Returns size */ + public function size(): int { + return sizeof($this->elements); + } + + /** Pushes an element */ + public function push(E $element): void { + $this->elements[]= $element; + $this->sorted= false; + } + + /** Pops an element */ + public function pop(): ?E { + if (!$this->sorted) { + $this->comparator ? usort($this->elements, $this->comparator) : sort($this->elements); + $this->sorted= true; + } + return array_pop($this->elements); + } +} diff --git a/src/main/php/de/thekid/dialog/import/LocalDirectory.php b/src/main/php/de/thekid/dialog/import/LocalDirectory.php index c5829ff..28e53a2 100755 --- a/src/main/php/de/thekid/dialog/import/LocalDirectory.php +++ b/src/main/php/de/thekid/dialog/import/LocalDirectory.php @@ -1,9 +1,11 @@ $args, $redirect= null): throw new IllegalStateException($p->getCommandLine().' exited with exit code '.$r); } + private function hsl($color) { + $r= $color->red / 255; + $g= $color->green / 255; + $b= $color->blue / 255; + + $max= max($r, $g, $b); + $min= min($r, $g, $b); + + $lum = ($max + $min) / 2; + + if ($max === $min) { + $hue= $sat= 0; + } else { + $c= $max - $min; + $sat= $c / (1 - abs(2 * $lum - 1)); + $hue= match ($max) { + $r => ($g - $b) / $c + ($g < $b ? 6 : 0), + $g => ($b - $r) / $c + 2, + $b => ($r - $g) / $c + 4, + }; + } + + return ['h' => round($hue * 60), 's' => round($sat * 100), 'l' => round($lum * 100)]; + } + /** Runs this command */ public function run(): int { $files= new Files() @@ -129,10 +158,22 @@ public function run(): int { } } - $upload= new RestUpload($this->api, $resource->request('PUT')->waiting(read: 3600)); - foreach ($processing->meta($source) as $key => $value) { - $upload->pass('meta['.$key.']', $value); + // Extract meta information, aggregating palette from preview image + $info= [...$processing->meta($source)] + ['palette' => []]; + try { + $palette= $this->colors->palette( + Image::loadFrom(new JpegStreamReader($transfer['preview'])), + Colors::DOMINANT + ); + foreach ($palette as $color) { + $info['palette'][]= $this->hsl($color); + } + } catch ($e) { + // Ignore palette } + + $upload= new RestUpload($this->api, $resource->request('PUT')->waiting(read: 3600)); + $upload->pass('meta-inf', Json::of($info)); foreach ($transfer as $kind => $file) { $upload->transfer($kind, $file->in(), $file->filename); } diff --git a/src/main/php/de/thekid/dialog/web/Search.php b/src/main/php/de/thekid/dialog/web/Search.php index 4cdaa2f..1a321d2 100755 --- a/src/main/php/de/thekid/dialog/web/Search.php +++ b/src/main/php/de/thekid/dialog/web/Search.php @@ -13,6 +13,49 @@ public function __construct(private Repository $repository) { } #[Get] public function search(#[Param] $q, #[Param] $page= 1) { + if ('(' === ($q[0] ?? null)) { + $hsl= sscanf($q, '(%d,%d,%d)'); + $range= [ + 'h' => [max(0, $hsl[0] - 5), min(360, $hsl[0] + 5)], + 's' => [max(0, $hsl[1] - 20), min(100, $hsl[1] + 20)], + 'l' => [max(0, $hsl[2] - 20), min(100, $hsl[2] + 20)], + ]; + + $cursor= $this->repository->database->collection('entries')->aggregate([ + ['$match' => [ + 'images.meta.palette.h' => ['$gte' => $range['h'][0], '$lte' => $range['h'][1]], + 'images.meta.palette.s' => ['$gte' => $range['s'][0], '$lte' => $range['s'][1]], + 'images.meta.palette.l' => ['$gte' => $range['l'][0], '$lte' => $range['l'][1]], + ]] + ]); + + $images= fn() => { + foreach ($cursor as $result) { + foreach ($result['images'] as $image) { + $color= $image['meta']['palette'][0]; + if ( + ($color['h'] >= $range['h'][0] && $color['h'] <= $range['h'][1]) && + ($color['s'] >= $range['s'][0] && $color['s'] <= $range['s'][1]) && + ($color['l'] >= $range['l'][0] && $color['l'] <= $range['l'][1]) + ) { + yield $image + ['in' => ['slug' => $result['slug']], 'matching' => $color]; + } + } + } + }; + + // Search all of these + $colors= []; + $colors[]= ['h' => $range['h'][0], 's' => $range['s'][0], 'l' => $range['l'][0]]; + $colors[]= ['h' => $range['h'][0], 's' => $range['s'][0], 'l' => $hsl[2]]; + $colors[]= ['h' => $range['h'][0], 's' => $hsl[1], 'l' => $hsl[2]]; + $colors[]= ['h' => $hsl[0], 's' => $hsl[1], 'l' => $hsl[2]]; + $colors[]= ['h' => $range['h'][1], 's' => $hsl[1], 'l' => $hsl[2]]; + $colors[]= ['h' => $range['h'][1], 's' => $range['s'][1], 'l' => $hsl[2]]; + $colors[]= ['h' => $range['h'][1], 's' => $range['s'][1], 'l' => $range['l'][1]]; + return View::named('colors')->with(['results' => ['images' => [...$images()]], 'colors' => $colors]); + } + $this->timer->start(); $result= $this->repository->search(trim($q), $this->pagination, $page); $this->timer->stop(); diff --git a/src/test/php/de/thekid/dialog/unittest/PriorityQueueTest.php b/src/test/php/de/thekid/dialog/unittest/PriorityQueueTest.php new file mode 100755 index 0000000..4ab05e7 --- /dev/null +++ b/src/test/php/de/thekid/dialog/unittest/PriorityQueueTest.php @@ -0,0 +1,86 @@ + $queue): array { + $elements= []; + while (null !== $element= $queue->pop()) { + $elements[]= $element; + } + return $elements; + } + + #[Test] + public function can_create() { + new PriorityQueue(); + } + + #[Test] + public function initially_empty() { + Assert::equals(0, new PriorityQueue()->size()); + } + + #[Test] + public function size_after_pushing() { + $queue= new PriorityQueue(); + $queue->push('Test'); + + Assert::equals(1, $queue->size()); + } + + #[Test] + public function pop_on_empty_queue() { + Assert::null(new PriorityQueue()->pop()); + } + + #[Test] + public function push_and_pop_roundtrip() { + $queue= new PriorityQueue(); + $queue->push('Test'); + + Assert::equals('Test', $queue->pop()); + } + + #[Test] + public function pop_after_end() { + $queue= new PriorityQueue(); + $queue->push('Test'); + $queue->pop(); + + Assert::null($queue->pop()); + } + + #[Test] + public function pop_returns_elements_according_to_their_sort_order() { + $queue= new PriorityQueue(); + $queue->push('B'); + $queue->push('A'); + $queue->push('C'); + + Assert::equals(['C', 'B', 'A'], $this->all($queue)); + } + + #[Test] + public function using_comparator() { + $queue= new PriorityQueue()->comparing(fn(string $a, string $b): int => $b <=> $a); + $queue->push('B'); + $queue->push('A'); + $queue->push('C'); + + Assert::equals(['A', 'B', 'C'], $this->all($queue)); + } + + #[Test] + public function using_default_comparator() { + $queue= new PriorityQueue()->comparing(null); + $queue->push('B'); + $queue->push('A'); + $queue->push('C'); + + Assert::equals(['C', 'B', 'A'], $this->all($queue)); + } +} \ No newline at end of file