Last active
September 17, 2025 03:58
-
-
Save lardratboy/f8a7ca1b1c56bd948d64726aa687064b to your computer and use it in GitHub Desktop.
Quantized Histogram for images (not robust for high unique counts)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>Scalable Image Color Frequency Analyzer</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| } | |
| canvas { | |
| width: 100%; | |
| height: 100%; | |
| } | |
| #dropZone { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| z-index: 1000; | |
| display: none; | |
| background: rgba(0, 0, 0, 0.7); | |
| color: white; | |
| justify-content: center; | |
| align-items: center; | |
| font-family: Arial, sans-serif; | |
| font-size: 24px; | |
| } | |
| #instructions { | |
| position: fixed; | |
| bottom: 80px; | |
| left: 20px; | |
| color: white; | |
| font-family: Arial, sans-serif; | |
| background: rgba(0, 0, 0, 0.5); | |
| padding: 10px; | |
| border-radius: 5px; | |
| } | |
| #objectList { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| color: white; | |
| font-family: Arial, sans-serif; | |
| background: rgba(0, 0, 0, 0.5); | |
| padding: 15px; | |
| border-radius: 5px; | |
| max-height: 80vh; | |
| overflow-y: auto; | |
| } | |
| .object-item { | |
| margin: 5px 0; | |
| padding: 5px; | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 3px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .delete-btn { | |
| background: #ff4444; | |
| border: none; | |
| color: white; | |
| padding: 2px 6px; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| } | |
| .delete-btn:hover { | |
| background: #ff6666; | |
| } | |
| .visibility-btn { | |
| background: none; | |
| border: none; | |
| color: white; | |
| cursor: pointer; | |
| padding: 2px 6px; | |
| } | |
| .object-info { | |
| flex-grow: 1; | |
| } | |
| #gridControls { | |
| position: fixed; | |
| top: 20px; | |
| left: 20px; | |
| color: white; | |
| font-family: Arial, sans-serif; | |
| background: rgba(0, 0, 0, 0.5); | |
| padding: 15px; | |
| border-radius: 5px; | |
| } | |
| .grid-toggle, .count-toggle { | |
| margin: 5px 0; | |
| cursor: pointer; | |
| } | |
| #fileInput { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| z-index: 100; | |
| } | |
| .thumbnail { | |
| width: 50px; | |
| height: 50px; | |
| background-color: #333; | |
| border: 1px solid #555; | |
| cursor: pointer; | |
| object-fit: contain; | |
| } | |
| #imagePopup { | |
| display: none; | |
| position: fixed; | |
| left: 0; | |
| top: 0; | |
| width: 100%; | |
| height: 100%; | |
| background-color: rgba(0,0,0,0.8); | |
| z-index: 2000; | |
| align-items: center; | |
| justify-content: center; | |
| flex-direction: column; | |
| } | |
| #popupImage { | |
| max-width: 90%; | |
| max-height: 80%; | |
| border: 2px solid white; | |
| background-color: #222; | |
| } | |
| #popupCaption { | |
| color: white; | |
| margin-top: 10px; | |
| font-family: Arial, sans-serif; | |
| } | |
| #closePopup { | |
| position: absolute; | |
| top: 20px; | |
| right: 30px; | |
| font-size: 30px; | |
| color: white; | |
| cursor: pointer; | |
| } | |
| #controlPanel { | |
| position: fixed; | |
| bottom: 0; | |
| left: 0; | |
| right: 0; | |
| background: rgba(0, 0, 0, 0.8); | |
| color: white; | |
| font-family: Arial, sans-serif; | |
| padding: 15px; | |
| display: flex; | |
| align-items: center; | |
| gap: 20px; | |
| justify-content: center; | |
| } | |
| #quantizationSelect { | |
| background: #333; | |
| color: white; | |
| border: 1px solid #555; | |
| padding: 4px 8px; | |
| border-radius: 3px; | |
| } | |
| .control-group { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .control-label { | |
| font-size: 14px; | |
| min-width: 80px; | |
| } | |
| #progressContainer { | |
| position: fixed; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| background: rgba(0, 0, 0, 0.9); | |
| padding: 20px; | |
| border-radius: 10px; | |
| display: none; | |
| z-index: 1500; | |
| color: white; | |
| font-family: Arial, sans-serif; | |
| } | |
| #progressBar { | |
| width: 300px; | |
| height: 20px; | |
| background: #333; | |
| border: 1px solid #555; | |
| border-radius: 10px; | |
| overflow: hidden; | |
| margin: 10px 0; | |
| } | |
| #progressFill { | |
| height: 100%; | |
| background: linear-gradient(90deg, #4CAF50, #45a049); | |
| width: 0%; | |
| transition: width 0.3s ease; | |
| } | |
| #progressText { | |
| text-align: center; | |
| font-size: 14px; | |
| } | |
| .warning { | |
| color: #ffaa00; | |
| font-size: 12px; | |
| margin-top: 5px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="dropZone">Drop images here</div> | |
| <div id="instructions"> | |
| Drag and drop images to analyze color frequency<br> | |
| Mouse: Left click + drag to rotate<br> | |
| Mouse wheel to zoom | |
| </div> | |
| <input type="file" id="fileInput" multiple accept="image/*" /> | |
| <div id="gridControls"> | |
| <label class="grid-toggle"><input type="checkbox" checked onchange="toggleGrid('xy')"> XY Plane (Blue)</label><br> | |
| <label class="count-toggle"><input type="checkbox" checked onchange="toggleCountLines()"> Count Lines</label><br> | |
| <label class="count-toggle"><input type="checkbox" checked onchange="toggleValueColors()"> Value Colors</label><br> | |
| <label class="count-toggle"><input type="checkbox" checked onchange="toggleAutoLOD()"> Auto LOD</label> | |
| </div> | |
| <div id="objectList"> | |
| <h3 style="margin-top: 0">Loaded Images</h3> | |
| <div id="objectItems"></div> | |
| </div> | |
| <div id="controlPanel"> | |
| <div class="control-group"> | |
| <label class="control-label">Quantization:</label> | |
| <select id="quantizationSelect"> | |
| <option value="4">4-bit (16³)</option> | |
| <option value="5">5-bit (32³)</option> | |
| <option value="6">6-bit (64³)</option> | |
| <option value="7">7-bit (128³)</option> | |
| <option value="8" selected>8-bit (256³)</option> | |
| </select> | |
| </div> | |
| <div class="control-group"> | |
| <label class="control-label">Max Points:</label> | |
| <select id="maxPointsSelect"> | |
| <option value="100000">100k</option> | |
| <option value="500000" selected>500k</option> | |
| <option value="1000000">1M</option> | |
| <option value="2000000">2M</option> | |
| <option value="-1">Unlimited</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div id="progressContainer"> | |
| <div id="progressText">Processing...</div> | |
| <div id="progressBar"> | |
| <div id="progressFill"></div> | |
| </div> | |
| <div id="progressDetails"></div> | |
| </div> | |
| <div id="imagePopup"> | |
| <span id="closePopup">×</span> | |
| <img id="popupImage"> | |
| <div id="popupCaption"></div> | |
| </div> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128/examples/js/controls/OrbitControls.js"></script> | |
| <script> | |
| let scene, camera, renderer, controls; | |
| let pointClouds = new Map(); | |
| let countLines = new Map(); | |
| let objectCounter = 0; | |
| let grids = {}; | |
| let quantizationBits = 8; | |
| let showCountLines = true; | |
| let useValueColors = true; | |
| let autoLOD = true; | |
| let maxPoints = 500000; | |
| // Scalable data structures | |
| class CompactColorCounter { | |
| constructor(quantizationBits) { | |
| this.bits = quantizationBits; | |
| this.range = 1 << quantizationBits; | |
| this.mask = this.range - 1; | |
| this.counts = new Map(); // Use Map for sparse storage | |
| this.maxCount = 0; | |
| } | |
| increment(r, g, b) { | |
| const qr = (r >> (8 - this.bits)) & this.mask; | |
| const qg = (g >> (8 - this.bits)) & this.mask; | |
| const qb = (b >> (8 - this.bits)) & this.mask; | |
| const key = (qb << (this.bits * 2)) | (qg << this.bits) | qr; | |
| const count = (this.counts.get(key) || 0) + 1; | |
| this.counts.set(key, count); | |
| if (count > this.maxCount) { | |
| this.maxCount = count; | |
| } | |
| return count === 1; // Return true if this is a new unique color | |
| } | |
| *entries() { | |
| for (const [key, count] of this.counts) { | |
| const qr = key & this.mask; | |
| const qg = (key >> this.bits) & this.mask; | |
| const qb = key >> (this.bits * 2); | |
| yield { qr, qg, qb, count, key }; | |
| } | |
| } | |
| size() { | |
| return this.counts.size; | |
| } | |
| } | |
| function updateProgress(percent, text = 'Processing...', details = '') { | |
| const container = document.getElementById('progressContainer'); | |
| const fill = document.getElementById('progressFill'); | |
| const textEl = document.getElementById('progressText'); | |
| const detailsEl = document.getElementById('progressDetails'); | |
| if (percent >= 0) { | |
| container.style.display = 'block'; | |
| fill.style.width = percent + '%'; | |
| textEl.textContent = text; | |
| detailsEl.textContent = details; | |
| } else { | |
| container.style.display = 'none'; | |
| } | |
| } | |
| function init() { | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x111111); | |
| camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.set(2, 2, 2); | |
| camera.lookAt(0, 0, 0); | |
| renderer = new THREE.WebGLRenderer({ antialias: false }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| document.body.appendChild(renderer.domElement); | |
| controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); | |
| scene.add(ambientLight); | |
| setupGrids(); | |
| setupDropZone(); | |
| setupImagePopup(); | |
| setupControls(); | |
| animate(); | |
| } | |
| async function scalableQuantizeProcess(buffer, quantizationBits = 8, maxColors = -1) { | |
| if (!buffer || !(buffer instanceof ArrayBuffer)) { | |
| throw new Error('Invalid buffer provided'); | |
| } | |
| const counter = new CompactColorCounter(quantizationBits); | |
| const view = new DataView(buffer); | |
| const maxTuples = Math.floor(buffer.byteLength / 3); | |
| updateProgress(0, 'Analyzing colors...', ''); | |
| // Phase 1: Count unique colors with early termination if needed | |
| const CHUNK_SIZE = 200000; | |
| let processedTuples = 0; | |
| let uniqueColors = 0; | |
| for (let chunkStart = 0; chunkStart < maxTuples; chunkStart += CHUNK_SIZE) { | |
| const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, maxTuples); | |
| for (let tupleIdx = chunkStart; tupleIdx < chunkEnd; tupleIdx++) { | |
| const baseOffset = tupleIdx * 3; | |
| const r = view.getUint8(baseOffset); | |
| const g = view.getUint8(baseOffset + 1); | |
| const b = view.getUint8(baseOffset + 2); | |
| if (counter.increment(r, g, b)) { | |
| uniqueColors++; | |
| // Early termination if we exceed max colors | |
| if (maxColors > 0 && uniqueColors >= maxColors) { | |
| console.log(`Early termination: reached ${maxColors} unique colors limit`); | |
| break; | |
| } | |
| } | |
| } | |
| processedTuples = chunkEnd; | |
| if (chunkStart % (CHUNK_SIZE * 5) === 0) { | |
| const progress = Math.round((processedTuples / maxTuples) * 50); | |
| updateProgress(progress, 'Analyzing colors...', | |
| `${uniqueColors.toLocaleString()} unique colors found`); | |
| await new Promise(resolve => setTimeout(resolve, 1)); | |
| } | |
| // Break if we hit max colors | |
| if (maxColors > 0 && uniqueColors >= maxColors) break; | |
| } | |
| updateProgress(50, 'Building geometry...', | |
| `${uniqueColors.toLocaleString()} unique colors to process`); | |
| // Phase 2: Build geometry from counted colors | |
| const actualColors = Math.min(uniqueColors, maxColors > 0 ? maxColors : uniqueColors); | |
| const points = new Float32Array(actualColors * 3); | |
| const colors = new Float32Array(actualColors * 3); | |
| const qToNormalized = 2 / (counter.range - 1); | |
| let pointIndex = 0; | |
| let processed = 0; | |
| for (const { qr, qg, qb, count } of counter.entries()) { | |
| if (pointIndex >= actualColors * 3) break; | |
| const x = qr * qToNormalized - 1; | |
| const y = qg * qToNormalized - 1; | |
| const z = qb * qToNormalized - 1; | |
| points[pointIndex] = x; | |
| points[pointIndex + 1] = y; | |
| points[pointIndex + 2] = z; | |
| colors[pointIndex] = (x + 1) / 2; | |
| colors[pointIndex + 1] = (y + 1) / 2; | |
| colors[pointIndex + 2] = (z + 1) / 2; | |
| pointIndex += 3; | |
| processed++; | |
| if (processed % 50000 === 0) { | |
| const progress = 50 + Math.round((processed / actualColors) * 40); | |
| updateProgress(progress, 'Building geometry...', | |
| `${processed.toLocaleString()}/${actualColors.toLocaleString()} colors processed`); | |
| await new Promise(resolve => setTimeout(resolve, 1)); | |
| } | |
| } | |
| updateProgress(100, 'Complete!', | |
| `${actualColors.toLocaleString()} unique colors visualized`); | |
| setTimeout(() => updateProgress(-1), 1000); | |
| return { | |
| points: points, | |
| colors: colors, | |
| numPoints: actualColors, | |
| counter: counter, | |
| truncated: uniqueColors > actualColors | |
| }; | |
| } | |
| async function generateScalableCountLines(counter, maxLines = -1) { | |
| const entries = Array.from(counter.entries()); | |
| const actualLines = maxLines > 0 ? Math.min(entries.length, maxLines) : entries.length; | |
| updateProgress(0, 'Generating count lines...', ''); | |
| const linePositions = new Float32Array(actualLines * 6); // 2 vertices per line | |
| const lineColors = new Float32Array(actualLines * 6); | |
| const qRange = counter.range; | |
| const sqrtQRange = Math.floor(Math.sqrt(qRange)); | |
| const maxTiledCoord = Math.max(sqrtQRange * qRange + qRange - 1, 1); | |
| const maxLogCount = Math.log(counter.maxCount); | |
| const qToNormalized = 2 / (qRange - 1); | |
| let lineIndex = 0; | |
| // Sort by count for LOD - show highest frequency colors first | |
| if (maxLines > 0) { | |
| entries.sort((a, b) => b.count - a.count); | |
| } | |
| for (let i = 0; i < actualLines; i++) { | |
| const { qr, qg, qb, count } = entries[i]; | |
| // Tiled projection | |
| const col = qb % sqrtQRange; | |
| const row = Math.floor(qb / sqrtQRange); | |
| const tiledX = col * qRange + qr; | |
| const tiledY = row * qRange + qg; | |
| const baseX = (tiledX / maxTiledCoord) * 2 - 1; | |
| const baseY = (tiledY / maxTiledCoord) * 2 - 1; | |
| const baseZ = 0; | |
| const logCount = Math.log(count); | |
| const normalizedHeight = logCount / maxLogCount; | |
| const height = normalizedHeight * 0.5; | |
| // Line vertices | |
| linePositions[lineIndex] = baseX; | |
| linePositions[lineIndex + 1] = baseY; | |
| linePositions[lineIndex + 2] = baseZ; | |
| linePositions[lineIndex + 3] = baseX; | |
| linePositions[lineIndex + 4] = baseY; | |
| linePositions[lineIndex + 5] = height; | |
| // Colors | |
| let r, g, b; | |
| if (useValueColors) { | |
| r = qr * qToNormalized / 2 + 0.5; | |
| g = qg * qToNormalized / 2 + 0.5; | |
| b = qb * qToNormalized / 2 + 0.5; | |
| } else { | |
| const intensity = normalizedHeight; | |
| r = intensity; | |
| g = 0.2; | |
| b = 1 - intensity; | |
| } | |
| lineColors[lineIndex] = r; | |
| lineColors[lineIndex + 1] = g; | |
| lineColors[lineIndex + 2] = b; | |
| lineColors[lineIndex + 3] = r; | |
| lineColors[lineIndex + 4] = g; | |
| lineColors[lineIndex + 5] = b; | |
| lineIndex += 6; | |
| if (i % 25000 === 0) { | |
| const progress = Math.round((i / actualLines) * 100); | |
| updateProgress(progress, 'Generating count lines...', | |
| `${i.toLocaleString()}/${actualLines.toLocaleString()} lines`); | |
| await new Promise(resolve => setTimeout(resolve, 1)); | |
| } | |
| } | |
| updateProgress(100, 'Lines complete!'); | |
| setTimeout(() => updateProgress(-1), 500); | |
| return { | |
| positions: linePositions, | |
| colors: lineColors, | |
| lineCount: actualLines, | |
| truncated: entries.length > actualLines | |
| }; | |
| } | |
| function calculateTiledProjection(points, quantizationBits) { | |
| const qRange = 1 << quantizationBits; | |
| const qMask = qRange - 1; | |
| const sqrtQRange = Math.floor(Math.sqrt(qRange)); | |
| const maxTiledCoord = Math.max(sqrtQRange * qRange + qRange - 1, 1); | |
| const projectedPoints = new Float32Array(points.length); | |
| for (let i = 0; i < points.length; i += 3) { | |
| const x = points[i]; | |
| const y = points[i + 1]; | |
| const z = points[i + 2]; | |
| const qr = Math.max(0, Math.min(qMask, Math.floor((x + 1) / 2 * qRange))); | |
| const qg = Math.max(0, Math.min(qMask, Math.floor((y + 1) / 2 * qRange))); | |
| const qb = Math.max(0, Math.min(qMask, Math.floor((z + 1) / 2 * qRange))); | |
| const col = qb % sqrtQRange; | |
| const row = Math.floor(qb / sqrtQRange); | |
| const tiledX = col * qRange + qr; | |
| const tiledY = row * qRange + qg; | |
| projectedPoints[i] = (tiledX / maxTiledCoord) * 2 - 1; | |
| projectedPoints[i + 1] = (tiledY / maxTiledCoord) * 2 - 1; | |
| projectedPoints[i + 2] = 0; | |
| } | |
| return projectedPoints; | |
| } | |
| function setupControls() { | |
| const quantizationSelect = document.getElementById('quantizationSelect'); | |
| const maxPointsSelect = document.getElementById('maxPointsSelect'); | |
| quantizationSelect.addEventListener('change', (e) => { | |
| quantizationBits = parseInt(e.target.value); | |
| regenerateAllData(); | |
| }); | |
| maxPointsSelect.addEventListener('change', (e) => { | |
| maxPoints = parseInt(e.target.value); | |
| regenerateAllData(); | |
| }); | |
| } | |
| function regenerateAllData() { | |
| const originalDataMap = new Map(); | |
| pointClouds.forEach((cloud, id) => { | |
| originalDataMap.set(id, { | |
| originalData: cloud.originalData, | |
| name: cloud.name | |
| }); | |
| }); | |
| pointClouds.forEach((cloud, id) => { | |
| scene.remove(cloud.object); | |
| if (countLines.has(id)) { | |
| scene.remove(countLines.get(id)); | |
| countLines.delete(id); | |
| } | |
| }); | |
| pointClouds.clear(); | |
| originalDataMap.forEach((data, id) => { | |
| createPointCloudFromProcessedData(data.originalData, data.name); | |
| }); | |
| updateObjectList(); | |
| } | |
| function setupGrids() { | |
| const gridSize = 2; | |
| const gridDivisions = 4; | |
| const gridXY = new THREE.GridHelper(gridSize, gridDivisions, 0x0000ff, 0x0000ff); | |
| gridXY.rotation.x = Math.PI / 2; | |
| scene.add(gridXY); | |
| grids.xy = gridXY; | |
| } | |
| function toggleGrid(plane) { | |
| if (grids[plane]) { | |
| grids[plane].visible = !grids[plane].visible; | |
| } | |
| } | |
| function toggleCountLines() { | |
| showCountLines = !showCountLines; | |
| countLines.forEach(lineSegments => { | |
| lineSegments.visible = showCountLines; | |
| }); | |
| } | |
| function toggleValueColors() { | |
| useValueColors = !useValueColors; | |
| regenerateCountLines(); | |
| } | |
| function toggleAutoLOD() { | |
| autoLOD = !autoLOD; | |
| // Could regenerate with different LOD settings | |
| } | |
| async function regenerateCountLines() { | |
| const promises = []; | |
| pointClouds.forEach((cloud, id) => { | |
| promises.push((async () => { | |
| if (countLines.has(id)) { | |
| scene.remove(countLines.get(id)); | |
| countLines.delete(id); | |
| } | |
| if (cloud.counter && cloud.counter.size() > 0) { | |
| const maxLines = autoLOD && cloud.counter.size() > 100000 ? 100000 : -1; | |
| const lineData = await generateScalableCountLines(cloud.counter, maxLines); | |
| const lineGeometry = new THREE.BufferGeometry(); | |
| lineGeometry.setAttribute('position', new THREE.Float32BufferAttribute(lineData.positions, 3)); | |
| lineGeometry.setAttribute('color', new THREE.Float32BufferAttribute(lineData.colors, 3)); | |
| const lineMaterial = new THREE.LineBasicMaterial({ | |
| vertexColors: true, | |
| linewidth: 1 | |
| }); | |
| const lineSegments = new THREE.LineSegments(lineGeometry, lineMaterial); | |
| lineSegments.visible = showCountLines && cloud.object.visible; | |
| countLines.set(id, lineSegments); | |
| scene.add(lineSegments); | |
| } | |
| })()); | |
| }); | |
| await Promise.all(promises); | |
| } | |
| function removeAlphaChannel(canvas, data) { | |
| const imagePixelCount = canvas.width * canvas.height; | |
| const rgbOnlyBytes = new Uint8ClampedArray(imagePixelCount * 3); | |
| for (let i = 0, j = 0; i < data.length; i += 4, j += 3) { | |
| rgbOnlyBytes[j] = data[i]; | |
| rgbOnlyBytes[j + 1] = data[i + 1]; | |
| rgbOnlyBytes[j + 2] = data[i + 2]; | |
| } | |
| return rgbOnlyBytes; | |
| } | |
| function handleFiles(files) { | |
| for (const file of files) { | |
| if (!file.type.startsWith('image/')) { | |
| console.log(`Skipping non-image file: ${file.name}`); | |
| continue; | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = function (e) { | |
| const img = new Image(); | |
| img.onload = function () { | |
| const canvas = document.createElement('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| canvas.width = img.width; | |
| canvas.height = img.height; | |
| ctx.drawImage(img, 0, 0); | |
| const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
| createPointCloudFromProcessedData(removeAlphaChannel(canvas, imageData.data), file.name); | |
| }; | |
| img.src = e.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| } | |
| document.addEventListener('paste', (e) => { | |
| e.preventDefault(); | |
| const items = (e.clipboardData || window.clipboardData).items; | |
| for (let item of items) { | |
| if (item.kind === 'file') { | |
| const file = item.getAsFile(); | |
| handleFiles([file]); | |
| } else { | |
| item.getAsString((text) => { | |
| tryFetchAPI(text); | |
| }); | |
| } | |
| } | |
| }); | |
| async function tryFetchAPI(src) { | |
| try { | |
| const response = await fetch(src); | |
| if (response.ok) { | |
| handleFiles([await response.blob()]); | |
| } | |
| } catch (_) { | |
| } | |
| } | |
| function handleNonFileDrop(text) { | |
| if (text.startsWith('<meta')) { | |
| try { | |
| const parser = new DOMParser(); | |
| const doc = parser.parseFromString(text, 'text/html'); | |
| const imgSrc = doc.querySelector('img').getAttribute('src'); | |
| tryFetchAPI(imgSrc); | |
| } catch (_) { | |
| } | |
| } | |
| } | |
| function handleDrop(e) { | |
| const dt = e.dataTransfer; | |
| const file = dt.files[0]; | |
| if (file) { | |
| handleFiles(e.dataTransfer.files); | |
| return; | |
| } | |
| for (let i = 0; i < dt.items.length; i++) { | |
| const item = dt.items[i]; | |
| if (!(item.kind === 'string')) continue; | |
| item.getAsString((s) => { | |
| handleNonFileDrop(s); | |
| }); | |
| } | |
| } | |
| function setupDropZone() { | |
| const dropZone = document.getElementById('dropZone'); | |
| document.addEventListener('dragenter', (e) => { | |
| e.preventDefault(); | |
| dropZone.style.display = 'flex'; | |
| }); | |
| dropZone.addEventListener('dragleave', (e) => { | |
| e.preventDefault(); | |
| dropZone.style.display = 'none'; | |
| }); | |
| dropZone.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| }); | |
| dropZone.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| dropZone.style.display = 'none'; | |
| handleDrop(e); | |
| }); | |
| } | |
| function createThumbnailImage(rgbData) { | |
| const pointCount = rgbData.length / 3; | |
| const side = Math.ceil(Math.sqrt(pointCount)); | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = side; | |
| canvas.height = side; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.fillStyle = 'black'; | |
| ctx.fillRect(0, 0, side, side); | |
| const imageData = ctx.createImageData(side, side); | |
| const data = imageData.data; | |
| data.fill(0); | |
| for (let i = 0; i < pointCount && i < side * side; i++) { | |
| const x = i % side; | |
| const y = Math.floor(i / side); | |
| const pixelIndex = (y * side + x) * 4; | |
| const rgbIndex = i * 3; | |
| data[pixelIndex] = rgbData[rgbIndex]; | |
| data[pixelIndex + 1] = rgbData[rgbIndex + 1]; | |
| data[pixelIndex + 2] = rgbData[rgbIndex + 2]; | |
| data[pixelIndex + 3] = 255; | |
| } | |
| ctx.putImageData(imageData, 0, 0); | |
| return { | |
| dataUrl: canvas.toDataURL('image/png'), | |
| width: side, | |
| height: side | |
| }; | |
| } | |
| async function createPointCloudFromProcessedData(buffer, fileName) { | |
| const arrayBuffer = buffer instanceof ArrayBuffer ? buffer : buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); | |
| const sizeInMB = arrayBuffer.byteLength / (1024 * 1024); | |
| const pixelCount = arrayBuffer.byteLength / 3; | |
| console.log(`Processing ${fileName}: ${sizeInMB.toFixed(1)}MB (${pixelCount.toLocaleString()} pixels)`); | |
| try { | |
| const effectiveMaxPoints = maxPoints > 0 ? maxPoints : -1; | |
| const result = await scalableQuantizeProcess(arrayBuffer, quantizationBits, effectiveMaxPoints); | |
| const vertices = result.points; | |
| const colors = result.colors; | |
| const pointCount = result.numPoints; | |
| const counter = result.counter; | |
| const projectedPositions = calculateTiledProjection(vertices, quantizationBits); | |
| const rgbDataFor255 = new Uint8ClampedArray(colors.length); | |
| for (let i = 0; i < colors.length; i++) { | |
| rgbDataFor255[i] = colors[i] * 255; | |
| } | |
| const thumbnail = createThumbnailImage(rgbDataFor255); | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.Float32BufferAttribute(projectedPositions, 3)); | |
| geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); | |
| const material = new THREE.PointsMaterial({ | |
| size: 0.01, | |
| vertexColors: true | |
| }); | |
| const points = new THREE.Points(geometry, material); | |
| const id = `cloud_${objectCounter++}`; | |
| pointClouds.set(id, { | |
| object: points, | |
| name: fileName || 'unknown', | |
| pointCount: pointCount, | |
| thumbnail: thumbnail, | |
| originalPositions: vertices, | |
| originalData: buffer, | |
| counter: counter, | |
| truncated: result.truncated | |
| }); | |
| scene.add(points); | |
| // Generate count lines with LOD | |
| if (counter.size() > 0) { | |
| const maxLines = autoLOD && counter.size() > 100000 ? 100000 : -1; | |
| const lineData = await generateScalableCountLines(counter, maxLines); | |
| const lineGeometry = new THREE.BufferGeometry(); | |
| lineGeometry.setAttribute('position', new THREE.Float32BufferAttribute(lineData.positions, 3)); | |
| lineGeometry.setAttribute('color', new THREE.Float32BufferAttribute(lineData.colors, 3)); | |
| const lineMaterial = new THREE.LineBasicMaterial({ | |
| vertexColors: true, | |
| linewidth: 1 | |
| }); | |
| const lineSegments = new THREE.LineSegments(lineGeometry, lineMaterial); | |
| lineSegments.visible = showCountLines; | |
| countLines.set(id, lineSegments); | |
| scene.add(lineSegments); | |
| console.log(`Created ${lineData.lineCount.toLocaleString()} count lines for ${fileName}${lineData.truncated ? ' (truncated)' : ''}`); | |
| } | |
| updateObjectList(); | |
| const statusMsg = result.truncated ? ` (limited to ${pointCount.toLocaleString()} colors)` : ''; | |
| console.log(`Completed processing ${fileName}: ${pointCount.toLocaleString()} unique colors${statusMsg}`); | |
| } catch (error) { | |
| console.error(`Error processing ${fileName}:`, error); | |
| updateProgress(-1); | |
| } | |
| } | |
| function setupImagePopup() { | |
| const popup = document.getElementById('imagePopup'); | |
| const closeBtn = document.getElementById('closePopup'); | |
| closeBtn.addEventListener('click', () => { | |
| popup.style.display = 'none'; | |
| }); | |
| popup.addEventListener('click', (e) => { | |
| if (e.target === popup) { | |
| popup.style.display = 'none'; | |
| } | |
| }); | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'Escape' && popup.style.display === 'flex') { | |
| popup.style.display = 'none'; | |
| } | |
| }); | |
| } | |
| function showImagePopup(id) { | |
| const cloud = pointClouds.get(id); | |
| if (!cloud || !cloud.thumbnail) return; | |
| const popup = document.getElementById('imagePopup'); | |
| const img = document.getElementById('popupImage'); | |
| const caption = document.getElementById('popupCaption'); | |
| img.src = cloud.thumbnail.dataUrl; | |
| const truncatedNote = cloud.truncated ? ' (truncated)' : ''; | |
| caption.textContent = `${cloud.name}: ${cloud.pointCount.toLocaleString()} unique colors${truncatedNote} (${cloud.thumbnail.width}×${cloud.thumbnail.height})`; | |
| popup.style.display = 'flex'; | |
| } | |
| function updateObjectList() { | |
| const container = document.getElementById('objectItems'); | |
| container.innerHTML = ''; | |
| pointClouds.forEach((cloud, id) => { | |
| const item = document.createElement('div'); | |
| item.className = 'object-item'; | |
| if (cloud.thumbnail) { | |
| const thumb = document.createElement('img'); | |
| thumb.className = 'thumbnail'; | |
| thumb.src = cloud.thumbnail.dataUrl; | |
| thumb.title = 'Click to view full image'; | |
| thumb.onclick = () => showImagePopup(id); | |
| item.appendChild(thumb); | |
| } | |
| const info = document.createElement('div'); | |
| info.className = 'object-info'; | |
| const truncatedNote = cloud.truncated ? ' (truncated)' : ''; | |
| info.innerHTML = `${cloud.name}<br><small>${cloud.pointCount.toLocaleString()} colors${truncatedNote}</small>`; | |
| const visibilityBtn = document.createElement('button'); | |
| visibilityBtn.className = 'visibility-btn'; | |
| visibilityBtn.innerHTML = cloud.object.visible ? '👁️' : '👁️🗨️'; | |
| visibilityBtn.onclick = () => toggleVisibility(id); | |
| const deleteBtn = document.createElement('button'); | |
| deleteBtn.className = 'delete-btn'; | |
| deleteBtn.textContent = '×'; | |
| deleteBtn.onclick = () => deletePointCloud(id); | |
| item.appendChild(info); | |
| item.appendChild(visibilityBtn); | |
| item.appendChild(deleteBtn); | |
| container.appendChild(item); | |
| }); | |
| } | |
| function toggleVisibility(id) { | |
| const cloud = pointClouds.get(id); | |
| if (cloud) { | |
| cloud.object.visible = !cloud.object.visible; | |
| if (countLines.has(id)) { | |
| countLines.get(id).visible = cloud.object.visible && showCountLines; | |
| } | |
| updateObjectList(); | |
| } | |
| } | |
| function deletePointCloud(id) { | |
| const cloud = pointClouds.get(id); | |
| if (cloud) { | |
| scene.remove(cloud.object); | |
| pointClouds.delete(id); | |
| if (countLines.has(id)) { | |
| scene.remove(countLines.get(id)); | |
| countLines.delete(id); | |
| } | |
| updateObjectList(); | |
| } | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| if (controls) controls.update(); | |
| renderer.render(scene, camera); | |
| } | |
| init(); | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| document.getElementById('fileInput').addEventListener('change', function (e) { | |
| handleFiles(e.target.files); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment