新增阿里云通用抠图链路,图片抠图升级三档降级

- 新增 platform-matting crate:手搓 SegmentCommonImage ACS3 签名调用、
  VIAPI 临时桶上传、分辨率守卫(缩图送抠 + alpha 回贴原图)
- api-server 接入 GENARRATIVE_ALIYUN_MATTING_* 配置与 MattingClient
- 画布生成图片抠图链改为 BgFilter → 阿里云 → 本地键色三档降级

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 03:50:32 +00:00
parent 754d364227
commit d0412705c4
12 changed files with 1312 additions and 2 deletions
+24
View File
@@ -241,6 +241,7 @@ dependencies = [
"platform-hyper3d",
"platform-image",
"platform-llm",
"platform-matting",
"platform-oss",
"platform-speech",
"platform-wechat",
@@ -4476,6 +4477,29 @@ dependencies = [
"tokio",
]
[[package]]
name = "platform-matting"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"dotenvy",
"hex",
"hmac",
"httpdate",
"image",
"platform-oss",
"reqwest 0.12.28",
"serde",
"serde_json",
"serde_urlencoded",
"sha1",
"sha2",
"time",
"tokio",
"tracing",
"uuid",
]
[[package]]
name = "platform-oss"
version = "0.1.0"
+2
View File
@@ -38,6 +38,7 @@ members = [
"crates/platform-hyper3d",
"crates/platform-image",
"crates/platform-llm",
"crates/platform-matting",
"crates/platform-wechat",
"crates/platform-speech",
"crates/platform-agent",
@@ -88,6 +89,7 @@ platform-audio = { path = "crates/platform-audio", default-features = false }
platform-hyper3d = { path = "crates/platform-hyper3d", default-features = false }
platform-image = { path = "crates/platform-image", default-features = false }
platform-llm = { path = "crates/platform-llm", default-features = false }
platform-matting = { path = "crates/platform-matting", default-features = false }
platform-oss = { path = "crates/platform-oss", default-features = false }
platform-speech = { path = "crates/platform-speech", default-features = false }
platform-wechat = { path = "crates/platform-wechat", default-features = false }
+1
View File
@@ -43,6 +43,7 @@ platform-auth = { workspace = true }
platform-hyper3d = { workspace = true }
platform-image = { workspace = true }
platform-llm = { workspace = true }
platform-matting = { workspace = true }
platform-oss = { workspace = true }
platform-speech = { workspace = true }
platform-wechat = { workspace = true }
@@ -0,0 +1,124 @@
//! 阿里云通用抠图在 api-server 侧的适配层。
//!
//! 输入 URL 策略:自有 OSS 在上海地域时直接上传草稿区并签名读 URL(抠图服务只认
//! 上海地域 OSS);其他地域交给 platform-matting 走 VIAPI 官方临时桶。
use std::collections::BTreeMap;
use axum::http::StatusCode;
use platform_oss::{
LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest,
};
use serde_json::json;
use crate::{
http_error::AppError, openai_image_generation::DownloadedOpenAiImage, state::AppState,
};
const MATTING_INPUT_PATH_SEGMENT: &str = "matting-input";
const MATTING_INPUT_READ_EXPIRE_SECONDS: u64 = 10 * 60;
const ALIYUN_MATTING_OSS_REGION_MARKER: &str = "oss-cn-shanghai";
/// 图片字节 → 阿里云通用抠图 → 原尺寸透明 PNG。
/// 未配置抠图客户端时返回错误,由调用方决定是否降级本地算法。
pub(crate) async fn segment_image_with_aliyun_matting(
state: &AppState,
image: &DownloadedOpenAiImage,
log_label: &str,
) -> Result<DownloadedOpenAiImage, AppError> {
let matting_client = state.matting_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-matting",
"message": "阿里云抠图客户端未配置或未启用。",
}))
})?;
let accessible_url = prepare_shanghai_oss_input_url(state, image, log_label).await;
let file_name = format!("{log_label}.png");
let started_at = std::time::Instant::now();
let output_bytes = matting_client
.segment_image_to_transparent_png(image.bytes.as_slice(), &file_name, accessible_url)
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-matting",
"message": error.message(),
}))
})?;
tracing::info!(
provider = "aliyun-matting",
log_label,
elapsed_ms = started_at.elapsed().as_millis() as u64,
"阿里云通用抠图完成"
);
Ok(DownloadedOpenAiImage {
bytes: output_bytes,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
}
/// 自有 OSS 在上海地域时,把待抠图图片传到草稿区并返回签名读 URL;
/// 其他情况返回 None(platform-matting 会转走官方临时桶)。
/// 该步骤失败不阻断抠图,只是退化为临时桶通道。
async fn prepare_shanghai_oss_input_url(
state: &AppState,
image: &DownloadedOpenAiImage,
log_label: &str,
) -> Option<String> {
let oss_client = state.oss_client()?;
let endpoint = state.config.oss_endpoint.as_deref().unwrap_or("");
if !endpoint.contains(ALIYUN_MATTING_OSS_REGION_MARKER) {
return None;
}
let http_client = reqwest::Client::new();
let file_name = format!(
"{log_label}-{}.{}",
shared_kernel::new_uuid_simple_string(),
image.extension
);
let put_result = oss_client
.put_object(
&http_client,
OssPutObjectRequest {
prefix: LegacyAssetPrefix::CharacterDrafts,
path_segments: vec![MATTING_INPUT_PATH_SEGMENT.to_string()],
file_name,
content_type: Some(image.mime_type.clone()),
access: OssObjectAccess::Private,
metadata: BTreeMap::new(),
body: image.bytes.clone(),
},
)
.await;
let put_result = match put_result {
Ok(result) => result,
Err(error) => {
tracing::warn!(
provider = "aliyun-matting",
log_label,
error = %error,
"抠图输入图上传自有 OSS 失败,回退官方临时桶"
);
return None;
}
};
match oss_client.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: put_result.object_key,
expire_seconds: Some(MATTING_INPUT_READ_EXPIRE_SECONDS),
}) {
Ok(signed) => Some(signed.signed_url),
Err(error) => {
tracing::warn!(
provider = "aliyun-matting",
log_label,
error = %error,
"抠图输入图签名读 URL 失败,回退官方临时桶"
);
None
}
}
}
+88
View File
@@ -18,6 +18,8 @@ const DEFAULT_EDITOR_BACKGROUND_REMOVAL_BASE_URL: &str = "http://58.87.105.82";
const DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS: u64 = 120_000;
const DEFAULT_EDITOR_BGFILTER_BASE_URL: &str = "http://58.87.105.82/bgfilter";
const DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS: u64 = 120_000;
const DEFAULT_ALIYUN_MATTING_ENDPOINT: &str = "imageseg.cn-shanghai.aliyuncs.com";
const DEFAULT_ALIYUN_MATTING_REQUEST_TIMEOUT_MS: u64 = 30_000;
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
#[derive(Clone, Debug)]
@@ -61,6 +63,11 @@ pub struct AppConfig {
pub editor_bgfilter_base_url: String,
pub editor_bgfilter_token: Option<String>,
pub editor_bgfilter_request_timeout_ms: u64,
pub aliyun_matting_enabled: bool,
pub aliyun_matting_endpoint: String,
pub aliyun_matting_access_key_id: Option<String>,
pub aliyun_matting_access_key_secret: Option<String>,
pub aliyun_matting_request_timeout_ms: u64,
pub image_editor_agent_sidebar_enabled: bool,
pub log_filter: String,
pub otel_enabled: bool,
@@ -292,6 +299,11 @@ impl Default for AppConfig {
editor_bgfilter_base_url: DEFAULT_EDITOR_BGFILTER_BASE_URL.to_string(),
editor_bgfilter_token: None,
editor_bgfilter_request_timeout_ms: DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS,
aliyun_matting_enabled: true,
aliyun_matting_endpoint: DEFAULT_ALIYUN_MATTING_ENDPOINT.to_string(),
aliyun_matting_access_key_id: None,
aliyun_matting_access_key_secret: None,
aliyun_matting_request_timeout_ms: DEFAULT_ALIYUN_MATTING_REQUEST_TIMEOUT_MS,
image_editor_agent_sidebar_enabled: false,
log_filter: "info,tower_http=info".to_string(),
otel_enabled: false,
@@ -496,6 +508,25 @@ impl AppConfig {
{
config.editor_bgfilter_request_timeout_ms = timeout_ms;
}
if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_ALIYUN_MATTING_ENABLED"]) {
config.aliyun_matting_enabled = enabled;
}
if let Some(endpoint) = read_first_non_empty_env(&["GENARRATIVE_ALIYUN_MATTING_ENDPOINT"]) {
config.aliyun_matting_endpoint = endpoint;
}
config.aliyun_matting_access_key_id = read_first_non_empty_env(&[
"GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID",
"ALIBABA_CLOUD_ACCESS_KEY_ID",
]);
config.aliyun_matting_access_key_secret = read_first_non_empty_env(&[
"GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET",
"ALIBABA_CLOUD_ACCESS_KEY_SECRET",
]);
if let Some(timeout_ms) =
read_first_positive_u64_env(&["GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS"])
{
config.aliyun_matting_request_timeout_ms = timeout_ms;
}
if let Some(enabled) =
read_first_bool_env(&["GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR"])
{
@@ -2263,6 +2294,63 @@ mod tests {
}
}
#[test]
fn from_env_reads_aliyun_matting_settings_and_reuses_alibaba_cloud_keys() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ENABLED");
std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ENDPOINT");
std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID");
std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET");
std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS");
std::env::remove_var("ALIBABA_CLOUD_ACCESS_KEY_ID");
std::env::remove_var("ALIBABA_CLOUD_ACCESS_KEY_SECRET");
std::env::set_var("ALIBABA_CLOUD_ACCESS_KEY_ID", "shared-ak-id");
std::env::set_var("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "shared-ak-secret");
std::env::set_var("GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS", "45000");
}
let config = AppConfig::from_env();
assert!(config.aliyun_matting_enabled);
assert_eq!(
config.aliyun_matting_endpoint,
"imageseg.cn-shanghai.aliyuncs.com"
);
assert_eq!(
config.aliyun_matting_access_key_id.as_deref(),
Some("shared-ak-id")
);
assert_eq!(
config.aliyun_matting_access_key_secret.as_deref(),
Some("shared-ak-secret")
);
assert_eq!(config.aliyun_matting_request_timeout_ms, 45_000);
unsafe {
std::env::set_var("GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID", "matting-ak-id");
std::env::set_var("GENARRATIVE_ALIYUN_MATTING_ENABLED", "false");
}
let config = AppConfig::from_env();
assert!(!config.aliyun_matting_enabled);
assert_eq!(
config.aliyun_matting_access_key_id.as_deref(),
Some("matting-ak-id")
);
unsafe {
std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ENABLED");
std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID");
std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS");
std::env::remove_var("ALIBABA_CLOUD_ACCESS_KEY_ID");
std::env::remove_var("ALIBABA_CLOUD_ACCESS_KEY_SECRET");
}
}
#[test]
fn from_env_reads_image_editor_agent_sidebar_runtime_flag() {
let _guard = ENV_LOCK
@@ -2221,9 +2221,27 @@ async fn remove_editor_generated_screen_background_with_bgfilter(
seg_model,
error = %error,
error_details = ?error.details(),
"editor_bgfilter_fallback_to_local_screen_background_removal"
"editor_bgfilter_fallback_to_aliyun_matting"
);
remove_editor_generated_green_screen_background(image, screen_color)
match crate::aliyun_matting::segment_image_with_aliyun_matting(
state,
image,
"editor-screen-background",
)
.await
{
Ok(image) => Ok(image),
Err(error) => {
tracing::warn!(
provider = "aliyun-matting",
screen_color = screen_color.hex,
error = %error,
error_details = ?error.details(),
"editor_aliyun_matting_fallback_to_local_screen_background_removal"
);
remove_editor_generated_green_screen_background(image, screen_color)
}
}
}
}
}
+1
View File
@@ -2,6 +2,7 @@
mod admin;
mod ai_generation_drafts;
mod aliyun_matting;
mod ai_tasks;
mod api_response;
mod app;
+40
View File
@@ -24,6 +24,7 @@ use platform_auth::{
SmsAuthProviderKind, SmsProviderError, WechatProvider, sign_access_token, verify_access_token,
};
use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider};
use platform_matting::{MattingClient, MattingConfig};
use platform_oss::{OssClient, OssConfig, OssError};
use platform_wechat::{WechatClient, WechatConfig, pay::WechatPayClient};
use serde_json::Value;
@@ -269,6 +270,7 @@ pub struct AppStateInner {
editor_generation_pricing_store: EditorGenerationPricingStore,
llm_client: Option<LlmClient>,
creative_agent_gpt5_client: Option<LlmClient>,
matting_client: Option<MattingClient>,
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
// Phase 1 任务 E 的 creative session facade 暂存在 api-server。
// creative_agent_* 表由任务 D 收口后,这里只保留读写 facade。
@@ -418,6 +420,7 @@ impl AppState {
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?;
let llm_client = build_llm_client(&config)?;
let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?;
let matting_client = build_matting_client(&config)?;
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
let (profile_recharge_order_updates, _) = broadcast::channel(128);
@@ -455,6 +458,7 @@ impl AppState {
editor_generation_pricing_store,
llm_client,
creative_agent_gpt5_client,
matting_client,
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())),
profile_recharge_order_updates,
@@ -917,6 +921,10 @@ impl AppState {
self.creative_agent_gpt5_client.as_ref()
}
pub fn matting_client(&self) -> Option<&MattingClient> {
self.matting_client.as_ref()
}
pub fn creative_agent_executor(&self) -> Arc<MockLangChainRustAgentExecutor> {
self.creative_agent_executor.clone()
}
@@ -1446,6 +1454,38 @@ fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateIni
Ok(Some(OssClient::new(oss_config)))
}
fn build_matting_client(config: &AppConfig) -> Result<Option<MattingClient>, AppStateInitError> {
if !config.aliyun_matting_enabled {
return Ok(None);
}
let (Some(access_key_id), Some(access_key_secret)) = (
config
.aliyun_matting_access_key_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty()),
config
.aliyun_matting_access_key_secret
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty()),
) else {
warn!("阿里云抠图 AccessKey 未配置,跳过抠图客户端初始化");
return Ok(None);
};
let matting_config = MattingConfig::with_timeout(
config.aliyun_matting_endpoint.clone(),
access_key_id.to_string(),
access_key_secret.to_string(),
config.aliyun_matting_request_timeout_ms,
)
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?;
MattingClient::new(matting_config)
.map(Some)
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))
}
fn build_wechat_client(config: &AppConfig) -> WechatClient {
WechatClient::new(WechatConfig {
app_id: config.wechat_mini_program_app_id.clone(),
@@ -0,0 +1,26 @@
[package]
name = "platform-matting"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
base64 = { workspace = true }
hmac = { workspace = true }
hex = { workspace = true }
httpdate = { workspace = true }
image = { workspace = true, features = ["png", "jpeg", "webp"] }
sha1 = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
serde = { workspace = true }
serde_json = { workspace = true }
serde_urlencoded = { workspace = true }
sha2 = { workspace = true }
time = { workspace = true, features = ["std"] }
tracing = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
[dev-dependencies]
dotenvy = { workspace = true }
platform-oss = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
@@ -0,0 +1,104 @@
//! 探测 SegmentCommonImage 在小尺寸(480p 级)输入下 crop/mask 的输出尺寸行为。
//!
//! cargo run -p platform-matting --example crop_size_probe
use std::io::Cursor;
use std::path::Path;
use image::{DynamicImage, GenericImage, ImageFormat, Rgba, RgbaImage};
use platform_matting::{
DEFAULT_IMAGESEG_ENDPOINT, MattingClient, MattingConfig, SegmentCommonImageRequest,
SegmentReturnForm,
};
fn load_env_files() {
for candidate in [
".env",
".env.local",
".env.secrets.local",
"../.env",
"../.env.local",
"../.env.secrets.local",
] {
if Path::new(candidate).exists() {
let _ = dotenvy::from_path_override(candidate);
}
}
}
fn encode_png(image: &DynamicImage) -> Vec<u8> {
let mut buffer = Cursor::new(Vec::new());
image
.write_to(&mut buffer, ImageFormat::Png)
.expect("PNG 编码应成功");
buffer.into_inner()
}
#[tokio::main]
async fn main() {
load_env_files();
let input_path = std::env::args().nth(1).unwrap_or_else(|| {
r"C:\Users\lingh\Downloads\gpt-image-2-elf-archer-2048x2048.png".to_string()
});
let source = image::open(&input_path).expect("测试图片应可解码");
// 变体1:等比缩到 480(模拟 1:1 480p 视频帧,主体居中)
let centered = source.resize(480, 480, image::imageops::FilterType::CatmullRom);
// 变体2:把 360 尺寸的主体贴到 640x480 画布左上角(模拟横版帧、主体偏移)
let small = source.resize(360, 360, image::imageops::FilterType::CatmullRom);
let corner_pixel = centered.to_rgba8().get_pixel(0, 0).0;
let mut offset_canvas = RgbaImage::from_pixel(640, 480, Rgba(corner_pixel));
offset_canvas
.copy_from(&small.to_rgba8(), 20, 40)
.expect("贴图应成功");
let offset = DynamicImage::ImageRgba8(offset_canvas);
let matting_config = MattingConfig::new(
std::env::var("ALIYUN_IMAGESEG_ENDPOINT")
.unwrap_or_else(|_| DEFAULT_IMAGESEG_ENDPOINT.to_string()),
std::env::var("ALIBABA_CLOUD_ACCESS_KEY_ID").expect("需要 ALIBABA_CLOUD_ACCESS_KEY_ID"),
std::env::var("ALIBABA_CLOUD_ACCESS_KEY_SECRET")
.expect("需要 ALIBABA_CLOUD_ACCESS_KEY_SECRET"),
)
.expect("抠图配置应有效");
let client = MattingClient::new(matting_config).expect("客户端应可构建");
let http_client = reqwest::Client::new();
for (label, variant) in [("居中480x480", &centered), ("偏移640x480", &offset)] {
let bytes = encode_png(variant);
let temp_url = client
.upload_temp_image(bytes, &format!("probe-{label}.png"), "image/png")
.await
.expect("上传临时桶应成功");
for return_form in [None, Some(SegmentReturnForm::Crop), Some(SegmentReturnForm::Mask)] {
let result = client
.segment_common_image(SegmentCommonImageRequest {
image_url: temp_url.clone(),
return_form,
})
.await
.expect("抠图调用应成功");
let output_bytes = http_client
.get(&result.image_url)
.send()
.await
.expect("下载结果应成功")
.bytes()
.await
.expect("读取结果字节应成功");
let output = image::load_from_memory(&output_bytes).expect("结果应可解码");
println!(
"{label} 输入 {}x{} -> {} 输出 {}x{} (alpha通道: {})",
variant.width(),
variant.height(),
return_form.map_or("默认(不传)".to_string(), |f| format!("{f:?}")),
output.width(),
output.height(),
output.color().has_alpha()
);
}
}
}
@@ -0,0 +1,158 @@
//! 通用抠图冒烟验证:本地图片 → OSS → SegmentCommonImage → 下载结果。
//!
//! 运行(在 server-rs 目录下):
//! cargo run -p platform-matting --example segment_smoke -- "C:\path\to\input.png"
//!
//! 依赖仓库根目录 .env.local(OSS bucket/endpoint)与 .env.secrets.local(AK/SK)。
use std::path::{Path, PathBuf};
use platform_matting::{
DEFAULT_IMAGESEG_ENDPOINT, MattingClient, MattingConfig, SegmentCommonImageRequest,
SegmentReturnForm,
};
fn load_env_files() {
// example 的工作目录通常是 server-rs,.env 都在仓库根目录。
for candidate in [
".env",
".env.local",
".env.secrets.local",
"../.env",
"../.env.local",
"../.env.secrets.local",
] {
if Path::new(candidate).exists() {
let _ = dotenvy::from_path_override(candidate);
}
}
}
#[tokio::main]
async fn main() {
load_env_files();
let input_path = std::env::args().nth(1).unwrap_or_else(|| {
r"C:\Users\lingh\Downloads\gpt-image-2-elf-archer-2048x2048.png".to_string()
});
let input_bytes = std::fs::read(&input_path)
.unwrap_or_else(|error| panic!("读取测试图片失败({input_path}):{error}"));
println!("[1/5] 已读取测试图片:{input_path}({} 字节)", input_bytes.len());
// SegmentCommonImage 要求分辨率低于 2000x2000,超限先等比缩小。
const MAX_EDGE: u32 = 1999;
let decoded = image::load_from_memory(&input_bytes).expect("测试图片应可解码");
let input_bytes = if decoded.width() > MAX_EDGE || decoded.height() > MAX_EDGE {
let resized = decoded.resize(
MAX_EDGE,
MAX_EDGE,
image::imageops::FilterType::CatmullRom,
);
let mut buffer = std::io::Cursor::new(Vec::new());
resized
.write_to(&mut buffer, image::ImageFormat::Png)
.expect("缩放后的图片应可编码为 PNG");
println!(
" 分辨率 {}x{} 超限,已缩放到 {}x{}",
decoded.width(),
decoded.height(),
resized.width(),
resized.height()
);
buffer.into_inner()
} else {
input_bytes
};
let http_client = reqwest::Client::new();
// --- 调用通用抠图 ---
// key 优先级:VIAPI 专用 → 官方 SDK 标准命名(#IMAGE_CALL)→ 短信 key 兜底。
let (matting_key_id, matting_key_secret) = [
("ALIYUN_IMAGESEG_ACCESS_KEY_ID", "ALIYUN_IMAGESEG_ACCESS_KEY_SECRET"),
("ALIBABA_CLOUD_ACCESS_KEY_ID", "ALIBABA_CLOUD_ACCESS_KEY_SECRET"),
("ALIYUN_SMS_ACCESS_KEY_ID", "ALIYUN_SMS_ACCESS_KEY_SECRET"),
]
.iter()
.find_map(|(id_name, secret_name)| {
let id = std::env::var(id_name).ok()?;
let secret = std::env::var(secret_name).ok()?;
if id.trim().is_empty() || secret.trim().is_empty() {
return None;
}
println!(" 使用 {id_name} 调用抠图服务");
Some((id, secret))
})
.expect("未找到可用的抠图 AccessKey 环境变量");
let matting_config = MattingConfig::new(
std::env::var("ALIYUN_IMAGESEG_ENDPOINT")
.unwrap_or_else(|_| DEFAULT_IMAGESEG_ENDPOINT.to_string()),
matting_key_id,
matting_key_secret,
)
.expect("抠图配置应有效");
let matting_client = MattingClient::new(matting_config).expect("抠图客户端应可构建");
// 本地 OSS 在北京地域,抠图服务要求上海地域,走 VIAPI 官方临时桶上传。
let temp_url = matting_client
.upload_temp_image(input_bytes, "segment-input.png", "image/png")
.await
.expect("上传 VIAPI 临时桶应成功");
println!("[2/5] 已上传 VIAPI 临时桶");
println!("[3/5] 输入图 URL host:{}", host_of(&temp_url));
let result = matting_client
.segment_common_image(SegmentCommonImageRequest {
image_url: temp_url,
return_form: Some(SegmentReturnForm::Crop),
})
.await;
let result = match result {
Ok(result) => result,
Err(error) => {
eprintln!("[4/5] 通用抠图调用失败:{error}");
std::process::exit(1);
}
};
println!(
"[4/5] 通用抠图成功,RequestId={}",
result.request_id.as_deref().unwrap_or("unknown")
);
// --- 下载结果 ---
let output_bytes = http_client
.get(&result.image_url)
.send()
.await
.expect("下载抠图结果应成功")
.error_for_status()
.expect("抠图结果 URL 应返回 200")
.bytes()
.await
.expect("读取抠图结果字节应成功");
let output_path = build_output_path(&input_path);
std::fs::write(&output_path, &output_bytes).expect("写出抠图结果应成功");
println!(
"[5/5] 抠图结果已保存:{}({} 字节)",
output_path.display(),
output_bytes.len()
);
}
fn build_output_path(input_path: &str) -> PathBuf {
let input = Path::new(input_path);
let stem = input
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("segment-output");
input.with_file_name(format!("{stem}-matting.png"))
}
fn host_of(url: &str) -> String {
url.split("//")
.nth(1)
.and_then(|rest| rest.split('/').next())
.unwrap_or("unknown")
.to_string()
}
File diff suppressed because it is too large Load Diff