灰度图,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,才判定为白色。