diff --git a/server-rs/crates/platform-matting/src/lib.rs b/server-rs/crates/platform-matting/src/lib.rs index 09cf22e9f..20c4b499b 100644 --- a/server-rs/crates/platform-matting/src/lib.rs +++ b/server-rs/crates/platform-matting/src/lib.rs @@ -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, (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, (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, 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" + ); + } }