Last active
April 28, 2025 19:32
-
-
Save lardratboy/b75e1f1e33f189e4c4c8dee6244f285e to your computer and use it in GitHub Desktop.
Bytes or images to PLY point cloud
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>B2Ply - Bytes To Points</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: 20px; | |
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; | |
} | |
.export-btn { | |
background: #44aaff; | |
border: none; | |
color: white; | |
padding: 2px 6px; | |
border-radius: 3px; | |
cursor: pointer; | |
} | |
.export-btn:hover { | |
background: #66bbff; | |
} | |
.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 { | |
margin: 5px 0; | |
cursor: pointer; | |
} | |
#fileInput { | |
position: absolute; | |
top: 10px; | |
left: 10px; | |
z-index: 100; | |
} | |
#exportAllBtn { | |
position: fixed; | |
bottom: 20px; | |
right: 20px; | |
background: #44cc44; | |
border: none; | |
color: white; | |
padding: 10px 15px; | |
border-radius: 5px; | |
font-family: Arial, sans-serif; | |
cursor: pointer; | |
z-index: 100; | |
} | |
#exportAllBtn:hover { | |
background: #66dd66; | |
} | |
/* Thumbnail styles */ | |
.thumbnail { | |
width: 50px; | |
height: 50px; | |
background-color: #333; | |
border: 1px solid #555; | |
cursor: pointer; | |
object-fit: contain; | |
} | |
/* Image popup styles */ | |
#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; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="dropZone">Drop data or images here</div> | |
<div id="instructions"> | |
Drag and drop files/images to view bytes/rgb as points<br> | |
Mouse: Left click + drag to rotate<br> | |
Mouse wheel to zoom | |
</div> | |
<input type="file" id="fileInput" multiple /> | |
<div id="gridControls"> | |
<label class="grid-toggle"><input type="checkbox" checked onchange="toggleGrid('xy')"> XY Plane | |
(Blue)</label><br> | |
<label class="grid-toggle"><input type="checkbox" checked onchange="toggleGrid('xz')"> XZ Plane | |
(Red)</label><br> | |
<label class="grid-toggle"><input type="checkbox" checked onchange="toggleGrid('yz')"> YZ Plane (Green)</label> | |
</div> | |
<div id="objectList"> | |
<h3 style="margin-top: 0">Loaded Objects</h3> | |
<div id="objectItems"></div> | |
</div> | |
<button id="exportAllBtn">Export All Visible as PLY</button> | |
<!-- Image popup container --> | |
<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/[email protected]/examples/js/controls/OrbitControls.js"></script> | |
<script> | |
let scene, camera, renderer, controls; | |
let pointClouds = new Map(); | |
let objectCounter = 0; | |
let grids = {}; | |
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(); | |
setupExportButtons(); | |
setupImagePopup(); | |
animate(); | |
} | |
function setupGrids() { | |
const gridSize = 2; // Reduced grid size to match normalized data | |
const gridDivisions = 4; // More divisions for finer reference | |
// XY Plane (Blue) | |
const gridXY = new THREE.GridHelper(gridSize, gridDivisions, 0x0000ff, 0x0000ff); | |
gridXY.rotation.x = Math.PI / 2; | |
scene.add(gridXY); | |
grids.xy = gridXY; | |
// XZ Plane (Red) | |
const gridXZ = new THREE.GridHelper(gridSize, gridDivisions, 0xff0000, 0xff0000); | |
scene.add(gridXZ); | |
grids.xz = gridXZ; | |
// YZ Plane (Green) | |
const gridYZ = new THREE.GridHelper(gridSize, gridDivisions, 0x00ff00, 0x00ff00); | |
gridYZ.rotation.z = Math.PI / 2; | |
scene.add(gridYZ); | |
grids.yz = gridYZ; | |
} | |
function toggleGrid(plane) { | |
if (grids[plane]) { | |
grids[plane].visible = !grids[plane].visible; | |
} | |
} | |
function removeAlphaChannel(canvas, data) { | |
const imagePixelCount = canvas.width * canvas.height; | |
const rgbOnlyBytes = new Uint8ClampedArray(imagePixelCount * 3); | |
let nextImageIdx = 0; | |
let nextRGBIdx = 0; | |
for (let i = 0; i < imagePixelCount; i++) { | |
rgbOnlyBytes[nextRGBIdx] = data[nextImageIdx]; | |
rgbOnlyBytes[nextRGBIdx + 1] = data[nextImageIdx + 1]; | |
rgbOnlyBytes[nextRGBIdx + 2] = data[nextImageIdx + 2]; | |
nextImageIdx += 4; | |
nextRGBIdx += 3; | |
} | |
return rgbOnlyBytes; | |
} | |
function handleFiles(files) { | |
for (const file of files) { | |
const reader = new FileReader(); | |
reader.onload = function (e) { | |
if (file.type.startsWith('image/')) { | |
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); | |
createPointCloud(removeAlphaChannel(canvas, imageData.data), file.name); | |
}; | |
img.src = e.target.result; | |
} else { | |
const arrayBuffer = e.target.result; | |
const uint8Array = new Uint8Array(arrayBuffer); | |
createPointCloud(uint8Array, file.name); | |
} | |
}; | |
if (file.type.startsWith('image/')) { | |
reader.readAsDataURL(file); | |
} else { | |
reader.readAsArrayBuffer(file); | |
} | |
} | |
} | |
// Handle clipboard operations | |
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]); | |
} | |
} | |
}); | |
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 getUniqueTuples(data) { | |
const bitArraySizeInUint32 = Math.ceil((256 * 256 * 256) / 32); | |
const tupleBitArray = new Uint32Array(bitArraySizeInUint32); | |
const resultTuples = []; | |
for (let i = 0; i < data.length; i += 3) { | |
const r = data[i]; | |
const g = data[i + 1]; | |
const b = data[i + 2]; | |
const index = (r << 16) | (g << 8) | b; | |
const elementIndex = index >> 5; | |
const bitPosition = index & 0x1F; | |
const mask = 1 << bitPosition; | |
if ((tupleBitArray[elementIndex] & mask) === 0) { | |
tupleBitArray[elementIndex] |= mask; | |
resultTuples.push(r, g, b); | |
} | |
} | |
return resultTuples; | |
} | |
function createThumbnailImage(rgbData) { | |
// Calculate optimal dimensions for a square image | |
const pointCount = rgbData.length / 3; | |
const side = Math.ceil(Math.sqrt(pointCount)); | |
// Create canvas and get context | |
const canvas = document.createElement('canvas'); | |
canvas.width = side; | |
canvas.height = side; | |
const ctx = canvas.getContext('2d'); | |
// Fill with black background | |
ctx.fillStyle = 'black'; | |
ctx.fillRect(0, 0, side, side); | |
// Create ImageData to draw the points | |
const imageData = ctx.createImageData(side, side); | |
const data = imageData.data; | |
// Fill data with transparent black | |
for (let i = 0; i < data.length; i += 4) { | |
data[i] = 0; // R | |
data[i+1] = 0; // G | |
data[i+2] = 0; // B | |
data[i+3] = 0; // A - transparent | |
} | |
// Add points to the image | |
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 pointIndex = i * 3; | |
data[pixelIndex] = rgbData[pointIndex]; // R | |
data[pixelIndex+1] = rgbData[pointIndex+1]; // G | |
data[pixelIndex+2] = rgbData[pointIndex+2]; // B | |
data[pixelIndex+3] = 255; // A - fully opaque | |
} | |
// Put the image data on the canvas | |
ctx.putImageData(imageData, 0, 0); | |
return { | |
dataUrl: canvas.toDataURL('image/png'), | |
width: side, | |
height: side | |
}; | |
} | |
function createPointCloud(buffer, fileName) { | |
const rgbData = new Uint8Array(getUniqueTuples(buffer)); | |
const pointCount = Math.floor(rgbData.length / 3); | |
const vertices = new Float32Array(pointCount * 3); | |
for (let i = 0; i < pointCount; i++) { | |
const rgbIndex = i * 3; | |
const vertexIndex = i * 3; | |
// Convert RGB values to normalized coordinates (-1 to 1) | |
const x = (rgbData[rgbIndex] / 255) * 2 - 1; | |
const y = (rgbData[rgbIndex + 1] / 255) * 2 - 1; | |
const z = (rgbData[rgbIndex + 2] / 255) * 2 - 1; | |
vertices[vertexIndex] = x; | |
vertices[vertexIndex + 1] = y; | |
vertices[vertexIndex + 2] = z; | |
} | |
// Create the 2D thumbnail image | |
const thumbnail = createThumbnailImage(rgbData); | |
const geometry = new THREE.BufferGeometry(); | |
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)); | |
geometry.setAttribute('color', new THREE.BufferAttribute(rgbData, 3, true)); | |
const material = new THREE.PointsMaterial({ | |
size: 0.005, // Smaller point size for normalized data | |
vertexColors: true | |
}); | |
const points = new THREE.Points(geometry, material); | |
const id = `cloud_${objectCounter++}`; | |
pointClouds.set(id, { | |
object: points, | |
name: fileName, | |
pointCount: pointCount, | |
thumbnail: thumbnail | |
}); | |
scene.add(points); | |
updateObjectList(); | |
} | |
function setupImagePopup() { | |
const popup = document.getElementById('imagePopup'); | |
const closeBtn = document.getElementById('closePopup'); | |
// Close popup when clicking the X | |
closeBtn.addEventListener('click', () => { | |
popup.style.display = 'none'; | |
}); | |
// Close popup when clicking outside the image | |
popup.addEventListener('click', (e) => { | |
if (e.target === popup) { | |
popup.style.display = 'none'; | |
} | |
}); | |
// Close popup with Escape key | |
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; | |
caption.textContent = `${cloud.name}: ${cloud.pointCount.toLocaleString()} unique points (${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'; | |
// Create thumbnail if available | |
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'; | |
info.textContent = `${cloud.name} (${cloud.pointCount.toLocaleString()} points)`; | |
const visibilityBtn = document.createElement('button'); | |
visibilityBtn.className = 'visibility-btn'; | |
visibilityBtn.innerHTML = cloud.object.visible ? '👁️' : '👁️🗨️'; | |
visibilityBtn.onclick = () => toggleVisibility(id); | |
const exportBtn = document.createElement('button'); | |
exportBtn.className = 'export-btn'; | |
exportBtn.textContent = 'PLY'; | |
exportBtn.title = 'Export as PLY'; | |
exportBtn.onclick = () => exportAsPLY(id); | |
const deleteBtn = document.createElement('button'); | |
deleteBtn.className = 'delete-btn'; | |
deleteBtn.textContent = '×'; | |
deleteBtn.onclick = () => deletePointCloud(id); | |
item.appendChild(info); | |
item.appendChild(visibilityBtn); | |
item.appendChild(exportBtn); | |
item.appendChild(deleteBtn); | |
container.appendChild(item); | |
}); | |
} | |
function toggleVisibility(id) { | |
const cloud = pointClouds.get(id); | |
if (cloud) { | |
cloud.object.visible = !cloud.object.visible; | |
updateObjectList(); | |
} | |
} | |
function deletePointCloud(id) { | |
const cloud = pointClouds.get(id); | |
if (cloud) { | |
scene.remove(cloud.object); | |
pointClouds.delete(id); | |
updateObjectList(); | |
} | |
} | |
function animate() { | |
requestAnimationFrame(animate); | |
if (controls) controls.update(); | |
renderer.render(scene, camera); | |
} | |
function setupExportButtons() { | |
document.getElementById('exportAllBtn').addEventListener('click', exportAllVisibleAsPLY); | |
} | |
function exportAsPLY(id) { | |
const cloud = pointClouds.get(id); | |
if (cloud) { | |
const plyData = generatePLY(cloud.object); | |
const filename = cloud.name.replace(/\.[^/.]+$/, "") + ".ply"; | |
downloadText(plyData, filename, 'application/octet-stream'); | |
} | |
} | |
function exportAllVisibleAsPLY() { | |
// Count total points in all visible point clouds | |
let totalPoints = 0; | |
const visibleClouds = []; | |
pointClouds.forEach(cloud => { | |
if (cloud.object.visible) { | |
totalPoints += cloud.pointCount; | |
visibleClouds.push(cloud); | |
} | |
}); | |
if (visibleClouds.length === 0) { | |
console.log('No visible point clouds to export'); | |
return; | |
} | |
// Generate combined PLY data | |
const plyData = generateCombinedPLY(visibleClouds, totalPoints); | |
downloadText(plyData, 'b2p_combined_export.ply', 'application/octet-stream'); | |
} | |
function generatePLY(pointCloud) { | |
const positions = pointCloud.geometry.getAttribute('position'); | |
const colors = pointCloud.geometry.getAttribute('color'); | |
const vertices = []; | |
// Header | |
let ply = 'ply\n'; | |
ply += 'format ascii 1.0\n'; | |
ply += `element vertex ${positions.count}\n`; | |
ply += 'property float x\n'; | |
ply += 'property float y\n'; | |
ply += 'property float z\n'; | |
ply += 'property uchar red\n'; | |
ply += 'property uchar green\n'; | |
ply += 'property uchar blue\n'; | |
ply += 'end_header\n'; | |
// Vertex data | |
for (let i = 0; i < positions.count; i++) { | |
const x = positions.getX(i); | |
const y = positions.getY(i); | |
const z = positions.getZ(i); | |
const r = colors.getX(i); | |
const g = colors.getY(i); | |
const b = colors.getZ(i); | |
ply += `${x} ${y} ${z} ${r} ${g} ${b}\n`; | |
} | |
return ply; | |
} | |
function generateCombinedPLY(clouds, totalPoints) { | |
// Header | |
let ply = 'ply\n'; | |
ply += 'format ascii 1.0\n'; | |
ply += `element vertex ${totalPoints}\n`; | |
ply += 'property float x\n'; | |
ply += 'property float y\n'; | |
ply += 'property float z\n'; | |
ply += 'property uchar red\n'; | |
ply += 'property uchar green\n'; | |
ply += 'property uchar blue\n'; | |
ply += 'end_header\n'; | |
// Add vertex data from all clouds | |
for (const cloud of clouds) { | |
if (cloud.object.visible) { | |
const positions = cloud.object.geometry.getAttribute('position'); | |
const colors = cloud.object.geometry.getAttribute('color'); | |
for (let i = 0; i < positions.count; i++) { | |
const x = positions.getX(i); | |
const y = positions.getY(i); | |
const z = positions.getZ(i); | |
const r = colors.getX(i); | |
const g = colors.getY(i); | |
const b = colors.getZ(i); | |
ply += `${x} ${y} ${z} ${r} ${g} ${b}\n`; | |
} | |
} | |
} | |
return ply; | |
} | |
function downloadText(text, filename, mimeType) { | |
const blob = new Blob([text], { type: mimeType }); | |
const url = URL.createObjectURL(blob); | |
const a = document.createElement('a'); | |
a.href = url; | |
a.download = filename; | |
document.body.appendChild(a); | |
a.click(); | |
document.body.removeChild(a); | |
URL.revokeObjectURL(url); | |
} | |
init(); | |
window.addEventListener('resize', () => { | |
camera.aspect = window.innerWidth / window.innerHeight; | |
camera.updateProjectionMatrix(); | |
renderer.setSize(window.innerWidth, window.innerHeight); | |
}); | |
const argument = new URL(document.URL).searchParams.get('fetch'); | |
if (argument) { | |
console.log(argument); | |
tryFetchAPI(argument); | |
} | |
// Handle file input | |
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