|
|
<!DOCTYPE html> |
|
|
|
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>I-JEPA Patch Correspondence Analyzer</title> |
|
|
<style> |
|
|
body { |
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
|
|
margin: 0; |
|
|
padding: 20px; |
|
|
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%); |
|
|
min-height: 100vh; |
|
|
color: #e2e8f0; |
|
|
} |
|
|
|
|
|
``` |
|
|
.container { |
|
|
max-width: 1400px; |
|
|
margin: 0 auto; |
|
|
background: rgba(45, 55, 72, 0.8); |
|
|
backdrop-filter: blur(10px); |
|
|
border-radius: 20px; |
|
|
padding: 30px; |
|
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); |
|
|
border: 1px solid #4a5568; |
|
|
} |
|
|
|
|
|
h1 { |
|
|
text-align: center; |
|
|
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%); |
|
|
-webkit-background-clip: text; |
|
|
-webkit-text-fill-color: transparent; |
|
|
background-clip: text; |
|
|
margin-bottom: 10px; |
|
|
font-size: 2.5em; |
|
|
font-weight: 700; |
|
|
} |
|
|
|
|
|
.subtitle { |
|
|
text-align: center; |
|
|
color: #a0aec0; |
|
|
margin-bottom: 30px; |
|
|
font-size: 1.1em; |
|
|
} |
|
|
|
|
|
.upload-section { |
|
|
display: grid; |
|
|
grid-template-columns: 1fr 1fr; |
|
|
gap: 30px; |
|
|
margin-bottom: 30px; |
|
|
} |
|
|
|
|
|
.upload-box { |
|
|
border: 2px dashed #4a5568; |
|
|
border-radius: 15px; |
|
|
padding: 40px; |
|
|
text-align: center; |
|
|
transition: all 0.3s ease; |
|
|
background: rgba(26, 32, 44, 0.6); |
|
|
position: relative; |
|
|
overflow: hidden; |
|
|
} |
|
|
|
|
|
.upload-box:hover { |
|
|
border-color: #60a5fa; |
|
|
background: rgba(26, 32, 44, 0.8); |
|
|
} |
|
|
|
|
|
.upload-box.has-image { |
|
|
border-color: #48bb78; |
|
|
background: rgba(26, 32, 44, 0.9); |
|
|
} |
|
|
|
|
|
.upload-input { |
|
|
position: absolute; |
|
|
top: 0; |
|
|
left: 0; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
opacity: 0; |
|
|
cursor: pointer; |
|
|
} |
|
|
|
|
|
.upload-content { |
|
|
pointer-events: none; |
|
|
} |
|
|
|
|
|
.upload-icon { |
|
|
font-size: 3em; |
|
|
margin-bottom: 15px; |
|
|
color: #718096; |
|
|
} |
|
|
|
|
|
.upload-text { |
|
|
font-size: 1.1em; |
|
|
color: #e2e8f0; |
|
|
margin-bottom: 10px; |
|
|
font-weight: 600; |
|
|
} |
|
|
|
|
|
.upload-hint { |
|
|
font-size: 0.9em; |
|
|
color: #a0aec0; |
|
|
} |
|
|
|
|
|
.preview-image { |
|
|
max-width: 100%; |
|
|
max-height: 200px; |
|
|
border-radius: 10px; |
|
|
margin-top: 15px; |
|
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); |
|
|
} |
|
|
|
|
|
.controls { |
|
|
display: flex; |
|
|
justify-content: center; |
|
|
gap: 20px; |
|
|
margin-bottom: 30px; |
|
|
flex-wrap: wrap; |
|
|
} |
|
|
|
|
|
.btn { |
|
|
padding: 12px 30px; |
|
|
border: none; |
|
|
border-radius: 12px; |
|
|
cursor: pointer; |
|
|
font-size: 1em; |
|
|
font-weight: 600; |
|
|
transition: all 0.3s ease; |
|
|
text-transform: uppercase; |
|
|
letter-spacing: 1px; |
|
|
} |
|
|
|
|
|
.btn-primary { |
|
|
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%); |
|
|
color: white; |
|
|
} |
|
|
|
|
|
.btn-primary:hover:not(:disabled) { |
|
|
transform: translateY(-2px); |
|
|
box-shadow: 0 8px 20px rgba(96, 165, 250, 0.4); |
|
|
} |
|
|
|
|
|
.btn-secondary { |
|
|
background: #4a5568; |
|
|
color: #e2e8f0; |
|
|
} |
|
|
|
|
|
.btn-secondary:hover { |
|
|
background: #2d3748; |
|
|
transform: translateY(-2px); |
|
|
} |
|
|
|
|
|
.btn:disabled { |
|
|
background: #2d3748; |
|
|
color: #718096; |
|
|
cursor: not-allowed; |
|
|
transform: none; |
|
|
} |
|
|
|
|
|
.loading { |
|
|
text-align: center; |
|
|
padding: 40px; |
|
|
display: none; |
|
|
} |
|
|
|
|
|
.spinner { |
|
|
width: 50px; |
|
|
height: 50px; |
|
|
border: 4px solid #2d3748; |
|
|
border-top: 4px solid #60a5fa; |
|
|
border-radius: 50%; |
|
|
animation: spin 1s linear infinite; |
|
|
margin: 0 auto 20px; |
|
|
} |
|
|
|
|
|
@keyframes spin { |
|
|
0% { transform: rotate(0deg); } |
|
|
100% { transform: rotate(360deg); } |
|
|
} |
|
|
|
|
|
.results { |
|
|
display: none; |
|
|
} |
|
|
|
|
|
.visualization { |
|
|
background: rgba(26, 32, 44, 0.6); |
|
|
border-radius: 15px; |
|
|
padding: 20px; |
|
|
margin-bottom: 20px; |
|
|
border: 1px solid #4a5568; |
|
|
} |
|
|
|
|
|
.images-container { |
|
|
display: grid; |
|
|
grid-template-columns: 1fr 1fr; |
|
|
gap: 30px; |
|
|
margin-bottom: 30px; |
|
|
} |
|
|
|
|
|
.image-analysis { |
|
|
text-align: center; |
|
|
} |
|
|
|
|
|
.image-analysis h3 { |
|
|
color: #e2e8f0; |
|
|
margin-bottom: 15px; |
|
|
} |
|
|
|
|
|
.canvas-container { |
|
|
position: relative; |
|
|
display: inline-block; |
|
|
border-radius: 10px; |
|
|
overflow: hidden; |
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); |
|
|
} |
|
|
|
|
|
.analysis-canvas { |
|
|
display: block; |
|
|
max-width: 100%; |
|
|
height: auto; |
|
|
cursor: crosshair; |
|
|
} |
|
|
|
|
|
.stats { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); |
|
|
gap: 15px; |
|
|
margin-top: 20px; |
|
|
} |
|
|
|
|
|
.stat-card { |
|
|
background: rgba(26, 32, 44, 0.8); |
|
|
padding: 20px; |
|
|
border-radius: 10px; |
|
|
text-align: center; |
|
|
border-left: 4px solid #60a5fa; |
|
|
} |
|
|
|
|
|
.stat-value { |
|
|
font-size: 2em; |
|
|
font-weight: bold; |
|
|
color: #e2e8f0; |
|
|
} |
|
|
|
|
|
.stat-label { |
|
|
color: #a0aec0; |
|
|
margin-top: 5px; |
|
|
} |
|
|
|
|
|
.similarity-threshold { |
|
|
margin: 20px 0; |
|
|
text-align: center; |
|
|
color: #e2e8f0; |
|
|
} |
|
|
|
|
|
.threshold-slider { |
|
|
width: 300px; |
|
|
margin: 0 10px; |
|
|
-webkit-appearance: none; |
|
|
appearance: none; |
|
|
height: 8px; |
|
|
background: #4a5568; |
|
|
border-radius: 4px; |
|
|
outline: none; |
|
|
} |
|
|
|
|
|
.threshold-slider::-webkit-slider-thumb { |
|
|
-webkit-appearance: none; |
|
|
appearance: none; |
|
|
width: 20px; |
|
|
height: 20px; |
|
|
background: #60a5fa; |
|
|
cursor: pointer; |
|
|
border-radius: 50%; |
|
|
} |
|
|
|
|
|
.threshold-slider::-moz-range-thumb { |
|
|
width: 20px; |
|
|
height: 20px; |
|
|
background: #60a5fa; |
|
|
cursor: pointer; |
|
|
border-radius: 50%; |
|
|
border: none; |
|
|
} |
|
|
|
|
|
.error { |
|
|
background: rgba(245, 101, 101, 0.2); |
|
|
color: #fc8181; |
|
|
padding: 15px; |
|
|
border-radius: 10px; |
|
|
margin: 20px 0; |
|
|
text-align: center; |
|
|
display: none; |
|
|
border: 1px solid rgba(245, 101, 101, 0.3); |
|
|
} |
|
|
|
|
|
.info-panel { |
|
|
background: rgba(26, 32, 44, 0.6); |
|
|
border-radius: 10px; |
|
|
padding: 20px; |
|
|
margin-bottom: 20px; |
|
|
border: 1px solid #4a5568; |
|
|
} |
|
|
|
|
|
.info-panel h4 { |
|
|
color: #60a5fa; |
|
|
margin-bottom: 10px; |
|
|
} |
|
|
|
|
|
.info-panel p { |
|
|
color: #a0aec0; |
|
|
margin: 5px 0; |
|
|
font-size: 0.9em; |
|
|
} |
|
|
|
|
|
@media (max-width: 768px) { |
|
|
.upload-section { |
|
|
grid-template-columns: 1fr; |
|
|
} |
|
|
|
|
|
.images-container { |
|
|
grid-template-columns: 1fr; |
|
|
} |
|
|
|
|
|
.controls { |
|
|
flex-direction: column; |
|
|
align-items: center; |
|
|
} |
|
|
|
|
|
.threshold-slider { |
|
|
width: 200px; |
|
|
} |
|
|
} |
|
|
</style> |
|
|
``` |
|
|
|
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
<h1>I-JEPA Patch Correspondence Analyzer</h1> |
|
|
<p class="subtitle">Upload two images to analyze cross-patch correspondences using I-JEPA embeddings</p> |
|
|
|
|
|
``` |
|
|
<div class="upload-section"> |
|
|
<div class="upload-box" id="upload1"> |
|
|
<input type="file" class="upload-input" accept="image/*" id="file1"> |
|
|
<div class="upload-content"> |
|
|
<div class="upload-icon">🖼️</div> |
|
|
<div class="upload-text">Upload Image 1</div> |
|
|
<div class="upload-hint">Click or drag image here</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="upload-box" id="upload2"> |
|
|
<input type="file" class="upload-input" accept="image/*" id="file2"> |
|
|
<div class="upload-content"> |
|
|
<div class="upload-icon">🖼️</div> |
|
|
<div class="upload-text">Upload Image 2</div> |
|
|
<div class="upload-hint">Click or drag image here</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="controls"> |
|
|
<button class="btn btn-primary" id="analyzeBtn" disabled> |
|
|
🔍 Analyze Cross-Patch Correspondences |
|
|
</button> |
|
|
<button class="btn btn-secondary" id="clearBtn"> |
|
|
🗑️ Clear Images |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
<div class="error" id="errorMsg"></div> |
|
|
|
|
|
<div class="loading" id="loading"> |
|
|
<div class="spinner"></div> |
|
|
<p>Loading I-JEPA model and analyzing images...</p> |
|
|
<p><small>Using onnx-community/ijepa_vith14_1k for optimal browser performance</small></p> |
|
|
</div> |
|
|
|
|
|
<div class="results" id="results"> |
|
|
<div class="info-panel"> |
|
|
<h4>How to Use:</h4> |
|
|
<p>• Hover over any patch in either image to see its corresponding patches in the other image</p> |
|
|
<p>• Adjust the similarity threshold to show more or fewer correspondences</p> |
|
|
<p>• Blue outline shows the patch you're hovering over</p> |
|
|
<p>• Colored patches show corresponding regions based on I-JEPA embeddings</p> |
|
|
</div> |
|
|
|
|
|
<div class="visualization"> |
|
|
<div class="similarity-threshold"> |
|
|
<label>Similarity Threshold: </label> |
|
|
<input type="range" class="threshold-slider" id="thresholdSlider" |
|
|
min="0" max="1" step="0.01" value="0.7"> |
|
|
<span id="thresholdValue">0.70</span> |
|
|
</div> |
|
|
|
|
|
<div class="images-container"> |
|
|
<div class="image-analysis"> |
|
|
<h3>Image 1</h3> |
|
|
<div class="canvas-container"> |
|
|
<canvas id="canvas1" class="analysis-canvas"></canvas> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="image-analysis"> |
|
|
<h3>Image 2</h3> |
|
|
<div class="canvas-container"> |
|
|
<canvas id="canvas2" class="analysis-canvas"></canvas> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="stats"> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" id="totalPatches">0</div> |
|
|
<div class="stat-label">Patches per Image</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" id="strongCorrespondences">0</div> |
|
|
<div class="stat-label">Strong Correspondences</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" id="avgSimilarity">0.00</div> |
|
|
<div class="stat-label">Average Cross-Similarity</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-value" id="maxSimilarity">0.00</div> |
|
|
<div class="stat-label">Maximum Similarity</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script type="module"> |
|
|
import { pipeline, RawImage, matmul } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.2"; |
|
|
|
|
|
|
|
|
const MODEL_ID = "onnx-community/ijepa_vith14_1k"; |
|
|
const SUPPORTED_RESOLUTIONS = [224, 336, 448]; |
|
|
const MAX_PIXELS = 2097152; |
|
|
|
|
|
|
|
|
const file1Input = document.getElementById('file1'); |
|
|
const file2Input = document.getElementById('file2'); |
|
|
const upload1 = document.getElementById('upload1'); |
|
|
const upload2 = document.getElementById('upload2'); |
|
|
const analyzeBtn = document.getElementById('analyzeBtn'); |
|
|
const clearBtn = document.getElementById('clearBtn'); |
|
|
const loading = document.getElementById('loading'); |
|
|
const results = document.getElementById('results'); |
|
|
const errorMsg = document.getElementById('errorMsg'); |
|
|
const thresholdSlider = document.getElementById('thresholdSlider'); |
|
|
const thresholdValue = document.getElementById('thresholdValue'); |
|
|
const canvas1 = document.getElementById('canvas1'); |
|
|
const canvas2 = document.getElementById('canvas2'); |
|
|
const ctx1 = canvas1.getContext('2d'); |
|
|
const ctx2 = canvas2.getContext('2d'); |
|
|
|
|
|
|
|
|
let extractor = null; |
|
|
let image1Data = null; |
|
|
let image2Data = null; |
|
|
let features1 = null; |
|
|
let features2 = null; |
|
|
let crossSimilarities = null; |
|
|
let patchesPerRow = 0; |
|
|
let originalImages = { img1: null, img2: null }; |
|
|
let imageCropParams = { img1: null, img2: null }; |
|
|
|
|
|
|
|
|
function showError(message) { |
|
|
errorMsg.textContent = message; |
|
|
errorMsg.style.display = 'block'; |
|
|
setTimeout(() => { |
|
|
errorMsg.style.display = 'none'; |
|
|
}, 5000); |
|
|
} |
|
|
|
|
|
function showLoading(show) { |
|
|
loading.style.display = show ? 'block' : 'none'; |
|
|
analyzeBtn.disabled = show; |
|
|
} |
|
|
|
|
|
function showResults(show) { |
|
|
results.style.display = show ? 'block' : 'none'; |
|
|
} |
|
|
|
|
|
function updateAnalyzeButton() { |
|
|
analyzeBtn.disabled = !image1Data || !image2Data || !extractor; |
|
|
} |
|
|
|
|
|
function findClosestSupportedResolution(targetDim) { |
|
|
return SUPPORTED_RESOLUTIONS.reduce((prev, curr) => |
|
|
Math.abs(curr - targetDim) < Math.abs(prev - targetDim) ? curr : prev |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
async function initializeModel() { |
|
|
try { |
|
|
showLoading(true); |
|
|
const isWebGpuSupported = !!navigator.gpu; |
|
|
const device = isWebGpuSupported ? "webgpu" : "wasm"; |
|
|
const dtype = isWebGpuSupported ? "q4" : "q8"; |
|
|
|
|
|
console.log(`Loading I-JEPA model with ${device.toUpperCase()}...`); |
|
|
extractor = await pipeline("image-feature-extraction", MODEL_ID, { device, dtype }); |
|
|
|
|
|
|
|
|
if (extractor?.processor?.image_processor) { |
|
|
extractor.processor.image_processor.do_resize = false; |
|
|
} |
|
|
|
|
|
console.log('Model loaded successfully'); |
|
|
updateAnalyzeButton(); |
|
|
showLoading(false); |
|
|
return true; |
|
|
} catch (error) { |
|
|
console.error('Error loading model:', error); |
|
|
showError('Failed to load I-JEPA model. Please refresh and try again.'); |
|
|
showLoading(false); |
|
|
return false; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function processImageToCanvas(file, canvas, ctx, imageKey) { |
|
|
return new Promise((resolve, reject) => { |
|
|
const img = new Image(); |
|
|
img.onload = () => { |
|
|
const { naturalWidth: w, naturalHeight: h } = img; |
|
|
|
|
|
|
|
|
const cropSize = Math.min(w, h); |
|
|
const sx = (w - cropSize) / 2; |
|
|
const sy = (h - cropSize) / 2; |
|
|
imageCropParams[imageKey] = { sx, sy, sWidth: cropSize, sHeight: cropSize }; |
|
|
|
|
|
|
|
|
let scaledCropSize = cropSize; |
|
|
if (scaledCropSize * scaledCropSize > MAX_PIXELS) { |
|
|
scaledCropSize = Math.sqrt(MAX_PIXELS); |
|
|
} |
|
|
const chosenResolution = findClosestSupportedResolution(scaledCropSize); |
|
|
|
|
|
|
|
|
canvas.width = chosenResolution; |
|
|
canvas.height = chosenResolution; |
|
|
|
|
|
ctx.drawImage( |
|
|
img, |
|
|
sx, sy, cropSize, cropSize, |
|
|
0, 0, chosenResolution, chosenResolution |
|
|
); |
|
|
|
|
|
originalImages[imageKey] = img; |
|
|
resolve(chosenResolution); |
|
|
}; |
|
|
img.onerror = reject; |
|
|
img.src = URL.createObjectURL(file); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
function handleFileUpload(fileInput, uploadBox, imageKey, canvasId) { |
|
|
const file = fileInput.files[0]; |
|
|
if (!file) return; |
|
|
|
|
|
const canvas = document.getElementById(canvasId); |
|
|
const ctx = canvas.getContext('2d'); |
|
|
|
|
|
processImageToCanvas(file, canvas, ctx, imageKey) |
|
|
.then(() => { |
|
|
|
|
|
if (imageKey === 'img1') { |
|
|
image1Data = file; |
|
|
} else { |
|
|
image2Data = file; |
|
|
} |
|
|
|
|
|
|
|
|
uploadBox.classList.add('has-image'); |
|
|
const content = uploadBox.querySelector('.upload-content'); |
|
|
content.innerHTML = ` |
|
|
<img src="${URL.createObjectURL(file)}" class="preview-image" alt="Preview"> |
|
|
<div style="margin-top: 10px; color: #48bb78; font-weight: 600;">✓ Image loaded</div> |
|
|
`; |
|
|
|
|
|
updateAnalyzeButton(); |
|
|
}) |
|
|
.catch(error => { |
|
|
console.error('Error processing image:', error); |
|
|
showError('Failed to process image. Please try a different file.'); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
async function extractFeatures(canvas) { |
|
|
try { |
|
|
const imageData = await RawImage.fromCanvas(canvas); |
|
|
const features = await extractor(imageData, { pooling: "none" }); |
|
|
|
|
|
|
|
|
const totalTokens = features.dims[1]; |
|
|
const nPatches = totalTokens - 1; |
|
|
const patchFeatures = features.slice(null, [1, nPatches]); |
|
|
|
|
|
|
|
|
const patchesPerRowCalc = Math.round(Math.sqrt(nPatches)); |
|
|
if (patchesPerRowCalc * patchesPerRowCalc !== nPatches) { |
|
|
console.warn("Patch count is not a perfect square:", nPatches); |
|
|
} |
|
|
|
|
|
return { features: patchFeatures, patchesPerRow: patchesPerRowCalc }; |
|
|
} catch (error) { |
|
|
console.error('Error extracting features:', error); |
|
|
throw error; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async function calculateCrossSimilarities(features1, features2) { |
|
|
try { |
|
|
|
|
|
const normalized1 = features1.normalize(2, -1); |
|
|
const normalized2 = features2.normalize(2, -1); |
|
|
|
|
|
|
|
|
const similarities = await matmul(normalized1, normalized2.permute(0, 2, 1)); |
|
|
|
|
|
return (await similarities.tolist())[0]; |
|
|
} catch (error) { |
|
|
console.error('Error calculating similarities:', error); |
|
|
throw error; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function redrawOriginalImage(canvas, ctx, imageKey) { |
|
|
if (!originalImages[imageKey] || !imageCropParams[imageKey]) return; |
|
|
|
|
|
const img = originalImages[imageKey]; |
|
|
const params = imageCropParams[imageKey]; |
|
|
|
|
|
ctx.drawImage( |
|
|
img, |
|
|
params.sx, params.sy, params.sWidth, params.sHeight, |
|
|
0, 0, canvas.width, canvas.height |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
const INFERNO_COLORMAP = [ |
|
|
[0.0, [0,0,4]], [0.1, [39,12,69]], [0.2, [84,15,104]], [0.3, [128,31,103]], [0.4, [170,48,88]], |
|
|
[0.5, [209,70,68]], [0.6, [240,97,47]], [0.7, [253,138,28]], [0.8, [252,185,26]], [0.9, [240,231,56]], [1.0, [252,255,160]] |
|
|
]; |
|
|
|
|
|
function getInfernoColor(t) { |
|
|
for (let i = 1; i < INFERNO_COLORMAP.length; i++) { |
|
|
const [tp, cp] = INFERNO_COLORMAP[i-1]; |
|
|
const [tc, cc] = INFERNO_COLORMAP[i]; |
|
|
if (t <= tc) { |
|
|
const a = (t - tp) / (tc - tp); |
|
|
const r = cp[0] + a * (cc[0] - cp[0]); |
|
|
const g = cp[1] + a * (cc[1] - cp[1]); |
|
|
const b = cp[2] + a * (cc[2] - cp[2]); |
|
|
return `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`; |
|
|
} |
|
|
} |
|
|
const last = INFERNO_COLORMAP[INFERNO_COLORMAP.length-1][1]; |
|
|
return `rgb(${last.join(",")})`; |
|
|
} |
|
|
|
|
|
|
|
|
function drawHighlights(canvas, ctx, imageKey, queryPatchIndex, isQueryImage) { |
|
|
if (!crossSimilarities || !patchesPerRow) return; |
|
|
|
|
|
const patchSize = canvas.width / patchesPerRow; |
|
|
const threshold = parseFloat(thresholdSlider.value); |
|
|
|
|
|
|
|
|
redrawOriginalImage(canvas, ctx, imageKey); |
|
|
|
|
|
if (isQueryImage) { |
|
|
|
|
|
const qy = Math.floor(queryPatchIndex / patchesPerRow); |
|
|
const qx = queryPatchIndex % patchesPerRow; |
|
|
|
|
|
ctx.strokeStyle = "#60a5fa"; |
|
|
ctx.lineWidth = 3; |
|
|
ctx.strokeRect(qx * patchSize, qy * patchSize, patchSize, patchSize); |
|
|
} else { |
|
|
|
|
|
const similarities = crossSimilarities[queryPatchIndex] || []; |
|
|
const maxSim = Math.max(...similarities); |
|
|
const minSim = Math.min(...similarities); |
|
|
const range = maxSim - minSim; |
|
|
|
|
|
for (let i = 0; i < similarities.length; i++) { |
|
|
const sim = similarities[i]; |
|
|
if (sim >= threshold) { |
|
|
const py = Math.floor(i / patchesPerRow); |
|
|
const px = i % patchesPerRow; |
|
|
|
|
|
|
|
|
const normalizedSim = range > 1e-4 ? (sim - minSim) / range : 1; |
|
|
const alpha = Math.pow(normalizedSim, 2) * 0.8; |
|
|
|
|
|
ctx.fillStyle = `rgba(96, 165, 250, ${alpha})`; |
|
|
ctx.fillRect(px * patchSize, py * patchSize, patchSize, patchSize); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function clearHighlights() { |
|
|
redrawOriginalImage(canvas1, ctx1, 'img1'); |
|
|
redrawOriginalImage(canvas2, ctx2, 'img2'); |
|
|
} |
|
|
|
|
|
|
|
|
function handleMouseMove(canvas, imageKey, isImage1) { |
|
|
return function(event) { |
|
|
if (!crossSimilarities || !patchesPerRow) return; |
|
|
|
|
|
const rect = canvas.getBoundingClientRect(); |
|
|
const scaleX = canvas.width / rect.width; |
|
|
const scaleY = canvas.height / rect.height; |
|
|
const x = (event.clientX - rect.left) * scaleX; |
|
|
const y = (event.clientY - rect.top) * scaleY; |
|
|
|
|
|
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return; |
|
|
|
|
|
const patchSize = canvas.width / patchesPerRow; |
|
|
const patchX = Math.floor(x / patchSize); |
|
|
const patchY = Math.floor(y / patchSize); |
|
|
const patchIndex = patchY * patchesPerRow + patchX; |
|
|
|
|
|
if (patchIndex < 0 || patchIndex >= patchesPerRow * patchesPerRow) return; |
|
|
|
|
|
|
|
|
drawHighlights(canvas1, ctx1, 'img1', patchIndex, isImage1); |
|
|
drawHighlights(canvas2, ctx2, 'img2', patchIndex, !isImage1); |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
function updateStatistics() { |
|
|
if (!crossSimilarities) return; |
|
|
|
|
|
const threshold = parseFloat(thresholdSlider.value); |
|
|
const totalPatches = patchesPerRow * patchesPerRow; |
|
|
|
|
|
let strongCorrespondences = 0; |
|
|
let totalSimilarity = 0; |
|
|
let maxSim = 0; |
|
|
let count = 0; |
|
|
|
|
|
for (let i = 0; i < crossSimilarities.length; i++) { |
|
|
for (let j = 0; j < crossSimilarities[i].length; j++) { |
|
|
const sim = crossSimilarities[i][j]; |
|
|
totalSimilarity += sim; |
|
|
maxSim = Math.max(maxSim, sim); |
|
|
count++; |
|
|
|
|
|
if (sim >= threshold) { |
|
|
strongCorrespondences++; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
document.getElementById('totalPatches').textContent = totalPatches; |
|
|
document.getElementById('strongCorrespondences').textContent = strongCorrespondences; |
|
|
document.getElementById('avgSimilarity').textContent = (totalSimilarity / count).toFixed(3); |
|
|
document.getElementById('maxSimilarity').textContent = maxSim.toFixed(3); |
|
|
} |
|
|
|
|
|
|
|
|
file1Input.addEventListener('change', () => handleFileUpload(file1Input, upload1, 'img1', 'canvas1')); |
|
|
file2Input.addEventListener('change', () => handleFileUpload(file2Input, upload2, 'img2', 'canvas2')); |
|
|
|
|
|
clearBtn.addEventListener('click', () => { |
|
|
|
|
|
image1Data = null; |
|
|
image2Data = null; |
|
|
features1 = null; |
|
|
features2 = null; |
|
|
crossSimilarities = null; |
|
|
patchesPerRow = 0; |
|
|
originalImages = { img1: null, img2: null }; |
|
|
imageCropParams = { img1: null, img2: null }; |
|
|
|
|
|
|
|
|
file1Input.value = ''; |
|
|
file2Input.value = ''; |
|
|
upload1.classList.remove('has-image'); |
|
|
upload2.classList.remove('has-image'); |
|
|
|
|
|
upload1.querySelector('.upload-content').innerHTML = ` |
|
|
<div class="upload-icon">🖼️</div> |
|
|
<div class="upload-text">Upload Image 1</div> |
|
|
<div class="upload-hint">Click or drag image here</div> |
|
|
`; |
|
|
|
|
|
upload2.querySelector('.upload-content').innerHTML = ` |
|
|
<div class="upload-icon">🖼️</div> |
|
|
<div class="upload-text">Upload Image 2</div> |
|
|
<div class="upload-hint">Click or drag image here</div> |
|
|
`; |
|
|
|
|
|
|
|
|
ctx1.clearRect(0, 0, canvas1.width, canvas1.height); |
|
|
ctx2.clearRect(0, 0, canvas2.width, canvas2.height); |
|
|
|
|
|
showResults(false); |
|
|
updateAnalyzeButton(); |
|
|
}); |
|
|
|
|
|
thresholdSlider.addEventListener('input', () => { |
|
|
const threshold = parseFloat(thresholdSlider.value); |
|
|
thresholdValue.textContent = threshold.toFixed(2); |
|
|
updateStatistics(); |
|
|
}); |
|
|
|
|
|
|
|
|
analyzeBtn.addEventListener('click', async () => { |
|
|
if (!image1Data || !image2Data || !extractor) return; |
|
|
|
|
|
showLoading(true); |
|
|
showResults(false); |
|
|
|
|
|
try { |
|
|
console.log('Extracting features from both images...'); |
|
|
|
|
|
|
|
|
const result1 = await extractFeatures(canvas1); |
|
|
const result2 = await extractFeatures(canvas2); |
|
|
|
|
|
features1 = result1.features; |
|
|
features2 = result2.features; |
|
|
patchesPerRow = result1.patchesPerRow; |
|
|
|
|
|
console.log(`Patch grid: ${patchesPerRow}x${patchesPerRow} patches per image`); |
|
|
|
|
|
|
|
|
console.log('Calculating cross-similarities...'); |
|
|
crossSimilarities = await calculateCrossSimilarities(features1, features2); |
|
|
|
|
|
|
|
|
canvas1.addEventListener('mousemove', handleMouseMove(canvas1, 'img1', true)); |
|
|
canvas1.addEventListener('mouseleave', clearHighlights); |
|
|
canvas2.addEventListener('mousemove', handleMouseMove(canvas2, 'img2', false)); |
|
|
canvas2.addEventListener('mouseleave', clearHighlights); |
|
|
|
|
|
|
|
|
updateStatistics(); |
|
|
|
|
|
|
|
|
showResults(true); |
|
|
showLoading(false); |
|
|
|
|
|
console.log('Analysis complete!'); |
|
|
|
|
|
} catch (error) { |
|
|
console.error('Analysis error:', error); |
|
|
showError('Failed to analyze images. Please try again with different images.'); |
|
|
showLoading(false); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
['upload1', 'upload2'].forEach((id, index) => { |
|
|
const uploadBox = document.getElementById(id); |
|
|
const fileInput = document.getElementById(`file${index + 1}`); |
|
|
|
|
|
uploadBox.addEventListener('dragover', (e) => { |
|
|
e.preventDefault(); |
|
|
uploadBox.style.borderColor = '#60a5fa'; |
|
|
}); |
|
|
|
|
|
uploadBox.addEventListener('dragleave', (e) => { |
|
|
e.preventDefault(); |
|
|
uploadBox.style.borderColor = '#4a5568'; |
|
|
}); |
|
|
|
|
|
uploadBox.addEventListener('drop', (e) => { |
|
|
e.preventDefault(); |
|
|
uploadBox.style.borderColor = '#4a5568'; |
|
|
|
|
|
const files = e.dataTransfer.files; |
|
|
if (files.length > 0 && files[0].type.startsWith('image/')) { |
|
|
fileInput.files = files; |
|
|
const imageKey = index === 0 ? 'img1' : 'img2'; |
|
|
const canvasId = index === 0 ? 'canvas1' : 'canvas2'; |
|
|
handleFileUpload(fileInput, uploadBox, imageKey, canvasId); |
|
|
} |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
window.addEventListener('load', () => { |
|
|
console.log('Initializing I-JEPA Patch Correspondence Analyzer...'); |
|
|
initializeModel(); |
|
|
}); |
|
|
</script> |
|
|
``` |
|
|
|
|
|
</body> |
|
|
</html> |