szaszm commented on code in PR #2258:
URL: https://github.com/apache/nifi-minifi-cpp/pull/2258#discussion_r3971165028


##########
minifi_rust/extensions/minifi_tensor/src/low_level_processors/classify_output/classify_output_def.rs:
##########
@@ -0,0 +1,162 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::{ClassifyOutput, ScoreActivation};
+use minifi_native::{
+    OutputAttribute, ProcessorDefinition, ProcessorInputRequirement, Property, 
PropertyDefinition,
+    Relationship, property_definitions,
+};
+use std::path::PathBuf;
+

Review Comment:
   Doesn't strictly belong here, but I wonder if it would be possible to change 
the API of Property<T> to have .with_default take T instead of a string?



##########
minifi_rust/extensions/minifi_tensor/src/utils/bounding_box.rs:
##########
@@ -0,0 +1,181 @@
+use image::{Rgb, RgbImage};
+use minifi_native::{MinifiError, PropertyConstraints, PropertySchema, 
PropertyType};
+use serde::{Deserialize, Serialize};
+
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct BoundingBox {
+    pub(crate) class_id: usize,
+    pub(crate) confidence: f32,
+    pub(crate) x_min: f32,
+    pub(crate) y_min: f32,
+    pub(crate) x_max: f32,
+    pub(crate) y_max: f32,
+}
+
+fn draw_thick_rect(
+    img: &mut RgbImage,
+    left: u32,
+    top: u32,
+    right: u32,
+    bottom: u32,
+    thickness: u32,
+    color: Rgb<u8>,
+) {
+    let box_width = right.saturating_sub(left);
+    let box_height = bottom.saturating_sub(top);
+
+    for t in 0..thickness {
+        if box_width > 2 * t && box_height > 2 * t {
+            let rect = imageproc::rect::Rect::at((left + t) as i32, (top + t) 
as i32)
+                .of_size(box_width - 2 * t, box_height - 2 * t);
+            imageproc::drawing::draw_hollow_rect_mut(img, rect, color);
+        }
+    }
+}
+
+impl BoundingBox {
+    pub fn class_id(&self) -> usize {
+        self.class_id
+    }
+    pub fn confidence(&self) -> f32 {
+        self.confidence
+    }
+
+    pub(crate) fn calculate_intersection_over_union(box1: &BoundingBox, box2: 
&BoundingBox) -> f32 {
+        let x_left = box1.x_min.max(box2.x_min);
+        let y_top = box1.y_min.max(box2.y_min);
+        let x_right = box1.x_max.min(box2.x_max);
+        let y_bottom = box1.y_max.min(box2.y_max);
+
+        if x_right < x_left || y_bottom < y_top {
+            return 0.0;
+        }
+
+        let intersection_area = (x_right - x_left) * (y_bottom - y_top);
+        let box1_area = (box1.x_max - box1.x_min) * (box1.y_max - box1.y_min);
+        let box2_area = (box2.x_max - box2.x_min) * (box2.y_max - box2.y_min);

Review Comment:
   AI review
   IoU division by zero — [bounding_box.rs 
L56](https://github.com/apache/nifi-minifi-cpp/blob/d3f4e76e304fa046395552af2f1ed7a90598fa41/minifi_rust/extensions/minifi_tensor/src/utils/bounding_box.rs#L42-L57):
 intersection / (area1 + area2 − intersection) is 0/0 = NaN for two zero-area 
boxes (reachable: Cxcywh decode with w=h=0, or clamped degenerate boxes). NaN 
IoU silently corrupts NMS ordering (partial_cmp → Equal fallback). Guard union 
<= 0.0 || !union.is_finite() → return 0.0.



##########
minifi_rust/extensions/minifi_tensor/src/processors/classify_image/classify_object_def.rs:
##########


Review Comment:
   AI review
   
   ClassifyImage metadata is inconsistent with its behavior: its failure 
relationship description says outputs "could not be interpreted as scores + 
boxes" (copy-pasted from DetectObject — classifiers have no boxes), and 
OUTPUT_ATTRIBUTES is &[] even though it emits class.count, class.top1.*, and 
mime.type (DetectObject does declare its attributes). Users browsing the 
manifest/UI will see neither. 
([classify_object_def.rs](https://github.com/apache/nifi-minifi-cpp/blob/d3f4e76e304fa046395552af2f1ed7a90598fa41/minifi_rust/extensions/minifi_tensor/src/processors/classify_image/classify_object_def.rs#L39-L56))



##########
minifi_rust/extensions/minifi_tensor/src/utils/tensor_helpers.rs:
##########
@@ -0,0 +1,156 @@
+use image::{DynamicImage, ImageResult};
+use minifi_native::{GetAttribute, InputStream, MinifiError};
+use strum_macros::{Display, EnumString};
+use tract::__ndarray_interop::TensorInterface;
+use tract::Tensor;
+use tract::prelude::DatumType;
+
+tract::impl_ndarray_interop!();
+
+fn parse_tensor_shape<Context: GetAttribute>(
+    context: &Context,
+    id: usize,
+) -> Result<Vec<usize>, MinifiError> {
+    let shape_str = context.get_required_attribute(&format!("tensor.{}.shape", 
id))?;
+
+    if shape_str.trim().is_empty() {
+        return Ok(Vec::new());
+    }
+
+    let shape = shape_str
+        .split(',')
+        .map(|s| s.trim().parse::<usize>())
+        .collect::<Result<Vec<usize>, _>>()?;
+
+    Ok(shape)
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Display, EnumString)]
+#[strum(serialize_all = "PascalCase", const_into_str)]
+pub(crate) enum MinifiDatumType {
+    F32,
+}
+
+impl From<MinifiDatumType> for DatumType {
+    fn from(value: MinifiDatumType) -> Self {
+        match value {
+            MinifiDatumType::F32 => DatumType::F32,
+        }
+    }
+}
+
+fn numeric_datum_type_from_str(s: &str) -> Option<DatumType> {
+    Some(match s {
+        "U8" => DatumType::U8,
+        "U16" => DatumType::U16,
+        "U32" => DatumType::U32,
+        "U64" => DatumType::U64,
+        "I8" => DatumType::I8,
+        "I16" => DatumType::I16,
+        "I32" => DatumType::I32,
+        "I64" => DatumType::I64,
+        "F16" => DatumType::F16,
+        "F32" => DatumType::F32,
+        "F64" => DatumType::F64,
+        _ => return None,
+    })
+}
+
+fn parse_tensor_dtype<Context: GetAttribute>(
+    context: &Context,
+    id: usize,
+) -> Result<DatumType, MinifiError> {
+    let dtype_str = context.get_required_attribute(&format!("tensor.{}.dtype", 
id))?;
+    numeric_datum_type_from_str(&dtype_str).ok_or_else(|| {
+        MinifiError::custom(format!(
+            "Unsupported tensor.{}.dtype '{}': only numeric tensors can be 
read",
+            id, dtype_str
+        ))
+    })
+}
+
+pub(crate) fn deserialize_tensors<Context: GetAttribute>(
+    context: &Context,
+    input_stream: &mut dyn InputStream,
+) -> Result<Vec<Tensor>, MinifiError> {
+    let mut result = vec![];
+
+    let mut flow_file_contents = Vec::new();
+    input_stream.read_to_end(&mut flow_file_contents)?;
+    let number_of_tensors = context
+        .get_required_attribute("tensors.len")?
+        .parse::<usize>()?;
+
+    let mut cursor = 0usize;
+    for i in 0..number_of_tensors {
+        let tensor_len = context
+            .get_required_attribute(&format!("tensor.{}.bytes", i))?
+            .parse::<usize>()?;
+        let tensor_shape = parse_tensor_shape(context, i)?;
+        let tensor_dtype = parse_tensor_dtype(context, i)?;
+        let tensor_data = &flow_file_contents[cursor..cursor + tensor_len];

Review Comment:
   AI review
   Panic on malformed tensor payload — [tensor_helpers.rs 
L91](https://github.com/apache/nifi-minifi-cpp/blob/d3f4e76e304fa046395552af2f1ed7a90598fa41/minifi_rust/extensions/minifi_tensor/src/utils/tensor_helpers.rs#L72-L101):
 &flow_file_contents[cursor..cursor + tensor_len] panics if tensor.{i}.bytes 
attributes disagree with the payload (truncated content, garbage attributes 
from a non-InvokeTractModel upstream). This is worse than a normal Rust panic: 
the workspace sets panic = "abort" and the code runs inside the C++ agent 
across an FFI boundary, so one malicious/corrupt flowfile aborts the whole 
MiNiFi process. Add cursor + tensor_len <= flow_file_contents.len() (and 
ideally a final cursor == len() check) and route to failure.



##########
minifi_rust/extensions/minifi_tensor/src/low_level_processors/classify_output.rs:
##########


Review Comment:
   I'd keep the defs in the same file as the impls and tests. It's gonna be a 
long file, but things that belong together stay together.



##########
minifi_rust/extensions/minifi_tensor/src/low_level_processors/invoke_tract_model.rs:
##########
@@ -0,0 +1,254 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::utils::tensor_helpers::MinifiDatumType;
+pub(crate) use invoke_tract_model_def::TRACT_MODEL_SERVICE;
+use invoke_tract_model_def::*;
+use minifi_native::macros::ComponentIdentifier;
+use minifi_native::{
+    FlowFileTransform, GetAttribute, GetControllerService, GetId, GetProperty, 
InputStream, Logger,
+    MinifiError, ProcessError, RouteErrorExt, Schedule, TransformedFlowFile,
+};
+use std::error::Error;
+use tract::__ndarray_interop::TensorInterface;
+use tract::Tensor;
+tract::impl_ndarray_interop!();
+
+mod invoke_tract_model_def;
+
+#[derive(ComponentIdentifier)]
+pub(crate) struct InvokeTractModel {}
+
+impl Schedule for InvokeTractModel {
+    fn schedule<Ctx: GetProperty, L: Logger>(
+        _context: &Ctx,
+        _logger: &L,
+    ) -> Result<Self, MinifiError>
+    where
+        Self: Sized,
+    {
+        Ok(Self {})
+    }
+}
+
+impl InvokeTractModel {
+    fn get_payload_as_f32_array(
+        input_stream: &mut dyn InputStream,
+    ) -> Result<Vec<f32>, Box<dyn Error>> {
+        let mut raw_bytes = Vec::new();
+        input_stream.read_to_end(&mut raw_bytes)?;
+        if raw_bytes.len() % 4 != 0 {
+            return Err(MinifiError::custom("Input bytes length is not a 
multiple of 4").into());
+        }
+        let mut f32_data = Vec::with_capacity(raw_bytes.len() / 4);
+        for chunk in raw_bytes.as_chunks::<4>().0.iter() {
+            let val = f32::from_le_bytes(*chunk);
+            f32_data.push(val);
+        }

Review Comment:
   AI review
   
   Duplicated payload-parsing logic: InvokeTractModel::get_payload_as_f32_array 
hand-rolls LE-f32 decoding that overlaps with 
tensor_helpers::deserialize_tensors — consolidating would give InvokeTractModel 
the bounds-check from item 4.1 for free.



##########
minifi_rust/extensions/minifi_tensor/src/low_level_processors/filter_bounding_boxes.rs:
##########
@@ -0,0 +1,566 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod filter_bounding_boxes_def;
+
+use crate::low_level_processors::image_to_tensor::ResizeMode;
+use crate::utils::bounding_box::BoundingBox;
+use crate::utils::dimensions::Dimensions;
+use crate::utils::score_activation::ScoreActivation;
+use crate::utils::tensor_helpers::{deserialize_tensors, tensor_as_f32};
+use filter_bounding_boxes_def::SUCCESS;
+pub(crate) use filter_bounding_boxes_def::{
+    BACKGROUND_CLASS_INDEX, BOX_FORMAT, BOX_OUTPUT_INDEX, CLASS_OUTPUT_INDEX, 
CONFIDENCE_THRESHOLD,
+    IOU_THRESHOLD, OUTPUT_ATTRIBUTE_NAME, SCORE_ACTIVATION, SCORE_OUTPUT_INDEX,
+};
+use minifi_native::macros::{ComponentIdentifier, PropertyType};
+use minifi_native::{
+    Content, FlowFileTransform, GetAttribute, GetId, GetProperty, InputStream, 
Logger, MinifiError,
+    ProcessError, RouteErrorExt, Schedule, TransformedFlowFile, debug,
+};
+use strum_macros::{Display, EnumString, IntoStaticStr, VariantNames};
+use tract::Tensor;
+
+#[derive(
+    Debug, Clone, Copy, PartialEq, Display, EnumString, VariantNames, 
IntoStaticStr, PropertyType,
+)]
+#[strum(serialize_all = "PascalCase", const_into_str)]
+pub(crate) enum BoxFormat {
+    /// `[x_min, y_min, x_max, y_max]` — SSD, MobileNet-SSD, most PyTorch 
models.
+    Xyxy,
+    /// `[y_min, x_min, y_max, x_max]` — TensorFlow Object Detection API.
+    Yxyx,
+    /// `[cx, cy, w, h]` — YOLOv3/5/8 raw output (center + size).
+    Cxcywh,
+}
+
+/// Convert the four floats at `box_floats[offset..offset+4]` into a canonical
+/// `(x_min, y_min, x_max, y_max)` tuple, regardless of the source layout.
+fn decode_box(box_floats: &[f32], offset: usize, format: BoxFormat) -> (f32, 
f32, f32, f32) {
+    let a = box_floats[offset];
+    let b = box_floats[offset + 1];
+    let c = box_floats[offset + 2];
+    let d = box_floats[offset + 3];
+    match format {
+        BoxFormat::Xyxy => (a, b, c, d),
+        BoxFormat::Yxyx => (b, a, d, c),
+        BoxFormat::Cxcywh => {
+            let (cx, cy, w, h) = (a, b, c, d);
+            (cx - w / 2.0, cy - h / 2.0, cx + w / 2.0, cy + h / 2.0)
+        }
+    }
+}
+
+struct ScoredClass {
+    class_id: usize,
+    confidence: f32,
+}
+
+fn score_box(
+    logits: &[f32],
+    activation: ScoreActivation,
+    background_class_index: Option<usize>,
+) -> ScoredClass {
+    let num_classes = logits.len();
+
+    let best_valid = logits
+        .iter()
+        .enumerate()
+        .filter(|&(_, &logit)| logit.is_finite())
+        .filter(|&(id, _)| match background_class_index {
+            Some(bg_idx) => !(num_classes > 1 && id == bg_idx),
+            None => true,
+        })
+        .max_by(|a, b| a.1.total_cmp(b.1));
+
+    let (class_id, &best_logit) = match best_valid {
+        Some(val) => val,
+        None => {
+            return ScoredClass {
+                class_id: 0,
+                confidence: f32::NEG_INFINITY,
+            };
+        }
+    };
+
+    let confidence = match activation {
+        ScoreActivation::Softmax => {
+            let max_logit = logits
+                .iter()
+                .copied()
+                .filter(|l| l.is_finite())
+                .reduce(f32::max)
+                .unwrap_or(f32::NEG_INFINITY);
+            let sum_exp: f32 = logits
+                .iter()
+                .filter(|l| l.is_finite())
+                .map(|&l| (l - max_logit).exp())
+                .sum();
+
+            (best_logit - max_logit).exp() / sum_exp
+        }
+        ScoreActivation::Sigmoid => 1.0 / (1.0 + (-best_logit).exp()),
+        ScoreActivation::None => best_logit,
+    };
+
+    ScoredClass {
+        class_id,
+        confidence,
+    }
+}
+
+/// Turn a single per-box score into a confidence for the "separate class-id
+/// tensor" path.
+/// Sigmoid maps a raw logit to a probability;
+/// Softmax has no meaning over a single scalar, thus pass-through.
+/// None passes the score through.
+fn activate_scalar(score: f32, activation: ScoreActivation) -> f32 {
+    match activation {
+        ScoreActivation::Sigmoid => 1.0 / (1.0 + (-score).exp()),
+        ScoreActivation::Softmax | ScoreActivation::None => score,
+    }
+}
+
+#[derive(ComponentIdentifier)]
+pub(crate) struct FilterBoundingBoxes {
+    confidence_threshold: f32,
+    iou_threshold: f32,
+    score_output_index: usize,
+    box_output_index: usize,
+    box_format: BoxFormat,
+    score_activation: ScoreActivation,
+    background_class_index: Option<usize>,
+    class_output_index: Option<usize>,
+}
+
+impl Schedule for FilterBoundingBoxes {
+    fn schedule<Ctx: GetProperty, L: Logger>(
+        context: &Ctx,
+        _logger: &L,
+    ) -> Result<Self, MinifiError> {
+        let confidence_threshold = 
context.get_property(&CONFIDENCE_THRESHOLD)?;
+        let iou_threshold = context.get_property(&IOU_THRESHOLD)?;
+        let score_output_index = context.get_property(&SCORE_OUTPUT_INDEX)?;
+        let box_output_index = context.get_property(&BOX_OUTPUT_INDEX)?;
+        let box_format = context.get_property(&BOX_FORMAT)?;
+        let score_activation = context.get_property(&SCORE_ACTIVATION)?;
+        let background_class_index = 
context.get_property(&BACKGROUND_CLASS_INDEX)?;
+        let class_output_index = context.get_property(&CLASS_OUTPUT_INDEX)?;
+
+        Ok(Self {
+            confidence_threshold,
+            iou_threshold,
+            score_output_index,
+            box_output_index,
+            box_format,
+            score_activation,
+            background_class_index,
+            class_output_index,
+        })
+    }
+}
+
+impl FilterBoundingBoxes {
+    pub(crate) fn filter<'a, Context: GetProperty, LoggerImpl: Logger>(
+        &self,
+        context: &Context,
+        logger: &LoggerImpl,
+        tensors: Vec<Tensor>,
+        orig_dim: Dimensions,
+        target_dim: Dimensions,
+        resize_mode: ResizeMode,
+    ) -> Result<TransformedFlowFile<'a>, ProcessError> {
+        let score_floats =
+            tensor_as_f32(&tensors, 
self.score_output_index).route_err_to_failure()?;
+        let box_floats = tensor_as_f32(&tensors, 
self.box_output_index).route_err_to_failure()?;
+
+        let (scale_x, scale_y, pad_x, pad_y) = match resize_mode {
+            ResizeMode::Letterbox => {
+                let scale =
+                    (target_dim.width / orig_dim.width).min(target_dim.height 
/ orig_dim.height);
+                let pad_x = (target_dim.width - (orig_dim.width * scale)) / 
2.0;
+                let pad_y = (target_dim.height - (orig_dim.height * scale)) / 
2.0;
+                (scale, scale, pad_x, pad_y)
+            }
+            ResizeMode::Stretch => (
+                target_dim.width / orig_dim.width,
+                target_dim.height / orig_dim.height,
+                0.0,
+                0.0,
+            ),
+        };
+
+        if !box_floats.len().is_multiple_of(4) {
+            return Err(MinifiError::custom(
+                "Box tensor byte length is not a multiple of 16 (4 f32 per 
box)",
+            )
+            .into());
+        }
+        let num_boxes = box_floats.len() / 4;
+        if num_boxes == 0 {
+            debug!(logger, "No boxes to filter; emitting empty array");
+            return Ok(TransformedFlowFile::new(&SUCCESS, None)
+                .with_content(b"[]".to_vec().into())
+                .with_attribute("object.count", "0")
+                .with_attribute("mime.type", "application/json"));
+        }

Review Comment:
   AI review
   Empty-detections path ignores Output attribute name in 
FilterBoundingBoxes::filter: the num_boxes == 0 early return always writes "[]" 
to content, while the normal path honors the attribute. Minor behavioral 
inconsistency for DetectObject users relying on the attribute always being set.



##########
minifi_rust/extensions/minifi_tensor/src/low_level_processors/image_to_tensor/image_to_tensor_def.rs:
##########
@@ -0,0 +1,200 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::{ColorFormat, ImageToTensor, ResizeFilter, ResizeMode, 
TensorShapeFormat};
+use crate::utils::per_channel_f32::PerChannelF32;
+use minifi_native::{
+    OutputAttribute, ProcessorDefinition, ProcessorInputRequirement, Property, 
PropertyDefinition,
+    Relationship, property_definitions,
+};
+
+pub(crate) const TARGET_WIDTH: Property<u32> = Property::new(
+    "Target width",
+    "Width in pixels the decoded image is resized to before normalisation and \
+                  inference.",
+);
+
+pub(crate) const TARGET_HEIGHT: Property<u32> = Property::new(
+    "Target height",
+    "Height in pixels the decoded image is resized to before normalisation and 
\
+                  inference.",
+);
+
+pub(crate) const RESIZE_FILTER: Property<ResizeFilter> = Property::new(
+    "Resize filter",
+    "Interpolation filter applied when resizing the decoded image. Nearest is 
fastest \
+                  but blocky; Bilinear is a good default; Bicubic and Lanczos3 
are higher-quality \
+                  but slower.",
+)
+.with_default(ResizeFilter::Bilinear.into_str());
+
+pub(crate) const RESIZE_MODE: Property<ResizeMode> = Property::new(
+    "Resize mode",
+    "How the source image is fitted into the target dimensions. 'Stretch' 
scales each \
+                  axis independently, distorting aspect ratio. 'Letterbox' 
preserves aspect ratio \
+                  and pads the remaining border with 'Letterbox pad value' 
(applied in normalised \
+                  output space).",
+)
+.with_default(ResizeMode::Stretch.into_str());
+
+pub(crate) const LETTERBOX_PAD_VALUE: Property<f32> = Property::new(
+    "Letterbox pad value",
+    "Value written for padding pixels when 'Resize mode' is 'Letterbox'. This 
is a \
+                  normalised value (post mean/std), so 0.0 corresponds to a 
neutral input for most \
+                  networks. Ignored when 'Resize mode' is 'Stretch'.",
+)
+.with_default("0.0");
+
+pub(crate) const COLOR_FORMAT: Property<ColorFormat> = Property::new(
+    "Color format",
+    "Colour space of the tensor fed to the model. RGB and BGR produce 
three-channel \
+                  tensors (channel order determined by the format); Grayscale 
produces a \
+                  single-channel luma tensor.",
+)
+.with_default(ColorFormat::Rgb.into_str());
+
+pub(crate) const TENSOR_SHAPE_FORMAT: Property<TensorShapeFormat> = 
Property::new(
+    "Tensor shape format",
+    "Memory layout of the tensor fed to the model. CHW (channels-first) is 
typical \
+                  for PyTorch/ONNX detectors. HWC (channels-last) matches 
TensorFlow/TFLite. \
+                  Ignored for Grayscale (always effectively 1xHxW).",
+)
+.with_default(TensorShapeFormat::Chw.into_str());
+
+pub(crate) const MEAN: Property<PerChannelF32> = Property::new(
+    "Mean",
+    "Mean subtracted from each pixel before dividing by 'Standard Deviation'. 
Accepts \
+                  either a single value (broadcast to all channels) or three 
comma-separated \
+                  values applied per channel in the order dictated by 'Color 
format'. Example: \
+                  '0.485, 0.456, 0.406' for ImageNet-style RGB normalisation.",
+)
+.with_default("0.0");
+
+pub(crate) const STD_DEV: Property<PerChannelF32> = Property::new(
+    "Standard Deviation",
+    "Divisor applied after subtracting 'Mean'. Accepts a single value 
(broadcast) or \
+                  three comma-separated values (per channel). Must be 
non-zero. Example: '255.0' \
+                  to scale u8 pixels into [0.0, 1.0]; '0.229, 0.224, 0.225' 
for ImageNet.",
+)
+.with_default("255.0");
+
+pub(crate) const PIXEL_DIVISOR: Property<f32> = Property::new(
+    "Pixel divisor",
+    "Divisor applied to raw u8 pixel values before subtracting 'Mean' and 
dividing \
+                  by 'Standard Deviation'. Defaults to 1.0 (mean/std 
interpreted in [0, 255] pixel \
+                  space, e.g. UltraFace's mean=127, std=128). Set to 255 to 
bring pixels into \
+                  [0.0, 1.0] first so ImageNet-style mean/std values like 
'0.485, 0.456, 0.406' / \
+                  '0.229, 0.224, 0.225' can be used directly, matching the 
PyTorch / torchvision / \
+                  ONNX MobileNet convention. Must be non-zero.",
+)
+.with_default("1.0");
+
+pub(super) const SUCCESS: Relationship = Relationship {
+    name: "success",
+    description: "The input image was decoded and converted to a tensor.",
+};
+
+pub(super) const FAILURE: Relationship = Relationship {
+    name: "failure",
+    description: "The input flow file could not be decoded as an image.",
+};
+
+pub(super) const TENSORS_LEN_ATTR: OutputAttribute = OutputAttribute {
+    name: "tensors.len",
+    relationships: &["success"],
+    description: "Number of tensors in the output FlowFile. Currently always 
'1'",
+};
+
+pub(super) const TENSOR_SHAPE_ATTR: OutputAttribute = OutputAttribute {
+    name: "tensor.0.shape",
+    relationships: &["success"],
+    description: "Comma-separated dimensions of the output tensor in the 
chosen layout, always \
+                  including a leading batch dimension of 1 (e.g. '1,3,224,224' 
for RGB CHW).",
+};
+
+pub(super) const TENSOR_DTYPE_ATTR: OutputAttribute = OutputAttribute {
+    name: "tensor.0.dtype",
+    relationships: &["success"],
+    description: "Element type of the values in the output tensor. Currently 
always 'F32'.",
+};
+
+pub(super) const IMG_ORG_HEIGHT_ATTR: OutputAttribute = OutputAttribute {
+    name: "image.original.height",
+    relationships: &["success"],
+    description: "The height of the original image before the resizing.",
+};
+
+pub(super) const IMG_ORG_WIDTH_ATTR: OutputAttribute = OutputAttribute {
+    name: "image.original.width",
+    relationships: &["success"],
+    description: "The width of the original image before the resizing.",
+};
+
+pub(super) const IMG_TRG_HEIGHT_ATTR: OutputAttribute = OutputAttribute {
+    name: "image.target.height",
+    relationships: &["success"],
+    description: "The height of the image after the resizing.",
+};
+
+pub(super) const IMG_TRG_WIDTH_ATTR: OutputAttribute = OutputAttribute {
+    name: "image.target.width",
+    relationships: &["success"],
+    description: "The width of the image after the resizing.",
+};
+
+pub(super) const IMG_RESIZE_MODE_ATTR: OutputAttribute = OutputAttribute {
+    name: "image.resize.mode",
+    relationships: &["success"],
+    description: "The resize mode ('Stretch' or 'Letterbox') applied to fit 
the image into the \
+                  target dimensions. Downstream processors such as 
FilterBoundingBoxes use this \
+                  to invert the coordinate mapping correctly.",
+};
+
+impl ProcessorDefinition for ImageToTensor {
+    const DESCRIPTION: &'static str = "Decodes an image from the flow file 
content and converts it into a normalised numeric \
+         tensor suitable for feeding into a downstream inference processor 
such as \
+         InvokeTractModel. Supports RGB / BGR / Grayscale, CHW / HWC layouts, 
stretch or \
+         letterbox resizing, and scalar or per-channel mean/std normalisation. 
The output payload \
+         is the raw little-endian f32 tensor; the 'tensor.shape' and 
'tensor.dtype' attributes \

Review Comment:
   AI review
   Doc drift in descriptions: ImageToTensor/InvokeTractModel DESCRIPTIONs 
reference 'tensor.shape' / 'tensor.dtype', but the actual attributes are 
tensor.0.shape / tensor.0.dtype (and tensor.{i}.* for outputs).



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to