blog updates. more demo assets, SURF-attempts and more

This commit is contained in:
2026-07-09 18:03:37 +02:00
parent a87f00174e
commit c09219c462
15 changed files with 5168 additions and 424 deletions
+1304 -196
View File
File diff suppressed because it is too large Load Diff
+1176 -74
View File
File diff suppressed because it is too large Load Diff
+675 -149
View File
@@ -2,19 +2,33 @@ body {
margin: 0;
padding: 0;
color: var(--foreground);
background: var(--background) url('bg.jpeg');
background-size: cover;
background: var(--background);
font-family: sans-serif;
height: 100%;
overflow: hidden;
font-size: 14px;
}
#wrapper {
overflow-x: hidden;
overflow-y: auto;
/* Fixed background layer, same construction as the landing page */
#bg {
position: fixed;
width: 100vw;
height: 100vh;
z-index: -1;
background: var(--background);
}
#bg img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 50%;
opacity: 0;
transition: opacity 3s linear;
}
#bg img.loaded {
opacity: 1;
}
h1, h2, h3, h4, h5, h6 {
@@ -25,7 +39,7 @@ h1, h2, h3, h4, h5, h6 {
h1 {
margin-top: 0;
font-size: 24px;
font-size: 28px;
}
h2 {
@@ -34,45 +48,98 @@ h2 {
p {
margin-top: 2px;
line-height: 1.5;
}
a {
color: var(--color2);
text-decoration: none;
font-weight: bold;
}
strong {
color: var(--color4);
a:hover {
color: var(--color3);
}
em {
color: var(--color3);
}
sup.fn {
font-size: 10px;
}
sup.fn a {
font-weight: normal;
}
pre {
margin: 0;
margin: 8px 0;
max-width: 640px;
}
pre > code {
border: 1px solid;
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
display: block;
padding: 8px 12px;
overflow-x: auto;
font-size: 13px;
}
article {
max-width: 1024px;
min-height: 128px;
margin: 25px auto;
padding: 20px;
background: color-mix(in srgb, var(--background) 90%, transparent);
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.9);
}
code.block {
display: block;
@media (max-width: 1064px) {
article {
margin: 15px 12px;
}
}
img {
max-width: 100%;
}
math[display="block"] {
margin: 12px 0 12px 24px;
font-size: 17px;
color: var(--color4);
}
aside.explainer {
border-left: 3px solid var(--color4);
background: color-mix(in srgb, var(--foreground) 5%, transparent);
padding: 8px 12px;
margin: 12px 0;
font-size: 13px;
}
.rewrite-target {
text-decoration: underline dashed var(--color3);
text-underline-offset: 3px;
}
.rewrite-target::after {
content: ' ✎';
color: var(--color3);
font-size: 11px;
}
.attribution {
font-size: 12px;
opacity: 0.7;
}
.big-number {
font-size: 26px;
color: var(--color1);
margin: 8px 0 4px 0;
}
/* Demo */
.needs-image {
@@ -88,135 +155,107 @@ img {
min-width: 256px;
}
/* Resize demo */
.sample {
height: 48px;
margin: 0 4px 4px 0;
cursor: pointer;
vertical-align: middle;
}
#resize {
.sample:hover {
outline: 2px solid var(--color1);
}
/* Comparison slider */
.compare {
position: relative;
height: 512px;
max-width: 512px;
overflow: hidden;
touch-action: none;
cursor: ew-resize;
user-select: none;
}
.compare img {
display: block;
width: 100%;
transition: height 2s linear;
pointer-events: none;
}
#resize.resize {
height: 256px;
}
#resize .image-original {
/* The top image stretches to the base image's box, so differently
sized results (8x8 thumbs, crops) stay comparable under the divider */
.compare .compare-b {
position: absolute;
width: 100%;
height: 512px;
top: 0;
left: 0;
height: 100%;
object-fit: fill;
clip-path: inset(0 0 0 50%);
}
#resize .image-original img {
max-height: 50vh;
min-height: 257px;
max-width: 100%;
min-width: 257px;
/* height: 512px; */
}
#resize-original {
position: relative;
height: 512px;
}
#resize-original img {
.compare .compare-divider {
position: absolute;
max-width: 90vw;
max-height: 512px;
min-width: 257px;
min-height: 257px;
opacity: 1;
transition:
width 2s linear,
height 2s linear,
filter 3s linear 2s,
opacity 3s ease-in 5s;
top: 0;
bottom: 0;
left: 50%;
width: 2px;
margin-left: -1px;
background: var(--color1);
}
#resize.resize #resize-original img {
width: 256px !important;
height: 256px !important;
filter: grayscale(1) blur(10px);
opacity: 0;
}
#resize-original img.grayscale {
filter: grayscale(1) blur(10px);
}
#resize-original img.fade {
opacity: 0;
}
#image-resize img {
width: 256px;
height: 256px;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
}
#image-dct {
margin-left: 32px;
display:grid;
grid-template-columns: repeat(8, 32px);
}
#image-dct > div {
height: 32px;
line-height:32px;
font-size: 11px;
.compare .compare-handle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 28px;
height: 28px;
line-height: 28px;
text-align: center;
border: 1px solid var(--color1);
box-sizing: border-box;
border-radius: 50%;
background: var(--color1);
color: var(--background);
font-size: 15px;
}
.similarity-example {
display: flex;
flex-flow: row nowrap;
align-items: flex-end;
.compare .compare-label {
position: absolute;
top: 6px;
padding: 2px 6px;
font-size: 11px;
background: color-mix(in srgb, var(--background) 75%, transparent);
color: var(--foreground);
}
figure {
margin: 10px;
width: 50%;
.compare .compare-label.a {
left: 6px;
}
/* Loader */
#loader.show {
border-radius: 50%;
width: 256px;
height: 256px;
font-size: 10px;
position: fixed;
z-index: 99;
top: calc(50vh - 128px);
left: calc(50vw - 128px);
border: 16px solid transparent;
border-top: 16px solid darkred;
transform: translateZ(0);
animation: loader 2s infinite ease-in-out;
.compare .compare-label.b {
right: 6px;
}
@keyframes loader {
0% {
transform: rotate(0deg);
}
50% {
transform: rotate(360deg);
}
100% {
transform: rotate(360deg);
}
}
/* Skeleton */
/* Busy indicator while the worker renders a mutation */
.todo {
opacity: 0.55;
font-style: italic;
border-left: 3px solid var(--color3);
padding-left: 8px;
.compare.busy::after {
content: 'mutating…';
position: absolute;
right: 8px;
bottom: 8px;
padding: 3px 9px;
background: color-mix(in srgb, var(--background) 80%, transparent);
color: var(--color1);
font-size: 12px;
animation: busy-pulse 1s ease-in-out infinite;
}
.distances.busy td {
opacity: 0.35;
}
@keyframes busy-pulse {
50% { opacity: 0.35; }
}
/* Hash visualisation */
@@ -233,6 +272,14 @@ figure {
letter-spacing: 2px;
}
.hash-value.small {
display: block;
margin-top: 4px;
font-size: 12px;
letter-spacing: 1px;
opacity: 0.8;
}
.bit-grid {
display: grid;
grid-template-columns: repeat(8, 20px);
@@ -248,6 +295,14 @@ figure {
border: 1px solid var(--background);
}
.bit-grid.small {
grid-template-columns: repeat(8, 13px);
}
.bit-grid.small .bit {
height: 13px;
}
.bit-grid .bit.on {
background: var(--foreground);
}
@@ -256,6 +311,20 @@ figure {
background: color-mix(in srgb, var(--foreground) 12%, transparent);
}
/* Changed bits keep their on/off brightness and get a red tint + border,
so light-changed and dark-changed cells stay distinguishable */
.bit-grid .bit.diff {
border-color: hsl(4 70% 55%);
}
.bit-grid .bit.on.diff {
background: color-mix(in srgb, var(--foreground) 55%, hsl(4 70% 50%));
}
.bit-grid .bit.off.diff {
background: hsl(4 60% 50% / 0.25);
}
.heat-grid {
display: grid;
grid-template-columns: repeat(8, 32px);
@@ -266,7 +335,15 @@ figure {
box-sizing: border-box;
}
.pixelated, #image-resize img, #phash-resize img {
.heat-grid.mini {
grid-template-columns: repeat(8, 22px);
}
.heat-grid.mini > div {
height: 22px;
}
.pixelated, #phash-resize img, .input-thumb img {
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
}
@@ -276,71 +353,520 @@ figure {
height: 128px;
}
.sample {
height: 48px;
margin: 0 4px;
cursor: pointer;
vertical-align: middle;
.input-thumb img {
width: 176px;
height: 176px;
}
/* Mutation compare */
/* DCT extras */
.compare {
.grid-overlay {
position: relative;
max-width: 640px;
width: 256px;
}
.compare img {
display: block;
width: 100%;
.grid-overlay.mini {
width: 176px;
}
.compare #compare-b {
#dct-basis-wrap {
position: relative;
display: inline-block;
}
.basis-hover {
display: none;
position: absolute;
outline: 2px solid var(--color1);
outline-offset: -1px;
pointer-events: none;
}
#dct-basis {
cursor: crosshair;
}
#dct-formula .formula-result {
display: none;
}
#dct-formula.live .formula-result {
display: inline;
}
#dct-formula.live .var-u,
#dct-formula.live .var-v,
#dct-formula.live .formula-value {
color: var(--color1);
}
#image-dct .hover-cell {
outline: 2px solid var(--color1);
outline-offset: -2px;
}
#dct-zigzag {
position: absolute;
top: 0;
left: 0;
clip-path: inset(0 0 0 50%);
pointer-events: none;
}
#compare-slider, #mutator-amount {
width: 320px;
#dct-zigzag polyline {
stroke: var(--color1);
stroke-width: 3;
stroke-linejoin: round;
}
#dct-zigzag polyline.play {
transition: stroke-dashoffset 2.5s ease-in-out;
}
#dct-basis {
background: var(--background);
}
/* Reconstruction demos */
.recon-row {
display: flex;
flex-flow: row wrap;
gap: 24px;
align-items: flex-start;
margin: 10px 0;
}
.recon-row figure {
margin: 0;
text-align: center;
}
.recon-row figcaption {
font-size: 12px;
opacity: 0.8;
margin-top: 4px;
}
.recon-row canvas {
width: 176px;
height: 176px;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
}
#recon-k {
width: 256px;
vertical-align: middle;
}
.recon-note {
font-size: 12px;
opacity: 0.7;
margin-left: 8px;
}
#recon-zigzag {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
#recon-zigzag polyline {
stroke-width: 2.5;
stroke-linejoin: round;
transition: stroke-dashoffset 0.12s linear;
}
#recon-zigzag .zig-head {
stroke: var(--color1);
}
#recon-zigzag .zig-tail {
stroke: color-mix(in srgb, var(--foreground) 45%, transparent);
}
/* Tables */
table.facts {
border-collapse: collapse;
margin: 10px 0;
}
table.facts th, table.facts td {
text-align: left;
padding: 4px 14px 4px 0;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
font-size: 13px;
}
table.facts th {
color: var(--color1);
font-weight: normal;
}
.distances td {
text-align: center;
font-size: 18px;
min-width: 64px;
}
.distances td.match {
color: hsl(135 50% 45%);
}
.distances td.nomatch {
color: hsl(4 70% 55%);
}
/* LSH demo */
.lsh-thumb img {
height: 110px;
margin-bottom: 6px;
display: block;
}
.lsh-distance {
font-size: 16px;
}
.lsh-distance .match {
font-size: 22px;
color: hsl(135 50% 45%);
}
.lsh-distance .nomatch {
font-size: 22px;
color: hsl(4 70% 55%);
}
/* Flip demo */
#flip-stage img {
max-width: 256px;
max-height: 256px;
transition: transform 0.7s ease-in-out;
}
#flip-stage img.flipped {
transform: scaleX(-1);
}
#ghost-canvas {
transition: transform 0.4s ease-in-out;
}
#ghost-canvas.flipped {
transform: scaleX(-1);
}
.flip-table th {
color: var(--color1);
font-weight: normal;
text-align: left;
padding-right: 12px;
}
.flip-table td {
vertical-align: top;
padding: 6px 12px 6px 0;
}
.flip-distance {
font-size: 24px;
}
.flip-distance.match {
color: hsl(135 50% 45%);
}
.flip-distance.nomatch {
color: hsl(4 70% 55%);
}
/* Ranking */
.thumb-grid {
.pool {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 4px;
max-height: 330px;
overflow-y: auto;
margin: 10px 0;
}
.pool img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
cursor: pointer;
display: block;
}
.pool img:hover {
outline: 2px solid var(--color1);
}
.pool img.query {
outline: 3px solid var(--color1);
}
.ranking-results h3 {
margin-top: 14px;
}
.thumb-row {
display: flex;
flex-flow: row wrap;
gap: 8px;
}
.thumb-grid figure {
width: 96px;
.thumb-row figure {
width: 84px;
margin: 0;
cursor: pointer;
text-align: center;
}
.thumb-grid figure.query {
outline: 2px solid var(--color1);
.thumb-row figure.hit {
outline: 2px solid hsl(135 50% 45%);
}
.thumb-grid img {
width: 96px;
height: 96px;
.thumb-row img {
width: 84px;
height: 84px;
object-fit: cover;
display: block;
}
.thumb-grid figcaption {
.thumb-row figcaption {
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Precision-recall charts */
.pr-controls {
margin: 10px 0;
}
#pr-legend {
display: flex;
flex-flow: row wrap;
gap: 4px 14px;
margin-bottom: 8px;
}
.pr-group {
display: flex;
flex-flow: row wrap;
gap: 4px;
}
.pr-chip {
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
background: none;
color: var(--foreground);
font-size: 11px;
padding: 2px 7px;
cursor: pointer;
transition: opacity 0.15s;
}
.pr-chip.dim {
opacity: 0.3;
}
.pr-chip.pinned {
border-color: var(--color1);
}
.pr-slider {
display: inline-block;
margin: 4px 0;
color: var(--color4);
}
.pr-slider input {
width: 220px;
vertical-align: middle;
margin-left: 8px;
}
.pr-charts {
display: flex;
flex-flow: row wrap;
gap: 12px;
}
.pr-charts figure {
margin: 0;
flex: 1 1 300px;
max-width: 340px;
}
.pr-charts figcaption {
color: var(--color1);
font-size: 14px;
margin-bottom: 2px;
}
.pr-svg {
width: 100%;
height: auto;
background: color-mix(in srgb, var(--foreground) 3%, transparent);
}
.pr-grid {
stroke: color-mix(in srgb, var(--foreground) 12%, transparent);
stroke-width: 1;
}
.pr-tick {
fill: color-mix(in srgb, var(--foreground) 55%, transparent);
font-size: 9px;
}
.pr-axis {
fill: color-mix(in srgb, var(--foreground) 70%, transparent);
font-size: 11px;
}
.pr-line {
fill: none;
stroke-width: 1.8;
transition: opacity 0.15s;
}
.pr-dot {
stroke: var(--background);
stroke-width: 2;
transition: opacity 0.15s;
}
.pr-line.faded, .pr-dot.faded {
opacity: 0.12;
}
#pr-tooltip {
position: fixed;
z-index: 10;
background: color-mix(in srgb, var(--background) 92%, transparent);
border: 1px solid var(--color1);
color: var(--foreground);
font-size: 12px;
padding: 4px 8px;
pointer-events: none;
white-space: nowrap;
}
/* Retrieval demos */
.dist-figure, .toy-figure {
margin: 10px 0;
}
.dist-figure figcaption, .toy-figure figcaption {
font-size: 12px;
opacity: 0.8;
margin-top: 4px;
}
.dist-svg {
max-width: 720px;
}
.dist-bar {
fill: color-mix(in srgb, var(--foreground) 45%, transparent);
}
.dist-copy {
fill: hsl(135 50% 45%);
stroke: var(--background);
stroke-width: 1;
}
.dist-region {
fill: color-mix(in srgb, var(--color1) 10%, transparent);
}
.dist-thresh {
stroke: var(--color1);
stroke-width: 2;
}
.dist-readout, .toy-stats {
margin-left: 12px;
color: var(--color4);
}
.toy-node {
fill: color-mix(in srgb, var(--foreground) 30%, transparent);
transition: opacity 0.3s, fill 0.3s;
}
.toy-node.seen {
fill: color-mix(in srgb, var(--foreground) 10%, var(--background));
stroke: var(--foreground);
stroke-width: 2;
}
.toy-node.match {
fill: hsl(135 50% 45%);
}
.toy-node.pruned {
opacity: 0.15;
}
.toy-edge {
stroke: color-mix(in srgb, var(--foreground) 45%, transparent);
stroke-width: 1.5;
transition: opacity 0.3s;
}
.toy-edge-label {
fill: color-mix(in srgb, var(--foreground) 70%, transparent);
font-size: 9px;
transition: opacity 0.3s;
}
.toy-edge.pruned, .toy-edge-label.pruned {
opacity: 0.15;
}
.big-stats .stat-note {
font-size: 12px;
opacity: 0.7;
margin: 0;
max-width: 220px;
}
.big-curve-svg {
width: 250px;
}
.big-curve-line {
stroke: var(--color2);
}
.big-curve-dot {
fill: var(--color1);
}
/* Lists */
ul.loose-ends li, ol.references li {
margin-bottom: 6px;
line-height: 1.5;
}
ol.references li:target {
outline: 1px dashed var(--color1);
outline-offset: 4px;
}
+71 -5
View File
@@ -1,15 +1,32 @@
import init, { hash_all, resize_preview, dct_coefficients, dct_masks, phash_lowfreq, mutate } from './pkg/image_similarity.js';
import init, { hash_all, resize_preview, dct_coefficients, dct_masks, phash_lowfreq, mutate, suite_hashes, suite_labels, BkIndex } from './pkg/image_similarity.js';
const ready = init();
// The retrieval demos keep their trees in wasm memory between calls:
// a small one that gets visualized and the full experiment index.
let toyIndex = null;
let bigIndex = null;
// Average runtime of fn, sampled until enough wall time passed to
// outrun the clamped performance.now resolution
function time(fn) {
const start = performance.now();
let reps = 0;
do {
fn();
reps++;
} while (performance.now() - start < 20);
return (performance.now() - start) / reps;
}
onmessage = async (e) => {
const { id, op, args } = e.data;
await ready;
try {
const bytes = new Uint8Array(args.buffer);
let result;
switch (op) {
case 'pipeline':
case 'pipeline': {
const bytes = new Uint8Array(args.buffer);
result = {
resize8: resize_preview(bytes, 8),
resize32: resize_preview(bytes, 32),
@@ -19,14 +36,63 @@ onmessage = async (e) => {
hashes: hash_all(bytes),
};
break;
}
case 'hashes':
result = { hashes: hash_all(bytes) };
result = { hashes: hash_all(new Uint8Array(args.buffer)) };
break;
case 'mutate': {
const png = mutate(bytes, args.kind, args.amount);
const png = mutate(new Uint8Array(args.buffer), args.kind, args.amount);
result = { png, hashes: hash_all(png) };
break;
}
case 'suite':
result = {
hashes: suite_hashes(new Uint8Array(args.buffer)),
labels: suite_labels().split('\n'),
};
break;
case 'toy_build': {
toyIndex?.free();
toyIndex = new BkIndex();
for (const key of args.keys) toyIndex.insert(key);
result = { structure: toyIndex.structure(), size: toyIndex.len() };
break;
}
case 'toy_query':
result = {
found: toyIndex.find(args.key, args.radius),
visited: toyIndex.trace(args.key, args.radius),
};
break;
case 'big_build': {
bigIndex?.free();
bigIndex = new BkIndex();
bigIndex.insert_bytes(new Uint8Array(args.buffer));
result = { size: bigIndex.len() };
break;
}
case 'big_query': {
const found = bigIndex.find(args.key, args.radius);
result = {
found,
compared: bigIndex.find_compared(args.key, args.radius),
treeMs: time(() => bigIndex.find(args.key, args.radius)),
scanMs: time(() => bigIndex.scan(args.key, args.radius)),
};
break;
}
// compared counts and found counts for every radius up to max,
// so the radius slider works without a round trip per step
case 'big_profile': {
const compared = [];
const found = [];
for (let t = 0; t <= args.max; t++) {
compared.push(bigIndex.find_compared(args.key, t));
found.push(bigIndex.find(args.key, t).length / 2);
}
result = { compared, found };
break;
}
default:
throw new Error(`unknown op ${op}`);
}