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, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct GenerateBabyObjectMatchAssetsResponse { assets: Vec, 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, 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, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct BabyObjectMatchVisualAssetPayload { asset_id: String, asset_kind: String, image_src: String, asset_object_id: Option, generation_provider: String, prompt: String, } #[derive(Debug)] struct BabyObjectMatchSheetAssets { items: Vec, 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, Extension(request_context): Extension, payload: Result, JsonRejection>, ) -> Result, 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) -> Result, AppError> { let normalized = item_names .into_iter() .map(|value| value.trim().to_string()) .collect::>(); 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 { 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 { 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 { 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 { let mut foreground: Option = 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::::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 { 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, 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 { 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); } }