新增阿里云通用抠图链路,图片抠图升级三档降级
- 新增 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:
Generated
+24
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod admin;
|
||||
mod ai_generation_drafts;
|
||||
mod aliyun_matting;
|
||||
mod ai_tasks;
|
||||
mod api_response;
|
||||
mod app;
|
||||
|
||||
@@ -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", ¢ered), ("偏移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
Reference in New Issue
Block a user