1140 lines
41 KiB
Rust
1140 lines
41 KiB
Rust
use std::time::Instant;
|
||
|
||
use axum::{
|
||
Json,
|
||
extract::{Extension, State, rejection::JsonRejection},
|
||
http::StatusCode,
|
||
response::Response,
|
||
};
|
||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||
use image::{ColorType, GenericImageView, ImageEncoder, ImageFormat, codecs::png::PngEncoder};
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::{
|
||
api_response::json_success_body,
|
||
config::DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS,
|
||
http_error::AppError,
|
||
openai_image_generation::{
|
||
DownloadedOpenAiImage, OpenAiImageSettings, build_openai_image_http_client,
|
||
create_openai_image_generation, require_openai_image_settings,
|
||
},
|
||
request_context::RequestContext,
|
||
state::AppState,
|
||
};
|
||
|
||
const BABY_OBJECT_MATCH_PROVIDER: &str = "vector-engine-gpt-image-2";
|
||
const BABY_OBJECT_MATCH_IMAGE_SIZE: &str = "1024x1024";
|
||
const BABY_OBJECT_MATCH_BACKGROUND_IMAGE_SIZE: &str = "1536x1024";
|
||
const BABY_OBJECT_MATCH_SHEET_GRID_SIZE: u32 = 2;
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct GenerateBabyObjectMatchAssetsRequest {
|
||
item_names: Vec<String>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GenerateBabyObjectMatchAssetsResponse {
|
||
assets: Vec<BabyObjectMatchItemAssetPayload>,
|
||
visual_package: BabyObjectMatchVisualPackagePayload,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct BabyObjectMatchItemAssetPayload {
|
||
item_id: String,
|
||
item_name: String,
|
||
image_src: String,
|
||
asset_object_id: Option<String>,
|
||
generation_provider: String,
|
||
prompt: String,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
enum BabyObjectMatchVisualAssetKind {
|
||
Background,
|
||
GiftBox,
|
||
Basket,
|
||
}
|
||
|
||
impl BabyObjectMatchVisualAssetKind {
|
||
fn asset_id(self) -> &'static str {
|
||
match self {
|
||
Self::Background => "baby-object-visual-background",
|
||
Self::GiftBox => "baby-object-visual-gift-box",
|
||
Self::Basket => "baby-object-visual-basket",
|
||
}
|
||
}
|
||
|
||
fn contract_kind(self) -> &'static str {
|
||
match self {
|
||
Self::Background => "background",
|
||
Self::GiftBox => "gift-box",
|
||
Self::Basket => "basket",
|
||
}
|
||
}
|
||
|
||
fn failure_context(self) -> &'static str {
|
||
match self {
|
||
Self::Background => "宝贝识物背景环境图片生成失败",
|
||
Self::GiftBox => "宝贝识物礼物盒图片生成失败",
|
||
Self::Basket => "宝贝识物篮子图片生成失败",
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
enum BabyObjectMatchSheetSlot {
|
||
ItemA,
|
||
ItemB,
|
||
Basket,
|
||
GiftBox,
|
||
}
|
||
|
||
impl BabyObjectMatchSheetSlot {
|
||
const ALL: [Self; 4] = [Self::ItemA, Self::ItemB, Self::Basket, Self::GiftBox];
|
||
|
||
fn row(self) -> u32 {
|
||
match self {
|
||
Self::ItemA | Self::ItemB => 0,
|
||
Self::Basket | Self::GiftBox => 1,
|
||
}
|
||
}
|
||
|
||
fn col(self) -> u32 {
|
||
match self {
|
||
Self::ItemA | Self::Basket => 0,
|
||
Self::ItemB | Self::GiftBox => 1,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct BabyObjectMatchVisualPackagePayload {
|
||
theme_prompt: String,
|
||
assets: Vec<BabyObjectMatchVisualAssetPayload>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct BabyObjectMatchVisualAssetPayload {
|
||
asset_id: String,
|
||
asset_kind: String,
|
||
image_src: String,
|
||
asset_object_id: Option<String>,
|
||
generation_provider: String,
|
||
prompt: String,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct BabyObjectMatchSheetAssets {
|
||
items: Vec<BabyObjectMatchItemAssetPayload>,
|
||
basket: BabyObjectMatchVisualAssetPayload,
|
||
gift_box: BabyObjectMatchVisualAssetPayload,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
struct BabyObjectMatchSheetCellBounds {
|
||
x0: u32,
|
||
y0: u32,
|
||
x1: u32,
|
||
y1: u32,
|
||
}
|
||
|
||
pub async fn generate_baby_object_match_assets(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
payload: Result<Json<GenerateBabyObjectMatchAssetsRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = payload.map_err(|error| {
|
||
baby_object_match_error_response(
|
||
&request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "edutainment-baby-object",
|
||
"message": error.body_text(),
|
||
})),
|
||
)
|
||
})?;
|
||
let item_names = normalize_item_names(payload.item_names)
|
||
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
|
||
|
||
let settings = require_openai_image_settings(&state)
|
||
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
|
||
let settings = with_baby_object_match_image_timeout(settings);
|
||
let http_client = build_openai_image_http_client(&settings)
|
||
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
|
||
|
||
let request_started_at = Instant::now();
|
||
tracing::info!(
|
||
item_count = item_names.len(),
|
||
"宝贝识物 image-2 资源生成开始"
|
||
);
|
||
let (sheet_assets, background_asset, theme_prompt) =
|
||
build_baby_object_match_optimized_assets(&http_client, &settings, item_names.as_slice())
|
||
.await
|
||
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
|
||
tracing::info!(
|
||
elapsed_ms = request_started_at.elapsed().as_millis() as u64,
|
||
"宝贝识物 image-2 资源生成完成"
|
||
);
|
||
|
||
let assets = sheet_assets.items;
|
||
let visual_package = BabyObjectMatchVisualPackagePayload {
|
||
theme_prompt,
|
||
assets: vec![background_asset, sheet_assets.gift_box, sheet_assets.basket],
|
||
};
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
GenerateBabyObjectMatchAssetsResponse {
|
||
assets,
|
||
visual_package,
|
||
},
|
||
))
|
||
}
|
||
|
||
fn normalize_item_names(item_names: Vec<String>) -> Result<Vec<String>, AppError> {
|
||
let normalized = item_names
|
||
.into_iter()
|
||
.map(|value| value.trim().to_string())
|
||
.collect::<Vec<_>>();
|
||
|
||
if normalized.len() != 2 || normalized.iter().any(|value| value.is_empty()) {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "edutainment-baby-object",
|
||
"message": "请填写两个物品名称。",
|
||
})),
|
||
);
|
||
}
|
||
|
||
Ok(normalized)
|
||
}
|
||
|
||
async fn build_baby_object_match_optimized_assets(
|
||
http_client: &reqwest::Client,
|
||
settings: &OpenAiImageSettings,
|
||
item_names: &[String],
|
||
) -> Result<
|
||
(
|
||
BabyObjectMatchSheetAssets,
|
||
BabyObjectMatchVisualAssetPayload,
|
||
String,
|
||
),
|
||
AppError,
|
||
> {
|
||
let theme_prompt = build_baby_object_match_visual_theme_prompt(item_names);
|
||
let sheet_prompt = build_baby_object_match_sheet_prompt(item_names, theme_prompt.as_str());
|
||
let background_prompt = build_baby_object_match_visual_asset_prompt(
|
||
BabyObjectMatchVisualAssetKind::Background,
|
||
item_names,
|
||
theme_prompt.as_str(),
|
||
);
|
||
|
||
let (sheet_assets, background_asset) = tokio::try_join!(
|
||
build_baby_object_match_sheet_assets(
|
||
http_client,
|
||
settings,
|
||
item_names,
|
||
sheet_prompt.as_str()
|
||
),
|
||
build_baby_object_match_background_asset(http_client, settings, background_prompt.as_str()),
|
||
)?;
|
||
|
||
Ok((sheet_assets, background_asset, theme_prompt))
|
||
}
|
||
|
||
fn build_baby_object_match_sheet_prompt(item_names: &[String], theme_prompt: &str) -> String {
|
||
let item_a = item_names.first().map(String::as_str).unwrap_or_default();
|
||
let item_b = item_names.get(1).map(String::as_str).unwrap_or_default();
|
||
|
||
format!(
|
||
"{theme_prompt}\n\
|
||
生成一张 1024x1024 的 2x2 游戏素材 sheet,严格均匀分成四格,但画面中不要绘制网格线、文字、标签或编号。\n\
|
||
四格内容必须固定为:左上格是单一物品“{item_a}”;右上格是单一物品“{item_b}”;左下格是游戏左右两侧复用的大号篮子;右下格是游戏中央使用的大号礼物盒。\n\
|
||
风格必须与寓教于乐板块统一:明亮、温暖、卡通绘本质感,适合 4-8 岁儿童,边缘清晰,色彩干净,能自然放在同一套游戏场景中。\n\
|
||
物品格只允许出现围绕对应关键词的单一主体,不能出现组合物、多个物体、人物、手、篮子、礼物盒、文字、水印或 UI。\n\
|
||
篮子格只生成一个主体饱满、开口清晰、可读性高的大号篮子,不能放入待分类物品,不能出现文字、人物、手或礼物盒,手柄和篮口镂空处不要留下白底描边或毛边。\n\
|
||
礼物盒格只生成一个主体饱满、中心构图的大号礼物盒,不能出现篮子、待分类物品、人物、手或文字。\n\
|
||
每格背景必须是统一纯白或接近纯白的干净背景,无场景、无阴影地面、无氛围渲染、无按钮、无边框,便于服务端将背景抠成透明 PNG。\n\
|
||
每个主体必须完整居中,四周保留安全留白,不得跨格、贴边或越界。"
|
||
)
|
||
}
|
||
|
||
fn build_baby_object_match_sheet_negative_prompt() -> &'static str {
|
||
"文字,数字,水印,Logo,网格线,标签,按钮,UI,人物,手,复杂背景,场景,草地,天空,房间,光效氛围,多个物品,组合套装,物体跨格,贴边,越界,真实照片风,复杂投影"
|
||
}
|
||
|
||
fn with_baby_object_match_image_timeout(mut settings: OpenAiImageSettings) -> OpenAiImageSettings {
|
||
settings.request_timeout_ms = settings
|
||
.request_timeout_ms
|
||
.max(DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS);
|
||
settings
|
||
}
|
||
|
||
async fn build_baby_object_match_sheet_assets(
|
||
http_client: &reqwest::Client,
|
||
settings: &OpenAiImageSettings,
|
||
item_names: &[String],
|
||
prompt: &str,
|
||
) -> Result<BabyObjectMatchSheetAssets, AppError> {
|
||
let asset_started_at = Instant::now();
|
||
tracing::info!("宝贝识物 image-2 2x2 素材 sheet 生成开始");
|
||
let generated = create_openai_image_generation(
|
||
http_client,
|
||
settings,
|
||
prompt,
|
||
Some(build_baby_object_match_sheet_negative_prompt()),
|
||
BABY_OBJECT_MATCH_IMAGE_SIZE,
|
||
1,
|
||
&[],
|
||
"宝贝识物 2x2 素材 sheet 生成失败",
|
||
)
|
||
.await?;
|
||
let generated_image = generated.images.into_iter().next().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": "宝贝识物 2x2 素材 sheet 生成没有返回图片。",
|
||
}))
|
||
})?;
|
||
let sliced = slice_baby_object_match_sheet(&generated_image)?;
|
||
tracing::info!(
|
||
elapsed_ms = asset_started_at.elapsed().as_millis() as u64,
|
||
"宝贝识物 image-2 2x2 素材 sheet 生成完成"
|
||
);
|
||
|
||
Ok(BabyObjectMatchSheetAssets {
|
||
items: vec![
|
||
BabyObjectMatchItemAssetPayload {
|
||
item_id: "baby-object-item-1".to_string(),
|
||
item_name: item_names.first().cloned().unwrap_or_default(),
|
||
image_src: sliced
|
||
.slot_data_url(BabyObjectMatchSheetSlot::ItemA)
|
||
.to_string(),
|
||
asset_object_id: None,
|
||
generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(),
|
||
prompt: prompt.to_string(),
|
||
},
|
||
BabyObjectMatchItemAssetPayload {
|
||
item_id: "baby-object-item-2".to_string(),
|
||
item_name: item_names.get(1).cloned().unwrap_or_default(),
|
||
image_src: sliced
|
||
.slot_data_url(BabyObjectMatchSheetSlot::ItemB)
|
||
.to_string(),
|
||
asset_object_id: None,
|
||
generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(),
|
||
prompt: prompt.to_string(),
|
||
},
|
||
],
|
||
basket: BabyObjectMatchVisualAssetPayload {
|
||
asset_id: BabyObjectMatchVisualAssetKind::Basket
|
||
.asset_id()
|
||
.to_string(),
|
||
asset_kind: BabyObjectMatchVisualAssetKind::Basket
|
||
.contract_kind()
|
||
.to_string(),
|
||
image_src: sliced
|
||
.slot_data_url(BabyObjectMatchSheetSlot::Basket)
|
||
.to_string(),
|
||
asset_object_id: None,
|
||
generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(),
|
||
prompt: prompt.to_string(),
|
||
},
|
||
gift_box: BabyObjectMatchVisualAssetPayload {
|
||
asset_id: BabyObjectMatchVisualAssetKind::GiftBox
|
||
.asset_id()
|
||
.to_string(),
|
||
asset_kind: BabyObjectMatchVisualAssetKind::GiftBox
|
||
.contract_kind()
|
||
.to_string(),
|
||
image_src: sliced
|
||
.slot_data_url(BabyObjectMatchSheetSlot::GiftBox)
|
||
.to_string(),
|
||
asset_object_id: None,
|
||
generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(),
|
||
prompt: prompt.to_string(),
|
||
},
|
||
})
|
||
}
|
||
|
||
async fn build_baby_object_match_background_asset(
|
||
http_client: &reqwest::Client,
|
||
settings: &OpenAiImageSettings,
|
||
prompt: &str,
|
||
) -> Result<BabyObjectMatchVisualAssetPayload, AppError> {
|
||
let asset_started_at = Instant::now();
|
||
let kind = BabyObjectMatchVisualAssetKind::Background;
|
||
tracing::info!(
|
||
asset_kind = kind.contract_kind(),
|
||
"宝贝识物 image-2 场景资源生成开始"
|
||
);
|
||
let generated = create_openai_image_generation(
|
||
http_client,
|
||
settings,
|
||
prompt,
|
||
Some(build_baby_object_match_visual_negative_prompt(kind)),
|
||
BABY_OBJECT_MATCH_BACKGROUND_IMAGE_SIZE,
|
||
1,
|
||
&[],
|
||
kind.failure_context(),
|
||
)
|
||
.await?;
|
||
let generated_image = generated.images.into_iter().next().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": format!("{}:VectorEngine 没有返回图片。", kind.failure_context()),
|
||
}))
|
||
})?;
|
||
let image_src = build_png_data_url(generated_image)?;
|
||
tracing::info!(
|
||
asset_kind = kind.contract_kind(),
|
||
elapsed_ms = asset_started_at.elapsed().as_millis() as u64,
|
||
"宝贝识物 image-2 场景资源生成完成"
|
||
);
|
||
|
||
Ok(BabyObjectMatchVisualAssetPayload {
|
||
asset_id: kind.asset_id().to_string(),
|
||
asset_kind: kind.contract_kind().to_string(),
|
||
image_src,
|
||
asset_object_id: None,
|
||
generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(),
|
||
prompt: prompt.to_string(),
|
||
})
|
||
}
|
||
|
||
fn build_baby_object_match_visual_theme_prompt(item_names: &[String]) -> String {
|
||
let item_a = item_names.first().map(String::as_str).unwrap_or_default();
|
||
let item_b = item_names.get(1).map(String::as_str).unwrap_or_default();
|
||
let theme_hint = resolve_baby_object_match_theme_hint(item_names);
|
||
|
||
format!(
|
||
"根据创作者填写的两个物品关键词“{item_a}”和“{item_b}”,为儿童动作 Demo 玩法“宝贝识物”生成一套完整游戏视觉包装。\n\
|
||
视觉必须保持寓教于乐板块统一的明亮、温暖、卡通绘本插画风,适合 4-8 岁儿童。\n\
|
||
主题匹配:{theme_hint}\n\
|
||
所有资源需要围绕这两个关键词形成统一主题,但不能改变物品识别和左右篮子固定规则。"
|
||
)
|
||
}
|
||
|
||
fn resolve_baby_object_match_theme_hint(item_names: &[String]) -> &'static str {
|
||
let joined = item_names.join(" ").to_lowercase();
|
||
let fruit_keywords = [
|
||
"苹果",
|
||
"橘子",
|
||
"桔子",
|
||
"香蕉",
|
||
"葡萄",
|
||
"草莓",
|
||
"西瓜",
|
||
"梨",
|
||
"桃",
|
||
"水果",
|
||
"apple",
|
||
"orange",
|
||
"banana",
|
||
"grape",
|
||
"strawberry",
|
||
"watermelon",
|
||
"fruit",
|
||
];
|
||
let character_keywords = [
|
||
"佩琪",
|
||
"小猪佩奇",
|
||
"小猪佩琪",
|
||
"奥特曼",
|
||
"动漫",
|
||
"动画",
|
||
"卡通",
|
||
"玩具",
|
||
"角色",
|
||
"公仔",
|
||
"peppa",
|
||
"ultraman",
|
||
"anime",
|
||
"cartoon",
|
||
"toy",
|
||
"doll",
|
||
"figure",
|
||
];
|
||
|
||
if fruit_keywords
|
||
.iter()
|
||
.any(|keyword| joined.contains(keyword))
|
||
{
|
||
return "若关键词属于水果,背景环境和 UI 元素匹配果园、自然、阳光、树叶等主题。";
|
||
}
|
||
|
||
if character_keywords
|
||
.iter()
|
||
.any(|keyword| joined.contains(keyword))
|
||
{
|
||
return "若关键词属于动漫角色、玩具或公仔,背景环境和 UI 元素匹配动漫、玩具房、儿童玩具等主题。";
|
||
}
|
||
|
||
"根据关键词语义自然匹配合适主题,保持儿童寓教于乐插画风。"
|
||
}
|
||
|
||
fn build_baby_object_match_visual_asset_prompt(
|
||
kind: BabyObjectMatchVisualAssetKind,
|
||
item_names: &[String],
|
||
theme_prompt: &str,
|
||
) -> String {
|
||
let item_a = item_names.first().map(String::as_str).unwrap_or_default();
|
||
let item_b = item_names.get(1).map(String::as_str).unwrap_or_default();
|
||
let base = format!(
|
||
"{theme_prompt}\n\
|
||
当前两个关键词:{item_a}、{item_b}。\n\
|
||
输出必须是无文字、无水印、无 Logo 的游戏美术资源。"
|
||
);
|
||
|
||
match kind {
|
||
BabyObjectMatchVisualAssetKind::Background => format!(
|
||
"{base}\n\
|
||
生成游戏背景环境图。背景需要根据关键词主题匹配环境,例如水果可偏果园自然,动漫角色或玩具可偏动漫玩具主题。\n\
|
||
保持中间、屏幕中下方和底部左右篮子区域清爽,给放大后的礼物盒、中央物品和左右大篮子预留足够空间,不能画入礼物盒、篮子、物品、人物、文字或操作 UI。"
|
||
),
|
||
BabyObjectMatchVisualAssetKind::GiftBox => format!(
|
||
"{base}\n\
|
||
生成透明 PNG 的大号礼物盒资源。礼物盒会在游戏中以约 2 倍视觉尺寸展示,需要主体饱满、轮廓清晰、中心构图、边缘安全留白少,打开动画时可被烟雾遮罩后移除。\n\
|
||
礼物盒要与关键词主题匹配,可以带主题贴纸感装饰,但不能出现任何文字、人物、手、篮子或待分类物品。背景需要纯白或透明友好,便于抠图。"
|
||
),
|
||
BabyObjectMatchVisualAssetKind::Basket => format!(
|
||
"{base}\n\
|
||
生成透明 PNG 的大号篮子资源,游戏左右两侧会复用同一个篮子造型并以约 1.5 倍视觉尺寸展示。篮子主体要饱满、开口清晰、可读性高、边缘安全留白少。\n\
|
||
篮子要与关键词主题匹配,可以有主题色和贴纸感边缘,但不能出现任何文字、礼物盒、人物、手或待分类物品。背景需要纯白或透明友好,便于抠图,手柄和篮口边缘不要留下白底描边或毛边。"
|
||
),
|
||
}
|
||
}
|
||
|
||
fn build_baby_object_match_visual_negative_prompt(
|
||
kind: BabyObjectMatchVisualAssetKind,
|
||
) -> &'static str {
|
||
match kind {
|
||
BabyObjectMatchVisualAssetKind::Background => {
|
||
"文字,数字,水印,Logo,按钮,说明面板,人物,手,礼物盒,篮子,中心物品,复杂前景遮挡,真实照片风,暗黑风"
|
||
}
|
||
BabyObjectMatchVisualAssetKind::GiftBox => {
|
||
"文字,数字,水印,Logo,人物,手,篮子,待分类物品,大面积背景,场景,真实照片风"
|
||
}
|
||
BabyObjectMatchVisualAssetKind::Basket => {
|
||
"文字,数字,水印,Logo,人物,手,礼物盒,待分类物品,大面积背景,场景,真实照片风"
|
||
}
|
||
}
|
||
}
|
||
|
||
struct BabyObjectMatchSlicedSheet {
|
||
item_a: String,
|
||
item_b: String,
|
||
basket: String,
|
||
gift_box: String,
|
||
}
|
||
|
||
impl BabyObjectMatchSlicedSheet {
|
||
fn slot_data_url(&self, slot: BabyObjectMatchSheetSlot) -> &str {
|
||
match slot {
|
||
BabyObjectMatchSheetSlot::ItemA => self.item_a.as_str(),
|
||
BabyObjectMatchSheetSlot::ItemB => self.item_b.as_str(),
|
||
BabyObjectMatchSheetSlot::Basket => self.basket.as_str(),
|
||
BabyObjectMatchSheetSlot::GiftBox => self.gift_box.as_str(),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn slice_baby_object_match_sheet(
|
||
image: &DownloadedOpenAiImage,
|
||
) -> Result<BabyObjectMatchSlicedSheet, AppError> {
|
||
let png_bytes = normalize_generated_image_to_png(image.bytes.as_slice())?;
|
||
let source = image::load_from_memory_with_format(png_bytes.as_slice(), ImageFormat::Png)
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": format!("解析宝贝识物 2x2 素材 sheet 失败:{error}"),
|
||
}))
|
||
})?;
|
||
let source = apply_baby_object_match_sheet_background_alpha(source);
|
||
let (width, height) = source.dimensions();
|
||
if width < BABY_OBJECT_MATCH_SHEET_GRID_SIZE || height < BABY_OBJECT_MATCH_SHEET_GRID_SIZE {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": "宝贝识物 2x2 素材 sheet 尺寸过小,无法切割。",
|
||
})),
|
||
);
|
||
}
|
||
|
||
let mut data_urls = Vec::with_capacity(BabyObjectMatchSheetSlot::ALL.len());
|
||
for slot in BabyObjectMatchSheetSlot::ALL {
|
||
let (crop_x, crop_y, crop_width, crop_height) =
|
||
resolve_baby_object_match_sheet_cell_crop(&source, slot);
|
||
let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height);
|
||
data_urls.push(encode_baby_object_match_dynamic_image_data_url(
|
||
cropped,
|
||
slot == BabyObjectMatchSheetSlot::Basket,
|
||
)?);
|
||
}
|
||
|
||
Ok(BabyObjectMatchSlicedSheet {
|
||
item_a: data_urls.remove(0),
|
||
item_b: data_urls.remove(0),
|
||
basket: data_urls.remove(0),
|
||
gift_box: data_urls.remove(0),
|
||
})
|
||
}
|
||
|
||
fn resolve_baby_object_match_sheet_cell_crop(
|
||
source: &image::DynamicImage,
|
||
slot: BabyObjectMatchSheetSlot,
|
||
) -> (u32, u32, u32, u32) {
|
||
let (image_width, image_height) = source.dimensions();
|
||
let cell = resolve_baby_object_match_sheet_cell_bounds(
|
||
image_width,
|
||
image_height,
|
||
slot.row(),
|
||
slot.col(),
|
||
);
|
||
let Some(foreground) = detect_baby_object_match_sheet_foreground_bounds(source, cell) else {
|
||
return cell.to_crop_tuple();
|
||
};
|
||
|
||
let pad_x = (cell.width() / 14).clamp(6, 24);
|
||
let pad_y = (cell.height() / 14).clamp(6, 24);
|
||
BabyObjectMatchSheetCellBounds {
|
||
x0: foreground.x0.saturating_sub(pad_x).max(cell.x0),
|
||
y0: foreground.y0.saturating_sub(pad_y).max(cell.y0),
|
||
x1: foreground.x1.saturating_add(pad_x).min(cell.x1),
|
||
y1: foreground.y1.saturating_add(pad_y).min(cell.y1),
|
||
}
|
||
.to_crop_tuple()
|
||
}
|
||
|
||
fn resolve_baby_object_match_sheet_cell_bounds(
|
||
image_width: u32,
|
||
image_height: u32,
|
||
row: u32,
|
||
col: u32,
|
||
) -> BabyObjectMatchSheetCellBounds {
|
||
let cell_x0 = col.saturating_mul(image_width) / BABY_OBJECT_MATCH_SHEET_GRID_SIZE;
|
||
let cell_x1 =
|
||
(col.saturating_add(1)).saturating_mul(image_width) / BABY_OBJECT_MATCH_SHEET_GRID_SIZE;
|
||
let cell_y0 = row.saturating_mul(image_height) / BABY_OBJECT_MATCH_SHEET_GRID_SIZE;
|
||
let cell_y1 =
|
||
(row.saturating_add(1)).saturating_mul(image_height) / BABY_OBJECT_MATCH_SHEET_GRID_SIZE;
|
||
|
||
BabyObjectMatchSheetCellBounds {
|
||
x0: cell_x0.min(image_width.saturating_sub(1)),
|
||
y0: cell_y0.min(image_height.saturating_sub(1)),
|
||
x1: cell_x1.clamp(cell_x0.saturating_add(1), image_width),
|
||
y1: cell_y1.clamp(cell_y0.saturating_add(1), image_height),
|
||
}
|
||
}
|
||
|
||
fn detect_baby_object_match_sheet_foreground_bounds(
|
||
source: &image::DynamicImage,
|
||
cell: BabyObjectMatchSheetCellBounds,
|
||
) -> Option<BabyObjectMatchSheetCellBounds> {
|
||
let mut foreground: Option<BabyObjectMatchSheetCellBounds> = None;
|
||
let mut foreground_pixels = 0u32;
|
||
|
||
for y in cell.y0..cell.y1 {
|
||
for x in cell.x0..cell.x1 {
|
||
let pixel = source.get_pixel(x, y).0;
|
||
if pixel[3] <= 24 {
|
||
continue;
|
||
}
|
||
foreground_pixels = foreground_pixels.saturating_add(1);
|
||
foreground = Some(match foreground {
|
||
Some(bounds) => BabyObjectMatchSheetCellBounds {
|
||
x0: bounds.x0.min(x),
|
||
y0: bounds.y0.min(y),
|
||
x1: bounds.x1.max(x.saturating_add(1)),
|
||
y1: bounds.y1.max(y.saturating_add(1)),
|
||
},
|
||
None => BabyObjectMatchSheetCellBounds {
|
||
x0: x,
|
||
y0: y,
|
||
x1: x.saturating_add(1),
|
||
y1: y.saturating_add(1),
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
let min_foreground_pixels = (cell.area() / 360).clamp(16, 240);
|
||
foreground.filter(|bounds| {
|
||
foreground_pixels >= min_foreground_pixels && bounds.width() > 2 && bounds.height() > 2
|
||
})
|
||
}
|
||
|
||
fn apply_baby_object_match_sheet_background_alpha(
|
||
source: image::DynamicImage,
|
||
) -> image::DynamicImage {
|
||
let mut image = source.to_rgba8();
|
||
let (width, height) = image.dimensions();
|
||
if width == 0 || height == 0 {
|
||
return image::DynamicImage::ImageRgba8(image);
|
||
}
|
||
|
||
for slot in BabyObjectMatchSheetSlot::ALL {
|
||
let cell =
|
||
resolve_baby_object_match_sheet_cell_bounds(width, height, slot.row(), slot.col());
|
||
remove_baby_object_match_sheet_cell_background(
|
||
image.as_mut(),
|
||
width as usize,
|
||
height as usize,
|
||
cell,
|
||
);
|
||
}
|
||
|
||
image::DynamicImage::ImageRgba8(image)
|
||
}
|
||
|
||
fn remove_baby_object_match_sheet_cell_background(
|
||
pixels: &mut [u8],
|
||
image_width: usize,
|
||
image_height: usize,
|
||
cell: BabyObjectMatchSheetCellBounds,
|
||
) -> bool {
|
||
let pixel_count = image_width.saturating_mul(image_height);
|
||
if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) {
|
||
return false;
|
||
}
|
||
|
||
let background =
|
||
sample_baby_object_match_sheet_cell_background(pixels, image_width, image_height, cell);
|
||
let mut background_mask = vec![0u8; pixel_count];
|
||
let mut queue = Vec::<usize>::new();
|
||
let mut queue_index = 0usize;
|
||
let mut changed = false;
|
||
|
||
let mut seed_background_pixel = |x: u32, y: u32| {
|
||
let pixel_index = y as usize * image_width + x as usize;
|
||
if background_mask[pixel_index] != 0 {
|
||
return;
|
||
}
|
||
let offset = pixel_index * 4;
|
||
let pixel = [
|
||
pixels[offset],
|
||
pixels[offset + 1],
|
||
pixels[offset + 2],
|
||
pixels[offset + 3],
|
||
];
|
||
if !is_baby_object_match_sheet_background_pixel(pixel, background) {
|
||
return;
|
||
}
|
||
background_mask[pixel_index] = 1;
|
||
queue.push(pixel_index);
|
||
};
|
||
|
||
for x in cell.x0..cell.x1 {
|
||
seed_background_pixel(x, cell.y0);
|
||
seed_background_pixel(x, cell.y1.saturating_sub(1));
|
||
}
|
||
for y in cell.y0.saturating_add(1)..cell.y1.saturating_sub(1) {
|
||
seed_background_pixel(cell.x0, y);
|
||
seed_background_pixel(cell.x1.saturating_sub(1), y);
|
||
}
|
||
|
||
while queue_index < queue.len() {
|
||
let pixel_index = queue[queue_index];
|
||
queue_index += 1;
|
||
|
||
let x = (pixel_index % image_width) as u32;
|
||
let y = (pixel_index / image_width) as u32;
|
||
let neighbors = [
|
||
(x.saturating_sub(1), y, x > cell.x0),
|
||
(x.saturating_add(1), y, x + 1 < cell.x1),
|
||
(x, y.saturating_sub(1), y > cell.y0),
|
||
(x, y.saturating_add(1), y + 1 < cell.y1),
|
||
];
|
||
|
||
for (next_x, next_y, within_cell) in neighbors {
|
||
if !within_cell || next_x as usize >= image_width || next_y as usize >= image_height {
|
||
continue;
|
||
}
|
||
let next_pixel_index = next_y as usize * image_width + next_x as usize;
|
||
if background_mask[next_pixel_index] != 0 {
|
||
continue;
|
||
}
|
||
let offset = next_pixel_index * 4;
|
||
let pixel = [
|
||
pixels[offset],
|
||
pixels[offset + 1],
|
||
pixels[offset + 2],
|
||
pixels[offset + 3],
|
||
];
|
||
if !is_baby_object_match_sheet_background_pixel(pixel, background) {
|
||
continue;
|
||
}
|
||
background_mask[next_pixel_index] = 1;
|
||
queue.push(next_pixel_index);
|
||
}
|
||
}
|
||
|
||
for y in cell.y0..cell.y1 {
|
||
for x in cell.x0..cell.x1 {
|
||
let pixel_index = y as usize * image_width + x as usize;
|
||
if background_mask[pixel_index] == 0 {
|
||
continue;
|
||
}
|
||
let alpha_offset = pixel_index * 4 + 3;
|
||
if pixels[alpha_offset] != 0 {
|
||
pixels[alpha_offset] = 0;
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
changed
|
||
}
|
||
|
||
fn sample_baby_object_match_sheet_cell_background(
|
||
pixels: &[u8],
|
||
image_width: usize,
|
||
image_height: usize,
|
||
cell: BabyObjectMatchSheetCellBounds,
|
||
) -> [u8; 4] {
|
||
let sample_size = (cell.width().min(cell.height()) / 12).clamp(2, 8);
|
||
let sample_points = [
|
||
(cell.x0, cell.y0),
|
||
(cell.x1.saturating_sub(sample_size), cell.y0),
|
||
(cell.x0, cell.y1.saturating_sub(sample_size)),
|
||
(
|
||
cell.x1.saturating_sub(sample_size),
|
||
cell.y1.saturating_sub(sample_size),
|
||
),
|
||
];
|
||
let mut samples = Vec::new();
|
||
|
||
for (start_x, start_y) in sample_points {
|
||
let mut totals = [0u32; 4];
|
||
let mut count = 0u32;
|
||
for y in start_y..start_y.saturating_add(sample_size).min(cell.y1) {
|
||
if y as usize >= image_height {
|
||
continue;
|
||
}
|
||
for x in start_x..start_x.saturating_add(sample_size).min(cell.x1) {
|
||
if x as usize >= image_width {
|
||
continue;
|
||
}
|
||
let offset = (y as usize * image_width + x as usize) * 4;
|
||
totals[0] = totals[0].saturating_add(pixels[offset] as u32);
|
||
totals[1] = totals[1].saturating_add(pixels[offset + 1] as u32);
|
||
totals[2] = totals[2].saturating_add(pixels[offset + 2] as u32);
|
||
totals[3] = totals[3].saturating_add(pixels[offset + 3] as u32);
|
||
count = count.saturating_add(1);
|
||
}
|
||
}
|
||
if count > 0 {
|
||
samples.push([
|
||
(totals[0] / count) as u8,
|
||
(totals[1] / count) as u8,
|
||
(totals[2] / count) as u8,
|
||
(totals[3] / count) as u8,
|
||
]);
|
||
}
|
||
}
|
||
|
||
samples
|
||
.into_iter()
|
||
.min_by_key(|sample| {
|
||
let luminance = sample[0] as u16 + sample[1] as u16 + sample[2] as u16;
|
||
(sample[3] as u16, u16::MAX.saturating_sub(luminance))
|
||
})
|
||
.unwrap_or([255, 255, 255, 255])
|
||
}
|
||
|
||
fn is_baby_object_match_sheet_background_pixel(pixel: [u8; 4], background: [u8; 4]) -> bool {
|
||
if pixel[3] <= 32 {
|
||
return true;
|
||
}
|
||
if background[3] <= 32 {
|
||
return pixel[3] <= 48;
|
||
}
|
||
|
||
let color_diff = (pixel[0] as i32 - background[0] as i32).abs()
|
||
+ (pixel[1] as i32 - background[1] as i32).abs()
|
||
+ (pixel[2] as i32 - background[2] as i32).abs();
|
||
if color_diff <= 58 {
|
||
return true;
|
||
}
|
||
|
||
let background_luminance = background[0] as u16 + background[1] as u16 + background[2] as u16;
|
||
let pixel_luminance = pixel[0] as u16 + pixel[1] as u16 + pixel[2] as u16;
|
||
background_luminance >= 720 && pixel_luminance >= 720 && color_diff <= 96
|
||
}
|
||
|
||
impl BabyObjectMatchSheetCellBounds {
|
||
fn width(self) -> u32 {
|
||
self.x1.saturating_sub(self.x0).max(1)
|
||
}
|
||
|
||
fn height(self) -> u32 {
|
||
self.y1.saturating_sub(self.y0).max(1)
|
||
}
|
||
|
||
fn area(self) -> u32 {
|
||
self.width().saturating_mul(self.height())
|
||
}
|
||
|
||
fn to_crop_tuple(self) -> (u32, u32, u32, u32) {
|
||
(self.x0, self.y0, self.width(), self.height())
|
||
}
|
||
}
|
||
|
||
fn build_png_data_url(image: DownloadedOpenAiImage) -> Result<String, AppError> {
|
||
let png_bytes = normalize_generated_image_to_png(image.bytes.as_slice())?;
|
||
Ok(format!(
|
||
"data:image/png;base64,{}",
|
||
BASE64_STANDARD.encode(png_bytes)
|
||
))
|
||
}
|
||
|
||
fn normalize_generated_image_to_png(source: &[u8]) -> Result<Vec<u8>, AppError> {
|
||
let rgba_image = image::load_from_memory(source)
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": format!("解析宝贝识物物品图片失败:{error}"),
|
||
}))
|
||
})?
|
||
.to_rgba8();
|
||
let (width, height) = rgba_image.dimensions();
|
||
let mut encoded = Vec::new();
|
||
let encoder = PngEncoder::new(&mut encoded);
|
||
encoder
|
||
.write_image(rgba_image.as_raw(), width, height, ColorType::Rgba8.into())
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": format!("转换宝贝识物物品图片为 PNG 失败:{error}"),
|
||
}))
|
||
})?;
|
||
|
||
Ok(encoded)
|
||
}
|
||
|
||
fn encode_baby_object_match_dynamic_image_data_url(
|
||
image: image::DynamicImage,
|
||
clean_white_edge_matte: bool,
|
||
) -> Result<String, AppError> {
|
||
let mut rgba_image = image.to_rgba8();
|
||
if clean_white_edge_matte {
|
||
remove_baby_object_match_basket_white_matte(&mut rgba_image);
|
||
}
|
||
let (width, height) = rgba_image.dimensions();
|
||
let mut encoded = Vec::new();
|
||
let encoder = PngEncoder::new(&mut encoded);
|
||
encoder
|
||
.write_image(rgba_image.as_raw(), width, height, ColorType::Rgba8.into())
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": format!("编码宝贝识物 2x2 素材切图失败:{error}"),
|
||
}))
|
||
})?;
|
||
Ok(format!(
|
||
"data:image/png;base64,{}",
|
||
BASE64_STANDARD.encode(encoded)
|
||
))
|
||
}
|
||
|
||
fn remove_baby_object_match_basket_white_matte(image: &mut image::RgbaImage) -> bool {
|
||
let mut changed = false;
|
||
for pixel in image.pixels_mut() {
|
||
let [red, green, blue, alpha] = pixel.0;
|
||
if alpha <= 24 {
|
||
continue;
|
||
}
|
||
|
||
let max_channel = red.max(green).max(blue);
|
||
let min_channel = red.min(green).min(blue);
|
||
let luminance = red as u16 + green as u16 + blue as u16;
|
||
let channel_spread = max_channel.saturating_sub(min_channel);
|
||
if luminance < 690 || channel_spread > 42 {
|
||
continue;
|
||
}
|
||
|
||
let next_alpha = if luminance >= 735 && channel_spread <= 30 {
|
||
0
|
||
} else {
|
||
((alpha as f32) * 0.18).round() as u8
|
||
};
|
||
if next_alpha != alpha {
|
||
pixel.0[3] = next_alpha;
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
changed
|
||
}
|
||
|
||
fn baby_object_match_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||
error.into_response_with_context(Some(request_context))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn sheet_prompt_locks_two_by_two_asset_layout() {
|
||
let names = vec!["苹果".to_string(), "香蕉".to_string()];
|
||
let theme_prompt = build_baby_object_match_visual_theme_prompt(names.as_slice());
|
||
let prompt = build_baby_object_match_sheet_prompt(names.as_slice(), theme_prompt.as_str());
|
||
|
||
assert!(prompt.contains("苹果"));
|
||
assert!(prompt.contains("香蕉"));
|
||
assert!(prompt.contains("2x2"));
|
||
assert!(prompt.contains("左上格"));
|
||
assert!(prompt.contains("右上格"));
|
||
assert!(prompt.contains("左下格"));
|
||
assert!(prompt.contains("右下格"));
|
||
assert!(prompt.contains("卡通绘本"));
|
||
assert!(prompt.contains("白底描边"));
|
||
assert!(prompt.contains("纯白"));
|
||
}
|
||
|
||
#[test]
|
||
fn visual_theme_prompt_maps_fruit_keywords_to_nature_theme() {
|
||
let names = vec!["苹果".to_string(), "橘子".to_string()];
|
||
let prompt = build_baby_object_match_visual_theme_prompt(names.as_slice());
|
||
|
||
assert!(prompt.contains("寓教于乐"));
|
||
assert!(prompt.contains("卡通绘本"));
|
||
assert!(prompt.contains("果园"));
|
||
assert!(prompt.contains("自然"));
|
||
}
|
||
|
||
#[test]
|
||
fn visual_theme_prompt_maps_character_keywords_to_toy_theme() {
|
||
let names = vec!["小猪佩琪".to_string(), "奥特曼".to_string()];
|
||
let prompt = build_baby_object_match_visual_theme_prompt(names.as_slice());
|
||
|
||
assert!(prompt.contains("寓教于乐"));
|
||
assert!(prompt.contains("动漫"));
|
||
assert!(prompt.contains("玩具"));
|
||
}
|
||
|
||
#[test]
|
||
fn visual_asset_prompt_keeps_background_clear_for_playfield() {
|
||
let names = vec!["苹果".to_string(), "香蕉".to_string()];
|
||
let theme_prompt = build_baby_object_match_visual_theme_prompt(names.as_slice());
|
||
let prompt = build_baby_object_match_visual_asset_prompt(
|
||
BabyObjectMatchVisualAssetKind::Background,
|
||
names.as_slice(),
|
||
theme_prompt.as_str(),
|
||
);
|
||
|
||
assert!(prompt.contains("背景环境图"));
|
||
assert!(prompt.contains("中间"));
|
||
assert!(prompt.contains("屏幕中下方"));
|
||
assert!(prompt.contains("无文字"));
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_item_names_requires_two_non_empty_names() {
|
||
let names = normalize_item_names(vec![" 苹果 ".to_string(), "香蕉".to_string()])
|
||
.expect("two names should be valid");
|
||
|
||
assert_eq!(names, vec!["苹果".to_string(), "香蕉".to_string()]);
|
||
assert!(normalize_item_names(vec!["苹果".to_string()]).is_err());
|
||
assert!(normalize_item_names(vec!["苹果".to_string(), " ".to_string()]).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn baby_object_match_image_timeout_keeps_long_generation_alive() {
|
||
let settings = with_baby_object_match_image_timeout(OpenAiImageSettings {
|
||
base_url: "https://vector.example".to_string(),
|
||
api_key: "secret".to_string(),
|
||
request_timeout_ms: 180_000,
|
||
});
|
||
|
||
assert_eq!(
|
||
settings.request_timeout_ms,
|
||
DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn slices_two_by_two_sheet_into_transparent_asset_data_urls() {
|
||
let width = 96;
|
||
let height = 96;
|
||
let mut sheet =
|
||
image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255]));
|
||
let slots = [
|
||
(8..40, 8..40, [220, 32, 48, 255]),
|
||
(56..88, 8..40, [250, 210, 70, 255]),
|
||
(8..40, 56..88, [42, 142, 92, 255]),
|
||
(56..88, 56..88, [92, 120, 230, 255]),
|
||
];
|
||
for (xs, ys, color) in slots {
|
||
for y in ys {
|
||
for x in xs.clone() {
|
||
sheet.put_pixel(x, y, image::Rgba(color));
|
||
}
|
||
}
|
||
}
|
||
let mut bytes = Vec::new();
|
||
let encoder = PngEncoder::new(&mut bytes);
|
||
encoder
|
||
.write_image(sheet.as_raw(), width, height, ColorType::Rgba8.into())
|
||
.expect("test sheet should encode");
|
||
|
||
let sliced = slice_baby_object_match_sheet(&DownloadedOpenAiImage {
|
||
bytes,
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
})
|
||
.expect("sheet should slice");
|
||
|
||
for slot in BabyObjectMatchSheetSlot::ALL {
|
||
let data_url = sliced.slot_data_url(slot);
|
||
assert!(data_url.starts_with("data:image/png;base64,"));
|
||
let payload = data_url
|
||
.strip_prefix("data:image/png;base64,")
|
||
.expect("data url should include png prefix");
|
||
let png_bytes = BASE64_STANDARD
|
||
.decode(payload)
|
||
.expect("data url should decode");
|
||
let decoded = image::load_from_memory(png_bytes.as_slice())
|
||
.expect("slice should decode")
|
||
.to_rgba8();
|
||
assert!(
|
||
decoded.pixels().any(|pixel| pixel[3] == 0),
|
||
"sheet white background should become transparent"
|
||
);
|
||
assert!(
|
||
decoded.pixels().any(|pixel| pixel[3] > 200),
|
||
"slice should keep foreground pixels"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn basket_white_matte_cleanup_removes_enclosed_white_handle_fill() {
|
||
let mut basket = image::RgbaImage::from_pixel(48, 48, image::Rgba([0, 0, 0, 0]));
|
||
for y in 12..36 {
|
||
for x in 8..40 {
|
||
basket.put_pixel(x, y, image::Rgba([186, 92, 24, 255]));
|
||
}
|
||
}
|
||
for y in 4..14 {
|
||
for x in 12..20 {
|
||
basket.put_pixel(x, y, image::Rgba([252, 251, 246, 255]));
|
||
}
|
||
}
|
||
for y in 4..14 {
|
||
for x in 28..36 {
|
||
basket.put_pixel(x, y, image::Rgba([249, 248, 242, 255]));
|
||
}
|
||
}
|
||
|
||
assert!(remove_baby_object_match_basket_white_matte(&mut basket));
|
||
assert_eq!(basket.get_pixel(16, 8).0[3], 0);
|
||
assert_eq!(basket.get_pixel(32, 8).0[3], 0);
|
||
assert_eq!(basket.get_pixel(20, 20).0[3], 255);
|
||
}
|
||
}
|