Skip to main content

runmat_runtime/builtins/image/
imread.rs

1use std::io::Cursor;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use image::io::Reader as ImageReader;
6use image::{DynamicImage, ImageFormat};
7use runmat_builtins::{NumericDType, Tensor, Value};
8use runmat_macros::runtime_builtin;
9use url::Url;
10
11use crate::builtins::common::spec::{
12    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
13    ReductionNaN, ResidencyPolicy, ShapeRequirements,
14};
15use crate::builtins::common::{map_control_flow_with_builtin, tensor};
16use crate::builtins::image::type_resolvers::imread_type;
17use crate::builtins::io::http::transport::{
18    self, HttpMethod, HttpRequest, TransportError, TransportErrorKind,
19};
20use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
21
22const BUILTIN_NAME: &str = "imread";
23const DEFAULT_TIMEOUT_SECONDS: f64 = 60.0;
24const DEFAULT_USER_AGENT: &str = "RunMat imread/0.0";
25
26#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::image::imread")]
27pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
28    name: "imread",
29    op_kind: GpuOpKind::Custom("image-read"),
30    supported_precisions: &[],
31    broadcast: BroadcastSemantics::None,
32    provider_hooks: &[],
33    constant_strategy: ConstantStrategy::InlineLiteral,
34    residency: ResidencyPolicy::GatherImmediately,
35    nan_mode: ReductionNaN::Include,
36    two_pass_threshold: None,
37    workgroup_size: None,
38    accepts_nan_mode: false,
39    notes: "Host-only image I/O and CPU decoding. Decoded tensors are host-resident; use gpuArray after import for GPU work.",
40};
41
42#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::image::imread")]
43pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
44    name: "imread",
45    shape: ShapeRequirements::Any,
46    constant_strategy: ConstantStrategy::InlineLiteral,
47    elementwise: None,
48    reduction: None,
49    emits_nan: false,
50    notes: "Not eligible for fusion; image loading performs file or network I/O and CPU decoding.",
51};
52
53fn imread_error(identifier: &'static str, message: impl Into<String>) -> RuntimeError {
54    build_runtime_error(message)
55        .with_builtin(BUILTIN_NAME)
56        .with_identifier(identifier)
57        .build()
58}
59
60fn map_flow(err: RuntimeError) -> RuntimeError {
61    map_control_flow_with_builtin(err, BUILTIN_NAME)
62}
63
64#[runtime_builtin(
65    name = "imread",
66    category = "image/io",
67    summary = "Read an image from a file path or HTTP(S) URL into a MATLAB-compatible array.",
68    keywords = "imread,image,read,file,jpeg,jpg,png,bmp,gif,tiff,webp,url",
69    accel = "sink",
70    type_resolver(imread_type),
71    builtin_path = "crate::builtins::image::imread"
72)]
73async fn imread_builtin(source: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
74    let source = gather_if_needed_async(&source).await.map_err(map_flow)?;
75    let mut gathered_rest = Vec::with_capacity(rest.len());
76    for arg in &rest {
77        gathered_rest.push(gather_if_needed_async(arg).await.map_err(map_flow)?);
78    }
79
80    let source = string_arg("filename", &source)?;
81    if source.is_empty() {
82        return Err(imread_error(
83            "RunMat:imread:InvalidFilename",
84            "imread: filename must not be empty",
85        ));
86    }
87
88    let format_hint = match gathered_rest.as_slice() {
89        [] => None,
90        [format] => Some(parse_format_hint(&string_arg("format", format)?)?),
91        _ => {
92            return Err(imread_error(
93                "RunMat:imread:TooManyInputs",
94                "imread: too many input arguments",
95            ))
96        }
97    };
98
99    let bytes = read_source_bytes(&source).await?;
100    let decoded = decode_image_bytes(&bytes, format_hint)?;
101    let materialized = materialize_image(decoded)?;
102
103    match crate::output_count::current_output_count() {
104        None => Ok(Value::Tensor(materialized.image)),
105        Some(0) => Ok(Value::OutputList(Vec::new())),
106        Some(1) => Ok(Value::OutputList(vec![Value::Tensor(materialized.image)])),
107        Some(2) => Ok(Value::OutputList(vec![
108            Value::Tensor(materialized.image),
109            empty_tensor_value()?,
110        ])),
111        Some(3) => Ok(Value::OutputList(vec![
112            Value::Tensor(materialized.image),
113            empty_tensor_value()?,
114            materialized
115                .alpha
116                .map(Value::Tensor)
117                .unwrap_or(empty_tensor_value()?),
118        ])),
119        Some(_) => Err(imread_error(
120            "RunMat:imread:TooManyOutputs",
121            "imread: too many output arguments",
122        )),
123    }
124}
125
126fn string_arg(label: &str, value: &Value) -> BuiltinResult<String> {
127    tensor::value_to_string(value).ok_or_else(|| {
128        imread_error(
129            "RunMat:imread:InvalidArgument",
130            format!("imread: {label} must be a string scalar or character vector"),
131        )
132    })
133}
134
135fn parse_format_hint(value: &str) -> BuiltinResult<ImageFormat> {
136    let label = value.trim().trim_start_matches('.').to_ascii_lowercase();
137    if label.is_empty() {
138        return Err(imread_error(
139            "RunMat:imread:InvalidFormat",
140            "imread: format hint must not be empty",
141        ));
142    }
143    let format = match label.as_str() {
144        "jpg" | "jpeg" | "jpe" => ImageFormat::Jpeg,
145        "tif" | "tiff" => ImageFormat::Tiff,
146        "png" => ImageFormat::Png,
147        "bmp" => ImageFormat::Bmp,
148        "gif" => ImageFormat::Gif,
149        "webp" => ImageFormat::WebP,
150        "ico" => ImageFormat::Ico,
151        other => ImageFormat::from_extension(other).ok_or_else(|| {
152            imread_error(
153                "RunMat:imread:UnsupportedFormat",
154                format!("imread: unsupported image format '{other}'"),
155            )
156        })?,
157    };
158    Ok(format)
159}
160
161async fn read_source_bytes(source: &str) -> BuiltinResult<Vec<u8>> {
162    if let Ok(url) = Url::parse(source) {
163        let scheme = url.scheme();
164        // A single-letter scheme is a Windows drive letter (e.g. "C:/..."), not a URL.
165        if scheme.len() > 1 {
166            return match scheme {
167                "http" | "https" => read_url_bytes(url).await,
168                "file" => {
169                    let path = file_url_to_path(&url)?;
170                    read_local_path(&path).await
171                }
172                _ => Err(imread_error(
173                    "RunMat:imread:UnsupportedScheme",
174                    format!("imread: unsupported URL scheme '{scheme}'"),
175                )),
176            };
177        }
178    }
179
180    read_local_path(Path::new(source)).await
181}
182
183async fn read_local_path(path: &Path) -> BuiltinResult<Vec<u8>> {
184    runmat_filesystem::read_async(path).await.map_err(|err| {
185        imread_error(
186            "RunMat:imread:FileReadError",
187            format!("imread: unable to read '{}': {err}", path.display()),
188        )
189    })
190}
191
192fn file_url_to_path(url: &Url) -> BuiltinResult<PathBuf> {
193    if let Some(host) = url.host_str() {
194        if !host.is_empty() && !host.eq_ignore_ascii_case("localhost") {
195            return Err(imread_error(
196                "RunMat:imread:InvalidFileUrl",
197                format!("imread: file URL host '{host}' is not local"),
198            ));
199        }
200    }
201
202    let decoded = percent_decode_url_path(url.path())?;
203
204    #[cfg(windows)]
205    {
206        let path =
207            if decoded.len() >= 3 && decoded.as_bytes()[0] == b'/' && decoded.as_bytes()[2] == b':'
208            {
209                &decoded[1..]
210            } else {
211                decoded.as_str()
212            };
213        Ok(PathBuf::from(path))
214    }
215
216    #[cfg(not(windows))]
217    {
218        Ok(PathBuf::from(decoded))
219    }
220}
221
222fn percent_decode_url_path(input: &str) -> BuiltinResult<String> {
223    let bytes = input.as_bytes();
224    let mut output = Vec::with_capacity(bytes.len());
225    let mut index = 0usize;
226    while index < bytes.len() {
227        if bytes[index] == b'%' {
228            if index + 2 >= bytes.len() {
229                return Err(imread_error(
230                    "RunMat:imread:InvalidFileUrl",
231                    "imread: invalid percent escape in file URL",
232                ));
233            }
234            let hi = hex_value(bytes[index + 1]).ok_or_else(|| {
235                imread_error(
236                    "RunMat:imread:InvalidFileUrl",
237                    "imread: invalid percent escape in file URL",
238                )
239            })?;
240            let lo = hex_value(bytes[index + 2]).ok_or_else(|| {
241                imread_error(
242                    "RunMat:imread:InvalidFileUrl",
243                    "imread: invalid percent escape in file URL",
244                )
245            })?;
246            output.push((hi << 4) | lo);
247            index += 3;
248        } else {
249            output.push(bytes[index]);
250            index += 1;
251        }
252    }
253
254    String::from_utf8(output).map_err(|err| {
255        imread_error(
256            "RunMat:imread:InvalidFileUrl",
257            format!("imread: file URL path is not valid UTF-8: {err}"),
258        )
259    })
260}
261
262fn hex_value(byte: u8) -> Option<u8> {
263    match byte {
264        b'0'..=b'9' => Some(byte - b'0'),
265        b'a'..=b'f' => Some(byte - b'a' + 10),
266        b'A'..=b'F' => Some(byte - b'A' + 10),
267        _ => None,
268    }
269}
270
271async fn read_url_bytes(url: Url) -> BuiltinResult<Vec<u8>> {
272    let request = HttpRequest {
273        url,
274        method: HttpMethod::Get,
275        headers: vec![(
276            "Accept".to_string(),
277            "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8".to_string(),
278        )],
279        body: None,
280        timeout: Duration::from_secs_f64(DEFAULT_TIMEOUT_SECONDS),
281        user_agent: DEFAULT_USER_AGENT.to_string(),
282    };
283    let response = transport::send_request(&request).map_err(imread_transport_error)?;
284    Ok(response.body)
285}
286
287fn imread_transport_error(err: TransportError) -> RuntimeError {
288    let identifier = match &err.kind {
289        TransportErrorKind::Timeout => "RunMat:imread:Timeout",
290        TransportErrorKind::Connect => "RunMat:imread:NetworkError",
291        TransportErrorKind::Status(_) => "RunMat:imread:HttpStatus",
292        TransportErrorKind::InvalidHeader(_) => "RunMat:imread:InvalidHeader",
293        TransportErrorKind::Other => "RunMat:imread:NetworkError",
294    };
295    let message = err.message_with_prefix(BUILTIN_NAME);
296    build_runtime_error(message)
297        .with_builtin(BUILTIN_NAME)
298        .with_identifier(identifier)
299        .with_source(err)
300        .build()
301}
302
303fn decode_image_bytes(bytes: &[u8], format: Option<ImageFormat>) -> BuiltinResult<DynamicImage> {
304    let reader = if let Some(format) = format {
305        ImageReader::with_format(Cursor::new(bytes), format)
306    } else {
307        ImageReader::new(Cursor::new(bytes))
308            .with_guessed_format()
309            .map_err(|err| {
310                imread_error(
311                    "RunMat:imread:DecodeError",
312                    format!("imread: unable to detect image format: {err}"),
313                )
314            })?
315    };
316    reader.decode().map_err(|err| {
317        imread_error(
318            "RunMat:imread:DecodeError",
319            format!("imread: unable to decode image: {err}"),
320        )
321    })
322}
323
324struct MaterializedImage {
325    image: Tensor,
326    alpha: Option<Tensor>,
327}
328
329fn materialize_image(image: DynamicImage) -> BuiltinResult<MaterializedImage> {
330    if let Some(buffer) = image.as_luma8() {
331        return Ok(MaterializedImage {
332            image: tensor_from_interleaved(
333                buffer.as_raw(),
334                buffer.width(),
335                buffer.height(),
336                1,
337                1,
338                NumericDType::U8,
339            )?,
340            alpha: None,
341        });
342    }
343    if let Some(buffer) = image.as_luma_alpha8() {
344        return Ok(MaterializedImage {
345            image: tensor_from_interleaved(
346                buffer.as_raw(),
347                buffer.width(),
348                buffer.height(),
349                2,
350                1,
351                NumericDType::U8,
352            )?,
353            alpha: Some(alpha_from_interleaved(
354                buffer.as_raw(),
355                buffer.width(),
356                buffer.height(),
357                2,
358                1,
359                NumericDType::U8,
360            )?),
361        });
362    }
363    if let Some(buffer) = image.as_rgb8() {
364        return Ok(MaterializedImage {
365            image: tensor_from_interleaved(
366                buffer.as_raw(),
367                buffer.width(),
368                buffer.height(),
369                3,
370                3,
371                NumericDType::U8,
372            )?,
373            alpha: None,
374        });
375    }
376    if let Some(buffer) = image.as_rgba8() {
377        return Ok(MaterializedImage {
378            image: tensor_from_interleaved(
379                buffer.as_raw(),
380                buffer.width(),
381                buffer.height(),
382                4,
383                3,
384                NumericDType::U8,
385            )?,
386            alpha: Some(alpha_from_interleaved(
387                buffer.as_raw(),
388                buffer.width(),
389                buffer.height(),
390                4,
391                3,
392                NumericDType::U8,
393            )?),
394        });
395    }
396    if let Some(buffer) = image.as_luma16() {
397        return Ok(MaterializedImage {
398            image: tensor_from_interleaved(
399                buffer.as_raw(),
400                buffer.width(),
401                buffer.height(),
402                1,
403                1,
404                NumericDType::U16,
405            )?,
406            alpha: None,
407        });
408    }
409    if let Some(buffer) = image.as_luma_alpha16() {
410        return Ok(MaterializedImage {
411            image: tensor_from_interleaved(
412                buffer.as_raw(),
413                buffer.width(),
414                buffer.height(),
415                2,
416                1,
417                NumericDType::U16,
418            )?,
419            alpha: Some(alpha_from_interleaved(
420                buffer.as_raw(),
421                buffer.width(),
422                buffer.height(),
423                2,
424                1,
425                NumericDType::U16,
426            )?),
427        });
428    }
429    if let Some(buffer) = image.as_rgb16() {
430        return Ok(MaterializedImage {
431            image: tensor_from_interleaved(
432                buffer.as_raw(),
433                buffer.width(),
434                buffer.height(),
435                3,
436                3,
437                NumericDType::U16,
438            )?,
439            alpha: None,
440        });
441    }
442    if let Some(buffer) = image.as_rgba16() {
443        return Ok(MaterializedImage {
444            image: tensor_from_interleaved(
445                buffer.as_raw(),
446                buffer.width(),
447                buffer.height(),
448                4,
449                3,
450                NumericDType::U16,
451            )?,
452            alpha: Some(alpha_from_interleaved(
453                buffer.as_raw(),
454                buffer.width(),
455                buffer.height(),
456                4,
457                3,
458                NumericDType::U16,
459            )?),
460        });
461    }
462    if let Some(buffer) = image.as_rgb32f() {
463        return Ok(MaterializedImage {
464            image: tensor_from_interleaved(
465                buffer.as_raw(),
466                buffer.width(),
467                buffer.height(),
468                3,
469                3,
470                NumericDType::F32,
471            )?,
472            alpha: None,
473        });
474    }
475    if let Some(buffer) = image.as_rgba32f() {
476        return Ok(MaterializedImage {
477            image: tensor_from_interleaved(
478                buffer.as_raw(),
479                buffer.width(),
480                buffer.height(),
481                4,
482                3,
483                NumericDType::F32,
484            )?,
485            alpha: Some(alpha_from_interleaved(
486                buffer.as_raw(),
487                buffer.width(),
488                buffer.height(),
489                4,
490                3,
491                NumericDType::F32,
492            )?),
493        });
494    }
495
496    let rgba = image.to_rgba8();
497    Ok(MaterializedImage {
498        image: tensor_from_interleaved(
499            rgba.as_raw(),
500            rgba.width(),
501            rgba.height(),
502            4,
503            3,
504            NumericDType::U8,
505        )?,
506        alpha: Some(alpha_from_interleaved(
507            rgba.as_raw(),
508            rgba.width(),
509            rgba.height(),
510            4,
511            3,
512            NumericDType::U8,
513        )?),
514    })
515}
516
517fn tensor_from_interleaved<T>(
518    raw: &[T],
519    width: u32,
520    height: u32,
521    input_channels: usize,
522    output_channels: usize,
523    dtype: NumericDType,
524) -> BuiltinResult<Tensor>
525where
526    T: Copy + Into<f64>,
527{
528    let rows = height as usize;
529    let cols = width as usize;
530    let pixels = rows.saturating_mul(cols);
531    let mut data = vec![0.0; pixels.saturating_mul(output_channels)];
532    for row in 0..rows {
533        for col in 0..cols {
534            let source_base = (row * cols + col) * input_channels;
535            let dest_base = row + rows * col;
536            for channel in 0..output_channels {
537                data[dest_base + pixels * channel] = raw[source_base + channel].into();
538            }
539        }
540    }
541    let shape = if output_channels == 1 {
542        vec![rows, cols]
543    } else {
544        vec![rows, cols, output_channels]
545    };
546    Tensor::new_with_dtype(data, shape, dtype)
547        .map_err(|err| imread_error("RunMat:imread:ShapeError", format!("imread: {err}")))
548}
549
550fn alpha_from_interleaved<T>(
551    raw: &[T],
552    width: u32,
553    height: u32,
554    input_channels: usize,
555    alpha_channel: usize,
556    dtype: NumericDType,
557) -> BuiltinResult<Tensor>
558where
559    T: Copy + Into<f64>,
560{
561    let rows = height as usize;
562    let cols = width as usize;
563    let mut data = vec![0.0; rows.saturating_mul(cols)];
564    for row in 0..rows {
565        for col in 0..cols {
566            let source_index = (row * cols + col) * input_channels + alpha_channel;
567            let dest_index = row + rows * col;
568            data[dest_index] = raw[source_index].into();
569        }
570    }
571    Tensor::new_with_dtype(data, vec![rows, cols], dtype)
572        .map_err(|err| imread_error("RunMat:imread:ShapeError", format!("imread: {err}")))
573}
574
575fn empty_tensor_value() -> BuiltinResult<Value> {
576    Tensor::new(Vec::new(), vec![0, 0])
577        .map(Value::Tensor)
578        .map_err(|err| imread_error("RunMat:imread:ShapeError", format!("imread: {err}")))
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use image::{ImageBuffer, ImageOutputFormat, Luma, Rgb, RgbImage, Rgba, RgbaImage};
585    use std::io::{Read, Write};
586    use std::net::{TcpListener, TcpStream};
587    use std::sync::Arc;
588
589    fn encode_image(image: DynamicImage, format: ImageOutputFormat) -> Vec<u8> {
590        let mut cursor = Cursor::new(Vec::new());
591        image.write_to(&mut cursor, format).expect("encode image");
592        cursor.into_inner()
593    }
594
595    fn rgb_png() -> Vec<u8> {
596        let image = RgbImage::from_fn(2, 2, |x, y| match (x, y) {
597            (0, 0) => Rgb([10, 20, 30]),
598            (1, 0) => Rgb([40, 50, 60]),
599            (0, 1) => Rgb([70, 80, 90]),
600            (1, 1) => Rgb([100, 110, 120]),
601            _ => unreachable!(),
602        });
603        encode_image(DynamicImage::ImageRgb8(image), ImageOutputFormat::Png)
604    }
605
606    fn rgba_png() -> Vec<u8> {
607        let image = RgbaImage::from_fn(2, 1, |x, _| match x {
608            0 => Rgba([1, 2, 3, 4]),
609            1 => Rgba([5, 6, 7, 8]),
610            _ => unreachable!(),
611        });
612        encode_image(DynamicImage::ImageRgba8(image), ImageOutputFormat::Png)
613    }
614
615    fn run_imread(bytes: &[u8], extension: &str, rest: Vec<Value>) -> Value {
616        let dir = tempfile::tempdir().expect("tempdir");
617        let path = dir.path().join(format!("image.{extension}"));
618        std::fs::write(&path, bytes).expect("write image");
619        futures::executor::block_on(imread_builtin(
620            Value::from(path.to_string_lossy().to_string()),
621            rest,
622        ))
623        .expect("imread")
624    }
625
626    #[test]
627    fn imread_decodes_rgb_png_as_column_major_truecolor_uint8() {
628        let result = run_imread(&rgb_png(), "png", Vec::new());
629        let Value::Tensor(tensor) = result else {
630            panic!("expected tensor, got {result:?}");
631        };
632        assert_eq!(tensor.shape, vec![2, 2, 3]);
633        assert_eq!(tensor.dtype, NumericDType::U8);
634        assert_eq!(
635            tensor.data,
636            vec![10.0, 70.0, 40.0, 100.0, 20.0, 80.0, 50.0, 110.0, 30.0, 90.0, 60.0, 120.0]
637        );
638    }
639
640    #[test]
641    fn imread_returns_alpha_as_third_output_for_rgba_png() {
642        let dir = tempfile::tempdir().expect("tempdir");
643        let path = dir.path().join("alpha.png");
644        std::fs::write(&path, rgba_png()).expect("write image");
645        let _guard = crate::output_count::push_output_count(Some(3));
646        let result = futures::executor::block_on(imread_builtin(
647            Value::from(path.to_string_lossy().to_string()),
648            Vec::new(),
649        ))
650        .expect("imread");
651        let Value::OutputList(outputs) = result else {
652            panic!("expected output list, got {result:?}");
653        };
654        assert_eq!(outputs.len(), 3);
655        match &outputs[0] {
656            Value::Tensor(rgb) => {
657                assert_eq!(rgb.shape, vec![1, 2, 3]);
658                assert_eq!(rgb.dtype, NumericDType::U8);
659                assert_eq!(rgb.data, vec![1.0, 5.0, 2.0, 6.0, 3.0, 7.0]);
660            }
661            other => panic!("expected rgb tensor, got {other:?}"),
662        }
663        match &outputs[1] {
664            Value::Tensor(map) => assert_eq!(map.shape, vec![0, 0]),
665            other => panic!("expected empty map tensor, got {other:?}"),
666        }
667        match &outputs[2] {
668            Value::Tensor(alpha) => {
669                assert_eq!(alpha.shape, vec![1, 2]);
670                assert_eq!(alpha.dtype, NumericDType::U8);
671                assert_eq!(alpha.data, vec![4.0, 8.0]);
672            }
673            other => panic!("expected alpha tensor, got {other:?}"),
674        }
675    }
676
677    #[test]
678    fn imread_reads_local_file_path() {
679        let result = run_imread(&rgb_png(), "png", Vec::new());
680        assert!(matches!(result, Value::Tensor(_)));
681    }
682
683    #[test]
684    fn imread_windows_drive_letter_path_is_not_treated_as_url_scheme() {
685        // Url::parse("C:/foo.png") succeeds with scheme "c", which must not be
686        // treated as an unsupported URL scheme — it should fall through to the
687        // local path reader and produce a file-not-found error, not a scheme error.
688        let err = futures::executor::block_on(imread_builtin(
689            Value::from("C:/nonexistent/photo.png"),
690            Vec::new(),
691        ))
692        .expect_err("expected error for missing file");
693        assert_ne!(
694            err.identifier(),
695            Some("RunMat:imread:UnsupportedScheme"),
696            "drive-letter path incorrectly rejected as unsupported URL scheme"
697        );
698    }
699
700    #[test]
701    fn imread_respects_explicit_format_hint() {
702        let dir = tempfile::tempdir().expect("tempdir");
703        let path = dir.path().join("image-no-extension");
704        std::fs::write(&path, rgb_png()).expect("write image");
705        let result = futures::executor::block_on(imread_builtin(
706            Value::from(path.to_string_lossy().to_string()),
707            vec![Value::from("png")],
708        ))
709        .expect("imread");
710        assert!(matches!(result, Value::Tensor(_)));
711    }
712
713    #[test]
714    fn imread_rejects_unknown_format_hint() {
715        let err = futures::executor::block_on(imread_builtin(
716            Value::from("missing"),
717            vec![Value::from("not-a-format")],
718        ))
719        .expect_err("expected error");
720        assert_eq!(err.identifier(), Some("RunMat:imread:UnsupportedFormat"));
721    }
722
723    #[test]
724    fn imread_dispatcher_reports_builtin_error_directly() {
725        let url = spawn_repeating_server(
726            2,
727            b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".to_vec(),
728        );
729        let err = crate::call_builtin("imread", &[Value::from(format!("{url}/missing.jpg"))])
730            .expect_err("expected 404");
731        assert_eq!(err.identifier(), Some("RunMat:imread:HttpStatus"));
732        assert!(err.message().contains("HTTP status 404"));
733        assert!(!err.message().contains("No matching overload"));
734    }
735
736    #[test]
737    fn imread_materializes_multi_outputs_with_empty_colormap() {
738        let dir = tempfile::tempdir().expect("tempdir");
739        let path = dir.path().join("rgb.png");
740        std::fs::write(&path, rgb_png()).expect("write image");
741        let _guard = crate::output_count::push_output_count(Some(2));
742        let result = futures::executor::block_on(imread_builtin(
743            Value::from(path.to_string_lossy().to_string()),
744            Vec::new(),
745        ))
746        .expect("imread");
747        let Value::OutputList(outputs) = result else {
748            panic!("expected output list, got {result:?}");
749        };
750        assert_eq!(outputs.len(), 2);
751        assert!(matches!(&outputs[0], Value::Tensor(_)));
752        match &outputs[1] {
753            Value::Tensor(map) => assert_eq!(map.shape, vec![0, 0]),
754            other => panic!("expected map tensor, got {other:?}"),
755        }
756    }
757
758    #[test]
759    fn imread_decodes_16_bit_grayscale() {
760        let image: ImageBuffer<Luma<u16>, Vec<u16>> = ImageBuffer::from_fn(2, 2, |x, y| {
761            let value = match (x, y) {
762                (0, 0) => 1,
763                (1, 0) => 2,
764                (0, 1) => 300,
765                (1, 1) => 65535,
766                _ => unreachable!(),
767            };
768            Luma([value])
769        });
770        let bytes = encode_image(DynamicImage::ImageLuma16(image), ImageOutputFormat::Png);
771        let result = run_imread(&bytes, "png", Vec::new());
772        let Value::Tensor(tensor) = result else {
773            panic!("expected tensor, got {result:?}");
774        };
775        assert_eq!(tensor.shape, vec![2, 2]);
776        assert_eq!(tensor.dtype, NumericDType::U16);
777        assert_eq!(tensor.data, vec![1.0, 300.0, 2.0, 65535.0]);
778    }
779
780    #[test]
781    fn imread_fetches_http_url() {
782        let body = rgb_png();
783        let response = http_response(200, "OK", "image/png", &body);
784        let url = spawn_server(response);
785        let result = futures::executor::block_on(imread_builtin(
786            Value::from(format!("{url}/image.png")),
787            Vec::new(),
788        ))
789        .expect("imread");
790        let Value::Tensor(tensor) = result else {
791            panic!("expected tensor, got {result:?}");
792        };
793        assert_eq!(tensor.shape, vec![2, 2, 3]);
794        assert_eq!(tensor.dtype, NumericDType::U8);
795    }
796
797    fn http_response(status: u16, reason: &str, content_type: &str, body: &[u8]) -> Vec<u8> {
798        let mut response = format!(
799            "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n\r\n",
800            body.len()
801        )
802        .into_bytes();
803        response.extend_from_slice(body);
804        response
805    }
806
807    fn spawn_server(response: Vec<u8>) -> String {
808        spawn_repeating_server(1, response)
809    }
810
811    fn spawn_repeating_server(limit: usize, response: Vec<u8>) -> String {
812        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
813        let addr = listener.local_addr().expect("addr");
814        let response = Arc::new(response);
815        std::thread::spawn(move || {
816            for stream in listener.incoming().take(limit) {
817                let Ok(mut stream) = stream else {
818                    continue;
819                };
820                write_response(&mut stream, &response);
821            }
822        });
823        format!("http://{addr}")
824    }
825
826    fn write_response(stream: &mut TcpStream, response: &[u8]) {
827        let mut buffer = [0u8; 1024];
828        let _ = stream.read(&mut buffer);
829        stream.write_all(response).expect("write response");
830        stream.flush().expect("flush response");
831    }
832}