Skip to main content

runmat_runtime/builtins/image/
imhist.rs

1//! MATLAB-compatible `imhist` grayscale and indexed-image histograms.
2
3use runmat_builtins::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
5    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6    IntValue, LogicalArray, NumericDType, Tensor, Value,
7};
8use runmat_macros::runtime_builtin;
9
10use crate::builtins::common::spec::{
11    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
12    ProviderHook, ReductionNaN, ResidencyPolicy, ShapeRequirements,
13};
14use crate::builtins::image::color::common;
15use crate::builtins::image::type_resolvers::imhist_type;
16use crate::{build_runtime_error, BuiltinResult, RuntimeError};
17
18#[cfg(feature = "plot-core")]
19use runmat_plot::plots::BarChart;
20
21const NAME: &str = "imhist";
22const DEFAULT_GRAYSCALE_BINS: usize = 256;
23const LOGICAL_BINS: usize = 2;
24const MAX_BINS: usize = 1_000_000;
25#[cfg(feature = "plot-core")]
26const MAX_PLOT_BINS: usize = 4096;
27const INTEGER_TOL: f64 = 1.0e-9;
28
29const IMHIST_OUTPUT_COUNTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
30    name: "counts",
31    ty: BuiltinParamType::NumericArray,
32    arity: BuiltinParamArity::Required,
33    default: None,
34    description: "Histogram bin counts as a column vector.",
35}];
36
37const IMHIST_OUTPUT_COUNTS_BINS: [BuiltinParamDescriptor; 2] = [
38    BuiltinParamDescriptor {
39        name: "counts",
40        ty: BuiltinParamType::NumericArray,
41        arity: BuiltinParamArity::Required,
42        default: None,
43        description: "Histogram bin counts as a column vector.",
44    },
45    BuiltinParamDescriptor {
46        name: "binLocations",
47        ty: BuiltinParamType::NumericArray,
48        arity: BuiltinParamArity::Required,
49        default: None,
50        description: "Intensity or colormap-index bin locations as a column vector.",
51    },
52];
53
54const IMHIST_INPUTS_IMAGE: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
55    name: "I",
56    ty: BuiltinParamType::Any,
57    arity: BuiltinParamArity::Required,
58    default: None,
59    description: "Grayscale intensity image.",
60}];
61
62const IMHIST_INPUTS_IMAGE_N: [BuiltinParamDescriptor; 2] = [
63    BuiltinParamDescriptor {
64        name: "I",
65        ty: BuiltinParamType::Any,
66        arity: BuiltinParamArity::Required,
67        default: None,
68        description: "Grayscale intensity image.",
69    },
70    BuiltinParamDescriptor {
71        name: "n",
72        ty: BuiltinParamType::NumericScalar,
73        arity: BuiltinParamArity::Required,
74        default: Some("256"),
75        description: "Number of bins.",
76    },
77];
78
79const IMHIST_INPUTS_INDEXED: [BuiltinParamDescriptor; 2] = [
80    BuiltinParamDescriptor {
81        name: "X",
82        ty: BuiltinParamType::Any,
83        arity: BuiltinParamArity::Required,
84        default: None,
85        description: "Indexed image matrix.",
86    },
87    BuiltinParamDescriptor {
88        name: "map",
89        ty: BuiltinParamType::NumericArray,
90        arity: BuiltinParamArity::Required,
91        default: None,
92        description: "Colormap with one RGB row per indexed-image bin.",
93    },
94];
95
96const IMHIST_SIGNATURES: [BuiltinSignatureDescriptor; 6] = [
97    BuiltinSignatureDescriptor {
98        label: "counts = imhist(I)",
99        inputs: &IMHIST_INPUTS_IMAGE,
100        outputs: &IMHIST_OUTPUT_COUNTS,
101    },
102    BuiltinSignatureDescriptor {
103        label: "counts = imhist(I, n)",
104        inputs: &IMHIST_INPUTS_IMAGE_N,
105        outputs: &IMHIST_OUTPUT_COUNTS,
106    },
107    BuiltinSignatureDescriptor {
108        label: "[counts, binLocations] = imhist(I)",
109        inputs: &IMHIST_INPUTS_IMAGE,
110        outputs: &IMHIST_OUTPUT_COUNTS_BINS,
111    },
112    BuiltinSignatureDescriptor {
113        label: "[counts, binLocations] = imhist(I, n)",
114        inputs: &IMHIST_INPUTS_IMAGE_N,
115        outputs: &IMHIST_OUTPUT_COUNTS_BINS,
116    },
117    BuiltinSignatureDescriptor {
118        label: "counts = imhist(X, map)",
119        inputs: &IMHIST_INPUTS_INDEXED,
120        outputs: &IMHIST_OUTPUT_COUNTS,
121    },
122    BuiltinSignatureDescriptor {
123        label: "[counts, binLocations] = imhist(X, map)",
124        inputs: &IMHIST_INPUTS_INDEXED,
125        outputs: &IMHIST_OUTPUT_COUNTS_BINS,
126    },
127];
128
129const IMHIST_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
130    code: "RM.IMHIST.INVALID_ARGUMENT",
131    identifier: Some("RunMat:imhist:InvalidArgument"),
132    when: "Image input, bin count, or colormap arguments are malformed or unsupported.",
133    message: "imhist: invalid argument",
134};
135
136const IMHIST_ERROR_UNSUPPORTED_IMAGE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
137    code: "RM.IMHIST.UNSUPPORTED_IMAGE",
138    identifier: Some("RunMat:imhist:UnsupportedImage"),
139    when: "Input cannot be interpreted as a grayscale or indexed image.",
140    message: "imhist: unsupported image input",
141};
142
143const IMHIST_ERROR_PLOT_FAILED: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
144    code: "RM.IMHIST.PLOT_FAILED",
145    identifier: Some("RunMat:imhist:PlotFailed"),
146    when: "Statement-form histogram rendering fails.",
147    message: "imhist: plotting failed",
148};
149
150const IMHIST_ERROR_TOO_MANY_OUTPUTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
151    code: "RM.IMHIST.TOO_MANY_OUTPUTS",
152    identifier: Some("RunMat:imhist:TooManyOutputs"),
153    when: "More than two outputs are requested.",
154    message: "imhist: too many output arguments",
155};
156
157const IMHIST_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
158    code: "RM.IMHIST.INTERNAL",
159    identifier: Some("RunMat:imhist:Internal"),
160    when: "Internal histogram assembly fails.",
161    message: "imhist: internal error",
162};
163
164const IMHIST_ERRORS: [BuiltinErrorDescriptor; 5] = [
165    IMHIST_ERROR_INVALID_ARGUMENT,
166    IMHIST_ERROR_UNSUPPORTED_IMAGE,
167    IMHIST_ERROR_PLOT_FAILED,
168    IMHIST_ERROR_TOO_MANY_OUTPUTS,
169    IMHIST_ERROR_INTERNAL,
170];
171
172pub const IMHIST_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
173    signatures: &IMHIST_SIGNATURES,
174    output_mode: BuiltinOutputMode::ByRequestedOutputCount,
175    completion_policy: BuiltinCompletionPolicy::Public,
176    errors: &IMHIST_ERRORS,
177};
178
179fn imhist_error_with_message(
180    error: &'static BuiltinErrorDescriptor,
181    message: impl Into<String>,
182) -> RuntimeError {
183    let mut builder = build_runtime_error(message).with_builtin(NAME);
184    if let Some(identifier) = error.identifier {
185        builder = builder.with_identifier(identifier);
186    }
187    builder.build()
188}
189
190fn imhist_error_with_detail(
191    error: &'static BuiltinErrorDescriptor,
192    detail: impl AsRef<str>,
193) -> RuntimeError {
194    let raw = detail.as_ref().trim();
195    let normalized = raw.strip_prefix("imhist:").map(str::trim).unwrap_or(raw);
196    let message = if normalized.is_empty() {
197        error.message.to_string()
198    } else {
199        format!("{}: {}", error.message, normalized)
200    };
201    imhist_error_with_message(error, message)
202}
203
204fn invalid(detail: impl AsRef<str>) -> RuntimeError {
205    imhist_error_with_detail(&IMHIST_ERROR_INVALID_ARGUMENT, detail)
206}
207
208fn unsupported(detail: impl AsRef<str>) -> RuntimeError {
209    imhist_error_with_detail(&IMHIST_ERROR_UNSUPPORTED_IMAGE, detail)
210}
211
212fn internal(detail: impl AsRef<str>) -> RuntimeError {
213    imhist_error_with_detail(&IMHIST_ERROR_INTERNAL, detail)
214}
215
216fn too_many_outputs() -> RuntimeError {
217    imhist_error_with_message(
218        &IMHIST_ERROR_TOO_MANY_OUTPUTS,
219        IMHIST_ERROR_TOO_MANY_OUTPUTS.message,
220    )
221}
222
223#[cfg(feature = "plot-core")]
224fn plot_failed(detail: impl AsRef<str>) -> RuntimeError {
225    imhist_error_with_detail(&IMHIST_ERROR_PLOT_FAILED, detail)
226}
227
228fn map_flow(err: RuntimeError) -> RuntimeError {
229    if err.identifier().is_some() {
230        err
231    } else {
232        invalid(err.message())
233    }
234}
235
236#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::image::imhist")]
237pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
238    name: NAME,
239    op_kind: GpuOpKind::Custom("image-histogram"),
240    supported_precisions: &[crate::builtins::common::spec::ScalarType::F32, crate::builtins::common::spec::ScalarType::F64],
241    broadcast: BroadcastSemantics::None,
242    provider_hooks: &[ProviderHook::Custom("imhist")],
243    constant_strategy: ConstantStrategy::InlineLiteral,
244    residency: ResidencyPolicy::GatherImmediately,
245    nan_mode: ReductionNaN::Include,
246    two_pass_threshold: None,
247    workgroup_size: None,
248    accepts_nan_mode: false,
249    notes: "imhist gathers gpuArray inputs today so image-class binning and output shapes remain MATLAB-compatible.",
250};
251
252#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::image::imhist")]
253pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
254    name: NAME,
255    shape: ShapeRequirements::Any,
256    constant_strategy: ConstantStrategy::InlineLiteral,
257    elementwise: None,
258    reduction: None,
259    emits_nan: false,
260    notes: "imhist materializes histogram counts and terminates fusion chains.",
261};
262
263#[runtime_builtin(
264    name = "imhist",
265    category = "image",
266    summary = "Compute or display grayscale and indexed-image histograms.",
267    keywords = "imhist,image,histogram,intensity,grayscale,indexed,colormap",
268    sink = true,
269    suppress_auto_output = true,
270    type_resolver(imhist_type),
271    descriptor(crate::builtins::image::imhist::IMHIST_DESCRIPTOR),
272    builtin_path = "crate::builtins::image::imhist"
273)]
274async fn imhist_builtin(image: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
275    let eval = evaluate(image, &rest).await?;
276
277    if crate::output_context::requested_output_count() == Some(0)
278        && crate::output_count::current_output_count().is_none()
279    {
280        eval.render_plot()?;
281        return Ok(Value::OutputList(Vec::new()));
282    }
283
284    if let Some(out_count) = crate::output_count::current_output_count() {
285        if out_count == 0 {
286            eval.render_plot()?;
287            return Ok(Value::OutputList(Vec::new()));
288        }
289        if out_count == 1 {
290            return Ok(Value::OutputList(vec![eval.counts_value()]));
291        }
292        if out_count == 2 {
293            return Ok(Value::OutputList(eval.outputs()));
294        }
295        return Err(too_many_outputs());
296    }
297
298    Ok(eval.counts_value())
299}
300
301pub async fn evaluate(image: Value, rest: &[Value]) -> BuiltinResult<ImhistEvaluation> {
302    let image = common::gather_value(NAME, &image).await.map_err(map_flow)?;
303    let mut gathered_rest = Vec::with_capacity(rest.len());
304    for arg in rest {
305        gathered_rest.push(common::gather_value(NAME, arg).await.map_err(map_flow)?);
306    }
307    let call = parse_call(image, &gathered_rest)?;
308    Ok(match call.mode {
309        ImhistMode::Grayscale { bins } => {
310            let input = GrayscaleInput::from_value(call.image)?;
311            input.evaluate(bins)?
312        }
313        ImhistMode::Indexed { bins } => {
314            let input = IndexedInput::from_value(call.image, bins)?;
315            input.evaluate()?
316        }
317    })
318}
319
320struct ParsedCall {
321    image: Value,
322    mode: ImhistMode,
323}
324
325enum ImhistMode {
326    Grayscale { bins: Option<usize> },
327    Indexed { bins: usize },
328}
329
330fn parse_call(image: Value, rest: &[Value]) -> BuiltinResult<ParsedCall> {
331    match rest {
332        [] => Ok(ParsedCall {
333            image,
334            mode: ImhistMode::Grayscale { bins: None },
335        }),
336        [second] => {
337            if let Some(bins) = parse_optional_bin_count(second)? {
338                Ok(ParsedCall {
339                    image,
340                    mode: ImhistMode::Grayscale { bins: Some(bins) },
341                })
342            } else {
343                let bins = parse_colormap_bins(second)?;
344                Ok(ParsedCall {
345                    image,
346                    mode: ImhistMode::Indexed { bins },
347                })
348            }
349        }
350        _ => Err(invalid(
351            "expected imhist(I), imhist(I, n), or imhist(X, map)",
352        )),
353    }
354}
355
356fn parse_optional_bin_count(value: &Value) -> BuiltinResult<Option<usize>> {
357    let Some(raw) = scalar_number(value) else {
358        return Ok(None);
359    };
360    if !raw.is_finite() || raw < 1.0 || (raw.round() - raw).abs() > INTEGER_TOL {
361        return Err(invalid("bin count must be a positive integer scalar"));
362    }
363    let bins = raw.round() as usize;
364    validate_bin_count(bins)?;
365    Ok(Some(bins))
366}
367
368fn parse_colormap_bins(value: &Value) -> BuiltinResult<usize> {
369    let tensor = Tensor::try_from(value)
370        .map_err(|err| invalid(format!("colormap must be an Nx3 numeric array: {err}")))?;
371    if tensor.shape.len() != 2 || tensor.cols != 3 || tensor.rows == 0 {
372        return Err(invalid("colormap must be a non-empty Nx3 numeric array"));
373    }
374    if !tensor.data.iter().all(|value| value.is_finite()) {
375        return Err(invalid("colormap values must be finite"));
376    }
377    if !tensor.data.iter().all(|value| (0.0..=1.0).contains(value)) {
378        return Err(invalid("colormap values must be in the range [0, 1]"));
379    }
380    validate_bin_count(tensor.rows)?;
381    Ok(tensor.rows)
382}
383
384fn validate_bin_count(bins: usize) -> BuiltinResult<()> {
385    if bins == 0 {
386        return Err(invalid("bin count must be positive"));
387    }
388    if bins > MAX_BINS {
389        return Err(invalid(format!(
390            "bin count {bins} exceeds maximum supported bin count {MAX_BINS}"
391        )));
392    }
393    Ok(())
394}
395
396#[derive(Clone)]
397struct GrayscaleInput {
398    values: Vec<f64>,
399    class_min: f64,
400    class_max: f64,
401    default_bins: usize,
402}
403
404impl GrayscaleInput {
405    fn from_value(value: Value) -> BuiltinResult<Self> {
406        match value {
407            Value::Tensor(tensor) => Self::from_tensor(tensor),
408            Value::LogicalArray(logical) => Self::from_logical(logical),
409            Value::Num(value) => Self::from_float_values(vec![value], NumericDType::F64, &[1, 1]),
410            Value::Int(IntValue::U8(value)) => Ok(Self {
411                values: vec![f64::from(value)],
412                class_min: 0.0,
413                class_max: 255.0,
414                default_bins: DEFAULT_GRAYSCALE_BINS,
415            }),
416            Value::Int(IntValue::U16(value)) => Ok(Self {
417                values: vec![f64::from(value)],
418                class_min: 0.0,
419                class_max: 65535.0,
420                default_bins: DEFAULT_GRAYSCALE_BINS,
421            }),
422            Value::Int(value) => {
423                Self::from_float_values(vec![value.to_f64()], NumericDType::F64, &[1, 1])
424            }
425            Value::Bool(value) => Ok(Self {
426                values: vec![if value { 1.0 } else { 0.0 }],
427                class_min: 0.0,
428                class_max: 1.0,
429                default_bins: LOGICAL_BINS,
430            }),
431            other => Err(unsupported(format!(
432                "expected grayscale numeric or logical image, got {other:?}"
433            ))),
434        }
435    }
436
437    fn from_tensor(tensor: Tensor) -> BuiltinResult<Self> {
438        if !is_grayscale_shape(&tensor.shape) {
439            return Err(unsupported(
440                "expected an MxN grayscale image; truecolor RGB images are not accepted",
441            ));
442        }
443        match tensor.dtype {
444            NumericDType::U8 => Self::from_integer_values(tensor.data, 0.0, 255.0),
445            NumericDType::U16 => Self::from_integer_values(tensor.data, 0.0, 65535.0),
446            NumericDType::F32 | NumericDType::F64 => {
447                Self::from_float_values(tensor.data, tensor.dtype, &tensor.shape)
448            }
449        }
450    }
451
452    fn from_logical(logical: LogicalArray) -> BuiltinResult<Self> {
453        if !is_grayscale_shape(&logical.shape) {
454            return Err(unsupported("expected an MxN logical image"));
455        }
456        Ok(Self {
457            values: logical
458                .data
459                .into_iter()
460                .map(|value| if value == 0 { 0.0 } else { 1.0 })
461                .collect(),
462            class_min: 0.0,
463            class_max: 1.0,
464            default_bins: LOGICAL_BINS,
465        })
466    }
467
468    fn from_integer_values(
469        values: Vec<f64>,
470        class_min: f64,
471        class_max: f64,
472    ) -> BuiltinResult<Self> {
473        if values.iter().any(|value| {
474            !value.is_finite()
475                || *value < class_min
476                || *value > class_max
477                || (value.round() - *value).abs() > INTEGER_TOL
478        }) {
479            return Err(invalid(format!(
480                "integer grayscale image values must be finite integer values in [{class_min:.0}, {class_max:.0}]"
481            )));
482        }
483        Ok(Self {
484            values,
485            class_min,
486            class_max,
487            default_bins: DEFAULT_GRAYSCALE_BINS,
488        })
489    }
490
491    fn from_float_values(
492        values: Vec<f64>,
493        _dtype: NumericDType,
494        shape: &[usize],
495    ) -> BuiltinResult<Self> {
496        if !is_grayscale_shape(shape) {
497            return Err(unsupported(
498                "expected an MxN grayscale image; truecolor RGB images are not accepted",
499            ));
500        }
501        if values
502            .iter()
503            .any(|value| !value.is_finite() || !(0.0..=1.0).contains(value))
504        {
505            return Err(invalid(
506                "floating-point grayscale image values must be finite and normalized to [0, 1]",
507            ));
508        }
509        Ok(Self {
510            values,
511            class_min: 0.0,
512            class_max: 1.0,
513            default_bins: DEFAULT_GRAYSCALE_BINS,
514        })
515    }
516
517    fn evaluate(&self, requested_bins: Option<usize>) -> BuiltinResult<ImhistEvaluation> {
518        let bins = requested_bins.unwrap_or(self.default_bins);
519        validate_bin_count(bins)?;
520        let locations = linspace(self.class_min, self.class_max, bins);
521        let counts =
522            histogram_counts_by_nearest_bin(&self.values, self.class_min, self.class_max, bins)?;
523        ImhistEvaluation::from_counts_locations(counts, locations)
524    }
525}
526
527struct IndexedInput {
528    values: Vec<f64>,
529    zero_based: bool,
530    bins: usize,
531}
532
533impl IndexedInput {
534    fn from_value(value: Value, bins: usize) -> BuiltinResult<Self> {
535        match value {
536            Value::Tensor(tensor) => {
537                if !is_grayscale_shape(&tensor.shape) {
538                    return Err(unsupported("indexed image must be an MxN matrix"));
539                }
540                let zero_based = matches!(tensor.dtype, NumericDType::U8 | NumericDType::U16);
541                Ok(Self {
542                    values: tensor.data,
543                    zero_based,
544                    bins,
545                })
546            }
547            Value::LogicalArray(logical) => {
548                if !is_grayscale_shape(&logical.shape) {
549                    return Err(unsupported("indexed image must be an MxN matrix"));
550                }
551                Ok(Self {
552                    values: logical
553                        .data
554                        .into_iter()
555                        .map(|value| if value == 0 { 0.0 } else { 1.0 })
556                        .collect(),
557                    zero_based: true,
558                    bins,
559                })
560            }
561            Value::Num(value) => Ok(Self {
562                values: vec![value],
563                zero_based: false,
564                bins,
565            }),
566            Value::Int(IntValue::U8(value)) => Ok(Self {
567                values: vec![f64::from(value)],
568                zero_based: true,
569                bins,
570            }),
571            Value::Int(IntValue::U16(value)) => Ok(Self {
572                values: vec![f64::from(value)],
573                zero_based: true,
574                bins,
575            }),
576            Value::Int(value) => Ok(Self {
577                values: vec![value.to_f64()],
578                zero_based: false,
579                bins,
580            }),
581            Value::Bool(value) => Ok(Self {
582                values: vec![if value { 1.0 } else { 0.0 }],
583                zero_based: true,
584                bins,
585            }),
586            other => Err(unsupported(format!(
587                "expected indexed numeric or logical image, got {other:?}"
588            ))),
589        }
590    }
591
592    fn evaluate(&self) -> BuiltinResult<ImhistEvaluation> {
593        let mut counts = vec![0.0; self.bins];
594        for &value in &self.values {
595            if !value.is_finite() || (value.round() - value).abs() > INTEGER_TOL {
596                return Err(invalid(
597                    "indexed image values must be finite integer indices",
598                ));
599            }
600            let rounded = value.round();
601            let index = if self.zero_based {
602                rounded as isize
603            } else {
604                rounded as isize - 1
605            };
606            if index < 0 || index as usize >= self.bins {
607                return Err(invalid(format!(
608                    "indexed image value {value} is outside the colormap range"
609                )));
610            }
611            counts[index as usize] += 1.0;
612        }
613        let locations: Vec<f64> = (1..=self.bins).map(|value| value as f64).collect();
614        ImhistEvaluation::from_counts_locations(counts, locations)
615    }
616}
617
618pub struct ImhistEvaluation {
619    counts: Tensor,
620    locations: Tensor,
621}
622
623impl ImhistEvaluation {
624    fn from_counts_locations(counts: Vec<f64>, locations: Vec<f64>) -> BuiltinResult<Self> {
625        if counts.len() != locations.len() {
626            return Err(internal("counts and bin locations length mismatch"));
627        }
628        let rows = counts.len();
629        let counts = Tensor::new(counts, vec![rows, 1])
630            .map_err(|err| internal(format!("counts tensor: {err}")))?;
631        let locations = Tensor::new(locations, vec![rows, 1])
632            .map_err(|err| internal(format!("bin location tensor: {err}")))?;
633        Ok(Self { counts, locations })
634    }
635
636    fn counts_value(&self) -> Value {
637        Value::Tensor(self.counts.clone())
638    }
639
640    fn locations_value(&self) -> Value {
641        Value::Tensor(self.locations.clone())
642    }
643
644    fn outputs(&self) -> Vec<Value> {
645        vec![self.counts_value(), self.locations_value()]
646    }
647
648    fn render_plot(&self) -> BuiltinResult<()> {
649        render_imhist_plot(&self.counts, &self.locations)
650    }
651}
652
653#[cfg(feature = "plot-core")]
654fn render_imhist_plot(counts: &Tensor, locations: &Tensor) -> BuiltinResult<()> {
655    let plot_data = plot_display_bins(counts, locations)?;
656    let mut chart = BarChart::new(plot_data.labels, plot_data.counts)
657        .map_err(|err| plot_failed(format!("chart construction failed: {err}")))?;
658    chart.set_bar_width(0.95);
659    chart.set_color(glam::Vec4::new(0.1, 0.1, 0.1, 0.95));
660    let mut chart = Some(chart);
661    let render_result = crate::builtins::plotting::state::render_active_plot(
662        NAME,
663        crate::builtins::plotting::state::PlotRenderOptions {
664            title: "Image Histogram",
665            x_label: "Intensity",
666            y_label: "Count",
667            ..Default::default()
668        },
669        move |figure, axes| {
670            figure.add_bar_chart_on_axes(
671                chart.take().expect("imhist chart consumed exactly once"),
672                axes,
673            );
674            Ok(())
675        },
676    );
677    if let Err(err) = render_result {
678        let lower = err.message().to_ascii_lowercase();
679        if lower.contains("plotting is unavailable") || lower.contains("non-main thread") {
680            return Ok(());
681        }
682        return Err(plot_failed(err.message()));
683    }
684    Ok(())
685}
686
687#[cfg(not(feature = "plot-core"))]
688fn render_imhist_plot(_counts: &Tensor, _locations: &Tensor) -> BuiltinResult<()> {
689    Ok(())
690}
691
692fn is_grayscale_shape(shape: &[usize]) -> bool {
693    matches!(shape.len(), 0..=2)
694}
695
696fn scalar_number(value: &Value) -> Option<f64> {
697    match value {
698        Value::Num(value) => Some(*value),
699        Value::Int(value) => Some(value.to_f64()),
700        Value::Bool(value) => Some(if *value { 1.0 } else { 0.0 }),
701        Value::Tensor(tensor) if tensor.data.len() == 1 => Some(tensor.data[0]),
702        _ => None,
703    }
704}
705
706fn linspace(start: f64, stop: f64, count: usize) -> Vec<f64> {
707    if count == 0 {
708        return Vec::new();
709    }
710    if count == 1 {
711        return vec![start];
712    }
713    let step = (stop - start) / (count - 1) as f64;
714    (0..count).map(|idx| start + step * idx as f64).collect()
715}
716
717fn histogram_counts_by_nearest_bin(
718    values: &[f64],
719    class_min: f64,
720    class_max: f64,
721    bins: usize,
722) -> BuiltinResult<Vec<f64>> {
723    let mut counts = vec![0.0; bins];
724    if bins == 0 {
725        return Ok(counts);
726    }
727    if bins == 1 || (class_max - class_min).abs() <= f64::EPSILON {
728        for &value in values {
729            if !value.is_finite() || value < class_min || value > class_max {
730                return Err(invalid(
731                    "grayscale image values are outside the image class range",
732                ));
733            }
734            counts[0] += 1.0;
735        }
736        return Ok(counts);
737    }
738    let scale = (bins - 1) as f64 / (class_max - class_min);
739    for &value in values {
740        if !value.is_finite() || value < class_min || value > class_max {
741            return Err(invalid(
742                "grayscale image values are outside the image class range",
743            ));
744        }
745        let relative = ((value - class_min) * scale).round();
746        let index = if relative <= 0.0 {
747            0
748        } else if relative >= (bins - 1) as f64 {
749            bins - 1
750        } else {
751            relative as usize
752        };
753        counts[index] += 1.0;
754    }
755    Ok(counts)
756}
757
758#[cfg(feature = "plot-core")]
759fn format_bin_label(value: f64) -> String {
760    if (value.round() - value).abs() <= INTEGER_TOL {
761        format!("{:.0}", value)
762    } else {
763        format!("{:.3}", value)
764    }
765}
766
767#[cfg(feature = "plot-core")]
768struct PlotDisplayBins {
769    labels: Vec<String>,
770    counts: Vec<f64>,
771}
772
773#[cfg(feature = "plot-core")]
774fn plot_display_bins(counts: &Tensor, locations: &Tensor) -> BuiltinResult<PlotDisplayBins> {
775    if counts.data.len() != locations.data.len() {
776        return Err(internal("counts and bin locations length mismatch"));
777    }
778    if counts.data.is_empty() {
779        return Err(internal("histogram has no bins to plot"));
780    }
781    if counts.data.len() <= MAX_PLOT_BINS {
782        return Ok(PlotDisplayBins {
783            labels: locations
784                .data
785                .iter()
786                .map(|value| format_bin_label(*value))
787                .collect(),
788            counts: counts.data.clone(),
789        });
790    }
791
792    let stride = counts.data.len().div_ceil(MAX_PLOT_BINS);
793    let mut labels = Vec::with_capacity(counts.data.len().div_ceil(stride));
794    let mut display_counts = Vec::with_capacity(labels.capacity());
795    for start in (0..counts.data.len()).step_by(stride) {
796        let end = (start + stride).min(counts.data.len());
797        let total = counts.data[start..end].iter().sum::<f64>();
798        let location = 0.5 * (locations.data[start] + locations.data[end - 1]);
799        labels.push(format_bin_label(location));
800        display_counts.push(total);
801    }
802
803    Ok(PlotDisplayBins {
804        labels,
805        counts: display_counts,
806    })
807}
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812    use futures::executor::block_on;
813    use runmat_builtins::NumericDType;
814
815    fn call(image: Value, rest: Vec<Value>, outputs: Option<usize>) -> Value {
816        let _guard = outputs.map(|count| crate::output_count::push_output_count(Some(count)));
817        block_on(imhist_builtin(image, rest)).expect("imhist")
818    }
819
820    fn tensor(data: Vec<f64>, shape: Vec<usize>, dtype: NumericDType) -> Tensor {
821        Tensor::new_with_dtype(data, shape, dtype).unwrap()
822    }
823
824    #[test]
825    fn uint8_grayscale_default_bins_count_exact_intensities() {
826        let image = tensor(vec![0.0, 1.0, 1.0, 255.0], vec![2, 2], NumericDType::U8);
827        let Value::Tensor(counts) = call(Value::Tensor(image), vec![], None) else {
828            panic!("expected counts tensor");
829        };
830        assert_eq!(counts.shape, vec![256, 1]);
831        assert_eq!(counts.data[0], 1.0);
832        assert_eq!(counts.data[1], 2.0);
833        assert_eq!(counts.data[255], 1.0);
834    }
835
836    #[test]
837    fn two_outputs_return_counts_and_bin_locations_as_columns() {
838        let image = tensor(vec![0.0, 0.5, 1.0, 1.0], vec![2, 2], NumericDType::F64);
839        let Value::OutputList(outputs) = call(Value::Tensor(image), vec![Value::Num(3.0)], Some(2))
840        else {
841            panic!("expected output list");
842        };
843        let counts = Tensor::try_from(&outputs[0]).unwrap();
844        let locations = Tensor::try_from(&outputs[1]).unwrap();
845        assert_eq!(counts.shape, vec![3, 1]);
846        assert_eq!(locations.shape, vec![3, 1]);
847        assert_eq!(counts.data, vec![1.0, 1.0, 2.0]);
848        assert_eq!(locations.data, vec![0.0, 0.5, 1.0]);
849    }
850
851    #[test]
852    fn logical_image_uses_two_bins() {
853        let logical = LogicalArray::new(vec![0, 1, 1, 0, 1, 0], vec![2, 3]).unwrap();
854        let Value::Tensor(counts) = call(Value::LogicalArray(logical), vec![], None) else {
855            panic!("expected counts");
856        };
857        assert_eq!(counts.shape, vec![2, 1]);
858        assert_eq!(counts.data, vec![3.0, 3.0]);
859    }
860
861    #[test]
862    fn indexed_image_counts_colormap_indices() {
863        let image = tensor(vec![1.0, 2.0, 3.0, 2.0], vec![2, 2], NumericDType::F64);
864        let map = tensor(
865            vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
866            vec![3, 3],
867            NumericDType::F64,
868        );
869        let Value::OutputList(outputs) =
870            call(Value::Tensor(image), vec![Value::Tensor(map)], Some(2))
871        else {
872            panic!("expected output list");
873        };
874        let counts = Tensor::try_from(&outputs[0]).unwrap();
875        let locations = Tensor::try_from(&outputs[1]).unwrap();
876        assert_eq!(counts.data, vec![1.0, 2.0, 1.0]);
877        assert_eq!(locations.data, vec![1.0, 2.0, 3.0]);
878    }
879
880    #[test]
881    fn uint8_indexed_image_uses_zero_based_colormap_indices() {
882        let image = tensor(vec![0.0, 1.0, 1.0, 2.0], vec![2, 2], NumericDType::U8);
883        let map = tensor(vec![0.0; 9], vec![3, 3], NumericDType::F64);
884        let Value::Tensor(counts) = call(Value::Tensor(image), vec![Value::Tensor(map)], None)
885        else {
886            panic!("expected counts");
887        };
888        assert_eq!(counts.data, vec![1.0, 2.0, 1.0]);
889    }
890
891    #[test]
892    fn rejects_out_of_range_floating_grayscale_values() {
893        let image = tensor(vec![0.0, 35.0, 220.0, 255.0], vec![2, 2], NumericDType::F64);
894        let err = block_on(imhist_builtin(Value::Tensor(image), vec![])).unwrap_err();
895        assert_eq!(err.identifier(), Some("RunMat:imhist:InvalidArgument"));
896        assert!(err.message().contains("normalized to [0, 1]"));
897    }
898
899    #[test]
900    fn rejects_nan_indexed_image_values() {
901        let image = tensor(vec![1.0, f64::NAN], vec![1, 2], NumericDType::F64);
902        let map = tensor(vec![0.0; 6], vec![2, 3], NumericDType::F64);
903        let err = block_on(imhist_builtin(
904            Value::Tensor(image),
905            vec![Value::Tensor(map)],
906        ))
907        .unwrap_err();
908        assert_eq!(err.identifier(), Some("RunMat:imhist:InvalidArgument"));
909        assert!(err.message().contains("finite integer indices"));
910    }
911
912    #[test]
913    fn rejects_colormap_values_outside_unit_range() {
914        let image = tensor(vec![1.0], vec![1, 1], NumericDType::F64);
915        let map = tensor(vec![1.5, 0.0, 0.0], vec![1, 3], NumericDType::F64);
916        let err = block_on(imhist_builtin(
917            Value::Tensor(image),
918            vec![Value::Tensor(map)],
919        ))
920        .unwrap_err();
921        assert_eq!(err.identifier(), Some("RunMat:imhist:InvalidArgument"));
922        assert!(err.message().contains("range [0, 1]"));
923    }
924
925    #[test]
926    fn rejects_more_than_two_outputs() {
927        let image = tensor(vec![0.0, 1.0], vec![1, 2], NumericDType::U8);
928        let err = {
929            let _guard = crate::output_count::push_output_count(Some(3));
930            block_on(imhist_builtin(Value::Tensor(image), vec![])).unwrap_err()
931        };
932        assert_eq!(err.identifier(), Some("RunMat:imhist:TooManyOutputs"));
933    }
934
935    #[test]
936    fn rejects_rgb_shaped_input() {
937        let image = tensor(vec![0.0; 12], vec![2, 2, 3], NumericDType::F64);
938        let err = block_on(imhist_builtin(Value::Tensor(image), vec![])).unwrap_err();
939        assert_eq!(err.identifier(), Some("RunMat:imhist:UnsupportedImage"));
940    }
941
942    #[cfg(feature = "plot-core")]
943    #[test]
944    fn plotting_downsamples_large_histograms_without_changing_outputs() {
945        let image = tensor(vec![0.0, 65535.0], vec![1, 2], NumericDType::U16);
946        let eval = block_on(evaluate(
947            Value::Tensor(image),
948            &[Value::Num((MAX_PLOT_BINS + 100) as f64)],
949        ))
950        .unwrap();
951        assert_eq!(eval.counts.data.len(), MAX_PLOT_BINS + 100);
952        let plot = plot_display_bins(&eval.counts, &eval.locations).unwrap();
953        assert!(plot.counts.len() <= MAX_PLOT_BINS);
954        assert_eq!(plot.counts.iter().sum::<f64>(), 2.0);
955    }
956
957    #[cfg(feature = "plot-core")]
958    #[test]
959    fn statement_form_renders_bar_chart_without_value() {
960        use crate::builtins::plotting::tests::{ensure_plot_test_env, lock_plot_registry};
961        use crate::builtins::plotting::{
962            clear_figure, clone_figure, current_figure_handle, reset_hold_state_for_run,
963        };
964        use runmat_plot::plots::PlotElement;
965
966        let _guard = lock_plot_registry();
967        ensure_plot_test_env();
968        reset_hold_state_for_run();
969        let _ = clear_figure(None);
970        let _requested = crate::output_context::push_output_count(0);
971        let image = tensor(vec![0.0, 1.0, 1.0, 2.0], vec![2, 2], NumericDType::U8);
972        let out = block_on(imhist_builtin(Value::Tensor(image), vec![])).unwrap();
973        assert_eq!(out, Value::OutputList(Vec::new()));
974        if let Some(fig) = clone_figure(current_figure_handle()) {
975            if let Some(plot) = fig.plots().next() {
976                assert!(matches!(plot, PlotElement::Bar(_)));
977            }
978        }
979    }
980
981    #[cfg(not(feature = "plot-core"))]
982    #[test]
983    fn statement_form_noops_without_plot_core() {
984        let _requested = crate::output_context::push_output_count(0);
985        let image = tensor(vec![0.0, 1.0], vec![1, 2], NumericDType::U8);
986        let out = block_on(imhist_builtin(Value::Tensor(image), vec![])).unwrap();
987        assert_eq!(out, Value::OutputList(Vec::new()));
988    }
989}