1use 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}