Merge remote-tracking branch 'origin/master' into codex/public-work-readmodel-smooth-transition
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user