阿里云抠图输入按格式/大小/分辨率限制归一化

原实现只按边长缩放,未缩放时直接上传原始 bytes 却固定声明 image/png:
小尺寸大体积 PNG、JPEG/WebP 原图、或异常位深 PNG 可能被上游 4xx 拒绝,
静默降级到低质量本地扣色。按阿里云 SegmentCommonImage 输入限制归一化:

- 格式:统一 to_rgba8 + encode_rgba_png,永远产出 8 位 RGBA PNG,消除 MIME
  名不副实与 8/16/64 位 PNG / 非 PNG 不受支持的问题。
- 大小:MAX_INPUT_BYTES=3MB,编码后超限则等比降尺寸重编码,直到达标。
- 分辨率:最长边 ≤1999 保留;新增每边 >32 的短路,过小图直接 InvalidRequest
  让调用方走本地兜底,省一次注定 4xx 的往返。
- URL:object 叶子名 ASCII 过滤,保证 ImageURL 不含中文/非 ASCII。
- 抽出 normalize_matting_input_png(_within),alpha 上采样按实际上传尺寸是否
  变化判定(覆盖边长缩 + 体积缩两种情况),补 3 个单测。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 13:50:55 +00:00
parent e796cefe9f
commit 1aaf075724
+134 -13
View File
@@ -32,6 +32,12 @@ pub const DEFAULT_MATTING_REQUEST_TIMEOUT_MS: u64 = 30_000;
/// SegmentCommonImage 要求每条边小于 2000。
const MAX_INPUT_EDGE: u32 = 1_999;
/// SegmentCommonImage 要求输入体积不超过 3 MB。
const MAX_INPUT_BYTES: usize = 3 * 1024 * 1024;
/// SegmentCommonImage 要求每条边大于 32 像素。
const MIN_INPUT_EDGE: u32 = 32;
#[derive(Clone, Debug)]
pub struct MattingConfig {
pub endpoint: String,
@@ -296,17 +302,18 @@ impl MattingClient {
let source_rgba = source.to_rgba8();
let (source_width, source_height) = source_rgba.dimensions();
let needs_resize = source_width > MAX_INPUT_EDGE || source_height > MAX_INPUT_EDGE;
let upload_bytes = if needs_resize {
let resized = source.resize(
MAX_INPUT_EDGE,
MAX_INPUT_EDGE,
image::imageops::FilterType::CatmullRom,
);
encode_rgba_png(&resized.to_rgba8())?
} else {
bytes.to_vec()
};
// 阿里云要求每条边 > 32px;过小的图直接拒绝,让调用方走本地兜底,省一次注定 4xx 的往返。
if source_width <= MIN_INPUT_EDGE || source_height <= MIN_INPUT_EDGE {
return Err(MattingError::InvalidRequest(format!(
"待抠图图片尺寸 {source_width}x{source_height} 过小,阿里云通用抠图要求每条边大于 {MIN_INPUT_EDGE} 像素"
)));
}
// 归一化成阿里云 SegmentCommonImage 接受的输入:统一 8 位 RGBA PNG(消除 MIME 名不副实
// 与不支持位深/格式的问题)、最长边 ≤1999、体积 ≤3MB。返回实际上传尺寸,用于判断结果
// 是否需要 alpha 上采样回原尺寸。
let (upload_bytes, upload_dims) = normalize_matting_input_png(&source)?;
let downscaled = upload_dims != (source_width, source_height);
let image_url = self
.upload_temp_image(upload_bytes, file_name, "image/png")
.await?;
@@ -325,7 +332,7 @@ impl MattingClient {
})?
.to_rgba8();
if !needs_resize {
if !downscaled {
if result_image.dimensions() != (source_width, source_height) {
return Err(MattingError::Upstream(format!(
"抠图结果尺寸 {}x{} 与输入 {}x{} 不一致",
@@ -397,11 +404,23 @@ impl MattingClient {
"file_name 不能为空".to_string(),
));
}
// 阿里云要求 ImageURL 不含中文/非 ASCII 字符;object 叶子名只保留 URL 安全的 ASCII 字符,
// 唯一性由前缀 uuid 保证。
let safe_file_name: String = file_name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'_'
}
})
.collect();
let object_key = format!(
"{}/{}/{}",
self.config.access_key_id,
uuid::Uuid::new_v4().simple(),
file_name
safe_file_name
);
let date = httpdate::fmt_http_date(std::time::SystemTime::now());
// OSS V1 头签名(带 STS security token)。
@@ -557,6 +576,45 @@ impl MattingClient {
}
}
/// 把源图归一化成阿里云 SegmentCommonImage 接受的输入:统一 8 位 RGBA PNG、最长边 ≤1999、体积 ≤3MB。
/// 返回编码后的 PNG 字节及其实际尺寸;尺寸若与源图不同说明发生了缩放,调用方需 alpha 上采样回原尺寸。
fn normalize_matting_input_png(
source: &image::DynamicImage,
) -> Result<(Vec<u8>, (u32, u32)), MattingError> {
normalize_matting_input_png_within(source, MAX_INPUT_BYTES)
}
/// `normalize_matting_input_png` 的可注入体积上限版本,便于单测用小阈值触发降尺寸循环。
fn normalize_matting_input_png_within(
source: &image::DynamicImage,
max_bytes: usize,
) -> Result<(Vec<u8>, (u32, u32)), MattingError> {
let mut max_edge = MAX_INPUT_EDGE;
loop {
// to_rgba8 统一成 8 位/通道 RGBA(32 位 PNG),规避阿里云不支持的 8/16/64 位 PNG 与非 PNG 格式。
let rgba = if source.width() > max_edge || source.height() > max_edge {
source
.resize(max_edge, max_edge, image::imageops::FilterType::CatmullRom)
.to_rgba8()
} else {
source.to_rgba8()
};
let encoded = encode_rgba_png(&rgba)?;
let (width, height) = rgba.dimensions();
if encoded.len() <= max_bytes {
return Ok((encoded, (width, height)));
}
// 仍超体积:等比缩到约 0.8 再试;一旦再缩会让边跌破阿里云 >32 下限就尽力返回当前
// (体积仍超限的图交给上游 4xx → 本地兜底,现实内容不会走到这一步)。
let longest = width.max(height);
let next = (longest as f32 * 0.8) as u32;
if next <= MIN_INPUT_EDGE || next >= longest {
return Ok((encoded, (width, height)));
}
max_edge = next;
}
}
fn encode_rgba_png(image: &image::RgbaImage) -> Result<Vec<u8>, MattingError> {
use image::ImageEncoder as _;
let mut encoded = Vec::new();
@@ -780,4 +838,67 @@ mod tests {
"下载抠图结果失败(transport=connect, timeout=false, connect=true)"
);
}
fn noise_image(width: u32, height: u32) -> image::DynamicImage {
// 不易压缩的伪随机噪声,保证 PNG 体积随像素数量增长,可触发降尺寸循环。
let mut img = image::RgbaImage::new(width, height);
for (x, y, px) in img.enumerate_pixels_mut() {
let v = ((x * 71 + y * 131 + x * y) % 256) as u8;
*px = image::Rgba([v, v.wrapping_mul(3), v.wrapping_add(97), 255]);
}
image::DynamicImage::ImageRgba8(img)
}
#[test]
fn normalize_keeps_small_image_and_outputs_png() {
let source =
image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(200, 150, image::Rgba([10, 20, 30, 255])));
let (bytes, dims) =
normalize_matting_input_png(&source).expect("normalize should succeed");
assert_eq!(dims, (200, 150), "小图不缩放,尺寸原样");
assert_eq!(
image::guess_format(&bytes).expect("guess format"),
image::ImageFormat::Png,
"输出必须是 PNG"
);
let decoded = image::load_from_memory(&bytes).expect("output should decode");
assert_eq!((decoded.width(), decoded.height()), (200, 150));
}
#[test]
fn normalize_caps_oversized_edge_to_1999() {
let source =
image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(2400, 1200, image::Rgba([0, 0, 0, 255])));
let (_bytes, (width, height)) =
normalize_matting_input_png(&source).expect("normalize should succeed");
assert!(width <= MAX_INPUT_EDGE && height <= MAX_INPUT_EDGE, "两边都 ≤1999,实得 {width}x{height}");
assert_eq!(width.max(height), MAX_INPUT_EDGE, "最长边压到 1999");
}
#[test]
fn normalize_downscales_until_under_byte_limit() {
let source = noise_image(300, 300);
let full = normalize_matting_input_png(&source)
.expect("full-size normalize should succeed")
.0;
// 阈值取全尺寸 PNG 的一半,必须触发降尺寸。
let limit = full.len() / 2;
let (bytes, (width, height)) = normalize_matting_input_png_within(&source, limit)
.expect("limited normalize should succeed");
assert!(width < 300 && height < 300, "应从 300x300 降尺寸,实得 {width}x{height}");
assert!(
bytes.len() <= limit || width.min(height) <= MIN_INPUT_EDGE + 1,
"编码 {} 字节应落在 {limit} 内(或已触最小边下限)",
bytes.len()
);
assert_eq!(
image::guess_format(&bytes).expect("guess format"),
image::ImageFormat::Png,
"降尺寸后仍是 PNG"
);
}
}