使用 Rust 开发图片切分工具:从零到发布的完整指南
1. 引言
在日常开发或设计工作中,我们经常会遇到需要将一张大图切割成多个小图的场景。例如,将游戏地图分割成瓦片(tile)、将大型海报切分成可打印的A4纸张、或者为机器学习准备图像数据集。虽然市面上已有许多图像处理软件可以完成这类任务,但作为一名开发者,自己动手用 Rust 编写一个专用的命令行工具不仅能够完全掌控功能细节,还能体验 Rust 在系统编程和高性能计算方面的魅力。
Rust 是一门强调性能、安全性和并发性的现代编程语言,其丰富的生态系统为我们提供了大量高质量的库。在图像处理领域,image库是最常用的选择,它支持多种图像格式的读写和基本操作。结合clap构建命令行接口、rayon实现并行处理,我们可以快速开发出一个高效、可靠的图片切分工具。
本文将从零开始,手把手教你如何使用 Rust 开发一个功能完备的图片切分工具,并最终将其发布到 crates.io 和 GitHub。全文约两万字,包含详细的代码实现、设计思路、测试方法以及发布流程,旨在帮助 Rust 初学者和有一定经验的开发者掌握完整的项目开发周期。
1.1 项目目标
我们将开发一个名为img-splitter的命令行工具,它能够:
将一张大图按指定的行数和列数切分成多个小图(网格切分)。
支持按每个切片的尺寸(宽度×高度)进行切分。
支持按总共的切片数量自动计算行列数(近似正方形布局)。
支持多种输入/输出图像格式(PNG、JPEG、BMP 等)。
提供灵活的输出文件名命名规则。
利用多线程加速切分过程,提升处理大图的效率。
处理各种错误情况(文件不存在、格式不支持、参数无效等)。
1.2 为什么选择 Rust?
性能:Rust 编译为本地代码,没有运行时开销,配合
rayon可以轻松利用多核 CPU。安全性:Rust 的所有权系统和类型系统可以避免常见的内存错误,确保图像数据处理的可靠性。
生态:
image、clap、rayon等库成熟稳定,社区活跃。跨平台:Rust 支持 Windows、macOS、Linux 等主流操作系统,一次编写,随处编译。
2. 环境准备
在开始编码之前,需要确保你的开发环境中已安装 Rust。如果尚未安装,请访问 rustup.rs 并按照指示安装。
安装完成后,打开终端并验证:
bash
rustc --version cargo --version
接下来,创建一个新的 Rust 项目:
bash
cargo new img-splitter cd img-splitter
项目结构如下:
text
img-splitter/ ├── Cargo.toml ├── src/ │ └── main.rs
我们将把核心逻辑放在src/lib.rs中,以便于单元测试和复用,src/main.rs只负责解析命令行参数并调用库函数。
2.1 添加依赖
编辑Cargo.toml,添加所需的依赖:
toml
[package] name = "img-splitter" version = "0.1.0" edition = "2021" [dependencies] image = "0.24" clap = { version = "4.0", features = ["derive"] } rayon = "1.7" anyhow = "1.0" # 简化错误处理 thiserror = "1.0" # 可选,用于自定义错误类型 [dev-dependencies] tempfile = "3" # 用于测试时创建临时文件image:图像处理的核心库。clap:用于构建命令行参数解析器,使用 derive 宏简化定义。rayon:提供并行迭代器,轻松实现多线程。anyhow:方便的错误处理库,用于 main 函数中的简单错误传播。tempfile:仅在测试中用于创建临时目录。
现在运行cargo build来下载依赖并验证配置是否正确。
3. 需求分析与设计
3.1 功能需求细化
根据项目目标,我们列出工具需要支持的具体功能点:
必须输入:待切分的图片路径(
--input或位置参数)。输出目录:可选,指定切分后图片的存放位置,默认与输入图片同目录(或当前目录)。
切分方式(三者至少指定其一):
按网格:
--rows和--cols指定行数和列数。按切片尺寸:
--tile-width和--tile-height指定每个切片的宽度和高度。按切片数量:
--tiles指定总共切分的块数,工具自动计算近似正方形的行列数。
输出文件名格式:支持自定义模板,例如
{name}_{row}_{col}.{ext},默认使用input_stem_row_col.ext。输出格式:可选,指定输出图片的格式(如 PNG、JPEG),默认与输入格式相同。
JPEG 质量:当输出为 JPEG 时,可指定压缩质量(1-100)。
并行处理:默认启用多线程,可添加
--sequential选项强制单线程(用于调试)。帮助信息:完善的
--help输出。
3.2 命令行接口设计
我们将使用clap的 derive API 来定义一个结构体,包含所有命令行选项。
例如:
rust
#[derive(Parser, Debug)] #[clap(author, version, about, long_about = None)] struct Args { /// 输入图片路径 input: PathBuf, /// 输出目录(默认:输入图片所在目录) #[arg(short, long)] output_dir: Option<PathBuf>, /// 切分行数 #[arg(short, long, conflicts_with_all = ["tile_width", "tile_height", "tiles"])] rows: Option<u32>, /// 切分列数 #[arg(short, long, requires = "rows")] cols: Option<u32>, /// 切片宽度(像素) #[arg(long, conflicts_with_all = ["rows", "cols", "tiles"])] tile_width: Option<u32>, /// 切片高度(像素) #[arg(long, requires = "tile_width")] tile_height: Option<u32>, /// 切片总数(自动计算行列) #[arg(short, long, conflicts_with_all = ["rows", "cols", "tile_width", "tile_height"])] tiles: Option<u32>, /// 输出文件名模板,支持 {name}, {row}, {col}, {ext} #[arg(short, long, default_value = "{name}_{row}_{col}.{ext}")] pattern: String, /// 输出图片格式(如 png, jpeg),默认与输入相同 #[arg(short, long)] format: Option<String>, /// JPEG 输出质量(1-100) #[arg(long, default_value_t = 90)] jpeg_quality: u8, /// 禁用并行处理(单线程) #[arg(long)] sequential: bool, }这里我们使用了conflicts_with_all和requires来确保参数之间的互斥和依赖关系。例如,--tiles不能与行列或尺寸同时使用,--cols必须在--rows存在时才有意义。
3.3 模块划分
我们将项目分为以下模块:
args:命令行参数定义(直接放在 main.rs 或单独模块)。splitter:核心切分逻辑,包括切分方式枚举和实际执行切分的函数。utils:一些辅助函数,如解析文件名模板、确保输出目录存在等。
src/lib.rs将公开主要的切分函数,供 main 调用和测试。
4. 实现命令行解析
在src/main.rs中,我们将使用 clap 的Parser来解析命令行参数,并调用库函数执行切分。
首先,导入依赖:
rust
use clap::Parser; use anyhow::Result; mod args; use args::Args; fn main() -> Result<()> { let args = Args::parse(); // 后续调用库函数 Ok(()) }args模块可以定义在同一个文件中,或者单独文件。为了清晰,我们可以在src下创建args.rs:
rust
// src/args.rs use clap::Parser; use std::path::PathBuf; #[derive(Parser, Debug)] #[clap(author, version, about, long_about = None)] pub struct Args { // 字段定义如前所述 }然后在main.rs中mod args;即可。
4.1 验证参数的有效性
虽然 clap 已经帮我们处理了互斥和依赖关系,但有些业务逻辑层面的验证仍需手动完成,例如:
确保输入文件存在且可读。
如果指定了输出格式,确保是支持的格式(
image库支持哪些格式?)。当指定切片尺寸时,确保宽度和高度不超过原图尺寸,并且大于0。
当指定切片数量时,计算出的行列数应为整数,且每片尺寸大于0。
这些验证可以在调用核心函数前进行,也可以在核心函数内部进行并返回错误。我们选择在main中做一些初步验证,以减少库函数的复杂度。
5. 图片处理基础
在编写核心切分逻辑之前,我们需要熟悉image库的基本用法。
5.1 加载图片
使用image::open打开图片文件,返回DynamicImage,它是一个枚举,涵盖了多种颜色类型和位深度。我们可以使用to_rgba8()或to_rgb8()等方法转换为标准格式以便处理。
rust
use image::io::Reader as ImageReader; let img = ImageReader::open(input_path)?.decode()?; let (width, height) = (img.width(), img.height());
5.2 保存图片
DynamicImage提供了save方法,可以根据文件扩展名自动推断格式,也可以指定格式:
rust
img.save(output_path)?; // 或指定格式 img.save_with_format(output_path, image::ImageFormat::Png)?;
5.3 裁剪图片
image库的crop方法可以从DynamicImage或具体的ImageBuffer中裁剪出一个矩形区域。注意,crop返回一个子图视图,但如果我们需要独立的所有权,可以使用to_image()将其转换为新的ImageBuffer。
rust
let sub_img = img.crop(x, y, tile_width, tile_height); sub_img.save(sub_path)?;
5.4 支持的格式
image库支持常见的格式:PNG, JPEG, GIF, BMP, TIFF, WEBP 等。可以通过ImageFormat::from_extension或ImageFormat::from_path获取格式。
6. 核心切分算法
我们将切分逻辑封装在splitter模块中。首先,定义切分方式的枚举:
rust
// src/splitter.rs pub enum SplitMethod { Grid { rows: u32, cols: u32 }, TileSize { width: u32, height: u32 }, TileCount { count: u32 }, }然后编写一个函数split_image,接收图片路径、输出目录、切分方式、文件名模板等参数,执行切分并返回切分后的文件列表。
rust
use image::{DynamicImage, ImageBuffer, ImageFormat}; use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; pub fn split_image( input_path: &Path, output_dir: &Path, method: SplitMethod, pattern: &str, output_format: Option<ImageFormat>, jpeg_quality: u8, sequential: bool, ) -> Result<Vec<PathBuf>> { // 1. 加载图片 let img = image::open(input_path) .with_context(|| format!("无法打开图片: {}", input_path.display()))?; let (orig_w, orig_h) = (img.width(), img.height()); // 2. 根据切分方式计算网格的行列数以及每个切片的大小 let (rows, cols, tile_w, tile_h) = match method { SplitMethod::Grid { rows, cols } => { let tile_w = orig_w / cols; let tile_h = orig_h / rows; // 处理不能整除的情况:可能最后一行/列尺寸不同,我们选择均匀分配,剩余部分丢弃或特殊处理? // 这里我们采用简单方式:如果原图尺寸不能被行列整除,则每个切片大小取 floor,最后一列/行可能不足。 // 但这样会导致切片尺寸不一致。另一种方式是允许指定填充或拉伸,但为了简化,我们先实现均匀切分, // 若有余数,则最后一行/列的切片尺寸会小一些。用户应注意输入尺寸。 (rows, cols, tile_w, tile_h) } SplitMethod::TileSize { width, height } => { let cols = (orig_w + width - 1) / width; // 向上取整 let rows = (orig_h + height - 1) / height; (rows, cols, width, height) } SplitMethod::TileCount { count } => { // 计算近似正方形的行列:优先让列数大于等于行数 let cols = (count as f64).sqrt().ceil() as u32; let rows = (count + cols - 1) / cols; // 向上取整确保总块数 >= count let tile_w = orig_w / cols; let tile_h = orig_h / rows; (rows, cols, tile_w, tile_h) } }; // 验证计算出的切片尺寸有效 if tile_w == 0 || tile_h == 0 { bail!("切片尺寸为零,请检查参数是否过大或图片尺寸不足"); } // 3. 创建输出目录(如果不存在) std::fs::create_dir_all(output_dir)?; // 4. 准备文件名模板解析 let input_stem = input_path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("image"); let ext = if let Some(fmt) = output_format { // 根据输出格式确定扩展名 fmt.extensions_str()[0] } else { // 使用输入文件的扩展名 input_path .extension() .and_then(|e| e.to_str()) .unwrap_or("png") }; // 5. 生成所有切片的坐标并执行裁剪和保存 let mut output_files = Vec::new(); // 决定是否使用并行迭代 let iter: Box<dyn Iterator<Item = (u32, u32)>> = if sequential { Box::new((0..rows).flat_map(move |r| (0..cols).map(move |c| (r, c)))) } else { use rayon::prelude::*; // 并行迭代需要收集坐标对到 Vec,然后并行处理 let coords: Vec<_> = (0..rows) .flat_map(|r| (0..cols).map(move |c| (r, c))) .collect(); // 使用 rayon 并行处理 let results: Vec<Result<PathBuf>> = coords .par_iter() .map(|&(r, c)| { let x = c * tile_w; let y = r * tile_h; // 计算当前切片的实际宽度和高度(考虑边界可能不足) let cur_w = if c == cols - 1 { orig_w - x } else { tile_w }; let cur_h = if r == rows - 1 { orig_h - y } else { tile_h }; if cur_w == 0 || cur_h == 0 { // 这种情况不应该发生,但以防万一 return Ok(None); } let sub_img = img.crop_imm(x, y, cur_w, cur_h); let filename = pattern .replace("{name}", input_stem) .replace("{row}", &r.to_string()) .replace("{col}", &c.to_string()) .replace("{ext}", ext); let output_path = output_dir.join(filename); // 根据输出格式保存 if let Some(fmt) = output_format { if fmt == ImageFormat::Jpeg { // 对于 JPEG,可以设置质量 let mut buf = std::io::Cursor::new(Vec::new()); sub_img.write_to(&mut buf, ImageFormat::Jpeg)?; // 使用 image 的 jpeg encoder 保存,但简单起见可以直接保存 // 这里使用 save_with_format 会丢失质量设置,我们需要更细粒度的控制 // 我们稍后实现一个专门的保存函数来处理质量 save_image_with_quality(&sub_img, &output_path, fmt, jpeg_quality)?; } else { sub_img.save_with_format(&output_path, fmt)?; } } else { sub_img.save(&output_path)?; } Ok(Some(output_path)) }) .collect(); for res in results { match res { Ok(Some(path)) => output_files.push(path), Ok(None) => {} // 忽略空切片 Err(e) => return Err(e), } } return Ok(output_files); }; // 单线程处理 for (r, c) in iter { // 类似上面的处理,但不需要收集结果 // ... } Ok(output_files) }上面代码中我们使用了crop_imm,它返回一个SubImage,但SubImage没有实现save方法。实际上crop_imm返回的是SubImage<&DynamicImage>,需要将其转换为DynamicImage才能保存。我们可以使用to_image()或直接调用DynamicImage::from(sub_img)。更好的做法是使用img.crop_imm(x, y, w, h).to_image()生成一个新的ImageBuffer。
6.1 处理切片边界
当原图尺寸不能被网格均匀分割时,最后一列和最后一行的切片尺寸会较小。我们在计算每个切片的实际大小时已经处理了这种情况。如果某个切片的宽度或高度为0(例如当请求的切片数超过图片尺寸时),我们跳过该切片。
6.2 文件名模板解析
我们简单地使用字符串替换,支持{name}、{row}、{col}和{ext}。更复杂的模板可以使用正则表达式或专门的模板库,但当前需求已经满足。
6.3 保存函数
对于 JPEG 格式,需要控制质量,我们编写一个辅助函数:
rust
fn save_image_with_quality( img: &DynamicImage, path: &Path, format: ImageFormat, quality: u8, ) -> Result<()> { match format { ImageFormat::Jpeg => { let mut output = std::fs::File::create(path)?; let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, quality); img.write_with_encoder(encoder)?; } _ => img.save_with_format(path, format)?, } Ok(()) }注意:write_with_encoder需要将DynamicImage转换为合适的颜色类型,例如Rgb8。我们可以调用img.to_rgb8()获得ImageBuffer<Rgb<u8>, Vec<u8>>,它实现了GenericImageView,可以传递给编码器。但write_with_encoder接受实现了Into<ImageEncoder>的类型,可能更复杂。另一种简单方法是使用img.to_rgb8().save_with_format(path, format),但这样无法设置质量。所以我们最好直接使用image::jpeg::JpegEncoder。
改进:
rust
fn save_image_with_quality( img: &DynamicImage, path: &Path, format: ImageFormat, quality: u8, ) -> Result<()> { match format { ImageFormat::Jpeg => { let rgb_img = img.to_rgb8(); // 转换为RGB8 let mut output = std::fs::File::create(path)?; let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, quality); encoder.encode( rgb_img.as_raw(), rgb_img.width(), rgb_img.height(), image::ColorType::Rgb8, )?; } _ => img.save_with_format(path, format)?, } Ok(()) }6.4 处理输出格式
如果用户指定了输出格式,我们使用该格式保存;否则,使用输入图片的格式(通过文件扩展名推断)。注意:输入图片可能是无扩展名的,我们可以默认使用 PNG。
7. 错误处理与边界情况
良好的错误处理能提升用户体验。我们使用anyhow简化错误传播,并添加上下文信息。
需要考虑的错误情况:
输入文件不存在或无读取权限。
输入文件不是有效的图片格式。
输出目录无法创建或无写入权限。
切分参数导致切片尺寸为0(如
--tiles数量大于图片像素总数)。用户指定的输出格式不支持(比如要求输出为 GIF,但
image库对 GIF 写入支持有限)。文件名模板包含非法字符导致无法创建文件。
我们可以在split_image函数中返回anyhow::Result,并在 main 中统一打印错误。
对于参数验证,我们可以在解析后立即检查:
rust
if !args.input.exists() { bail!("输入文件不存在: {}", args.input.display()); } if let Some(ref fmt) = args.format { if image::ImageFormat::from_extension(fmt).is_none() { bail!("不支持的输出格式: {}", fmt); } } // 等等8. 并行处理优化
我们在前面的代码中已经集成了rayon的可选并行处理。当sequential为false时,我们收集所有坐标,然后使用par_iter()并行处理。注意,这里需要将img共享给多个线程,但DynamicImage没有实现Sync因为它内部可能包含不可共享的引用?实际上DynamicImage是拥有所有权的,可以直接在多线程中共享不可变引用,只要它实现了Sync。DynamicImage内部可能是ImageBuffer,而ImageBuffer通过Vec存储数据,Vec的不可变引用是Sync的,所以应该没问题。但我们仍需要确保在并行迭代中img是共享引用,而不是移动。我们可以在闭包中捕获&img,但par_iter()要求闭包是Fn,可以捕获引用。所以直接使用&img即可。
但是注意,crop_imm返回的SubImage引用原图,因此如果我们同时从多个线程裁剪,只要只读就是安全的。save操作是独立于原图的,所以整个处理是只读并发生成新文件,没有竞态条件。
因此并行化是安全的。
然而,上面的代码片段中,我们使用了rayon的par_iter处理coords,并将结果收集到results,然后再推入output_files。这样是正确的,但需要确保每个线程的错误处理正确。
另一种写法是使用try_for_each来并行处理并收集结果,但收集路径列表需要线程安全的结构,比如Mutex<Vec<PathBuf>>。使用map+collect是更函数式的做法,但要注意错误类型必须满足rayon的要求。我们上面将每个结果包装为Result<Option<PathBuf>>,然后手动处理,可以工作。
8.1 性能考虑
对于非常大的图片,并行裁剪可以显著提升速度,因为每个切片的保存是独立的。但是,裁剪操作本身可能涉及像素复制,如果图片非常大且切片数量多,内存占用可能会很高,因为每个线程都需要持有原图的引用(只读),同时生成新的切片图像缓冲区。这通常是可以接受的。
9. 测试
测试是保证软件质量的重要环节。我们将编写单元测试和集成测试。
9.1 单元测试
单元测试主要针对核心函数,比如根据切分方式计算网格和切片尺寸。我们将split_image拆分成更小的函数以便测试。
例如,我们可以编写一个函数compute_grid,它接收原图尺寸和切分方式,返回(rows, cols, tile_w, tile_h),然后对这个函数进行测试。
rust
#[cfg(test)] mod tests { use super::*; #[test] fn test_compute_grid_grid() { let (rows, cols, tw, th) = compute_grid(100, 200, SplitMethod::Grid { rows: 2, cols: 3 }); assert_eq!(rows, 2); assert_eq!(cols, 3); assert_eq!(tw, 33); // 100/3=33 assert_eq!(th, 100); // 200/2=100 } #[test] fn test_compute_grid_tile_size() { let (rows, cols, tw, th) = compute_grid(100, 200, SplitMethod::TileSize { width: 30, height: 40 }); assert_eq!(cols, 4); // ceil(100/30)=4 assert_eq!(rows, 5); // ceil(200/40)=5 assert_eq!(tw, 30); assert_eq!(th, 40); } // 更多测试... }9.2 集成测试
集成测试会实际调用split_image函数,使用临时文件验证切分结果。我们将利用tempfile库创建临时目录,并生成一个简单的测试图片(例如纯色或渐变),然后检查切分后的文件数量和尺寸是否符合预期。
在tests/目录下创建集成测试文件。
9.3 命令行测试
可以使用assert_cmd和predicates库来测试命令行工具的行为,但这会增加依赖。为了简化,我们可以手动运行命令并检查输出。
10. 性能分析
我们可以使用cargo bench进行基准测试,或者使用perf等工具分析。但作为指南,我们可以简要介绍如何对切分性能进行测量,并讨论可能的优化点,例如:
使用
rayon的工作窃取调度。减少内存分配:重用缓冲区(但 image 库的 crop 可能涉及复制)。
使用更快的编码器(如 mozjpeg)。
我们还可以比较单线程和多线程的性能差异,并给出建议。
11. 文档与示例
11.1 代码文档
使用///为公共函数和结构体编写文档注释。例如:
rust
/// 将图片切分为多个瓦片。 /// /// # 参数 /// - `input_path`: 输入图片路径。 /// - `output_dir`: 输出目录。 /// - `method`: 切分方式。 /// - `pattern`: 文件名模板。 /// - `output_format`: 输出格式,若为 `None` 则与输入相同。 /// - `jpeg_quality`: JPEG 质量(1-100)。 /// - `sequential`: 是否禁用并行处理。 /// /// # 返回值 /// 成功时返回切分后文件的路径列表。 /// /// # 错误 /// 可能返回 `anyhow::Error`,包括文件 I/O 错误、图片解码错误、参数无效等。 pub fn split_image(...) -> Result<Vec<PathBuf>> { ... }11.2 生成 HTML 文档
运行cargo doc --open可以在浏览器中查看生成的文档。
11.3 提供示例
在 README.md 中提供命令行使用示例,例如:
bash
# 按 3x4 网格切分 img-splitter input.jpg --rows 3 --cols 4 # 按每片 256x256 切分 img-splitter input.png --tile-width 256 --tile-height 256 # 切成 9 块并输出为 JPEG,质量 85 img-splitter input.bmp --tiles 9 --format jpeg --jpeg-quality 85 # 自定义输出文件名 img-splitter input.png --rows 2 --cols 2 --pattern "tile_{row}_{col}.{ext}"12. 打包与发布
12.1 编译为可执行文件
在项目根目录运行:
bash
cargo build --release
生成的可执行文件位于target/release/img-splitter(Windows 下为.exe)。
12.2 发布到 crates.io
确保
Cargo.toml中的description,license,repository,keywords,categories等字段填写完整。运行
cargo publish --dry-run检查。登录 crates.io(需要 GitHub 授权):
cargo login。运行
cargo publish。
12.3 创建 GitHub Release
将编译好的二进制文件上传到 GitHub Releases,方便用户直接下载。可以使用cargo release工具自动化流程,但手动操作也很简单。
12.4 交叉编译
如果希望为不同平台提供预编译二进制,可以设置交叉编译环境。例如,在 Linux 上编译 Windows 目标:
bash
rustup target add x86_64-pc-windows-gnu cargo build --release --target x86_64-pc-windows-gnu
需要安装相应的链接器(如 mingw-w64)。
13. 进阶功能
如果时间允许,我们可以添加以下功能增强工具的实用性:
填充模式:当图片尺寸不能被切片尺寸整除时,可以选择填充背景色或拉伸最后一行/列。
自动旋转:根据 EXIF 信息自动旋转图片。
缩略图:在切分的同时生成缩略图。
递归处理:批量处理目录中的所有图片。
支持更多图像格式:如 WebP, HEIC 等(需要额外依赖)。
进度条:使用
indicatif库显示处理进度。
14. 总结与展望
通过本指南,我们完整地经历了一个 Rust 项目从零到发布的全过程。我们学习了如何使用clap构建 CLI、用image处理图片、用rayon实现并行化,以及如何进行错误处理、测试、文档编写和发布。最终产出了一个功能实用的图片切分工具。
Rust 的强大之处不仅在于性能和安全,还在于其丰富的生态和工具链。希望本文能帮助读者掌握 Rust 项目开发的常用技巧,并激发更多创意。
你可以将本项目的代码作为基础,继续扩展功能,或将其整合到更复杂的图像处理管道中。如果你有任何问题或改进建议,欢迎在 GitHub 上提交 issue 或 pull request。
附:完整项目代码仓库
GitHub - yourname/img-splitter(请替换为实际链接)
附录 A:完整代码清单
为了节省篇幅,这里仅列出关键文件的内容。
Cargo.toml
toml
[package] name = "img-splitter" version = "0.1.0" edition = "2021" description = "A command-line tool to split an image into tiles" license = "MIT OR Apache-2.0" repository = "https://github.com/yourname/img-splitter" keywords = ["image", "split", "tile", "cli"] categories = ["command-line-utilities", "multimedia::images"] [dependencies] image = { version = "0.24", default-features = false, features = ["png", "jpeg", "bmp", "gif", "tiff", "webp"] } clap = { version = "4.0", features = ["derive"] } rayon = "1.7" anyhow = "1.0" thiserror = "1.0" [dev-dependencies] tempfile = "3"src/args.rs
rust
use clap::Parser; use std::path::PathBuf; #[derive(Parser, Debug)] #[clap(author, version, about, long_about = None)] pub struct Args { pub input: PathBuf, #[arg(short, long)] pub output_dir: Option<PathBuf>, #[arg(short, long, conflicts_with_all = ["tile_width", "tile_height", "tiles"])] pub rows: Option<u32>, #[arg(short, long, requires = "rows")] pub cols: Option<u32>, #[arg(long, conflicts_with_all = ["rows", "cols", "tiles"])] pub tile_width: Option<u32>, #[arg(long, requires = "tile_width")] pub tile_height: Option<u32>, #[arg(short, long, conflicts_with_all = ["rows", "cols", "tile_width", "tile_height"])] pub tiles: Option<u32>, #[arg(short, long, default_value = "{name}_{row}_{col}.{ext}")] pub pattern: String, #[arg(short, long)] pub format: Option<String>, #[arg(long, default_value_t = 90)] pub jpeg_quality: u8, #[arg(long)] pub sequential: bool, }src/splitter.rs
rust
use anyhow::{bail, Context, Result}; use image::{DynamicImage, ImageBuffer, ImageFormat, Rgb}; use rayon::prelude::*; use std::path::{Path, PathBuf}; pub enum SplitMethod { Grid { rows: u32, cols: u32 }, TileSize { width: u32, height: u32 }, TileCount { count: u32 }, } fn compute_grid(orig_w: u32, orig_h: u32, method: SplitMethod) -> (u32, u32, u32, u32) { match method { SplitMethod::Grid { rows, cols } => { let tile_w = orig_w / cols; let tile_h = orig_h / rows; (rows, cols, tile_w, tile_h) } SplitMethod::TileSize { width, height } => { let cols = (orig_w + width - 1) / width; let rows = (orig_h + height - 1) / height; (rows, cols, width, height) } SplitMethod::TileCount { count } => { let cols = (count as f64).sqrt().ceil() as u32; let rows = (count + cols - 1) / cols; let tile_w = orig_w / cols; let tile_h = orig_h / rows; (rows, cols, tile_w, tile_h) } } } fn save_image_with_quality( img: &DynamicImage, path: &Path, format: ImageFormat, quality: u8, ) -> Result<()> { match format { ImageFormat::Jpeg => { let rgb_img = img.to_rgb8(); let mut output = std::fs::File::create(path)?; let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, quality); encoder.encode( rgb_img.as_raw(), rgb_img.width(), rgb_img.height(), image::ColorType::Rgb8, )?; } _ => img.save_with_format(path, format)?, } Ok(()) } pub fn split_image( input_path: &Path, output_dir: &Path, method: SplitMethod, pattern: &str, output_format: Option<ImageFormat>, jpeg_quality: u8, sequential: bool, ) -> Result<Vec<PathBuf>> { let img = image::open(input_path) .with_context(|| format!("无法打开图片: {}", input_path.display()))?; let (orig_w, orig_h) = (img.width(), img.height()); let (rows, cols, tile_w, tile_h) = compute_grid(orig_w, orig_h, method); if tile_w == 0 || tile_h == 0 { bail!("切片尺寸为零,请检查参数"); } std::fs::create_dir_all(output_dir)?; let input_stem = input_path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("image"); let ext = if let Some(fmt) = output_format { fmt.extensions_str()[0] } else { input_path .extension() .and_then(|e| e.to_str()) .unwrap_or("png") }; let coords: Vec<_> = (0..rows) .flat_map(|r| (0..cols).map(move |c| (r, c))) .collect(); let process_tile = |(r, c): (u32, u32)| -> Result<Option<PathBuf>> { let x = c * tile_w; let y = r * tile_h; let cur_w = if c == cols - 1 { orig_w - x } else { tile_w }; let cur_h = if r == rows - 1 { orig_h - y } else { tile_h }; if cur_w == 0 || cur_h == 0 { return Ok(None); } let sub_img = img.crop_imm(x, y, cur_w, cur_h).to_image(); let dyn_img = DynamicImage::from(sub_img); let filename = pattern .replace("{name}", input_stem) .replace("{row}", &r.to_string()) .replace("{col}", &c.to_string()) .replace("{ext}", ext); let output_path = output_dir.join(filename); if let Some(fmt) = output_format { if fmt == ImageFormat::Jpeg { save_image_with_quality(&dyn_img, &output_path, fmt, jpeg_quality)?; } else { dyn_img.save_with_format(&output_path, fmt)?; } } else { dyn_img.save(&output_path)?; } Ok(Some(output_path)) }; let results: Vec<Result<Option<PathBuf>>> = if sequential { coords.into_iter().map(process_tile).collect() } else { coords.par_iter().map(|&coord| process_tile(coord)).collect() }; let mut output_files = Vec::new(); for res in results { match res { Ok(Some(path)) => output_files.push(path), Ok(None) => {} Err(e) => return Err(e), } } Ok(output_files) }src/main.rs
rust
mod args; mod splitter; use anyhow::{bail, Result}; use args::Args; use clap::Parser; use image::ImageFormat; use splitter::{split_image, SplitMethod}; use std::path::Path; fn main() -> Result<()> { let args = Args::parse(); // 验证输入文件 if !args.input.exists() { bail!("输入文件不存在: {}", args.input.display()); } // 确定输出目录 let output_dir = args .output_dir .unwrap_or_else(|| args.input.parent().unwrap_or(Path::new(".")).to_path_buf()); // 解析切分方式 let method = if let (Some(rows), Some(cols)) = (args.rows, args.cols) { SplitMethod::Grid { rows, cols } } else if let (Some(width), Some(height)) = (args.tile_width, args.tile_height) { SplitMethod::TileSize { width, height } } else if let Some(count) = args.tiles { SplitMethod::TileCount { count } } else { bail!("必须指定一种切分方式:--rows/--cols, --tile-width/--tile-height, 或 --tiles"); }; // 解析输出格式 let output_format = if let Some(fmt_str) = args.format { match ImageFormat::from_extension(&fmt_str) { Some(fmt) => Some(fmt), None => bail!("不支持的输出格式: {}", fmt_str), } } else { None }; // 执行切分 let files = split_image( &args.input, &output_dir, method, &args.pattern, output_format, args.jpeg_quality, args.sequential, )?; println!("成功切分出 {} 个切片。", files.len()); for f in files { println!("{}", f.display()); } Ok(()) }tests/integration_test.rs
rust
use std::fs; use tempfile::tempdir; use image::{RgbImage, ImageBuffer}; use img_splitter::split_image; // 假设库名是 img_splitter use img_splitter::SplitMethod; #[test] fn test_split_grid() { let dir = tempdir().unwrap(); let input_path = dir.path().join("test.png"); // 创建一个 100x200 的测试图片 let img: RgbImage = ImageBuffer::from_fn(100, 200, |x, y| { image::Rgb([(x % 256) as u8, (y % 256) as u8, 0]) }); img.save(&input_path).unwrap(); let output_dir = dir.path().join("output"); fs::create_dir(&output_dir).unwrap(); let method = SplitMethod::Grid { rows: 2, cols: 3 }; let files = split_image( &input_path, &output_dir, method, "{name}_{row}_{col}.{ext}", None, 90, false, ).unwrap(); assert_eq!(files.len(), 6); // 2x3 = 6 for file in files { assert!(file.exists()); } } // 更多测试...附录 B:常见问题解答
Q:如何处理图片非常大导致内存不足?
A:image库在解码时会分配整个图像的内存,因此内存消耗与图像尺寸成正比。对于超大图片,可以考虑使用流式解码,但image库支持有限。另一种方法是使用专门的库如gif或jpeg-decoder逐步处理。但在大多数场景下,内存应该足够。
Q:切分后的图片质量如何?
A:默认保存为 PNG 无损,JPEG 有损且质量可调。工具会保持原始颜色空间(RGB/RGBA)。
Q:能否切分 GIF 动图?
A:image库对 GIF 动图的处理有限,只能读取第一帧。如果需要切分动图的每一帧,需要更专业的库。
