Skip to content

Instantly share code, notes, and snippets.

@lardratboy
Last active April 28, 2025 19:32
Show Gist options
  • Save lardratboy/b75e1f1e33f189e4c4c8dee6244f285e to your computer and use it in GitHub Desktop.
Save lardratboy/b75e1f1e33f189e4c4c8dee6244f285e to your computer and use it in GitHub Desktop.
Bytes or images to PLY point cloud
<!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">&times;</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