Java小强个人技术博客站点    手机版
当前位置: 首页 >> JS >> PGM文件Canvas渲染器

PGM文件Canvas渲染器

60 JS | 2025-9-17

灰度图,Gray Scale Image 或是Grey Scale Image,又称灰阶图。把白色与黑色之间按对数关系分为若干等级,称为灰度。灰度分为256阶。

用灰度表示的图像称作灰度图。除了常见的卫星图像、航空照片外,许多地球物理观测数据也以灰度表示。

wechat_2025-09-17_100329_621.jpgwechat_2025-09-17_100339_321.jpg

上面就是一个把普通图片转为灰度图的示例,可以寻找在线【在线图像转灰度图像工具】进行测试。

可以看到,所谓的灰度图,可以简单理解为,彩色相机和黑白相机,但是注意,这里的黑白也不少非黑即白。


在当前机器人等领域,机器扫描点云图,通过点云图生成灰度图,然后在灰度图上规划行进路径。在行进过程中,再通过配合激光扫描,进行隔离避障。

这里要实现一个功能,屏幕点击时,怎么判断点击的位置,是否为黑色(或者说深色),因为这个位置是障碍物。注意,这里要通过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>


页面显示效果

wechat_2025-09-17_105258_784.jpg


点击页面提示效果

wechat_2025-09-17_105345_673.jpg

如果点击白色区域,那么提示否。


另外注意,在这张灰度图上,非黑即白,所有可以看到白色为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,才判定为白色。

推荐您阅读更多有关于“ js html PGM 灰度图 Canvas 渲染 ”的文章

下一篇:Debian13安装MariaDB

猜你喜欢

发表评论: