diff --git a/imageflow_core/src/graphics/bitmaps.rs b/imageflow_core/src/graphics/bitmaps.rs index 8fb5bd44..558d579f 100644 --- a/imageflow_core/src/graphics/bitmaps.rs +++ b/imageflow_core/src/graphics/bitmaps.rs @@ -301,6 +301,25 @@ impl<'a,T> BitmapWindowMut<'a, T> { } } +impl<'a> BitmapWindowMut<'a, u8> { + + pub fn fill_rectangle(&mut self, color: imageflow_helpers::colors::Color32, x: u32, y: u32, x2: u32, y2: u32) -> Result<(), FlowError>{ + if y2 == y || x2 == x { return Ok(()); } // Don't fail on zero width rect + if y2 < y || x2 < x || x2 > self.w() || y2 > self.h(){ + return Err(nerror!(ErrorKind::InvalidArgument, "Coordinates must be within image dimensions")); + } + if self.info().pixel_layout() != PixelLayout::BGRA { + return Err(nerror!(ErrorKind::InvalidArgument, "Only BGRA supported for rounded corners")); + } + let bgra = color.to_bgra8(); + for y in y..y2 { + let mut row_window = self.row_window(y).unwrap(); + let row_pixels = row_window.slice_of_pixels_first_row().unwrap(); + row_pixels[x as usize..x2 as usize].fill(bgra.clone()); + } + Ok(()) + } +} impl Bitmap{ pub fn get_window_u8(&mut self) -> Option>{ diff --git a/imageflow_core/src/graphics/rounded_corners.rs b/imageflow_core/src/graphics/rounded_corners.rs index 331f1483..5a57ad36 100644 --- a/imageflow_core/src/graphics/rounded_corners.rs +++ b/imageflow_core/src/graphics/rounded_corners.rs @@ -1,78 +1,318 @@ use imageflow_types::{Color, RoundCornersMode}; use crate::graphics::prelude::*; +fn get_radius(radius: RoundCornersMode, w: u32, h: u32) -> RoundCornersRadius{ + let smallest_dimension = w.min(h) as f32; + match radius{ + RoundCornersMode::Percentage(p) => + RoundCornersRadius::All( + smallest_dimension * + p.min(100f32).max(0f32) / 200f32), + RoundCornersMode::Pixels(p) => + RoundCornersRadius::All(p.max(0f32).min(smallest_dimension / 2f32)), + RoundCornersMode::Circle => + RoundCornersRadius::Circle, + RoundCornersMode::PercentageCustom { top_left, top_right, bottom_right, bottom_left } => + RoundCornersRadius::Custom([ + smallest_dimension * + top_left.min(100f32).max(0f32) / 200f32, + smallest_dimension * + top_right.min(100f32).max(0f32) / 200f32, + smallest_dimension * + bottom_left.min(100f32).max(0f32) / 200f32, + smallest_dimension * + bottom_right.min(100f32).max(0f32) / 200f32 + ]), + RoundCornersMode::PixelsCustom { top_left, top_right, bottom_right, bottom_left } => + RoundCornersRadius::Custom([ + top_left.max(0f32).min(smallest_dimension / 2f32), + top_right.max(0f32).min(smallest_dimension / 2f32), + bottom_left.max(0f32).min(smallest_dimension / 2f32), + bottom_right.max(0f32).min(smallest_dimension / 2f32) + ]) + } +} +#[derive(Copy, Clone, PartialEq, Debug)] +enum RoundCornersRadius{ + All(f32), + Circle, + Custom([f32;4]) +} + +fn plan_quadrants(radii: RoundCornersRadius, w: u32, h: u32) -> Result<[QuadrantInfo;4], FlowError>{ + // Simplify Circle scenario + if radii == RoundCornersRadius::Circle{ + let smallest_dimension = w.min(h) as f32; + let offset_x = ((w as i64 - (h as i64)).max(0) / 2) as u32; + let offset_y = ((h as i64 - (w as i64)).max(0) / 2) as u32; + let mut quadrants = plan_quadrants( + RoundCornersRadius::All(smallest_dimension / 2f32), w.min(h), w.min(h)) + .map_err(|e| e.at(here!()))?; + for q in quadrants.iter_mut(){ + q.x = q.x + offset_x; + q.y = q.y + offset_y; + q.image_width = w; + q.image_height = h; + q.center_x = q.center_x + offset_x as f32; + q.center_y = q.center_y + offset_y as f32; + } + return Ok(quadrants); + } + // Expand 'all' into corners + if let RoundCornersRadius::All(v) = radii{ + return plan_quadrants(RoundCornersRadius::Custom([v,v,v,v]), w, h).map_err(|e| e.at(here!())); + } + // Ok, deal with radius pixels + if let RoundCornersRadius::Custom([top_left, top_right, bottom_left, bottom_right]) = radii{ + // Integer division so we don't overlap quadrants when dimensions are odd numbers + let right_half_width = w / 2; + let bottom_half_height = h / 2; + let left_half_width = w - right_half_width; + let top_half_height = h - bottom_half_height; -fn get_radius_pixels(radius: RoundCornersMode, w: u32, h: u32) -> Result{ - match radius{ - RoundCornersMode::Percentage(p) => Ok(w.min(h) as f32 * p / 200f32), - RoundCornersMode::Pixels(p) => Ok(p), - RoundCornersMode::Circle => Err(unimpl!("RoundCornersMode::Circle is not implemented")), - RoundCornersMode::PercentageCustom {.. } => Err(unimpl!("RoundCornersMode::PercentageCustom is not implemented")), - RoundCornersMode::PixelsCustom {.. } => Err(unimpl!("RoundCornersMode::PixelsCustom is not implemented")) + Ok([QuadrantInfo{ + which: Quadrant::TopLeft, + x: 0, + y: 0, + width: left_half_width, + height: top_half_height, + image_width: w, + image_height: h, + radius: top_left, + center_x: top_left, + center_y: top_left, + is_top: true, + is_left: true, + }, + QuadrantInfo{ + which: Quadrant::TopRight, + x: left_half_width, + y: 0, + width: right_half_width, + height: top_half_height, + image_width: w, + image_height: h, + radius: top_right, + center_x: w as f32 - top_right, + center_y: top_right, + is_top: true, + is_left: false + }, + QuadrantInfo{ + which: Quadrant::BottomLeft, + x: 0, + y: top_half_height, + width: left_half_width, + height: bottom_half_height, + image_width: w, + image_height: h, + radius: bottom_left, + center_x: bottom_left, + center_y: h as f32 - bottom_left, + is_top: false, + is_left: true, + }, + QuadrantInfo{ + which: Quadrant::BottomRight, + x: left_half_width, + y: top_half_height, + width: right_half_width, + height: bottom_half_height, + image_width: w, + image_height: h, + radius: bottom_right, + center_x: w as f32 - bottom_right, + center_y: h as f32 - bottom_right, + is_top: false, + is_left: false, + } + ]) + }else { + Err(unimpl!("Enum not handled, must be new")) } } +#[derive(Copy, Clone, PartialEq, Debug)] +enum Quadrant{ + TopLeft, + TopRight, + BottomRight, + BottomLeft +} +#[derive(Copy, Clone, PartialEq, Debug)] +struct QuadrantInfo{ + which: Quadrant, + x: u32, + y: u32, + width: u32, + height: u32, + image_width: u32, + image_height: u32, + radius: f32, + center_x: f32, + center_y: f32, + is_top: bool, + is_left: bool, +} + +impl QuadrantInfo{ + fn bottom(&self) -> u32{ + self.y + self.height + } + fn right(&self) -> u32{ + self.x + self.width + } +} + + +// +// fn plan_quadrant(center_x: f32, center_y: f32, +// radius: f32, +// canvas_width: u32, +// canvas_height: u32) -> Result, FlowError>{ +// +// let mut orders = Vec::with_capacity(radius.ceil() * 8); +// let r2f = radius * radius; +// +// +// +// for y in (0..=radius_ceil).rev(){ +// let yf = y as f32 - 0.5; +// clear_widths.push(radius_ceil - f32::sqrt(r2f - yf * yf).round() as usize); +// } +// } pub unsafe fn flow_bitmap_bgra_clear_around_rounded_corners( b: &mut BitmapWindowMut, - radius_mode: RoundCornersMode, - color: imageflow_types::Color + round_corners_mode: RoundCornersMode, + color: Color ) -> Result<(), FlowError> { if b.info().pixel_layout() != PixelLayout::BGRA { - return Err(nerror!(ErrorKind::InvalidArgument)); + return Err(nerror!(ErrorKind::InvalidArgument, "Only BGRA supported for rounded corners")); } - let radius = get_radius_pixels(radius_mode, b.w(), b.h())?; - let radius_ceil = radius.ceil() as usize; + let colorcontext = ColorContext::new(WorkingFloatspace::LinearRGB,0f32); + let matte32 = color.to_color_32().map_err(|e| FlowError::from(e).at(here!()))?; + let matte = matte32.to_bgra8(); - let rf = radius as f32; - let r2f = rf * rf; + let alpha_to_float = (1.0f32) / 255.0f32; - let mut clear_widths = Vec::with_capacity(radius_ceil); - for y in (0..=radius_ceil).rev(){ - let yf = y as f32 - 0.5; - clear_widths.push(radius_ceil - f32::sqrt(r2f - yf * yf).round() as usize); - } + let matte_a = matte.a as f32 * alpha_to_float; + let matte_b = colorcontext.srgb_to_floatspace(matte.b); + let matte_g = colorcontext.srgb_to_floatspace(matte.g); + let matte_r = colorcontext.srgb_to_floatspace(matte.r); - let bgcolor = color.to_color_32().unwrap().to_bgra8(); + let w = b.w(); + let h = b.h(); - let radius_usize = radius_ceil; - let width = b.w() as usize; - let height = b.h() as usize; + //If you created a circle with the surface area of a 1x1 square, this would be its radius + //Useful for calculating pixel intensities while being correct on average regardless of angle + let volumetric_offset = 0.56419f32; - //eprintln!("color {},{},{},{:?}", bgcolor.r, bgcolor.g, bgcolor.b, bgcolor.a); + let radius_set = get_radius(round_corners_mode, b.w(), b.h()); + let quadrants = plan_quadrants(radius_set,b.w(), b.h()) + .map_err(|e| e.at(here!()))?; - for y in 0..height{ - if y <= radius_usize || y >= height - radius_usize { - let mut row = b.row_window(y as u32).unwrap(); + for quadrant in quadrants{ + if quadrant.y > 0 && quadrant.which == Quadrant::TopLeft{ + //Clear top rows, must be a circle + b.fill_rectangle(matte32, 0, 0, w, quadrant.y) + .map_err(|e| e.at(here!()))?; + } + if h > quadrant.bottom() && quadrant.which == Quadrant::BottomLeft{ + //Clear bottom rows, must be a circle + b.fill_rectangle(matte32, 0, quadrant.bottom(), w, h) + .map_err(|e| e.at(here!()))?; + } + let radius_ceil = quadrant.radius.ceil() as usize; + let start_y = if quadrant.is_top { quadrant.y as usize } else { quadrant.bottom() as usize - radius_ceil}; + let end_y = if quadrant.is_top { quadrant.y as usize + radius_ceil } else { quadrant.bottom() as usize }; + let start_x = if quadrant.is_left { quadrant.x as usize} else { quadrant.right() as usize - radius_ceil}; + let end_x = if quadrant.is_left { quadrant.x as usize + radius_ceil } else { quadrant.right() as usize }; + + let (clear_x_from, clear_x_to) = if quadrant.is_left { (0, quadrant.x) } else { (quadrant.right(), w)}; + + //Clear the edges for rows where the quadrant isn't rendering an arc + if clear_x_from != clear_x_to{ + for y in (quadrant.y..start_y as u32).chain(end_y as u32..quadrant.bottom()){ + b.fill_rectangle(matte32, clear_x_from, y, clear_x_to, y+1) + .map_err(|e| e.at(here!()))?; + } + } + + // Calculate radii + // Pixels within the radius of solid are never touched + // Pixels within the radius of influence may be aliased + // Pixels outside the radius of influence are replaced with the matte + let radius_of_influence = quadrant.radius + (1f32 - volumetric_offset); + let radius_of_solid = quadrant.radius - volumetric_offset; + let radius_aliasing_width = radius_of_influence - radius_of_solid; + + + let radius_of_influence_squared = radius_of_influence * radius_of_influence; + let radius_of_solid_squared= radius_of_solid * radius_of_solid; + + for y in start_y..end_y{ + let mut row_window = b.row_window(y as u32).unwrap(); + let row_pixels = row_window.slice_of_pixels_first_row().unwrap(); + let yf = y as f32 + 0.5; + let y_dist_from_center = (quadrant.center_y - yf).abs(); + let y_dist_squared = y_dist_from_center * y_dist_from_center; - let row_width = row.w(); - let slice = row.slice_of_pixels_first_row().unwrap(); + let x_dist_from_center_solid = f32::sqrt((radius_of_solid_squared - y_dist_squared).max(0f32)); + let x_dist_from_center_influenced = f32::sqrt((radius_of_influence_squared - y_dist_squared).max(0f32)); - let pixels_from_bottom = height - y - 1; + let edge_solid_x1 = (quadrant.center_x - x_dist_from_center_solid).ceil().max(0f32) as usize; + let edge_solid_x2 = (quadrant.center_x + x_dist_from_center_solid).floor().min(w as f32) as usize; - let nearest_line_index = y.min(pixels_from_bottom); + let edge_influence_x1 = (quadrant.center_x - x_dist_from_center_influenced).floor().max(0f32) as usize; + let edge_influence_x2 = (quadrant.center_x + x_dist_from_center_influenced).ceil().min(w as f32) as usize; - let mut clear_width = if nearest_line_index < clear_widths.len() { - clear_widths[nearest_line_index] + //Clear what we don't need to alias + if quadrant.is_left { + row_pixels[0..edge_influence_x1].fill(matte.clone()); } else { - 0 + row_pixels[edge_influence_x2..w as usize].fill(matte.clone()); }; - //eprintln!("row width {}, slice width {}, bitmap width {}", row_width, slice.len(), width); - if slice.len() != width { panic!("Width mismatch bug"); } + let (alias_from, alias_to) = if quadrant.is_left{ + (edge_influence_x1,edge_solid_x1) + }else{ + (edge_solid_x2, edge_influence_x2) + }; - clear_width = clear_width.min(width); + for x in alias_from..alias_to{ + let xf = x as f32 + 0.5; + let diff_x = quadrant.center_x - xf; + let distance = (diff_x * diff_x + y_dist_squared).sqrt(); - if clear_width > 0 { - //eprintln!("clear {}", clear_width); - slice[0..clear_width].fill(bgcolor.clone()); - slice[width-clear_width..width].fill(bgcolor.clone()); - } + if distance > radius_of_influence{ + row_pixels[x] = matte.clone(); + } else if distance > radius_of_solid{ + //Intensity should be 0..1, where 1 is full matte color and 0 is full image color + let intensity = (distance - radius_of_solid) / (radius_aliasing_width); - } - } + let pixel = row_pixels[x].clone(); + let pixel_a = pixel.a; + let pixel_a_f32 = pixel_a as i32 as f32 * alpha_to_float * (1f32 - intensity); + + let matte_a = (1.0f32 - pixel_a_f32) * matte_a; + let final_a: f32 = matte_a + pixel_a_f32; + row_pixels[x] = rgb::alt::BGRA8 { + b: colorcontext.floatspace_to_srgb( + (colorcontext.srgb_to_floatspace(pixel.b) * pixel_a_f32 + matte_b * matte_a) / final_a), + g: colorcontext.floatspace_to_srgb( + (colorcontext.srgb_to_floatspace(pixel.g) * pixel_a_f32 + matte_g * matte_a) / final_a), + r: colorcontext.floatspace_to_srgb( + (colorcontext.srgb_to_floatspace(pixel.r) * pixel_a_f32 + matte_r * matte_a) / final_a), + a: uchar_clamp_ff(255f32 * final_a) + }; + } + } + } + + } Ok(()) } diff --git a/imageflow_core/tests/visuals.rs b/imageflow_core/tests/visuals.rs index 6a9abc84..69e8f25a 100644 --- a/imageflow_core/tests/visuals.rs +++ b/imageflow_core/tests/visuals.rs @@ -104,7 +104,7 @@ fn test_transparent_png_to_png_rounded_corners() { vec![ Node::CommandString{ kind: CommandStringKind::ImageResizer4, - value: "format=png&s.roundcorners=100".to_owned(), + value: "format=png&crop=10,10,70,70&cropxunits=100&cropyunits=100&s.roundcorners=100".to_owned(), decode: Some(0), encode: Some(1), watermarks: None @@ -337,6 +337,38 @@ fn test_round_corners_small(){ assert!(matched); } +#[test] +fn test_round_corners_custom_pixels(){ + let matte = Color::Srgb(ColorSrgb::Hex("000000BB".to_owned())); + let matched = compare(None, 1, "RoundCornersCustomPixelsSemiTransparent", POPULATE_CHECKSUMS, DEBUG_GRAPH, vec![ + Node::CreateCanvas {w: 100, h: 99, format: PixelFormat::Bgra32, color: Color::Srgb(ColorSrgb::Hex("ddeecc88".to_owned()))}, + Node::RoundImageCorners { background_color: matte, radius: RoundCornersMode::PixelsCustom { + top_left: 0.0, + top_right: 1f32, + bottom_right: 50f32, + bottom_left: 20f32 + }} + ] + ); + assert!(matched); +} + +#[test] +fn test_round_corners_custom_percent(){ + let matte = Color::Srgb(ColorSrgb::Hex("000000DD".to_owned())); + let matched = compare(None, 1, "RoundCornersCustomPercentSemiTransparent", POPULATE_CHECKSUMS, DEBUG_GRAPH, vec![ + Node::CreateCanvas {w: 100, h: 99, format: PixelFormat::Bgra32, color: Color::Srgb(ColorSrgb::Hex("2288ffEE".to_owned()))}, + Node::RoundImageCorners { background_color: matte, radius: RoundCornersMode::PixelsCustom { + top_left: 50f32, + top_right: 5f32, + bottom_right: 100f32, + bottom_left: 200f32 + }} + ] + ); + assert!(matched); +} + #[test] fn test_round_corners_excessive_radius(){ @@ -350,6 +382,28 @@ fn test_round_corners_excessive_radius(){ assert!(matched); } +#[test] +fn test_round_corners_circle_wide_canvas(){ + //let white = Color::Srgb(ColorSrgb::Hex("FFFFFFFF".to_owned())); + let matte = Color::Srgb(ColorSrgb::Hex("000000FF".to_owned())); + let matched = compare(None, 1, "RoundCornersCircleWider", POPULATE_CHECKSUMS, DEBUG_GRAPH, vec![ + Node::CreateCanvas {w: 200, h: 150, format: PixelFormat::Bgra32, color: Color::Srgb(ColorSrgb::Hex("FFFFFFFF".to_owned()))}, + Node::RoundImageCorners { background_color: matte, radius: RoundCornersMode::Circle} + ] + ); + assert!(matched); +} +#[test] +fn test_round_corners_circle_tall_canvas(){ + //let white = Color::Srgb(ColorSrgb::Hex("FFFFFFFF".to_owned())); + let matte = Color::Srgb(ColorSrgb::Hex("00000000".to_owned())); + let matched = compare(None, 1, "RoundCornersCircleTaller", POPULATE_CHECKSUMS, DEBUG_GRAPH, vec![ + Node::CreateCanvas {w: 150, h: 200, format: PixelFormat::Bgra32, color: Color::Srgb(ColorSrgb::Hex("FFFFFFFF".to_owned()))}, + Node::RoundImageCorners { background_color: matte, radius: RoundCornersMode::Circle} + ] + ); + assert!(matched); +} #[test] fn test_round_image_corners_transparent() { @@ -363,6 +417,8 @@ fn test_round_image_corners_transparent() { assert!(matched); } + + #[test] fn test_scale_image() { let matched = compare(Some(IoTestEnum::Url("https://s3-us-west-2.amazonaws.com/imageflow-resources/test_inputs/waterhouse.jpg".to_owned())), 500, @@ -710,7 +766,7 @@ fn test_round_corners_command_string() { let matched = compare(Some(IoTestEnum::Url(url)), 500, &title, POPULATE_CHECKSUMS, DEBUG_GRAPH, vec![Node::CommandString { kind: CommandStringKind::ImageResizer4, - value: "w=70&h=70&s.roundcorners=100&format=png".to_string(), + value: "w=70&h=70&s.roundcorners=100,20,70,30&format=png".to_string(), decode: Some(0), encode: None, watermarks: None diff --git a/imageflow_core/tests/visuals/checksums.json b/imageflow_core/tests/visuals/checksums.json index 6ef75b02..53ac63c4 100644 --- a/imageflow_core/tests/visuals/checksums.json +++ b/imageflow_core/tests/visuals/checksums.json @@ -7,10 +7,14 @@ "MarsRGB_ICC_Scaled400300": "051EA508DFA6C0FDA_0EED88CCC2F4CD12F", "MarsRGB_ICCv4_Scaled400300": "0CEA7717E76E828C0_0EED88CCC2F4CD12F", "RingsDownscaling": "019EAD88244775C0C_05C92341756E39E1D", - "RoundCornersExcessiveRadius": "003FAA736DCBE680A_02E471F17F7F9607D", - "RoundCornersLarge": "0C305A9B7191B90A2_05C92341756E39E1D", - "RoundCornersSmall": "0BDD3055414AD86FE_0172196390B512E97", - "RoundImageCornersTransparent": "000A50748F5391A1E_05C9232CFE2672D1C", + "RoundCornersCircleTaller": "03F352E662F908110_01AA36664748E727D", + "RoundCornersCircleWider": "0AFF835739E386A12_02E471F17F7F9607D", + "RoundCornersCustomPercentSemiTransparent": "0110973B94D95FAAF_0AB5E1C2525624E58", + "RoundCornersCustomPixelsSemiTransparent": "0FF6D76B9E4752FBE_0AB5E1C2525624E58", + "RoundCornersExcessiveRadius": "0B3DBECF5967D7AF4_02E471F17F7F9607D", + "RoundCornersLarge": "0E4832C8B06698929_05C92341756E39E1D", + "RoundCornersSmall": "02DE05B755CD85DEF_0172196390B512E97", + "RoundImageCornersTransparent": "090A2E5CC38966EE9_05C9232CFE2672D1C", "ScaleIDCTFastvsSlow": "04549C40B272C69E8_0EED88CCC2F4CD12F", "ScaleIDCT_approx_gamma": "01DD6D368FDF435EF_0E685FA15F0460A97", "ScaleTheHouse": "0B16D8BA1DA8542C8_0EED88CCC2F4CD12F", @@ -56,11 +60,11 @@ "test rotate jpeg 90 degrees": "0285B89DDE072042D_0AE4839D1D9B04C57", "test_rot_90_and_red_dot": "0176FBE641002F3ED_0AE4839D1D9B04C57", "test_rot_90_and_red_dot_command_string": "0C790C6600AFBBADA_0AE4839D1D9B04C57", - "test_round_corners_command_string": "0961D137E68D8D1AB_0BF80F0AE71CD9A63", + "test_round_corners_command_string": "05218274046A5989B_0BF80F0AE71CD9A63", "transparent_png_to_jpeg": "0DC709F50C5148224.jpg", "transparent_png_to_jpeg_constrained": "0D6B7D34193494A6C.jpg", "transparent_png_to_png": "0A839287BD1939BE8.png", - "transparent_png_to_png_round_corners": "0463E25A35437EF7E.png", + "transparent_png_to_png_round_corners": "070F860839693FDC7.png", "transparent_trim_whitespace": "0FBC6A5C3930ADEF7.png", "transparent_webp_to_webp": "0026D28C52CC20F72.webp", "watermark_jpeg_over_pnga": "04C0143A72CB4EBAA_073B7BE0C1ABF189F",