Files
Genarrative/server-rs/crates/api-server/src/edutainment_baby_object.rs

1140 lines
41 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}