fix: stabilize match3d demo discovery

This commit is contained in:
2026-05-26 00:13:08 +08:00
parent 5d3e2ac111
commit f79a6ea81e
123 changed files with 1778 additions and 233 deletions

View File

@@ -247,8 +247,14 @@ pub(crate) fn slice_generated_asset_sheet_two_items_per_row(
let items_per_row = grid_size / views_per_item;
let max_item_count = grid_size.saturating_mul(items_per_row);
let mut slices = Vec::with_capacity(item_names.len().min(max_item_count));
for item_index in 0..item_names.len().min(max_item_count) {
let item_count = item_names.len().min(max_item_count);
if let Some(slices) =
slice_generated_asset_sheet_by_alpha_components(&source, item_count, views_per_item)?
{
return Ok(slices);
}
let mut slices = Vec::with_capacity(item_count);
for item_index in 0..item_count {
let row = (item_index / items_per_row) as u32;
let start_col = ((item_index % items_per_row) * views_per_item) as u32;
let mut views = Vec::with_capacity(views_per_item);
@@ -469,6 +475,12 @@ struct GeneratedAssetSheetCellBounds {
y1: u32,
}
#[derive(Clone, Copy, Debug)]
struct GeneratedAssetSheetDetectedComponent {
bounds: GeneratedAssetSheetCellBounds,
area: u32,
}
impl GeneratedAssetSheetCellBounds {
fn width(self) -> u32 {
self.x1.saturating_sub(self.x0).max(1)
@@ -487,6 +499,272 @@ impl GeneratedAssetSheetCellBounds {
}
}
fn detect_generated_asset_sheet_alpha_components(
image: &image::RgbaImage,
) -> Vec<GeneratedAssetSheetDetectedComponent> {
let (width, height) = image.dimensions();
let pixel_count = width.saturating_mul(height) as usize;
if width == 0 || height == 0 || pixel_count == 0 {
return Vec::new();
}
let mut visited = vec![0u8; pixel_count];
let min_area = resolve_generated_asset_sheet_alpha_component_min_area(width, height);
let mut components = Vec::new();
for start in 0..pixel_count {
if visited[start] != 0 {
continue;
}
let start_pixel = image
.get_pixel(start as u32 % width, start as u32 / width)
.0;
if !is_generated_asset_sheet_visible_pixel(start_pixel) {
visited[start] = 1;
continue;
}
let component =
flood_fill_generated_asset_sheet_alpha_component(image, &mut visited, start);
if component.area >= min_area {
components.push(component);
}
}
components
}
fn slice_generated_asset_sheet_by_alpha_components(
source: &image::DynamicImage,
item_count: usize,
views_per_item: usize,
) -> Result<Option<Vec<Vec<GeneratedAssetSheetSliceImage>>>, AppError> {
if item_count == 0 {
return Ok(Some(Vec::new()));
}
let sheet_grid_size = views_per_item.checked_mul(2).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": GENERATED_ASSET_SHEET_PROVIDER,
"message": "系列素材图集的每物品视图数超出可支持范围。",
}))
})?;
let sheet_grid_size_u32 = u32::try_from(sheet_grid_size).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": GENERATED_ASSET_SHEET_PROVIDER,
"message": "系列素材图集的每物品视图数超出可支持范围。",
}))
})?;
let source = source.to_rgba8();
let (width, height) = source.dimensions();
if width == 0 || height == 0 {
return Ok(None);
}
let cell_width = width / sheet_grid_size_u32;
let cell_height = height / sheet_grid_size_u32;
if cell_width == 0 || cell_height == 0 {
return Ok(None);
}
let components = detect_generated_asset_sheet_alpha_components(&source);
let expected_slot_count = item_count.saturating_mul(views_per_item);
if components.len() < expected_slot_count {
return Ok(None);
}
let components = sort_generated_asset_sheet_components_by_original_position(
components,
cell_height as f32 * 0.65,
);
let components = components
.into_iter()
.take(expected_slot_count)
.collect::<Vec<_>>();
if components.len() < expected_slot_count {
return Ok(None);
}
let mut slices = Vec::with_capacity(item_count);
for item_index in 0..item_count {
let mut views = Vec::with_capacity(views_per_item);
for view_index in 0..views_per_item {
let slot_index = item_index * views_per_item + view_index;
let component = &components[slot_index];
let pad_x = (cell_width / 16).clamp(4, 16);
let pad_y = (cell_height / 16).clamp(4, 16);
let crop_x = component.bounds.x0.saturating_sub(pad_x);
let crop_y = component.bounds.y0.saturating_sub(pad_y);
let crop_x1 = component.bounds.x1.saturating_add(pad_x).min(width);
let crop_y1 = component.bounds.y1.saturating_add(pad_y).min(height);
let cropped = image::DynamicImage::ImageRgba8(
image::imageops::crop_imm(
&source,
crop_x,
crop_y,
crop_x1.saturating_sub(crop_x).max(1),
crop_y1.saturating_sub(crop_y).max(1),
)
.to_image(),
);
let mut cursor = std::io::Cursor::new(Vec::new());
cropped
.write_to(&mut cursor, ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": GENERATED_ASSET_SHEET_PROVIDER,
"message": format!("系列素材图集切割失败:{error}"),
}))
})?;
views.push(GeneratedAssetSheetSliceImage {
bytes: cursor.into_inner(),
});
}
slices.push(views);
}
Ok(Some(slices))
}
fn resolve_generated_asset_sheet_alpha_component_min_area(width: u32, height: u32) -> u32 {
(width.saturating_mul(height) / 12_000).clamp(16, 800)
}
fn flood_fill_generated_asset_sheet_alpha_component(
image: &image::RgbaImage,
visited: &mut [u8],
start: usize,
) -> GeneratedAssetSheetDetectedComponent {
let (width, height) = image.dimensions();
let mut stack = vec![start];
visited[start] = 1;
let mut min_x = start as u32 % width;
let mut max_x = min_x;
let mut min_y = start as u32 / width;
let mut max_y = min_y;
let mut area = 0u32;
while let Some(index) = stack.pop() {
let x = index as u32 % width;
let y = index as u32 / width;
area = area.saturating_add(1);
min_x = min_x.min(x);
max_x = max_x.max(x);
min_y = min_y.min(y);
max_y = max_y.max(y);
visit_generated_asset_sheet_alpha_component_neighbor(
image,
visited,
&mut stack,
index.wrapping_sub(1),
x > 0,
);
visit_generated_asset_sheet_alpha_component_neighbor(
image,
visited,
&mut stack,
index + 1,
x + 1 < width,
);
visit_generated_asset_sheet_alpha_component_neighbor(
image,
visited,
&mut stack,
index.saturating_sub(width as usize),
y > 0,
);
visit_generated_asset_sheet_alpha_component_neighbor(
image,
visited,
&mut stack,
index + width as usize,
y + 1 < height,
);
}
GeneratedAssetSheetDetectedComponent {
bounds: GeneratedAssetSheetCellBounds {
x0: min_x,
y0: min_y,
x1: max_x.saturating_add(1),
y1: max_y.saturating_add(1),
},
area,
}
}
fn visit_generated_asset_sheet_alpha_component_neighbor(
image: &image::RgbaImage,
visited: &mut [u8],
stack: &mut Vec<usize>,
index: usize,
in_bounds: bool,
) {
if !in_bounds || visited.get(index).copied().unwrap_or(1) != 0 {
return;
}
let (width, _) = image.dimensions();
let pixel = image
.get_pixel(index as u32 % width, index as u32 / width)
.0;
if !is_generated_asset_sheet_visible_pixel(pixel) {
visited[index] = 1;
return;
}
visited[index] = 1;
stack.push(index);
}
fn sort_generated_asset_sheet_components_by_original_position(
components: Vec<GeneratedAssetSheetDetectedComponent>,
row_tolerance_hint: f32,
) -> Vec<GeneratedAssetSheetDetectedComponent> {
if components.is_empty() {
return components;
}
let average_height = components
.iter()
.map(|component| component.bounds.height() as f32)
.sum::<f32>()
/ components.len() as f32;
let row_tolerance = row_tolerance_hint.max(average_height * 0.65).max(2.0);
let mut rows: Vec<Vec<GeneratedAssetSheetDetectedComponent>> = Vec::new();
let mut sorted = components;
sorted.sort_by(|left, right| {
left.bounds
.y0
.cmp(&right.bounds.y0)
.then_with(|| left.bounds.x0.cmp(&right.bounds.x0))
});
for component in sorted {
let center_y = component.bounds.y0 as f32 + component.bounds.height() as f32 / 2.0;
if let Some(row) = rows.iter_mut().find(|items| {
let row_center = items
.iter()
.map(|item| item.bounds.y0 as f32 + item.bounds.height() as f32 / 2.0)
.sum::<f32>()
/ items.len() as f32;
(row_center - center_y).abs() <= row_tolerance
}) {
row.push(component);
} else {
rows.push(vec![component]);
}
}
rows.into_iter()
.flat_map(|mut row| {
row.sort_by(|left, right| left.bounds.x0.cmp(&right.bounds.x0));
row
})
.collect()
}
fn resolve_generated_asset_sheet_cell_crop(
source: &image::DynamicImage,
grid_size: u32,
@@ -1674,6 +1952,193 @@ mod tests {
assert_eq!(slices[1].len(), 5);
}
#[test]
fn generated_asset_sheet_two_items_per_row_uses_alpha_components_when_views_cross_cell_boundaries() {
let width = 1_000;
let height = 1_000;
let item_names = vec!["樱桃".to_string(), "苹果".to_string()];
let colors = [
([220, 20, 24, 255], [246, 178, 46, 255]),
([230, 88, 20, 255], [248, 196, 78, 255]),
([240, 170, 28, 255], [250, 214, 110, 255]),
([90, 180, 54, 255], [188, 236, 86, 255]),
([20, 150, 200, 255], [92, 214, 248, 255]),
([70, 96, 220, 255], [124, 150, 252, 255]),
([150, 80, 210, 255], [188, 118, 248, 255]),
([210, 80, 170, 255], [248, 130, 204, 255]),
([140, 92, 48, 255], [190, 136, 82, 255]),
([34, 34, 34, 255], [88, 88, 88, 255]),
];
let positions = [
(90u32, 40u32),
(190, 44),
(290, 38),
(390, 42),
(490, 36),
(590, 540),
(690, 536),
(790, 544),
(890, 538),
(960, 542),
];
let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 0]));
for (index, (left_color, right_color)) in colors.iter().enumerate() {
let (start_x, start_y) = positions[index];
for y in start_y..start_y + 36 {
for x in start_x..start_x + 16 {
sheet.put_pixel(x, y, image::Rgba(*left_color));
}
for x in start_x + 16..start_x + 32 {
sheet.put_pixel(x, y, image::Rgba(*right_color));
}
}
}
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(sheet)
.write_to(&mut encoded, ImageFormat::Png)
.expect("sheet should encode");
let image = DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let slices = slice_generated_asset_sheet_two_items_per_row(&image, &item_names, 10, 5)
.expect("sheet should slice");
assert_eq!(slices.len(), 2);
assert_eq!(slices[0].len(), 5);
assert_eq!(slices[1].len(), 5);
for (index, (left_color, right_color)) in colors.iter().enumerate() {
let item_index = index / 5;
let view_index = index % 5;
let decoded = image::load_from_memory(slices[item_index][view_index].bytes.as_slice())
.expect("view should decode")
.to_rgba8();
assert!(
decoded.pixels().any(|pixel| pixel.0 == *left_color),
"第 {index} 个格位应保留左侧主体颜色"
);
assert!(
decoded.pixels().any(|pixel| pixel.0 == *right_color),
"第 {index} 个格位应保留右侧主体颜色"
);
}
}
#[test]
fn generated_asset_sheet_two_items_per_row_keeps_cell_order_when_views_are_vertically_scrambled()
{
let width = 1_000;
let height = 1_000;
let item_names = vec!["樱桃".to_string(), "苹果".to_string()];
let colors = [
[220, 20, 24, 255],
[230, 88, 20, 255],
[240, 170, 28, 255],
[90, 180, 54, 255],
[20, 150, 200, 255],
[70, 96, 220, 255],
[150, 80, 210, 255],
[210, 80, 170, 255],
[140, 92, 48, 255],
[34, 34, 34, 255],
];
let top_offsets = [62u32, 18, 74, 26, 68, 20, 72, 24, 66, 22];
let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 0]));
for (index, color) in colors.iter().enumerate() {
let start_x = index as u32 * 100 + 34;
let start_y = top_offsets[index];
for y in start_y..start_y + 36 {
for x in start_x..start_x + 32 {
sheet.put_pixel(x, y, image::Rgba(*color));
}
}
}
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(sheet)
.write_to(&mut encoded, ImageFormat::Png)
.expect("sheet should encode");
let image = DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let slices = slice_generated_asset_sheet_two_items_per_row(&image, &item_names, 10, 5)
.expect("sheet should slice");
assert_eq!(slices.len(), 2);
assert_eq!(slices[0].len(), 5);
assert_eq!(slices[1].len(), 5);
for (index, color) in colors.iter().enumerate() {
let item_index = index / 5;
let view_index = index % 5;
let decoded = image::load_from_memory(slices[item_index][view_index].bytes.as_slice())
.expect("view should decode")
.to_rgba8();
assert!(
decoded.pixels().any(|pixel| pixel.0 == *color),
"第 {index} 个格位应按列顺序保留对应主体颜色"
);
}
}
#[test]
fn generated_asset_sheet_two_items_per_row_falls_back_to_fixed_grid_when_alpha_components_are_insufficient()
{
let width = 1_000;
let height = 1_000;
let item_names = vec!["樱桃".to_string(), "苹果".to_string()];
let mut sheet = image::RgbaImage::new(width, height);
for row in 0..10 {
for col in 0..10 {
let color = image::Rgba([
16 + row as u8 * 20,
12 + col as u8 * 18,
230 - row as u8 * 12,
255,
]);
for y in row * 100..(row + 1) * 100 {
for x in col * 100..(col + 1) * 100 {
sheet.put_pixel(x, y, color);
}
}
}
}
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(sheet)
.write_to(&mut encoded, ImageFormat::Png)
.expect("sheet should encode");
let image = DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let slices = slice_generated_asset_sheet_two_items_per_row(&image, &item_names, 10, 5)
.expect("sheet should slice");
assert_eq!(slices.len(), 2);
assert_eq!(slices[0].len(), 5);
assert_eq!(slices[1].len(), 5);
for (item_index, views) in slices.iter().enumerate() {
for (view_index, view) in views.iter().enumerate() {
let decoded = image::load_from_memory(view.bytes.as_slice())
.expect("view should decode")
.to_rgba8();
let pixel = decoded.get_pixel(decoded.width() / 2, decoded.height() / 2);
let row = 0u8;
let col = (item_index * 5 + view_index) as u8;
assert_eq!(
pixel.0,
[16 + row * 20, 12 + col * 18, 230 - row * 12, 255,],
"item {item_index} view {view_index} should keep the fixed grid fallback"
);
}
}
}
#[test]
fn generated_asset_sheet_prepare_put_request_packs_prompt_metadata() {
let request = prepare_generated_asset_sheet_put_request(GeneratedAssetSheetPersistInput {