灰度图,Gray Scale Image 或是Grey Scale Image,又称灰阶图。把白色与黑色之间按对数关系分为若干等级,称为灰度。灰度分为256阶。
用灰度表示的图像称作灰度图。除了常见的卫星图像、航空照片外,许多地球物理观测数据也以灰度表示。
上面就是一个把普通图片转为灰度图的示例,可以寻找在线【在线图像转灰度图像工具】进行测试。
可以看到,所谓的灰度图,可以简单理解为,彩色相机和黑白相机,但是注意,这里的黑白也不少非黑即白。
在当前机器人等领域,机器扫描点云图,通过点云图生成灰度图,然后在灰度图上规划行进路径。在行进过程中,再通过配合激光扫描,进行隔离避障。
这里要实现一个功能,屏幕点击时,怎么判断点击的位置,是否为黑色(或者说深色),因为这个位置是障碍物。注意,这里要通过HTML+JS来实现,是前端功能。
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>PGM文件Canvas渲染器 (完整修复版)</title> <script src="https://cdn.tailwindcss.com"></script> <script> tailwind.config = { theme: { extend: { colors: { primary: '#3B82F6', }, } } } </script> <style type="text/tailwindcss"> @layer utilities { .canvas-wrapper { @apply border-2 border-gray-200 rounded-lg shadow-md overflow-auto bg-white; } .control-panel { @apply mb-4 p-4 bg-gray-50 rounded-lg border border-gray-200; } .status-text { @apply text-sm font-medium; } .log-text { @apply text-xs text-gray-500 mt-2 p-2 bg-gray-100 rounded max-h-40 overflow-auto; } .btn { @apply px-3 py-1.5 rounded text-sm font-medium transition-colors; } .btn-primary { @apply bg-primary text-white hover:bg-primary/90; } .loading-spinner { @apply w-8 h-8 border-4 border-gray-200 border-t-primary rounded-full animate-spin; } } </style> </head> <body class="bg-gray-100 min-h-screen p-4 md:p-8"> <div class="max-w-5xl mx-auto"> <header> <h1 class="text-2xl md:text-3xl font-bold text-gray-800">PGM文件Canvas渲染器</h1> <p class="text-gray-600 mt-1">修复头部解析错误,支持正确识别像素数据起始位置</p> </header> <div> <div class="flex flex-col md:flex-row md:items-center gap-4"> <div> <label for="pgmUrl" class="block text-sm font-medium text-gray-700 mb-1">PGM文件URL:</label> <input type="text" id="pgmUrl" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50" value="http://221.239.38.150:19904/2025/09/08/72d422f6-b60c-4f29-9f17-3f7e6d79c5dd.pgm" > </div> <div class="flex items-center gap-2"> <button id="loadBtn" class="btn btn-primary">加载并渲染</button> <button id="clearLogBtn" class="btn bg-gray-200 hover:bg-gray-300">清除日志</button> </div> </div> <div id="status" class="mt-3 flex items-center gap-2"> <span class="status-text text-gray-600">状态: 未加载</span> </div> <div id="debugLog" class="log-text hidden"></div> </div> <div> <!-- 加载状态 --> <div id="loadingIndicator" class="hidden flex justify-center items-center min-h-[300px]"> <div class="flex flex-col items-center"> <div class="loading-spinner mb-3"></div> <p id="loadingText">正在加载和解析PGM文件...</p> </div> </div> <!-- 错误信息 --> <div id="errorMessage" class="hidden p-6 text-center text-red-600 min-h-[300px] flex flex-col items-center justify-center"> <p id="errorDetails">加载或解析PGM文件时发生错误</p> <div id="headerSample" class="mt-4 p-3 bg-gray-100 rounded text-xs max-w-md hidden"> <p class="font-medium mb-1">文件头部样本:</p> <pre id="headerSampleContent" class="text-left whitespace-pre-wrap break-all"></pre> </div> </div> <!-- Canvas容器 --> <canvas id="pgmCanvas"></canvas> </div> </div> <script> // DOM元素 const pgmUrlInput = document.getElementById('pgmUrl'); const loadBtn = document.getElementById('loadBtn'); const clearLogBtn = document.getElementById('clearLogBtn'); const canvas = document.getElementById('pgmCanvas'); const ctx = canvas.getContext('2d'); const statusElement = document.getElementById('status'); const loadingIndicator = document.getElementById('loadingIndicator'); const loadingText = document.getElementById('loadingText'); const errorMessage = document.getElementById('errorMessage'); const errorDetails = document.getElementById('errorDetails'); const debugLog = document.getElementById('debugLog'); const headerSample = document.getElementById('headerSample'); const headerSampleContent = document.getElementById('headerSampleContent'); // 配置 const LOAD_TIMEOUT = 30000; // 30秒超时 const MAX_FILE_SIZE = 50 * 1024 * 1024; // 最大文件大小50MB const MAX_HEADER_SIZE = 1024 * 10; // 最大头部大小10KB // 初始状态 let imageData = null; let imageWidth = 0; let imageHeight = 0; let loadTimeoutId = null; // 事件监听 loadBtn.addEventListener('click', loadAndRenderPGM); clearLogBtn.addEventListener('click', () => { debugLog.innerHTML = ''; debugLog.classList.add('hidden'); }); canvas.addEventListener('click', handleCanvasClick); /** * 添加调试日志 */ function addDebugLog(message) { const time = new Date().toLocaleTimeString(); debugLog.innerHTML += `[${time}] ${message}<br>`; debugLog.classList.remove('hidden'); debugLog.scrollTop = debugLog.scrollHeight; } /** * 更新状态信息 */ function updateStatus(text, colorClass) { statusElement.innerHTML = `<span class="status-text ${colorClass}">状态: ${text}</span>`; addDebugLog(text); } /** * 显示/隐藏加载状态 */ function showLoading(text = '正在加载和解析PGM文件...') { loadingText.textContent = text; loadingIndicator.classList.remove('hidden'); canvas.classList.add('hidden'); errorMessage.classList.add('hidden'); headerSample.classList.add('hidden'); } function hideLoading() { loadingIndicator.classList.add('hidden'); canvas.classList.remove('hidden'); if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null; } } /** * 显示错误信息 */ function showError(message, headerSampleData = null) { errorDetails.textContent = `错误: ${message}`; errorMessage.classList.remove('hidden'); canvas.classList.add('hidden'); loadingIndicator.classList.add('hidden'); // 显示头部样本(如果有) if (headerSampleData) { headerSample.classList.remove('hidden'); headerSampleContent.textContent = headerSampleData; } if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null; } } /** * 加载并渲染PGM文件 */ async function loadAndRenderPGM() { const url = pgmUrlInput.value.trim(); if (!url) { updateStatus('请输入有效的PGM文件URL', 'text-red-600'); return; } try { updateStatus('开始加载文件...', 'text-blue-600'); showLoading('正在加载文件...'); // 设置超时 loadTimeoutId = setTimeout(() => { throw new Error(`加载超时(超过${LOAD_TIMEOUT/1000}秒)`); }, LOAD_TIMEOUT); // 下载PGM文件 addDebugLog(`开始下载: ${url}`); const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP错误: ${response.status} ${response.statusText}`); } // 检查文件大小 const contentLength = response.headers.get('Content-Length'); if (contentLength && parseInt(contentLength) > MAX_FILE_SIZE) { throw new Error(`文件过大(${(contentLength/1024/1024).toFixed(2)}MB),最大支持${MAX_FILE_SIZE/1024/1024}MB`); } // 读取文件内容 addDebugLog('文件下载完成,开始读取内容...'); const arrayBuffer = await response.arrayBuffer(); addDebugLog(`文件大小: ${arrayBuffer.byteLength} 字节`); // 解析PGM文件 showLoading('正在解析PGM文件...'); updateStatus('开始解析PGM文件...', 'text-blue-600'); const { width, height, data } = parsePGM(new Uint8Array(arrayBuffer)); // 保存图像数据 imageWidth = width; imageHeight = height; addDebugLog(`解析完成,图像尺寸: ${width}×${height}`); // 设置Canvas尺寸 canvas.width = width; canvas.height = height; // 创建ImageData并设置像素值 const canvasImageData = ctx.createImageData(width, height); // PGM是灰度图,RGB值相同 for (let i = 0; i < data.length; i++) { const index = i * 4; canvasImageData.data[index] = data[i]; // R canvasImageData.data[index + 1] = data[i]; // G canvasImageData.data[index + 2] = data[i]; // B canvasImageData.data[index + 3] = 255; // A (不透明) } // 保存图像数据供后续使用 imageData = canvasImageData; // 绘制到Canvas ctx.putImageData(canvasImageData, 0, 0); // 更新状态 updateStatus(`已加载: ${width}×${height} 像素`, 'text-green-600'); hideLoading(); } catch (err) { console.error('处理PGM文件失败:', err); updateStatus(`错误: ${err.message}`, 'text-red-600'); // 尝试提取头部样本用于调试 let headerSample = null; try { const response = await fetch(url); if (response.ok) { const ab = await response.arrayBuffer(); const bytes = new Uint8Array(ab); const sampleSize = Math.min(500, bytes.length); headerSample = String.fromCharCode.apply(null, bytes.subarray(0, sampleSize)) .replace(/\r/g, '\\r') .replace(/\n/g, '\\n\n'); } } catch (e) { console.error('获取头部样本失败:', e); } showError(err.message, headerSample); } } /** * 解析PGM文件 - 修复了头部结束位置计算错误 */ function parsePGM(bytes) { addDebugLog('开始解析PGM文件头部...'); // 提取头部文本(处理所有可能的换行符) let headerText = ''; const maxHeaderCheck = Math.min(MAX_HEADER_SIZE, bytes.length); for (let i = 0; i < maxHeaderCheck; i++) { headerText += String.fromCharCode(bytes[i]); } // 按各种换行符分割并处理每行 const allLines = headerText.split(/\r?\n/); addDebugLog(`原始头部行数: ${allLines.length}`); // 过滤注释行和空行,但保留注释行信息用于调试 const validLines = []; const commentLines = []; for (let line of allLines) { // 移除行内注释(但保留注释内容用于调试) const commentIndex = line.indexOf('#'); if (commentIndex !== -1) { commentLines.push(line.substring(commentIndex)); line = line.substring(0, commentIndex).trim(); } // 只保留非空的有效行 if (line.trim() !== '') { validLines.push(line.trim()); } } addDebugLog(`注释行数: ${commentLines.length}`); addDebugLog(`有效数据行数: ${validLines.length}`); for (let i = 0; i < validLines.length && i < 5; i++) { addDebugLog(`有效行 ${i}: "${validLines[i]}"`); } // 验证是否有足够的有效行 if (validLines.length < 3) { throw new Error(`有效头部行不足,需要至少3行,实际只有${validLines.length}行`); } // 验证PGM格式标识(第一行必须是P2或P5) const format = validLines[0]; if (!['P2', 'P5'].includes(format)) { throw new Error(`无效的PGM格式标识: "${format}",应为P2或P5`); } const isBinary = format === 'P5'; addDebugLog(`检测到${isBinary ? 'P5 (二进制)' : 'P2 (ASCII)'}格式`); // 解析尺寸信息(第二行有效行) const dimsLine = validLines[1]; const dims = dimsLine.split(/\s+/) .filter(part => part.trim() !== '') .map(Number) .filter(n => !isNaN(n)); if (dims.length !== 2) { throw new Error(`尺寸行格式无效: "${dimsLine}",应包含两个数字(宽度 高度)`); } const width = dims[0]; const height = dims[1]; if (width <= 0 || height <= 0) { throw new Error(`无效的图像尺寸: 宽度=${width}, 高度=${height},必须为正数`); } addDebugLog(`图像尺寸: ${width}×${height}`); // 解析最大像素值(第三行有效行) const maxValueLine = validLines[2]; const maxValue = parseInt(maxValueLine, 10); if (isNaN(maxValue) || maxValue <= 0 || maxValue > 65535) { throw new Error(`无效的最大像素值: "${maxValueLine}",应为1-65535之间的整数`); } addDebugLog(`最大像素值: ${maxValue} (${maxValue > 255 ? '16位' : '8位'})`); // 【关键修复】使用状态机精确计算头部结束位置(数据开始位置) let dataStart = 0; let state = 'format'; // 状态:format(找P5)→ dims(找宽高)→ maxVal(找最大像素值)→ data(数据开始) let currentLine = ''; const whitespace = new Set([0x20, 0x0A, 0x0D, 0x09]); // 空格、换行、回车、制表符 for (let i = 0; i < bytes.length; i++) { const byte = bytes[i]; // 处理注释(#开头直到换行) if (byte === 0x23) { // # while (i < bytes.length && bytes[i] !== 0x0A && bytes[i] !== 0x0D) { i++; // 跳过注释内容 } continue; } // 处理状态切换 if (state !== 'data') { if (whitespace.has(byte)) { // 遇到空白字符,判断当前行是否完成 if (currentLine.trim() !== '') { // 根据状态处理当前行 if (state === 'format' && currentLine === 'P5') { state = 'dims'; } else if (state === 'dims') { // 宽高行已读取 state = 'maxVal'; } else if (state === 'maxVal') { // 最大像素值行已读取,接下来是数据 state = 'data'; dataStart = i; // 记录数据开始位置 // 跳过后续连续空白字符 while (dataStart < bytes.length && whitespace.has(bytes[dataStart])) { dataStart++; } break; // 找到数据开始位置,退出循环 } currentLine = ''; } } else { // 非空白字符,累加当前行 currentLine += String.fromCharCode(byte); } } } // 验证数据开始位置是否有效 if (dataStart === 0) { throw new Error('无法确定像素数据的起始位置,头部格式异常'); } addDebugLog(`修正后的数据开始位置: ${dataStart}`); // 计算像素总数 const pixelCount = width * height; addDebugLog(`总像素数: ${pixelCount}`); // 准备像素数据数组 const data = new Uint8Array(pixelCount); // 处理P5二进制格式 if (isBinary) { // 对于16位深度的PGM文件(maxValue > 255) if (maxValue > 255) { addDebugLog('处理16位P5格式...'); // 16位数据需要2字节表示一个像素 const requiredBytes = pixelCount * 2; const availableBytes = bytes.length - dataStart; if (availableBytes < requiredBytes) { throw new Error(`P5文件数据不完整,需要${requiredBytes}字节,实际只有${availableBytes}字节`); } // 读取16位像素值(大端格式) for (let j = 0; j < pixelCount; j++) { const highByte = bytes[dataStart + j * 2]; const lowByte = bytes[dataStart + j * 2 + 1]; const value = (highByte << 8) | lowByte; // 缩放到0-255范围 data[j] = Math.round((value / maxValue) * 255); } } // 8位深度的P5文件 else { addDebugLog('处理8位P5格式...'); const requiredBytes = pixelCount; const availableBytes = bytes.length - dataStart; if (availableBytes < requiredBytes) { throw new Error(`P5文件数据不完整,需要${requiredBytes}字节,实际只有${availableBytes}字节`); } // 直接复制8位像素值 for (let j = 0; j < pixelCount; j++) { data[j] = bytes[dataStart + j]; } } } // 处理P2 ASCII格式 else { addDebugLog('处理P2 ASCII格式...'); // 提取所有像素数据(只匹配非负整数) const asciiData = String.fromCharCode.apply(null, bytes.subarray(dataStart)); const numbers = asciiData.match(/\d+/g)?.map(Number) || []; if (numbers.length < pixelCount) { throw new Error(`P2文件数据不完整,需要${pixelCount}个像素值,实际只有${numbers.length}个`); } // 填充像素数据 for (let j = 0; j < pixelCount; j++) { // 缩放到0-255范围 data[j] = Math.round((numbers[j] / maxValue) * 255); } } addDebugLog('像素数据解析完成'); return { width, height, data }; } /** * 处理Canvas点击事件 */ function handleCanvasClick(e) { if (!imageData) { alert('请先加载PGM图像'); return; } // 获取Canvas的位置信息 const rect = canvas.getBoundingClientRect(); // 计算点击位置在Canvas中的坐标 const x = Math.floor(e.clientX - rect.left); const y = Math.floor(e.clientY - rect.top); // 检查坐标是否在有效范围内 if (x < 0 || x >= imageWidth || y < 0 || y >= imageHeight) { alert('请点击图像区域内的位置'); return; } // 计算像素在数据数组中的索引 const pixelIndex = (y * imageWidth + x) * 4; // 获取RGB值 const r = imageData.data[pixelIndex]; const g = imageData.data[pixelIndex + 1]; const b = imageData.data[pixelIndex + 2]; // 判断是否偏黑色 const luminance = 0.299 * r + 0.587 * g + 0.114 * b; const isDark = luminance <= 30; // 显示结果 alert( `坐标: (${x}, ${y})\n` + `RGB值: R=${r}, G=${g}, B=${b}\n` + `亮度: ${luminance.toFixed(1)}\n` + `是否偏黑色: ${isDark ? '是' : '否'}` ); } </script> </body> </html>
页面显示效果
点击页面提示效果
如果点击白色区域,那么提示否。
另外注意,在这张灰度图上,非黑即白,所有可以看到白色为0,黑色为255,但是实际上如最上面的示例图,真正的灰度图不是非黑即白的。
这时,就要调整判断逻辑,如上代码,实际也不是必须为0才是判断为白色的。
// 获取RGB值 const r = imageData.data[pixelIndex]; const g = imageData.data[pixelIndex + 1]; const b = imageData.data[pixelIndex + 2]; // 判断是否偏黑色 const luminance = 0.299 * r + 0.587 * g + 0.114 * b; const isDark = luminance <= 30;
根据公式计算记过后,小于30,才判定为白色。
Java小强
未曾清贫难成人,不经打击老天真。
自古英雄出炼狱,从来富贵入凡尘。
发表评论: