awacke1 commited on
Commit
0663be1
·
verified ·
1 Parent(s): 13dc2fa

Create index.html

Browse files
Files changed (1) hide show
  1. index.html +671 -0
index.html ADDED
@@ -0,0 +1,671 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>DINOv3 Web - Interactive Selection</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
9
+ <style>
10
+ body {
11
+ font-family:
12
+ "Inter",
13
+ -apple-system,
14
+ BlinkMacSystemFont,
15
+ "Segoe UI",
16
+ Roboto,
17
+ Helvetica,
18
+ Arial,
19
+ sans-serif;
20
+ }
21
+ /* Custom styles for the range slider */
22
+ input[type="range"] {
23
+ -webkit-appearance: none;
24
+ appearance: none;
25
+ width: 100%;
26
+ height: 0.5rem;
27
+ background: #4a5568; /* gray-700 */
28
+ border-radius: 0.25rem;
29
+ outline: none;
30
+ opacity: 0.7;
31
+ transition: opacity 0.2s;
32
+ }
33
+ input[type="range"]:hover {
34
+ opacity: 1;
35
+ }
36
+ input[type="range"]::-webkit-slider-thumb {
37
+ -webkit-appearance: none;
38
+ appearance: none;
39
+ width: 1.25rem;
40
+ height: 1.25rem;
41
+ background: #90cdf4; /* blue-300 */
42
+ cursor: pointer;
43
+ border-radius: 50%;
44
+ }
45
+ input[type="range"]::-moz-range-thumb {
46
+ width: 1.25rem;
47
+ height: 1.25rem;
48
+ background: #90cdf4; /* blue-300 */
49
+ cursor: pointer;
50
+ border-radius: 50%;
51
+ }
52
+ /* Additional styles for the toggle switch */
53
+ #modeToggle:checked ~ .dot {
54
+ transform: translateX(1.5rem); /* 24px */
55
+ }
56
+ #modeToggle:checked ~ .block {
57
+ background-color: #3b82f6; /* blue-500 */
58
+ }
59
+ </style>
60
+ </head>
61
+ <body class="bg-gray-900 text-gray-300 flex flex-col items-center justify-center min-h-screen p-4 sm:p-6 lg:p-8">
62
+ <div
63
+ class="w-full max-w-3xl bg-gray-800/50 backdrop-blur-sm rounded-2xl shadow-2xl shadow-black/30 border border-gray-700 p-6 sm:p-8 text-center"
64
+ >
65
+ <h1
66
+ class="text-3xl sm:text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 mb-2"
67
+ >
68
+ DINOv3 Web
69
+ </h1>
70
+ <p class="text-gray-400 mb-8 max-w-xl mx-auto">
71
+ Visualize features locally. Hover to see the heatmap, then click on an object to select it.
72
+ </p>
73
+
74
+ <div class="space-y-6">
75
+ <div
76
+ id="dropZone"
77
+ class="relative flex flex-col items-center justify-center bg-gray-900/50 border-2 border-dashed border-gray-600 rounded-xl p-6 text-center group hover:border-blue-500 transition-colors duration-300"
78
+ >
79
+ <svg
80
+ class="w-12 h-12 mb-4 text-gray-500 group-hover:text-blue-500 transition-colors duration-300"
81
+ aria-hidden="true"
82
+ xmlns="http://www.w3.org/2000/svg"
83
+ fill="none"
84
+ viewBox="0 0 20 16"
85
+ >
86
+ <path
87
+ stroke="currentColor"
88
+ stroke-linecap="round"
89
+ stroke-linejoin="round"
90
+ stroke-width="1.5"
91
+ d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
92
+ />
93
+ </svg>
94
+ <p class="font-semibold text-gray-300">Click to upload or drag & drop</p>
95
+ <p class="text-xs text-gray-500 mb-2">PNG, JPG, or other image formats</p>
96
+ <p class="text-sm text-gray-400">
97
+ Or
98
+ <button
99
+ id="exampleBtn"
100
+ class="relative z-10 text-blue-400 hover:text-blue-300 font-semibold underline bg-transparent border-none cursor-pointer p-0"
101
+ >
102
+ try an example</button
103
+ >.
104
+ </p>
105
+ <label for="imageLoader" class="absolute inset-0 cursor-pointer z-0"></label>
106
+ <input type="file" id="imageLoader" accept="image/*" class="hidden" />
107
+ </div>
108
+
109
+ <div class="bg-gray-900/50 p-4 rounded-xl border border-gray-700 space-y-4">
110
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4 items-center">
111
+ <div class="flex items-center justify-center w-full space-x-3">
112
+ <label for="scaleSlider" class="text-sm font-medium text-gray-400 whitespace-nowrap">Scale:</label>
113
+ <input id="scaleSlider" type="range" min="0.25" max="4" step="0.25" value="1" class="w-full" />
114
+ <span id="scaleValue" class="text-sm font-medium text-gray-400 w-12 text-right">1.00x</span>
115
+ </div>
116
+ <div class="flex items-center justify-center space-x-3">
117
+ <span class="text-sm font-medium text-gray-400">Overlay</span>
118
+ <label for="modeToggle" class="flex items-center cursor-pointer">
119
+ <div class="relative">
120
+ <input type="checkbox" id="modeToggle" class="sr-only" />
121
+ <div class="block bg-gray-600 w-14 h-8 rounded-full"></div>
122
+ <div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition transform"></div>
123
+ </div>
124
+ </label>
125
+ <span class="text-sm font-medium text-gray-400">Heatmap</span>
126
+ </div>
127
+ </div>
128
+ </div>
129
+
130
+ <div id="status" class="flex items-center justify-center w-full font-medium text-gray-400 h-6">
131
+ <svg
132
+ id="spinner"
133
+ class="animate-spin mr-3 h-5 w-5 text-blue-400 hidden"
134
+ xmlns="http://www.w3.org/2000/svg"
135
+ fill="none"
136
+ viewBox="0 0 24 24"
137
+ >
138
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
139
+ <path
140
+ class="opacity-75"
141
+ fill="currentColor"
142
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
143
+ ></path>
144
+ </svg>
145
+ <span id="statusText"></span>
146
+ </div>
147
+
148
+ <div
149
+ id="canvasContainer"
150
+ class="w-full bg-gray-900/50 rounded-lg border border-gray-700 shadow-inner overflow-hidden min-h-[250px] flex items-center justify-center p-2"
151
+ >
152
+ <canvas id="imageCanvas" class="hidden rounded-lg cursor-crosshair block max-w-full h-auto"></canvas>
153
+ <div id="canvasPlaceholder" class="text-gray-500">Your image will appear here</div>
154
+ </div>
155
+
156
+ <div id="selectionTools" class="hidden bg-gray-900/50 p-4 rounded-xl border border-gray-700 space-y-4">
157
+ <h2 class="text-lg font-semibold text-gray-300">Selection Tools</h2>
158
+ <div class="flex items-center justify-center w-full space-x-3">
159
+ <label for="thresholdSlider" class="text-sm font-medium text-gray-400 whitespace-nowrap">Threshold:</label>
160
+ <input id="thresholdSlider" type="range" min="0" max="1" step="0.01" value="0.5" class="w-full" />
161
+ <span id="thresholdValue" class="text-sm font-medium text-gray-400 w-12 text-right">0.50</span>
162
+ </div>
163
+ <div id="selectionCanvasContainer" class="w-full bg-gray-900/50 rounded-lg border border-gray-700 shadow-inner overflow-hidden min-h-[250px] flex items-center justify-center p-2">
164
+ <canvas id="selectionCanvas" class="rounded-lg block max-w-full h-auto"></canvas>
165
+ </div>
166
+ <div class="flex justify-center items-center space-x-4 pt-2">
167
+ <button id="saveBtn" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg transition-colors">
168
+ Save Selection
169
+ </button>
170
+ <button id="clearBtn" class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-lg transition-colors">
171
+ Clear Selections
172
+ </button>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ </div>
177
+
178
+ <script type="module">
179
+ import { pipeline, RawImage, matmul } from "https://cdn.jsdelivr.net/npm/@huggingface/[email protected]";
180
+
181
+ // --- 1. Configuration & Global Variables ---
182
+ const MODEL_ID = "onnx-community/dinov3-vits16-pretrain-lvd1689m-ONNX";
183
+ const EXAMPLE_IMAGE_URL =
184
+ "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/cats.png";
185
+
186
+ // DOM Elements
187
+ const imageLoader = document.getElementById("imageLoader");
188
+ const exampleBtn = document.getElementById("exampleBtn");
189
+ const imageCanvas = document.getElementById("imageCanvas");
190
+ const ctx = imageCanvas.getContext("2d");
191
+ const spinner = document.getElementById("spinner");
192
+ const statusText = document.getElementById("statusText");
193
+ const canvasContainer = document.getElementById("canvasContainer");
194
+ const canvasPlaceholder = document.getElementById("canvasPlaceholder");
195
+ const dropZone = document.getElementById("dropZone");
196
+ const modeToggle = document.getElementById("modeToggle");
197
+ const scaleSlider = document.getElementById("scaleSlider");
198
+ const scaleValue = document.getElementById("scaleValue");
199
+
200
+ // New Selection Elements
201
+ const selectionTools = document.getElementById("selectionTools");
202
+ const thresholdSlider = document.getElementById("thresholdSlider");
203
+ const thresholdValue = document.getElementById("thresholdValue");
204
+ const selectionCanvas = document.getElementById("selectionCanvas");
205
+ const selectionCtx = selectionCanvas.getContext("2d");
206
+ const saveBtn = document.getElementById("saveBtn");
207
+ const clearBtn = document.getElementById("clearBtn");
208
+
209
+ // Application State
210
+ let extractor = null;
211
+ let similarityScores = null;
212
+ let originalImage = null;
213
+ let currentImageUrl = null;
214
+ let patchSize = null;
215
+ let isOverlayMode = true;
216
+ let lastHoverData = null;
217
+ let imageScale = 1.0;
218
+ let animationFrameId = null;
219
+ let lastMouseEvent = null;
220
+ let maxPixels = null;
221
+ let selectionThreshold = 0.5; // New state for selection
222
+ let selectedPatchesMask = new Set(); // New state for combined selections
223
+
224
+ // --- 2. Core Application Logic ---
225
+ function updateStatus(text, isLoading = false) {
226
+ statusText.textContent = text;
227
+ spinner.style.display = isLoading ? "block" : "none";
228
+ }
229
+
230
+ async function initialize() {
231
+ const isWebGpuSupported = !!navigator.gpu;
232
+ const isMobile = /Mobi|Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
233
+ navigator.userAgent,
234
+ );
235
+ maxPixels = isMobile ? 1048576 : 2097152;
236
+
237
+ const device = isWebGpuSupported ? "webgpu" : "wasm";
238
+ const dtype = isWebGpuSupported ? "q4" : "q8";
239
+
240
+ let statusMessage = `Loading model (${device.toUpperCase()})`;
241
+ if (isMobile) statusMessage += ". Mobile Detected.";
242
+ updateStatus(statusMessage, true);
243
+
244
+ try {
245
+ extractor = await pipeline("image-feature-extraction", MODEL_ID, {
246
+ device,
247
+ dtype,
248
+ });
249
+ extractor.processor.image_processor.do_resize = false;
250
+ patchSize = extractor.model.config.patch_size;
251
+ updateStatus("Ready. Please select an image.");
252
+ } catch (error) {
253
+ updateStatus("Failed to load the model. Please refresh.");
254
+ console.error("Model loading error:", error);
255
+ }
256
+
257
+ imageLoader.addEventListener("change", handleImageUpload);
258
+ exampleBtn.addEventListener("click", handleExample);
259
+ imageCanvas.addEventListener("mousemove", handleMouseMove);
260
+ imageCanvas.addEventListener("mouseleave", clearHighlights);
261
+ imageCanvas.addEventListener("touchmove", handleTouchMove);
262
+ imageCanvas.addEventListener("touchend", clearHighlights);
263
+ dropZone.addEventListener("dragover", handleDragOver);
264
+ dropZone.addEventListener("dragleave", handleDragLeave);
265
+ dropZone.addEventListener("drop", handleDrop);
266
+ modeToggle.addEventListener("change", handleModeChange);
267
+ scaleSlider.addEventListener("input", handleSliderInput);
268
+ scaleSlider.addEventListener("change", handleSliderChange);
269
+
270
+ // New Event Listeners
271
+ imageCanvas.addEventListener("click", handleCanvasClick);
272
+ thresholdSlider.addEventListener("input", handleThresholdChange);
273
+ saveBtn.addEventListener("click", handleSave);
274
+ clearBtn.addEventListener("click", handleClearSelections);
275
+ }
276
+
277
+ async function handleExample() {
278
+ updateStatus("Loading example image...", true);
279
+ try {
280
+ const response = await fetch(EXAMPLE_IMAGE_URL);
281
+ const blob = await response.blob();
282
+ loadImageOntoCanvas(URL.createObjectURL(blob));
283
+ } catch (error) {
284
+ updateStatus("Failed to load example image.");
285
+ console.error("Example load error:", error);
286
+ }
287
+ }
288
+
289
+ function handleImageUpload(event) {
290
+ const file = event.target.files[0];
291
+ if (file) loadImageOntoCanvas(URL.createObjectURL(file));
292
+ }
293
+
294
+ function handleDragOver(event) {
295
+ event.preventDefault();
296
+ dropZone.classList.add("border-blue-500", "bg-gray-800");
297
+ }
298
+
299
+ function handleDragLeave(event) {
300
+ event.preventDefault();
301
+ dropZone.classList.remove("border-blue-500", "bg-gray-800");
302
+ }
303
+
304
+ function handleDrop(event) {
305
+ event.preventDefault();
306
+ dropZone.classList.remove("border-blue-500", "bg-gray-800");
307
+ const file = event.dataTransfer.files[0];
308
+ if (file && file.type.startsWith("image/")) {
309
+ if (event.target.id === "exampleBtn") return;
310
+ loadImageOntoCanvas(URL.createObjectURL(file));
311
+ } else {
312
+ updateStatus("Please drop an image file.");
313
+ }
314
+ }
315
+
316
+ function handleModeChange(event) {
317
+ isOverlayMode = !event.target.checked;
318
+ if (lastHoverData) {
319
+ drawHighlights(lastHoverData.queryIndex, lastHoverData.allPatches);
320
+ } else {
321
+ clearHighlights();
322
+ }
323
+ }
324
+
325
+ function handleSliderInput(event) {
326
+ imageScale = parseFloat(event.target.value);
327
+ scaleValue.textContent = `${imageScale.toFixed(2)}x`;
328
+ }
329
+
330
+ function handleSliderChange() {
331
+ if (currentImageUrl) {
332
+ loadImageOntoCanvas(currentImageUrl);
333
+ }
334
+ }
335
+
336
+ function loadImageOntoCanvas(imageUrl) {
337
+ currentImageUrl = imageUrl;
338
+ originalImage = new Image();
339
+ originalImage.onload = async () => {
340
+ if (!patchSize) {
341
+ updateStatus("Error: Model not ready, patch size is unknown.");
342
+ return;
343
+ }
344
+
345
+ canvasPlaceholder.style.display = "none";
346
+ imageCanvas.style.display = "block";
347
+ selectionTools.style.display = 'block'; // Show selection tools
348
+
349
+ let newWidth = originalImage.naturalWidth * imageScale;
350
+ let newHeight = originalImage.naturalHeight * imageScale;
351
+
352
+ const numPixels = newWidth * newHeight;
353
+ if (numPixels > maxPixels) {
354
+ const scaleRatio = Math.sqrt(maxPixels / numPixels);
355
+ newWidth *= scaleRatio;
356
+ newHeight *= scaleRatio;
357
+ }
358
+
359
+ const croppedWidth = Math.floor(newWidth / patchSize) * patchSize;
360
+ const croppedHeight = Math.floor(newHeight / patchSize) * patchSize;
361
+
362
+ if (croppedWidth < patchSize || croppedHeight < patchSize) {
363
+ updateStatus("Scaled image is too small to process.");
364
+ imageCanvas.style.display = "none";
365
+ selectionTools.style.display = 'none';
366
+ canvasPlaceholder.style.display = "block";
367
+ canvasPlaceholder.textContent = "Scaled image is too small.";
368
+ return;
369
+ }
370
+
371
+ imageCanvas.width = croppedWidth;
372
+ imageCanvas.height = croppedHeight;
373
+ // Also set the selection canvas size
374
+ selectionCanvas.width = croppedWidth;
375
+ selectionCanvas.height = croppedHeight;
376
+
377
+ ctx.drawImage(originalImage, 0, 0, croppedWidth, croppedHeight);
378
+ handleClearSelections(); // Clear previous selections
379
+ await processImage();
380
+
381
+ setTimeout(() => {
382
+ canvasContainer.scrollIntoView({ behavior: "smooth", block: "center" });
383
+ }, 100);
384
+ };
385
+ originalImage.onerror = () => {
386
+ updateStatus("Failed to load the selected image.");
387
+ canvasPlaceholder.style.display = "block";
388
+ imageCanvas.style.display = "none";
389
+ selectionTools.style.display = 'none';
390
+ };
391
+ originalImage.src = imageUrl;
392
+ }
393
+
394
+ async function processImage() {
395
+ if (!extractor) return;
396
+ updateStatus("Analyzing image... 🧠", true);
397
+ similarityScores = null;
398
+ lastHoverData = null;
399
+ try {
400
+ const imageData = await RawImage.fromCanvas(imageCanvas);
401
+ const features = await extractor(imageData, { pooling: "none" });
402
+ const numRegisterTokens = extractor.model.config.num_register_tokens ?? 0;
403
+ const startIndex = 1 + numRegisterTokens;
404
+ const patchFeatures = features.slice(null, [startIndex, null]);
405
+ const normalizedFeatures = patchFeatures.normalize(2, -1);
406
+ const scores = await matmul(normalizedFeatures, normalizedFeatures.permute(0, 2, 1));
407
+ similarityScores = (await scores.tolist())[0];
408
+ updateStatus(
409
+ `Image processed (${imageCanvas.width}x${imageCanvas.height}). Hover and click to select objects. ✨`,
410
+ );
411
+ } catch (error) {
412
+ updateStatus("An error occurred during image processing.");
413
+ console.error("Processing error:", error);
414
+ }
415
+ }
416
+
417
+ function handleTouchMove(event) {
418
+ event.preventDefault();
419
+ if (event.touches.length > 0) {
420
+ handleMouseMove(event.touches[0]);
421
+ }
422
+ }
423
+
424
+ function handleMouseMove(event) {
425
+ lastMouseEvent = event;
426
+ if (!animationFrameId) {
427
+ animationFrameId = requestAnimationFrame(drawLoop);
428
+ }
429
+ }
430
+
431
+ function drawLoop() {
432
+ if (!lastMouseEvent || !similarityScores || !originalImage) {
433
+ animationFrameId = null;
434
+ return;
435
+ }
436
+
437
+ const event = lastMouseEvent;
438
+ const rect = imageCanvas.getBoundingClientRect();
439
+ const scaleX = imageCanvas.width / rect.width;
440
+ const scaleY = imageCanvas.height / rect.height;
441
+ const x = (event.clientX - rect.left) * scaleX;
442
+ const y = (event.clientY - rect.top) * scaleY;
443
+
444
+ if (x < 0 || x >= imageCanvas.width || y < 0 || y >= imageCanvas.height) {
445
+ animationFrameId = null;
446
+ return;
447
+ }
448
+
449
+ const patchesPerRow = imageCanvas.width / patchSize;
450
+ const patchX = Math.floor(x / patchSize);
451
+ const patchY = Math.floor(y / patchSize);
452
+ const queryPatchIndex = patchY * patchesPerRow + patchX;
453
+
454
+ if (queryPatchIndex < 0 || queryPatchIndex >= similarityScores.length || !similarityScores[queryPatchIndex]) {
455
+ animationFrameId = null;
456
+ return;
457
+ }
458
+
459
+ const allPatches = Array.from(similarityScores[queryPatchIndex]).map((score, index) => ({ score, index }));
460
+
461
+ lastHoverData = { queryIndex: queryPatchIndex, allPatches };
462
+ drawHighlights(queryPatchIndex, allPatches);
463
+
464
+ animationFrameId = null;
465
+ }
466
+
467
+ const INFERNO_COLORMAP = [
468
+ [0.0, [0, 0, 4]],
469
+ [0.1, [39, 12, 69]],
470
+ [0.2, [84, 15, 104]],
471
+ [0.3, [128, 31, 103]],
472
+ [0.4, [170, 48, 88]],
473
+ [0.5, [209, 70, 68]],
474
+ [0.6, [240, 97, 47]],
475
+ [0.7, [253, 138, 28]],
476
+ [0.8, [252, 185, 26]],
477
+ [0.9, [240, 231, 56]],
478
+ [1.0, [252, 255, 160]],
479
+ ];
480
+
481
+ function getInfernoColor(t) {
482
+ for (let i = 1; i < INFERNO_COLORMAP.length; i++) {
483
+ const [t_prev, c_prev] = INFERNO_COLORMAP[i - 1];
484
+ const [t_curr, c_curr] = INFERNO_COLORMAP[i];
485
+ if (t <= t_curr) {
486
+ const t_interp = (t - t_prev) / (t_curr - t_prev);
487
+ const r = c_prev[0] + t_interp * (c_curr[0] - c_prev[0]);
488
+ const g = c_prev[1] + t_interp * (c_curr[1] - c_prev[1]);
489
+ const b = c_prev[2] + t_interp * (c_curr[2] - c_prev[2]);
490
+ return `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;
491
+ }
492
+ }
493
+ return `rgb(${INFERNO_COLORMAP[INFERNO_COLORMAP.length - 1][1].join(",")})`;
494
+ }
495
+
496
+ function drawHighlights(queryIndex, allPatches) {
497
+ const patchesPerRow = imageCanvas.width / patchSize;
498
+
499
+ if (isOverlayMode) {
500
+ ctx.drawImage(originalImage, 0, 0, imageCanvas.width, imageCanvas.height);
501
+ ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
502
+ ctx.fillRect(0, 0, imageCanvas.width, imageCanvas.height);
503
+ } else {
504
+ ctx.fillStyle = getInfernoColor(0);
505
+ ctx.fillRect(0, 0, imageCanvas.width, imageCanvas.height);
506
+ }
507
+
508
+ if (allPatches.length > 0) {
509
+ const scores = allPatches.map((p) => p.score);
510
+ const minScore = Math.min(...scores);
511
+ const maxScore = Math.max(...scores);
512
+ const scoreRange = maxScore - minScore;
513
+
514
+ for (const patch of allPatches) {
515
+ if (patch.index === queryIndex) continue;
516
+ const normalizedScore = scoreRange > 0.0001 ? (patch.score - minScore) / scoreRange : 1;
517
+ const patchY = Math.floor(patch.index / patchesPerRow);
518
+ const patchX = patch.index % patchesPerRow;
519
+
520
+ if (isOverlayMode) {
521
+ const brightness = Math.pow(normalizedScore, 2) * 0.8;
522
+ ctx.fillStyle = `rgba(255, 255, 255, ${brightness})`;
523
+ } else {
524
+ ctx.fillStyle = getInfernoColor(normalizedScore);
525
+ }
526
+ ctx.fillRect(patchX * patchSize, patchY * patchSize, patchSize, patchSize);
527
+ }
528
+ }
529
+
530
+ const queryY = Math.floor(queryIndex / patchesPerRow);
531
+ const queryX = queryIndex % patchesPerRow;
532
+ ctx.strokeStyle = isOverlayMode ? "rgba(129, 188, 255, 0.9)" : "cyan";
533
+ ctx.lineWidth = 2;
534
+ ctx.strokeRect(queryX * patchSize, queryY * patchSize, patchSize, patchSize);
535
+ }
536
+
537
+ function clearHighlights() {
538
+ if (animationFrameId) {
539
+ cancelAnimationFrame(animationFrameId);
540
+ animationFrameId = null;
541
+ }
542
+ lastMouseEvent = null;
543
+ lastHoverData = null;
544
+ if (originalImage) {
545
+ ctx.drawImage(originalImage, 0, 0, imageCanvas.width, imageCanvas.height);
546
+ }
547
+ }
548
+
549
+ // --- 3. New Selection Logic ---
550
+
551
+ function handleThresholdChange(event) {
552
+ selectionThreshold = parseFloat(event.target.value);
553
+ thresholdValue.textContent = selectionThreshold.toFixed(2);
554
+ }
555
+
556
+ function handleCanvasClick(event) {
557
+ if (!lastHoverData) {
558
+ updateStatus("Please hover over the image first to generate a heatmap.");
559
+ return;
560
+ }
561
+
562
+ const { queryIndex, allPatches } = lastHoverData;
563
+ const patchesPerRow = imageCanvas.width / patchSize;
564
+ const numRows = imageCanvas.height / patchSize;
565
+
566
+ const selectedInThisClick = floodFillSelect(
567
+ queryIndex,
568
+ allPatches,
569
+ patchesPerRow,
570
+ numRows,
571
+ selectionThreshold
572
+ );
573
+
574
+ // Add the new selection to the master mask
575
+ selectedInThisClick.forEach(patchIndex => {
576
+ selectedPatchesMask.add(patchIndex);
577
+ });
578
+
579
+ updateSelectionCanvas();
580
+ }
581
+
582
+ function floodFillSelect(startPatchIndex, allPatches, patchesPerRow, numRows, threshold) {
583
+ const selected = new Set();
584
+ const queue = [startPatchIndex];
585
+ const visited = new Set([startPatchIndex]);
586
+
587
+ const scores = allPatches.map(p => p.score);
588
+ const minScore = Math.min(...scores);
589
+ const maxScore = Math.max(...scores);
590
+ const scoreRange = maxScore - minScore;
591
+
592
+ const getNormalizedScore = (index) => {
593
+ if (scoreRange <= 0.0001) return 1;
594
+ return (allPatches[index].score - minScore) / scoreRange;
595
+ };
596
+
597
+ while (queue.length > 0) {
598
+ const currentIndex = queue.shift();
599
+
600
+ if (getNormalizedScore(currentIndex) >= threshold) {
601
+ selected.add(currentIndex);
602
+
603
+ const currentY = Math.floor(currentIndex / patchesPerRow);
604
+ const currentX = currentIndex % patchesPerRow;
605
+
606
+ // Check neighbors [top, right, bottom, left]
607
+ const neighbors = [
608
+ { x: currentX, y: currentY - 1 },
609
+ { x: currentX + 1, y: currentY },
610
+ { x: currentX, y: currentY + 1 },
611
+ { x: currentX - 1, y: currentY },
612
+ ];
613
+
614
+ for (const neighbor of neighbors) {
615
+ if (neighbor.x >= 0 && neighbor.x < patchesPerRow && neighbor.y >= 0 && neighbor.y < numRows) {
616
+ const neighborIndex = neighbor.y * patchesPerRow + neighbor.x;
617
+ if (!visited.has(neighborIndex)) {
618
+ visited.add(neighborIndex);
619
+ queue.push(neighborIndex);
620
+ }
621
+ }
622
+ }
623
+ }
624
+ }
625
+ return selected;
626
+ }
627
+
628
+ function updateSelectionCanvas() {
629
+ selectionCtx.clearRect(0, 0, selectionCanvas.width, selectionCanvas.height);
630
+ const patchesPerRow = imageCanvas.width / patchSize;
631
+
632
+ for (const patchIndex of selectedPatchesMask) {
633
+ const patchY = Math.floor(patchIndex / patchesPerRow);
634
+ const patchX = patchIndex % patchesPerRow;
635
+
636
+ const sx = patchX * patchSize;
637
+ const sy = patchY * patchSize;
638
+
639
+ selectionCtx.drawImage(
640
+ originalImage,
641
+ sx, sy, patchSize, patchSize, // Source rectangle from original image
642
+ sx, sy, patchSize, patchSize // Destination rectangle on selection canvas
643
+ );
644
+ }
645
+ }
646
+
647
+ function handleSave() {
648
+ if (selectedPatchesMask.size === 0) {
649
+ updateStatus("No selection to save.");
650
+ return;
651
+ }
652
+ const link = document.createElement('a');
653
+ link.download = 'selection.png';
654
+ link.href = selectionCanvas.toDataURL('image/png');
655
+ link.click();
656
+ updateStatus("Selection saved.");
657
+ }
658
+
659
+ function handleClearSelections() {
660
+ selectedPatchesMask.clear();
661
+ selectionCtx.clearRect(0, 0, selectionCanvas.width, selectionCanvas.height);
662
+ if (originalImage) {
663
+ updateStatus("Selections cleared. Hover and click to select again.");
664
+ }
665
+ }
666
+
667
+
668
+ initialize();
669
+ </script>
670
+ </body>
671
+ </html>