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 @@
{{/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