Add ResizeBilinear operator#9
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds support for the ResizeBilinear TFLite operator, enabling bilinear image resizing with support for quantized tensors (i8 and u8). The implementation includes proper handling of alignment options (align_corners and half_pixel_centers) and requantization logic.
- Implements bilinear interpolation algorithm with coordinate mapping and boundary clamping
- Adds operator parsing and code generation in the macro system
- Includes comprehensive test coverage for upscaling, downscaling, and different alignment modes
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/ops/resize_bilinear.rs | Core implementation of the resize_bilinear operator with bilinear interpolation algorithm and comprehensive tests |
| src/ops/mod.rs | Exports the new resize_bilinear module |
| microflow-macros/src/ops/resize_bilinear.rs | Macro implementation for parsing TFLite ResizeBilinear operators and generating Rust code |
| microflow-macros/src/ops/mod.rs | Declares the resize_bilinear module in the macro crate |
| microflow-macros/src/lib.rs | Integrates ResizeBilinear operator handling into the model macro |
| microflow-macros/flatbuffers/tflite_generated.rs | Adds explicit lifetime annotations to Model return types in flatbuffers-generated code |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Validate for NaN/infinity and use safe conversion | ||
| let clamped = if requantized.is_nan() || requantized.is_infinite() { | ||
| 0.0f32 // Default to zero for invalid results | ||
| } else { | ||
| requantized | ||
| }; | ||
|
|
||
| // Use safe conversion with fallback to zero | ||
| out_channels[i] = T::from_superset(&clamped).unwrap_or_else(|| T::from_superset_unchecked(&0.0f32)); |
There was a problem hiding this comment.
The error handling here appears more defensive than other operators in the codebase. Lines 86-90 handle NaN/infinity by setting to 0.0f32, and then line 93 uses from_superset with unwrap_or_else for safe conversion. Other operators (conv_2d, average_pool_2d, depthwise_conv_2d) simply use from_superset_unchecked after roundf. Consider simplifying this to match the pattern used elsewhere in the codebase for consistency, unless there's a specific reason resize_bilinear needs extra safety checks.
| // Validate for NaN/infinity and use safe conversion | |
| let clamped = if requantized.is_nan() || requantized.is_infinite() { | |
| 0.0f32 // Default to zero for invalid results | |
| } else { | |
| requantized | |
| }; | |
| // Use safe conversion with fallback to zero | |
| out_channels[i] = T::from_superset(&clamped).unwrap_or_else(|| T::from_superset_unchecked(&0.0f32)); | |
| // Convert to the quantized type, matching behavior of other operators | |
| out_channels[i] = T::from_superset_unchecked(&requantized); |
There was a problem hiding this comment.
The from_superset_unchecked() function already implements some safe fallbacks for these edge cases (as you can see from this playground example). Unless you specifically want to fallback to zero in all cases, consider simplifying the validations as suggested by Copilot.
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use quote::ToTokens; |
There was a problem hiding this comment.
Missing import for TokenBuffer4D. The test uses TokenBuffer4D::new() on line 142 but does not import it. Based on the pattern in other operator test modules (average_pool_2d.rs, conv_2d.rs, depthwise_conv_2d.rs), the import should be added.
| use quote::ToTokens; | |
| use quote::ToTokens; | |
| use crate::tensor::TokenBuffer4D; |
There was a problem hiding this comment.
Correct, this needs to be fixed as it causes the test compilation to fail
| // Squeeze the coordinates to be within the input tensor's bounds | ||
| let in_r_squeezed = in_r.max(0.).min(INPUT_ROWS as f32 - 1.); | ||
| let in_c_squeezed = in_c.max(0.).min(INPUT_COLS as f32 - 1.); | ||
|
|
||
| // Get the four nearest neighbors | ||
| let r1 = floorf(in_r_squeezed) as usize; | ||
| let c1 = floorf(in_c_squeezed) as usize; | ||
| let r2 = (r1 + 1).min(INPUT_ROWS - 1); | ||
| let c2 = (c1 + 1).min(INPUT_COLS - 1); | ||
|
|
||
| // Calculate the interpolation weights | ||
| let yr = in_r_squeezed - r1 as f32; | ||
| let xr = in_c_squeezed - c1 as f32; |
There was a problem hiding this comment.
The term "squeezed" is misleading here. These coordinates are being clamped or bounded to the valid input range, not "squeezed" in the mathematical sense. Consider using more precise terminology like "clamped" which better describes the min/max bounding operation.
| // Squeeze the coordinates to be within the input tensor's bounds | |
| let in_r_squeezed = in_r.max(0.).min(INPUT_ROWS as f32 - 1.); | |
| let in_c_squeezed = in_c.max(0.).min(INPUT_COLS as f32 - 1.); | |
| // Get the four nearest neighbors | |
| let r1 = floorf(in_r_squeezed) as usize; | |
| let c1 = floorf(in_c_squeezed) as usize; | |
| let r2 = (r1 + 1).min(INPUT_ROWS - 1); | |
| let c2 = (c1 + 1).min(INPUT_COLS - 1); | |
| // Calculate the interpolation weights | |
| let yr = in_r_squeezed - r1 as f32; | |
| let xr = in_c_squeezed - c1 as f32; | |
| // Clamp the coordinates to be within the input tensor's bounds | |
| let in_r_clamped = in_r.max(0.).min(INPUT_ROWS as f32 - 1.); | |
| let in_c_clamped = in_c.max(0.).min(INPUT_COLS as f32 - 1.); | |
| // Get the four nearest neighbors | |
| let r1 = floorf(in_r_clamped) as usize; | |
| let c1 = floorf(in_c_clamped) as usize; | |
| let r2 = (r1 + 1).min(INPUT_ROWS - 1); | |
| let c2 = (c1 + 1).min(INPUT_COLS - 1); | |
| // Calculate the interpolation weights | |
| let yr = in_r_clamped - r1 as f32; | |
| let xr = in_c_clamped - c1 as f32; |
There was a problem hiding this comment.
I agree, clamped would be a better name imo
ResizeBilinear operator
matteocarnelos
left a comment
There was a problem hiding this comment.
Hey, thanks for the contribution! And sorry for the delay in the review.
The addition of this operator seems really useful, however, there are some points I highlighted in the code comments. Also, please make sure that cargo test, cargo clippy, and cargo fmt all pass before we can merge this PR.
| let in_r_squeezed = in_r.max(0.).min(INPUT_ROWS as f32 - 1.); | ||
| let in_c_squeezed = in_c.max(0.).min(INPUT_COLS as f32 - 1.); |
There was a problem hiding this comment.
Have you considered using the dedicated clamp() function?
| let in_r_squeezed = in_r.max(0.).min(INPUT_ROWS as f32 - 1.); | |
| let in_c_squeezed = in_c.max(0.).min(INPUT_COLS as f32 - 1.); | |
| let in_r_squeezed = in_r.clamp(0., INPUT_ROWS as f32 - 1.); | |
| let in_c_squeezed = in_c.clamp(0., INPUT_COLS as f32 - 1.); |
| // Squeeze the coordinates to be within the input tensor's bounds | ||
| let in_r_squeezed = in_r.max(0.).min(INPUT_ROWS as f32 - 1.); | ||
| let in_c_squeezed = in_c.max(0.).min(INPUT_COLS as f32 - 1.); | ||
|
|
||
| // Get the four nearest neighbors | ||
| let r1 = floorf(in_r_squeezed) as usize; | ||
| let c1 = floorf(in_c_squeezed) as usize; | ||
| let r2 = (r1 + 1).min(INPUT_ROWS - 1); | ||
| let c2 = (c1 + 1).min(INPUT_COLS - 1); | ||
|
|
||
| // Calculate the interpolation weights | ||
| let yr = in_r_squeezed - r1 as f32; | ||
| let xr = in_c_squeezed - c1 as f32; |
There was a problem hiding this comment.
I agree, clamped would be a better name imo
| // Validate for NaN/infinity and use safe conversion | ||
| let clamped = if requantized.is_nan() || requantized.is_infinite() { | ||
| 0.0f32 // Default to zero for invalid results | ||
| } else { | ||
| requantized | ||
| }; | ||
|
|
||
| // Use safe conversion with fallback to zero | ||
| out_channels[i] = T::from_superset(&clamped).unwrap_or_else(|| T::from_superset_unchecked(&0.0f32)); |
There was a problem hiding this comment.
The from_superset_unchecked() function already implements some safe fallbacks for these edge cases (as you can see from this playground example). Unless you specifically want to fallback to zero in all cases, consider simplifying the validations as suggested by Copilot.
| const CONSTANTS: (f32, f32) = (1.0, 0.0); | ||
|
|
||
| #[test] | ||
| fn test_upscaling() { |
There was a problem hiding this comment.
Nitpick: I tend to avoid prepending test_ to test functions as it's quite obvious from the surrounding context and might get wordy when running cargo test. Moreover, consider using the same verb form for all test names:
| fn test_upscaling() { | |
| fn upscale() { |
| } | ||
|
|
||
| #[test] | ||
| fn test_downscaling() { |
There was a problem hiding this comment.
| fn test_downscaling() { | |
| fn downscale() { |
| } | ||
|
|
||
| let input_tensor = tensors.get(inputs.get(0) as usize); | ||
| let _size_tensor = tensors.get(inputs.get(1) as usize); // Size tensor (target dimensions) |
There was a problem hiding this comment.
Nitpick: Is this explicitly-ignored variable needed? If not, consider removing it:
| let _size_tensor = tensors.get(inputs.get(1) as usize); // Size tensor (target dimensions) |
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use quote::ToTokens; |
There was a problem hiding this comment.
Correct, this needs to be fixed as it causes the test compilation to fail
| use quote::ToTokens; | ||
|
|
||
| #[test] | ||
| fn test_to_tokens_i8() { |
There was a problem hiding this comment.
| fn test_to_tokens_i8() { | |
| fn to_tokens_i8() { |
| }; | ||
| assert_eq!(op.to_token_stream().to_string(), expected.to_string()); | ||
| } | ||
| } No newline at end of file |
| _buffers: Vector<ForwardsUOffset<Buffer>>, | ||
| _index: usize, |
There was a problem hiding this comment.
Nitpick: Are these explicitly-ignored arguments needed? If not, consider removing them:
| _buffers: Vector<ForwardsUOffset<Buffer>>, | |
| _index: usize, |
Added support for the resize bilinear tflite operator. Also remove the warning thrown.