简介:PGM-Editor是一款基于Java开发的PGM灰度图像编辑器源码包,面向Java入门开发者和图像处理学习者,旨在帮助读者掌握用AWT与Swing组件从零搭建图形界面的思路,同时理解PGM灰度图像文件的创建、读取和修改过程。压缩包仅786KB,共11个文件,包含Java源程序、编译生成的class、可直接运行的exe、IDE工程配置iml以及7张pgm示例图片,覆盖源码阅读、程序运行和格式测试等不同环节。目前已有978人学习/下载。项目基于BufferedImage实现图像显示与像素级灰度编辑,支持亮度/对比度调整、翻转旋转等操作,并分别解析PGM的P2(ASCII)和P5(二进制)两种编码格式,演示了文件头信息解析、像素遍历、鼠标点击取点、菜单栏与工具栏搭建、事件驱动处理以及多线程后台任务等Java开发要点。该工程体量小巧、目录结构清晰,适合Java课程设计、期末作业或自学图形界面编程时作为参考。
1. 一个纯 JDK 就能跑的 PGM 编辑器:从灰度图读写到全套滤镜
之前在一台不能联网的旧机器上做灰度图批处理,想给一组 PGM 文件统一做反色和直方图均衡化,结果发现手头连 OpenCV 都装不上。于是索性用纯 Java 写了一个 PGM 编辑器:它只依赖 JDK 原生库,没有框架,没有三方依赖,却能完成 PGM(含 ASCII 的 P2 和二进制 P5 两种格式)的读取、编辑、滤镜处理和保存。它的核心不是界面有多花哨,而是把图像处理里最基础的一套流程——文件解析、像素矩阵操作、直方图统计、卷积滤波——用最直白的方式落地了。适合正在做图像处理课设、或者需要在嵌入式/离线环境里做灰度图预处理的开发者拿来当算法练习板或批处理工具。
2. 架构设计与目录结构:把界面、编解码和像素算法彻底拆开
2.1 为什么选 PGM,以及 Java 技术选型
PGM(Portable GrayMap)是 Netpbm 格式族里最简单的一种灰度图格式,整个文件就是「文本头 + 像素数组」两段。它没有任何压缩、没有调色板、没有通道交错,所以特别适合用来练习图像算法——你不需要先学会 PNG 的 zlib 解压才能碰到像素。另一个好处是几乎所有图像库都支持它,写出来的结果可以用 IrfanView、ImageMagick 直接验证。
技术选型上没有悬念:纯 Java + Swing。Swing 用来做图像预览和交互编辑,核心的编解码和算法单独抽到一个无 UI 依赖的包。这样命令行批处理和 GUI 编辑共用同一套代码,不会出现「界面能跑但算法没法复用」的尴尬。Maven 可选,真机上直接 javac 也能编。
2.2 模块划分与目录结构
src/main/java/io/pgm/ core/ model/PgmImage.java # 图像数据模型:宽、高、maxVal、像素 int[] codec/PgmCodec.java # PGM 读写,自动识别 P2/P5 codec/TokenReader.java # 字节流上的 ASCII token 读取器 filter/Filter.java # 滤镜函数式接口 filter/NegateFilter.java # 灰度反转 filter/ThresholdFilter.java# 阈值二值化 filter/EqualizeFilter.java # 直方图均衡化 filter/ConvolveFilter.java # 卷积滤波(均值、边缘检测等) ui/ MainFrame.java # 主窗口:菜单、工具栏、画布 ImageCanvas.java # 自定义画布,负责灰度映射和重绘 cli/ PgmCli.java # 命令行批处理入口core 包是整个项目的地基,ui 和 cli 都只依赖 core 而不互相引用。PgmImage 内部用一个一维 int[] 存像素,通过int getPixel(int x, int y)和void setPixel(int x, int y, int v)访问,这样的好处是后续写滤波算法时不用关心二维数组的索引换算。PgmCodec 只负责「把文件变 PgmImage」和「把 PgmImage 变文件」,Filter 接口则统一了所有图像操作的入口。
2.3 编译与运行
纯 JDK 项目编译非常简单,在项目根目录执行:
javac -d out src/main/java/io/pgm/**/*.java java -cp out io.pgm.cli.PgmCli in.pgm out.pgm --filter negate第一条命令把全部源码编译到 out 目录,第二条命令跑命令行批处理:读入 in.pgm,执行 negate 滤镜,写出 out.pgm。--filter支持的参数包括 negate、threshold、equalize、blur、sobel,后面跟参数时用--threshold=128这种键值对形式。
GUI 启动则执行:
java -cp out io.pgm.ui.MainFrame打开主窗口后可以加载 PGM 文件、在右侧预览灰度图、点工具栏按钮应用滤镜,再保存回 PGM。为了让预览不依赖 AWT 的图像缓冲,ImageCanvas 直接把 int[] 像素映射成new Color(v, v, v)的 Color 数组,再用 drawImage 一次绘制,处理 1024x1024 的图体感上完全流畅。
3. 核心模块拆解:PGM 头部解析、像素读写与三个滤波算法
3.1 TokenReader:一个兼容注释与换行的轻量读取器
PGM 头部由四个 token 组成:格式标识(P2 或 P5)、宽度、高度、最大灰度值。真正麻烦的是头部允许出现#注释,且 P5 格式的头部结束之后紧跟的是二进制像素数据,不能用字符流按行读。所以第一步是写一个基于 PushbackInputStream 的 token 读取器,把所有空白和注释统一处理掉:
public class TokenReader { private final PushbackInputStream in; public TokenReader(PushbackInputStream in) { this.in = in; } public String nextToken() throws IOException { int c; while ((c = in.read()) != -1) { if (c == '#') { // 跳过整行注释,直到换行或文件结束 while ((c = in.read()) != -1 && c != '\n' && c != '\r') { } } else if (!Character.isWhitespace(c)) { StringBuilder sb = new StringBuilder(); while (c != -1 && !Character.isWhitespace(c)) { sb.append((char) c); c = in.read(); } return sb.toString(); } } return null; } }这里的关键设计是:底层输入流是字节流,读取 ASCII token 时把字节强转成 char,遇到空白就截断;遇到#就一直读到行尾,因为 Netpbm 规范允许头部任意位置插入注释。PushbackInputStream在这里不是为了推回字节,而是为后面 P5 像素数据的无缝衔接做准备——读完头部最后一个 token 后,流的位置正好停在像素数据的第一个字节。
3.2 PgmCodec:一个读取方法同时兼容 P2 和 P5
有了 TokenReader,P2 和 P5 的解析就能并到一个方法里。区别只在像素段:P2 的像素是 ASCII 十进制数,P5 的像素是原始字节。读取代码分成两段处理:
public PgmImage read(InputStream is) throws IOException { PushbackInputStream in = new PushbackInputStream(is, 8); TokenReader tr = new TokenReader(in); String magic = tr.nextToken(); int width = Integer.parseInt(tr.nextToken()); int height = Integer.parseInt(tr.nextToken()); int maxVal = Integer.parseInt(tr.nextToken()); int size = width * height; int[] pixels = new int[size]; if ("P2".equals(magic)) { for (int i = 0; i < size; i++) { pixels[i] = Integer.parseInt(tr.nextToken()); } } else if ("P5".equals(magic)) { // 头部结束后,剩下的字节全是像素数据,不能再用 TokenReader 读取 byte[] raw = in.readAllBytes(); boolean twoByte = maxVal > 255; int bytesPerPixel = twoByte ? 2 : 1; for (int i = 0; i < size && i * bytesPerPixel < raw.length; i++) { if (twoByte) { // 两字节大端序:高字节在前,低字节在后 int hi = raw[i * 2] & 0xFF; int lo = raw[i * 2 + 1] & 0xFF; pixels[i] = (hi << 8) | lo; } else { pixels[i] = raw[i] & 0xFF; } } } else { throw new IOException("不支持的格式: " + magic); } return new PgmImage(width, height, maxVal, pixels); }写 P5 时反过来做:如果maxVal > 255,每个像素拆成高低两个字节按大端序写出;否则直接按字节写出。P2 则用StringBuilder拼接数字并补空格。这里最值得说的是readAllBytes()的时机:解析完 maxVal 之后,TokenReader 已经吞掉了像素数据前的一个空白符,而in.readAllBytes()从流的当前位置开始读,拿到的全部是像素字节。如果追求更稳,遇到\r时再补读一个\n可以兼容 Windows 换行。
3.3 两个典型算法:直方图均衡化与 3x3 卷积
直方图均衡化是灰度图处理里最有代表性的算法,它把像素值分布拉伸到整个 0-255 区间,让暗图和亮图的对比度都更自然。实现分三步:统计直方图、计算累积分布、生成查找表:
public int[] equalize(int[] pixels) { int[] hist = new int[256]; for (int v : pixels) { hist[v]++; } int minNonZero = 0; while (minNonZero < 255 && hist[minNonZero] == 0) { minNonZero++; } int sum = 0; int[] lut = new int[256]; int total = pixels.length; for (int i = 0; i < 256; i++) { sum += hist[i]; if (total - hist[minNonZero] == 0) { lut[i] = 0; } else { lut[i] = Math.round((sum - hist[minNonZero]) * 255f / (total - hist[minNonZero])); } } for (int i = 0; i < pixels.length; i++) { pixels[i] = lut[pixels[i]]; } return pixels; }minNonZero是最小的非零直方图桶,减去它可以让纯黑背景不会被拉伸成灰色发雾的效果;sum是累计像素数,这样像素值 255 对应累计数 255,整张图刚好覆盖全灰度区间。查找表是这段代码的核心产出物,后面不管有几十万像素,都只是查一遍表,所以性能非常稳。
卷积滤波则是 PGM 编辑器里可扩展性最强的一部分,3x3 均值模糊是最简单的入门核:
public int[] convolve(int[] pixels, int width, int height, float[] kernel) { int[] out = new int[pixels.length]; int kSize = (int) Math.sqrt(kernel.length); // 3x3 核就是 3 int offset = kSize / 2; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { float acc = 0; for (int ky = 0; ky < kSize; ky++) { for (int kx = 0; kx < kSize; kx++) { int sx = Math.min(Math.max(x + kx - offset, 0), width - 1); int sy = Math.min(Math.max(y + ky - offset, 0), height - 1); acc += pixels[sy * width + sx] * kernel[ky * kSize + kx]; } } out[y * width + x] = Math.round(acc); } } return out; }边缘像素直接用复制边缘的策略处理:当卷积核窗口越界时,把坐标钳制在图像边界内。这比「跳过边缘像素」更简单,处理小图时也不会出现黑色边框,缺点是边缘处会有轻微的模糊不对称,但对练习项目完全够用。换个 Sobel 核传进来,同一段代码就变成了边缘检测,这也是我把 kernel 设计成参数而不是写死均值核的原因。
4. 避坑记录:PGM 读写与像素处理中我踩过的五个坑
4.1 字节流混用:BufferedReader 的缓冲吞掉了像素数据
现象:读取 P5 文件时,头部解析正确,但保存出来的图像颜色错乱、右边出现斜向错位,甚至图像整体偏移了几十像素。
原因:一开始偷懒用BufferedReader.readLine()解析头部,再用底层DataInputStream读像素。BufferedReader内部有 8KB 缓冲,读头部时会把后面的像素字节也一起读进缓冲区,而DataInputStream根本不知道这些字节已经被「预读」了。等真正读像素时,流的起始位置早就跳过了前几千个字节。
解决:整个读取过程统一走字节流,按TokenReader的方式手工拼 ASCII token,读完头部后直接从当前流位置读像素。从那以后我凡是涉及「文本头 + 二进制数据体」的格式,都强制用这种统一字节流的做法,绝不再混用 Reader 和 InputStream。
4.2 byte 符号扩展:像素值读出来全是负数
现象:P5 文件能正常读到像素,但转成 Color 预览时图像发蓝发黑,数值打印出来是 -1、-78 之类的负数。
原因:Java 的 byte 是有符号的,直接把byte赋给 int 或拿去做 Color 构造参数,会自动做符号扩展。比如 0xFF 变成 -1,阈值和直方图统计也跟着全乱。
解决:任何 byte 转像素值都必须补& 0xFF,即int pixel = raw[i] & 0xFF。这是 Java 图像处理里最经典的翻车点,我后来在 PgmCodec 的入口处统一做转换,不让 unsigned byte 流出到上层。
4.3 maxVal 不是 255 的 PGM 被直接当成 255
现象:读一个 maxVal=16 的 PGM 文件,预览时整张图白得像被过曝,但文件的像素值其实只有 0-15 区间。
原因:Netpbm 规范允许 maxVal 取任意正整数,常见的 255 之外,16、63、1024 都很常见。处理代码如果只写了new Color(v, v, v),当 v 只有 15 时,映射出来的灰色接近黑色,相反如果直接把 15 当 255 用,整张图就是白的。
解决:在读文件时统一按 maxVal 做归一化到 0-255,保存时再按原 maxVal 还原。比如int normalized = (int) Math.round(v * 255.0 / maxVal);如果 maxVal > 255,则按两字节大端序解析并同样归一化。这个转换放在 PgmCodec.read() 里,上层算法永远只面对 0-255 的像素。
4.4 P2 文件用 readLine() 按行解析,遇到跨平台换行就断
现象:P2 格式的像素数据是 ASCII 数字,用readLine()逐行读,在 Windows 上生成的 CRLF 文件偶尔会多读出一个空行,导致 Integer.parseInt 抛异常。
原因:readLine()把\r\n、\n、\r都当行结束符,但 P2 的数字并不保证每一行固定几个,也不保证最后一行之后一定换行。依赖「行」这个概念本身就不符合 PGM 规范——规范明确说头部和像素之间、像素与像素之间都只是空白字符。
解决:不再按行解析,直接用 TokenReader 的nextToken()按空白分割数字。当文件恰好有一个空行或行尾有多余的\r时,token 读取器会自动跳过,解析稳定得多。
4.5 卷积滤波的核归一化没做好,整图变亮或变暗
现象:均值模糊跑完,图像整体亮度变了,原本 128 的灰色变成了 160 左右。
原因:3x3 均值核的系数是1/9,但如果用整数除法或忘记除以权重和,卷积结果就不是加权平均,而是累加。比如核全部是 1 时,像素值放大 9 倍,图当然过曝。
解决:传入的 kernel 保证总和为 1,或者在convolve里先算一遍float weightSum = Arrays.stream(kernel).sum(),再加一个acc /= weightSum的归一化分支。这样以后自定义 Sobel、锐化核时,即使忘了算权重,代码也能兜底。
5. 二次开发:从新增一个高斯模糊滤镜到回归验证的完整路径
想给这个编辑器加新滤镜,最省事的做法是在 core/filter 下新增一个类,然后在 CLI 的--filter参数里注册一行。以高斯模糊为例,核心是高斯核的生成:
public class GaussianBlurFilter { public static float[] makeKernel(int size, float sigma) { float[] kernel = new float[size * size]; float sum = 0; int center = size / 2; for (int y = 0; y < size; y++) { for (int x = 0; x < size; x++) { double d = Math.pow(x - center, 2) + Math.pow(y - center, 2); kernel[y * size + x] = (float) Math.exp(-d / (2 * sigma * sigma)); sum += kernel[y * size + x]; } } for (int i = 0; i < kernel.length; i++) { kernel[i] /= sum; } return kernel; } }然后把ConvolveFilter的接口接进来,CLI 加一个分支:
java -cp out io.pgm.cli.PgmCli in.pgm out.pgm --filter gaussian --sigma=1.5验证时不要只靠肉眼。我一般会写一段临时比对代码:对同一张输入图分别跑原版均值和新增的高斯核,输出最大绝对差和均方误差。肉眼看着「差不多」不算数,数值差异能接受才算通过。再把输出的 P5 文件用外部图像查看器打开,确认没有出现边缘黑框或亮度偏移。
这套「生成核 → 跑 CLI → 导出对比图 → 数值 + 肉眼双重确认」的流程,让后面加锐化、浮雕、Sobel 都变得特别顺手。从那以后我每次往这个项目里加滤镜,都强制走一遍完整回归,宁可多花十分钟也不赌一把直接上线。希望帮到你。
本文还有配套的精品资源,点击获取