使用 Rust 开发图片切分工具:从零到发布的完整指南

发布时间:2026/7/4 4:57:59
使用 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 并按照指示安装。安装完成后打开终端并验证bashrustc --version cargo --version接下来创建一个新的 Rust 项目bashcargo new img-splitter cd img-splitter项目结构如下textimg-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: OptionPathBuf, /// 切分行数 #[arg(short, long, conflicts_with_all [tile_width, tile_height, tiles])] rows: Optionu32, /// 切分列数 #[arg(short, long, requires rows)] cols: Optionu32, /// 切片宽度像素 #[arg(long, conflicts_with_all [rows, cols, tiles])] tile_width: Optionu32, /// 切片高度像素 #[arg(long, requires tile_width)] tile_height: Optionu32, /// 切片总数自动计算行列 #[arg(short, long, conflicts_with_all [rows, cols, tile_width, tile_height])] tiles: Optionu32, /// 输出文件名模板支持 {name}, {row}, {col}, {ext} #[arg(short, long, default_value {name}_{row}_{col}.{ext})] pattern: String, /// 输出图片格式如 png, jpeg默认与输入相同 #[arg(short, long)] format: OptionString, /// 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来解析命令行参数并调用库函数执行切分。首先导入依赖rustuse clap::Parser; use anyhow::Result; mod args; use args::Args; fn main() - Result() { let args Args::parse(); // 后续调用库函数 Ok(()) }args模块可以定义在同一个文件中或者单独文件。为了清晰我们可以在src下创建args.rsrust// 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()等方法转换为标准格式以便处理。rustuse image::io::Reader as ImageReader; let img ImageReader::open(input_path)?.decode()?; let (width, height) (img.width(), img.height());5.2 保存图片DynamicImage提供了save方法可以根据文件扩展名自动推断格式也可以指定格式rustimg.save(output_path)?; // 或指定格式 img.save_with_format(output_path, image::ImageFormat::Png)?;5.3 裁剪图片image库的crop方法可以从DynamicImage或具体的ImageBuffer中裁剪出一个矩形区域。注意crop返回一个子图视图但如果我们需要独立的所有权可以使用to_image()将其转换为新的ImageBuffer。rustlet 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接收图片路径、输出目录、切分方式、文件名模板等参数执行切分并返回切分后的文件列表。rustuse 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: OptionImageFormat, jpeg_quality: u8, sequential: bool, ) - ResultVecPathBuf { // 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: Boxdyn IteratorItem (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: VecResultPathBuf 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返回的是SubImageDynamicImage需要将其转换为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 格式需要控制质量我们编写一个辅助函数rustfn 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()获得ImageBufferRgbu8, Vecu8它实现了GenericImageView可以传递给编码器。但write_with_encoder接受实现了IntoImageEncoder的类型可能更复杂。另一种简单方法是使用img.to_rgb8().save_with_format(path, format)但这样无法设置质量。所以我们最好直接使用image::jpeg::JpegEncoder。改进rustfn 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 中统一打印错误。对于参数验证我们可以在解析后立即检查rustif !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来并行处理并收集结果但收集路径列表需要线程安全的结构比如MutexVecPathBuf。使用mapcollect是更函数式的做法但要注意错误类型必须满足rayon的要求。我们上面将每个结果包装为ResultOptionPathBuf然后手动处理可以工作。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/333 assert_eq!(th, 100); // 200/2100 } #[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(...) - ResultVecPathBuf { ... }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 编译为可执行文件在项目根目录运行bashcargo build --release生成的可执行文件位于target/release/img-splitterWindows 下为.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 目标bashrustup 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.tomltoml[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 3src/args.rsrustuse 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: OptionPathBuf, #[arg(short, long, conflicts_with_all [tile_width, tile_height, tiles])] pub rows: Optionu32, #[arg(short, long, requires rows)] pub cols: Optionu32, #[arg(long, conflicts_with_all [rows, cols, tiles])] pub tile_width: Optionu32, #[arg(long, requires tile_width)] pub tile_height: Optionu32, #[arg(short, long, conflicts_with_all [rows, cols, tile_width, tile_height])] pub tiles: Optionu32, #[arg(short, long, default_value {name}_{row}_{col}.{ext})] pub pattern: String, #[arg(short, long)] pub format: OptionString, #[arg(long, default_value_t 90)] pub jpeg_quality: u8, #[arg(long)] pub sequential: bool, }src/splitter.rsrustuse 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: OptionImageFormat, jpeg_quality: u8, sequential: bool, ) - ResultVecPathBuf { 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)| - ResultOptionPathBuf { 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: VecResultOptionPathBuf 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.rsrustmod 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.rsrustuse 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如何处理图片非常大导致内存不足Aimage库在解码时会分配整个图像的内存因此内存消耗与图像尺寸成正比。对于超大图片可以考虑使用流式解码但image库支持有限。另一种方法是使用专门的库如gif或jpeg-decoder逐步处理。但在大多数场景下内存应该足够。Q切分后的图片质量如何A默认保存为 PNG 无损JPEG 有损且质量可调。工具会保持原始颜色空间RGB/RGBA。Q能否切分 GIF 动图Aimage库对 GIF 动图的处理有限只能读取第一帧。如果需要切分动图的每一帧需要更专业的库。