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::{
8    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
9    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
10    NumericDType, Tensor, Value,
11};
12use runmat_macros::runtime_builtin;
13use url::Url;
14
15use crate::builtins::common::spec::{
16    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
17    ReductionNaN, ResidencyPolicy, ShapeRequirements,
18};
19use crate::builtins::common::{map_control_flow_with_builtin, tensor};
20use crate::builtins::image::type_resolvers::imread_type;
21use crate::builtins::io::http::transport::{
22    self, HttpMethod, HttpRequest, TransportError, TransportErrorKind,
23};
24use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
25
26const BUILTIN_NAME: &str = "imread";
27const DEFAULT_TIMEOUT_SECONDS: f64 = 60.0;
28const DEFAULT_USER_AGENT: &str = "RunMat imread/0.0";
29
30const IMREAD_OUTPUT_I: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
31    name: "I",
32    ty: BuiltinParamType::NumericArray,
33    arity: BuiltinParamArity::Required,
34    default: None,
35    description: "Loaded image array (grayscale, truecolor, or multi-channel numeric tensor).",
36}];
37
38const IMREAD_OUTPUT_IMAP: [BuiltinParamDescriptor; 2] = [
39    BuiltinParamDescriptor {
40        name: "I",
41        ty: BuiltinParamType::NumericArray,
42        arity: BuiltinParamArity::Required,
43        default: None,
44        description: "Loaded image array.",
45    },
46    BuiltinParamDescriptor {
47        name: "map",
48        ty: BuiltinParamType::NumericArray,
49        arity: BuiltinParamArity::Required,
50        default: None,
51        description: "Colormap output placeholder (empty for direct-color image formats).",
52    },
53];
54
55const IMREAD_OUTPUT_IMAP_ALPHA: [BuiltinParamDescriptor; 3] = [
56    BuiltinParamDescriptor {
57        name: "I",
58        ty: BuiltinParamType::NumericArray,
59        arity: BuiltinParamArity::Required,
60        default: None,
61        description: "Loaded image array.",
62    },
63    BuiltinParamDescriptor {
64        name: "map",
65        ty: BuiltinParamType::NumericArray,
66        arity: BuiltinParamArity::Required,
67        default: None,
68        description: "Colormap output placeholder (empty for direct-color image formats).",
69    },
70    BuiltinParamDescriptor {
71        name: "alpha",
72        ty: BuiltinParamType::NumericArray,
73        arity: BuiltinParamArity::Required,
74        default: None,
75        description: "Alpha channel output when present; otherwise empty.",
76    },
77];
78
79const IMREAD_INPUTS_SOURCE: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
80    name: "filename",
81    ty: BuiltinParamType::StringScalar,
82    arity: BuiltinParamArity::Required,
83    default: None,
84    description: "File path or HTTP(S) URL to load.",
85}];
86
87const IMREAD_INPUTS_SOURCE_FORMAT: [BuiltinParamDescriptor; 2] = [
88    BuiltinParamDescriptor {
89        name: "filename",
90        ty: BuiltinParamType::StringScalar,
91        arity: BuiltinParamArity::Required,
92        default: None,
93        description: "File path or HTTP(S) URL to load.",
94    },
95    BuiltinParamDescriptor {
96        name: "fmt",
97        ty: BuiltinParamType::StringScalar,
98        arity: BuiltinParamArity::Optional,
99        default: None,
100        description: "Explicit image format hint (e.g. 'png', 'jpg', 'tiff').",
101    },
102];
103
104const IMREAD_SIGNATURES: [BuiltinSignatureDescriptor; 6] = [
105    BuiltinSignatureDescriptor {
106        label: "I = imread(filename)",
107        inputs: &IMREAD_INPUTS_SOURCE,
108        outputs: &IMREAD_OUTPUT_I,
109    },
110    BuiltinSignatureDescriptor {
111        label: "I = imread(filename, fmt)",
112        inputs: &IMREAD_INPUTS_SOURCE_FORMAT,
113        outputs: &IMREAD_OUTPUT_I,
114    },
115    BuiltinSignatureDescriptor {
116        label: "[I, map] = imread(filename)",
117        inputs: &IMREAD_INPUTS_SOURCE,
118        outputs: &IMREAD_OUTPUT_IMAP,
119    },
120    BuiltinSignatureDescriptor {
121        label: "[I, map] = imread(filename, fmt)",
122        inputs: &IMREAD_INPUTS_SOURCE_FORMAT,
123        outputs: &IMREAD_OUTPUT_IMAP,
124    },
125    BuiltinSignatureDescriptor {
126        label: "[I, map, alpha] = imread(filename)",
127        inputs: &IMREAD_INPUTS_SOURCE,
128        outputs: &IMREAD_OUTPUT_IMAP_ALPHA,
129    },
130    BuiltinSignatureDescriptor {
131        label: "[I, map, alpha] = imread(filename, fmt)",
132        inputs: &IMREAD_INPUTS_SOURCE_FORMAT,
133        outputs: &IMREAD_OUTPUT_IMAP_ALPHA,
134    },
135];
136
137const IMREAD_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
138    code: "RM.IMREAD.INVALID_ARGUMENT",
139    identifier: Some("RunMat:imread:InvalidArgument"),
140    when: "Input argument types are invalid (for example non-string filename or format).",
141    message: "imread: invalid argument",
142};
143
144const IMREAD_ERROR_INVALID_FILENAME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
145    code: "RM.IMREAD.INVALID_FILENAME",
146    identifier: Some("RunMat:imread:InvalidFilename"),
147    when: "Filename input is empty.",
148    message: "imread: invalid filename",
149};
150
151const IMREAD_ERROR_INVALID_FORMAT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
152    code: "RM.IMREAD.INVALID_FORMAT",
153    identifier: Some("RunMat:imread:InvalidFormat"),
154    when: "Format hint input is empty.",
155    message: "imread: invalid format hint",
156};
157
158const IMREAD_ERROR_UNSUPPORTED_FORMAT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
159    code: "RM.IMREAD.UNSUPPORTED_FORMAT",
160    identifier: Some("RunMat:imread:UnsupportedFormat"),
161    when: "Requested format hint is not supported.",
162    message: "imread: unsupported image format",
163};
164
165const IMREAD_ERROR_TOO_MANY_INPUTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
166    code: "RM.IMREAD.TOO_MANY_INPUTS",
167    identifier: Some("RunMat:imread:TooManyInputs"),
168    when: "More than two input arguments are supplied.",
169    message: "imread: too many input arguments",
170};
171
172const IMREAD_ERROR_TOO_MANY_OUTPUTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
173    code: "RM.IMREAD.TOO_MANY_OUTPUTS",
174    identifier: Some("RunMat:imread:TooManyOutputs"),
175    when: "More than three outputs are requested.",
176    message: "imread: too many output arguments",
177};
178
179const IMREAD_ERROR_UNSUPPORTED_SCHEME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
180    code: "RM.IMREAD.UNSUPPORTED_SCHEME",
181    identifier: Some("RunMat:imread:UnsupportedScheme"),
182    when: "Source URL uses an unsupported non-file scheme.",
183    message: "imread: unsupported URL scheme",
184};
185
186const IMREAD_ERROR_FILE_READ: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
187    code: "RM.IMREAD.FILE_READ",
188    identifier: Some("RunMat:imread:FileReadError"),
189    when: "Local file source cannot be read.",
190    message: "imread: file read error",
191};
192
193const IMREAD_ERROR_INVALID_FILE_URL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
194    code: "RM.IMREAD.INVALID_FILE_URL",
195    identifier: Some("RunMat:imread:InvalidFileUrl"),
196    when: "File URL path/host encoding is invalid.",
197    message: "imread: invalid file URL",
198};
199
200const IMREAD_ERROR_TIMEOUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
201    code: "RM.IMREAD.TIMEOUT",
202    identifier: Some("RunMat:imread:Timeout"),
203    when: "HTTP request times out.",
204    message: "imread: request timed out",
205};
206
207const IMREAD_ERROR_NETWORK: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
208    code: "RM.IMREAD.NETWORK",
209    identifier: Some("RunMat:imread:NetworkError"),
210    when: "HTTP request fails due to network/connectivity issues.",
211    message: "imread: network error",
212};
213
214const IMREAD_ERROR_HTTP_STATUS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
215    code: "RM.IMREAD.HTTP_STATUS",
216    identifier: Some("RunMat:imread:HttpStatus"),
217    when: "HTTP response returns non-success status.",
218    message: "imread: HTTP status error",
219};
220
221const IMREAD_ERROR_INVALID_HEADER: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
222    code: "RM.IMREAD.INVALID_HEADER",
223    identifier: Some("RunMat:imread:InvalidHeader"),
224    when: "HTTP request contains invalid headers.",
225    message: "imread: invalid request header",
226};
227
228const IMREAD_ERROR_DECODE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
229    code: "RM.IMREAD.DECODE",
230    identifier: Some("RunMat:imread:DecodeError"),
231    when: "Image bytes cannot be decoded into a supported raster format.",
232    message: "imread: decode error",
233};
234
235const IMREAD_ERROR_SHAPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
236    code: "RM.IMREAD.SHAPE",
237    identifier: Some("RunMat:imread:ShapeError"),
238    when: "Decoded image cannot be materialized into tensor shape.",
239    message: "imread: shape materialization error",
240};
241
242const IMREAD_ERRORS: [BuiltinErrorDescriptor; 15] = [
243    IMREAD_ERROR_INVALID_ARGUMENT,
244    IMREAD_ERROR_INVALID_FILENAME,
245    IMREAD_ERROR_INVALID_FORMAT,
246    IMREAD_ERROR_UNSUPPORTED_FORMAT,
247    IMREAD_ERROR_TOO_MANY_INPUTS,
248    IMREAD_ERROR_TOO_MANY_OUTPUTS,
249    IMREAD_ERROR_UNSUPPORTED_SCHEME,
250    IMREAD_ERROR_FILE_READ,
251    IMREAD_ERROR_INVALID_FILE_URL,
252    IMREAD_ERROR_TIMEOUT,
253    IMREAD_ERROR_NETWORK,
254    IMREAD_ERROR_HTTP_STATUS,
255    IMREAD_ERROR_INVALID_HEADER,
256    IMREAD_ERROR_DECODE,
257    IMREAD_ERROR_SHAPE,
258];
259
260pub const IMREAD_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
261    signatures: &IMREAD_SIGNATURES,
262    output_mode: BuiltinOutputMode::ByRequestedOutputCount,
263    completion_policy: BuiltinCompletionPolicy::Public,
264    errors: &IMREAD_ERRORS,
265};
266
267#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::image::imread")]
268pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
269    name: "imread",
270    op_kind: GpuOpKind::Custom("image-read"),
271    supported_precisions: &[],
272    broadcast: BroadcastSemantics::None,
273    provider_hooks: &[],
274    constant_strategy: ConstantStrategy::InlineLiteral,
275    residency: ResidencyPolicy::GatherImmediately,
276    nan_mode: ReductionNaN::Include,
277    two_pass_threshold: None,
278    workgroup_size: None,
279    accepts_nan_mode: false,
280    notes: "Host-only image I/O and CPU decoding. Decoded tensors are host-resident; use gpuArray after import for GPU work.",
281};
282
283#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::image::imread")]
284pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
285    name: "imread",
286    shape: ShapeRequirements::Any,
287    constant_strategy: ConstantStrategy::InlineLiteral,
288    elementwise: None,
289    reduction: None,
290    emits_nan: false,
291    notes: "Not eligible for fusion; image loading performs file or network I/O and CPU decoding.",
292};
293
294fn imread_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
295    let mut builder = build_runtime_error(error.message).with_builtin(BUILTIN_NAME);
296    if let Some(identifier) = error.identifier {
297        builder = builder.with_identifier(identifier);
298    }
299    builder.build()
300}
301
302fn imread_error_with_detail(
303    error: &'static BuiltinErrorDescriptor,
304    detail: impl AsRef<str>,
305) -> RuntimeError {
306    let detail = detail.as_ref();
307    let message = if detail.starts_with("imread:") {
308        detail.to_string()
309    } else {
310        format!("{}: {}", error.message, detail)
311    };
312    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
313    if let Some(identifier) = error.identifier {
314        builder = builder.with_identifier(identifier);
315    }
316    builder.build()
317}
318
319fn map_flow(err: RuntimeError) -> RuntimeError {
320    map_control_flow_with_builtin(err, BUILTIN_NAME)
321}
322
323#[runtime_builtin(
324    name = "imread",
325    category = "image/io",
326    summary = "Read image files into arrays.",
327    keywords = "imread,image,read,file,jpeg,jpg,png,bmp,gif,tiff,webp,url",
328    accel = "sink",
329    type_resolver(imread_type),
330    descriptor(crate::builtins::image::imread::IMREAD_DESCRIPTOR),
331    builtin_path = "crate::builtins::image::imread"
332)]
333async fn imread_builtin(source: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
334    let source = gather_if_needed_async(&source).await.map_err(map_flow)?;
335    let mut gathered_rest = Vec::with_capacity(rest.len());
336    for arg in &rest {
337        gathered_rest.push(gather_if_needed_async(arg).await.map_err(map_flow)?);
338    }
339
340    let source = string_arg("filename", &source)?;
341    if source.is_empty() {
342        return Err(imread_error_with_detail(
343            &IMREAD_ERROR_INVALID_FILENAME,
344            "filename must not be empty",
345        ));
346    }
347
348    let format_hint = match gathered_rest.as_slice() {
349        [] => None,
350        [format] => Some(parse_format_hint(&string_arg("format", format)?)?),
351        _ => return Err(imread_error(&IMREAD_ERROR_TOO_MANY_INPUTS)),
352    };
353
354    let bytes = read_source_bytes(&source).await?;
355    let decoded = decode_image_bytes(&bytes, format_hint)?;
356    let materialized = materialize_image(decoded)?;
357
358    match crate::output_count::current_output_count() {
359        None => Ok(Value::Tensor(materialized.image)),
360        Some(0) => Ok(Value::OutputList(Vec::new())),
361        Some(1) => Ok(Value::OutputList(vec![Value::Tensor(materialized.image)])),
362        Some(2) => Ok(Value::OutputList(vec![
363            Value::Tensor(materialized.image),
364            empty_tensor_value()?,
365        ])),
366        Some(3) => Ok(Value::OutputList(vec![
367            Value::Tensor(materialized.image),
368            empty_tensor_value()?,
369            materialized
370                .alpha
371                .map(Value::Tensor)
372                .unwrap_or(empty_tensor_value()?),
373        ])),
374        Some(_) => Err(imread_error(&IMREAD_ERROR_TOO_MANY_OUTPUTS)),
375    }
376}
377
378fn string_arg(label: &str, value: &Value) -> BuiltinResult<String> {
379    tensor::value_to_string(value).ok_or_else(|| {
380        imread_error_with_detail(
381            &IMREAD_ERROR_INVALID_ARGUMENT,
382            format!("{label} must be a string scalar or character vector"),
383        )
384    })
385}
386
387fn parse_format_hint(value: &str) -> BuiltinResult<ImageFormat> {
388    let label = value.trim().trim_start_matches('.').to_ascii_lowercase();
389    if label.is_empty() {
390        return Err(imread_error_with_detail(
391            &IMREAD_ERROR_INVALID_FORMAT,
392            "format hint must not be empty",
393        ));
394    }
395    let format = match label.as_str() {
396        "jpg" | "jpeg" | "jpe" => ImageFormat::Jpeg,
397        "tif" | "tiff" => ImageFormat::Tiff,
398        "png" => ImageFormat::Png,
399        "bmp" => ImageFormat::Bmp,
400        "gif" => ImageFormat::Gif,
401        "webp" => ImageFormat::WebP,
402        "ico" => ImageFormat::Ico,
403        other => ImageFormat::from_extension(other).ok_or_else(|| {
404            imread_error_with_detail(
405                &IMREAD_ERROR_UNSUPPORTED_FORMAT,
406                format!("unsupported image format '{other}'"),
407            )
408        })?,
409    };
410    Ok(format)
411}
412
413async fn read_source_bytes(source: &str) -> BuiltinResult<Vec<u8>> {
414    if let Ok(url) = Url::parse(source) {
415        let scheme = url.scheme();
416        // A single-letter scheme is a Windows drive letter (e.g. "C:/..."), not a URL.
417        if scheme.len() > 1 {
418            return match scheme {
419                "http" | "https" => read_url_bytes(url).await,
420                "file" => {
421                    let path = file_url_to_path(&url)?;
422                    read_local_path(&path).await
423                }
424                _ => Err(imread_error_with_detail(
425                    &IMREAD_ERROR_UNSUPPORTED_SCHEME,
426                    format!("unsupported URL scheme '{scheme}'"),
427                )),
428            };
429        }
430    }
431
432    read_local_path(Path::new(source)).await
433}
434
435async fn read_local_path(path: &Path) -> BuiltinResult<Vec<u8>> {
436    runmat_filesystem::read_async(path).await.map_err(|err| {
437        imread_error_with_detail(
438            &IMREAD_ERROR_FILE_READ,
439            format!("unable to read '{}': {err}", path.display()),
440        )
441    })
442}
443
444fn file_url_to_path(url: &Url) -> BuiltinResult<PathBuf> {
445    if let Some(host) = url.host_str() {
446        if !host.is_empty() && !host.eq_ignore_ascii_case("localhost") {
447            return Err(imread_error_with_detail(
448                &IMREAD_ERROR_INVALID_FILE_URL,
449                format!("file URL host '{host}' is not local"),
450            ));
451        }
452    }
453
454    let decoded = percent_decode_url_path(url.path())?;
455
456    #[cfg(windows)]
457    {
458        let path =
459            if decoded.len() >= 3 && decoded.as_bytes()[0] == b'/' && decoded.as_bytes()[2] == b':'
460            {
461                &decoded[1..]
462            } else {
463                decoded.as_str()
464            };
465        Ok(PathBuf::from(path))
466    }
467
468    #[cfg(not(windows))]
469    {
470        Ok(PathBuf::from(decoded))
471    }
472}
473
474fn percent_decode_url_path(input: &str) -> BuiltinResult<String> {
475    let bytes = input.as_bytes();
476    let mut output = Vec::with_capacity(bytes.len());
477    let mut index = 0usize;
478    while index < bytes.len() {
479        if bytes[index] == b'%' {
480            if index + 2 >= bytes.len() {
481                return Err(imread_error_with_detail(
482                    &IMREAD_ERROR_INVALID_FILE_URL,
483                    "invalid percent escape in file URL",
484                ));
485            }
486            let hi = hex_value(bytes[index + 1]).ok_or_else(|| {
487                imread_error_with_detail(
488                    &IMREAD_ERROR_INVALID_FILE_URL,
489                    "invalid percent escape in file URL",
490                )
491            })?;
492            let lo = hex_value(bytes[index + 2]).ok_or_else(|| {
493                imread_error_with_detail(
494                    &IMREAD_ERROR_INVALID_FILE_URL,
495                    "invalid percent escape in file URL",
496                )
497            })?;
498            output.push((hi << 4) | lo);
499            index += 3;
500        } else {
501            output.push(bytes[index]);
502            index += 1;
503        }
504    }
505
506    String::from_utf8(output).map_err(|err| {
507        imread_error_with_detail(
508            &IMREAD_ERROR_INVALID_FILE_URL,
509            format!("file URL path is not valid UTF-8: {err}"),
510        )
511    })
512}
513
514fn hex_value(byte: u8) -> Option<u8> {
515    match byte {
516        b'0'..=b'9' => Some(byte - b'0'),
517        b'a'..=b'f' => Some(byte - b'a' + 10),
518        b'A'..=b'F' => Some(byte - b'A' + 10),
519        _ => None,
520    }
521}
522
523async fn read_url_bytes(url: Url) -> BuiltinResult<Vec<u8>> {
524    let request = HttpRequest {
525        url,
526        method: HttpMethod::Get,
527        headers: vec![(
528            "Accept".to_string(),
529            "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8".to_string(),
530        )],
531        body: None,
532        timeout: Duration::from_secs_f64(DEFAULT_TIMEOUT_SECONDS),
533        user_agent: DEFAULT_USER_AGENT.to_string(),
534    };
535    let response = transport::send_request(&request).map_err(imread_transport_error)?;
536    Ok(response.body)
537}
538
539fn imread_transport_error(err: TransportError) -> RuntimeError {
540    let error = match &err.kind {
541        TransportErrorKind::Timeout => &IMREAD_ERROR_TIMEOUT,
542        TransportErrorKind::Connect => &IMREAD_ERROR_NETWORK,
543        TransportErrorKind::Status(_) => &IMREAD_ERROR_HTTP_STATUS,
544        TransportErrorKind::InvalidHeader(_) => &IMREAD_ERROR_INVALID_HEADER,
545        TransportErrorKind::Other => &IMREAD_ERROR_NETWORK,
546    };
547    let message = err.message_with_prefix(BUILTIN_NAME);
548    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
549    if let Some(identifier) = error.identifier {
550        builder = builder.with_identifier(identifier);
551    }
552    builder.with_source(err).build()
553}
554
555fn decode_image_bytes(bytes: &[u8], format: Option<ImageFormat>) -> BuiltinResult<DynamicImage> {
556    let reader = if let Some(format) = format {
557        ImageReader::with_format(Cursor::new(bytes), format)
558    } else {
559        ImageReader::new(Cursor::new(bytes))
560            .with_guessed_format()
561            .map_err(|err| {
562                imread_error_with_detail(
563                    &IMREAD_ERROR_DECODE,
564                    format!("unable to detect image format: {err}"),
565                )
566            })?
567    };
568    reader.decode().map_err(|err| {
569        imread_error_with_detail(
570            &IMREAD_ERROR_DECODE,
571            format!("unable to decode image: {err}"),
572        )
573    })
574}
575
576struct MaterializedImage {
577    image: Tensor,
578    alpha: Option<Tensor>,
579}
580
581fn materialize_image(image: DynamicImage) -> BuiltinResult<MaterializedImage> {
582    if let Some(buffer) = image.as_luma8() {
583        return Ok(MaterializedImage {
584            image: tensor_from_interleaved(
585                buffer.as_raw(),
586                buffer.width(),
587                buffer.height(),
588                1,
589                1,
590                NumericDType::U8,
591            )?,
592            alpha: None,
593        });
594    }
595    if let Some(buffer) = image.as_luma_alpha8() {
596        return Ok(MaterializedImage {
597            image: tensor_from_interleaved(
598                buffer.as_raw(),
599                buffer.width(),
600                buffer.height(),
601                2,
602                1,
603                NumericDType::U8,
604            )?,
605            alpha: Some(alpha_from_interleaved(
606                buffer.as_raw(),
607                buffer.width(),
608                buffer.height(),
609                2,
610                1,
611                NumericDType::U8,
612            )?),
613        });
614    }
615    if let Some(buffer) = image.as_rgb8() {
616        return Ok(MaterializedImage {
617            image: tensor_from_interleaved(
618                buffer.as_raw(),
619                buffer.width(),
620                buffer.height(),
621                3,
622                3,
623                NumericDType::U8,
624            )?,
625            alpha: None,
626        });
627    }
628    if let Some(buffer) = image.as_rgba8() {
629        return Ok(MaterializedImage {
630            image: tensor_from_interleaved(
631                buffer.as_raw(),
632                buffer.width(),
633                buffer.height(),
634                4,
635                3,
636                NumericDType::U8,
637            )?,
638            alpha: Some(alpha_from_interleaved(
639                buffer.as_raw(),
640                buffer.width(),
641                buffer.height(),
642                4,
643                3,
644                NumericDType::U8,
645            )?),
646        });
647    }
648    if let Some(buffer) = image.as_luma16() {
649        return Ok(MaterializedImage {
650            image: tensor_from_interleaved(
651                buffer.as_raw(),
652                buffer.width(),
653                buffer.height(),
654                1,
655                1,
656                NumericDType::U16,
657            )?,
658            alpha: None,
659        });
660    }
661    if let Some(buffer) = image.as_luma_alpha16() {
662        return Ok(MaterializedImage {
663            image: tensor_from_interleaved(
664                buffer.as_raw(),
665                buffer.width(),
666                buffer.height(),
667                2,
668                1,
669                NumericDType::U16,
670            )?,
671            alpha: Some(alpha_from_interleaved(
672                buffer.as_raw(),
673                buffer.width(),
674                buffer.height(),
675                2,
676                1,
677                NumericDType::U16,
678            )?),
679        });
680    }
681    if let Some(buffer) = image.as_rgb16() {
682        return Ok(MaterializedImage {
683            image: tensor_from_interleaved(
684                buffer.as_raw(),
685                buffer.width(),
686                buffer.height(),
687                3,
688                3,
689                NumericDType::U16,
690            )?,
691            alpha: None,
692        });
693    }
694    if let Some(buffer) = image.as_rgba16() {
695        return Ok(MaterializedImage {
696            image: tensor_from_interleaved(
697                buffer.as_raw(),
698                buffer.width(),
699                buffer.height(),
700                4,
701                3,
702                NumericDType::U16,
703            )?,
704            alpha: Some(alpha_from_interleaved(
705                buffer.as_raw(),
706                buffer.width(),
707                buffer.height(),
708                4,
709                3,
710                NumericDType::U16,
711            )?),
712        });
713    }
714    if let Some(buffer) = image.as_rgb32f() {
715        return Ok(MaterializedImage {
716            image: tensor_from_interleaved(
717                buffer.as_raw(),
718                buffer.width(),
719                buffer.height(),
720                3,
721                3,
722                NumericDType::F32,
723            )?,
724            alpha: None,
725        });
726    }
727    if let Some(buffer) = image.as_rgba32f() {
728        return Ok(MaterializedImage {
729            image: tensor_from_interleaved(
730                buffer.as_raw(),
731                buffer.width(),
732                buffer.height(),
733                4,
734                3,
735                NumericDType::F32,
736            )?,
737            alpha: Some(alpha_from_interleaved(
738                buffer.as_raw(),
739                buffer.width(),
740                buffer.height(),
741                4,
742                3,
743                NumericDType::F32,
744            )?),
745        });
746    }
747
748    let rgba = image.to_rgba8();
749    Ok(MaterializedImage {
750        image: tensor_from_interleaved(
751            rgba.as_raw(),
752            rgba.width(),
753            rgba.height(),
754            4,
755            3,
756            NumericDType::U8,
757        )?,
758        alpha: Some(alpha_from_interleaved(
759            rgba.as_raw(),
760            rgba.width(),
761            rgba.height(),
762            4,
763            3,
764            NumericDType::U8,
765        )?),
766    })
767}
768
769fn tensor_from_interleaved<T>(
770    raw: &[T],
771    width: u32,
772    height: u32,
773    input_channels: usize,
774    output_channels: usize,
775    dtype: NumericDType,
776) -> BuiltinResult<Tensor>
777where
778    T: Copy + Into<f64>,
779{
780    let rows = height as usize;
781    let cols = width as usize;
782    let pixels = rows.saturating_mul(cols);
783    let mut data = vec![0.0; pixels.saturating_mul(output_channels)];
784    for row in 0..rows {
785        for col in 0..cols {
786            let source_base = (row * cols + col) * input_channels;
787            let dest_base = row + rows * col;
788            for channel in 0..output_channels {
789                data[dest_base + pixels * channel] = raw[source_base + channel].into();
790            }
791        }
792    }
793    let shape = if output_channels == 1 {
794        vec![rows, cols]
795    } else {
796        vec![rows, cols, output_channels]
797    };
798    Tensor::new_with_dtype(data, shape, dtype)
799        .map_err(|err| imread_error_with_detail(&IMREAD_ERROR_SHAPE, &err))
800}
801
802fn alpha_from_interleaved<T>(
803    raw: &[T],
804    width: u32,
805    height: u32,
806    input_channels: usize,
807    alpha_channel: usize,
808    dtype: NumericDType,
809) -> BuiltinResult<Tensor>
810where
811    T: Copy + Into<f64>,
812{
813    let rows = height as usize;
814    let cols = width as usize;
815    let mut data = vec![0.0; rows.saturating_mul(cols)];
816    for row in 0..rows {
817        for col in 0..cols {
818            let source_index = (row * cols + col) * input_channels + alpha_channel;
819            let dest_index = row + rows * col;
820            data[dest_index] = raw[source_index].into();
821        }
822    }
823    Tensor::new_with_dtype(data, vec![rows, cols], dtype)
824        .map_err(|err| imread_error_with_detail(&IMREAD_ERROR_SHAPE, &err))
825}
826
827fn empty_tensor_value() -> BuiltinResult<Value> {
828    Tensor::new(Vec::new(), vec![0, 0])
829        .map(Value::Tensor)
830        .map_err(|err| imread_error_with_detail(&IMREAD_ERROR_SHAPE, &err))
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836    use image::{ImageBuffer, ImageOutputFormat, Luma, Rgb, RgbImage, Rgba, RgbaImage};
837    use std::io::{Read, Write};
838    use std::net::{TcpListener, TcpStream};
839    use std::sync::Arc;
840
841    fn encode_image(image: DynamicImage, format: ImageOutputFormat) -> Vec<u8> {
842        let mut cursor = Cursor::new(Vec::new());
843        image.write_to(&mut cursor, format).expect("encode image");
844        cursor.into_inner()
845    }
846
847    fn rgb_png() -> Vec<u8> {
848        let image = RgbImage::from_fn(2, 2, |x, y| match (x, y) {
849            (0, 0) => Rgb([10, 20, 30]),
850            (1, 0) => Rgb([40, 50, 60]),
851            (0, 1) => Rgb([70, 80, 90]),
852            (1, 1) => Rgb([100, 110, 120]),
853            _ => unreachable!(),
854        });
855        encode_image(DynamicImage::ImageRgb8(image), ImageOutputFormat::Png)
856    }
857
858    fn rgba_png() -> Vec<u8> {
859        let image = RgbaImage::from_fn(2, 1, |x, _| match x {
860            0 => Rgba([1, 2, 3, 4]),
861            1 => Rgba([5, 6, 7, 8]),
862            _ => unreachable!(),
863        });
864        encode_image(DynamicImage::ImageRgba8(image), ImageOutputFormat::Png)
865    }
866
867    fn run_imread(bytes: &[u8], extension: &str, rest: Vec<Value>) -> Value {
868        let dir = tempfile::tempdir().expect("tempdir");
869        let path = dir.path().join(format!("image.{extension}"));
870        std::fs::write(&path, bytes).expect("write image");
871        futures::executor::block_on(imread_builtin(
872            Value::from(path.to_string_lossy().to_string()),
873            rest,
874        ))
875        .expect("imread")
876    }
877
878    #[test]
879    fn imread_decodes_rgb_png_as_column_major_truecolor_uint8() {
880        let result = run_imread(&rgb_png(), "png", Vec::new());
881        let Value::Tensor(tensor) = result else {
882            panic!("expected tensor, got {result:?}");
883        };
884        assert_eq!(tensor.shape, vec![2, 2, 3]);
885        assert_eq!(tensor.dtype, NumericDType::U8);
886        assert_eq!(
887            tensor.data,
888            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]
889        );
890    }
891
892    #[test]
893    fn imread_returns_alpha_as_third_output_for_rgba_png() {
894        let dir = tempfile::tempdir().expect("tempdir");
895        let path = dir.path().join("alpha.png");
896        std::fs::write(&path, rgba_png()).expect("write image");
897        let _guard = crate::output_count::push_output_count(Some(3));
898        let result = futures::executor::block_on(imread_builtin(
899            Value::from(path.to_string_lossy().to_string()),
900            Vec::new(),
901        ))
902        .expect("imread");
903        let Value::OutputList(outputs) = result else {
904            panic!("expected output list, got {result:?}");
905        };
906        assert_eq!(outputs.len(), 3);
907        match &outputs[0] {
908            Value::Tensor(rgb) => {
909                assert_eq!(rgb.shape, vec![1, 2, 3]);
910                assert_eq!(rgb.dtype, NumericDType::U8);
911                assert_eq!(rgb.data, vec![1.0, 5.0, 2.0, 6.0, 3.0, 7.0]);
912            }
913            other => panic!("expected rgb tensor, got {other:?}"),
914        }
915        match &outputs[1] {
916            Value::Tensor(map) => assert_eq!(map.shape, vec![0, 0]),
917            other => panic!("expected empty map tensor, got {other:?}"),
918        }
919        match &outputs[2] {
920            Value::Tensor(alpha) => {
921                assert_eq!(alpha.shape, vec![1, 2]);
922                assert_eq!(alpha.dtype, NumericDType::U8);
923                assert_eq!(alpha.data, vec![4.0, 8.0]);
924            }
925            other => panic!("expected alpha tensor, got {other:?}"),
926        }
927    }
928
929    #[test]
930    fn imread_reads_local_file_path() {
931        let result = run_imread(&rgb_png(), "png", Vec::new());
932        assert!(matches!(result, Value::Tensor(_)));
933    }
934
935    #[test]
936    fn imread_windows_drive_letter_path_is_not_treated_as_url_scheme() {
937        // Url::parse("C:/foo.png") succeeds with scheme "c", which must not be
938        // treated as an unsupported URL scheme — it should fall through to the
939        // local path reader and produce a file-not-found error, not a scheme error.
940        let err = futures::executor::block_on(imread_builtin(
941            Value::from("C:/nonexistent/photo.png"),
942            Vec::new(),
943        ))
944        .expect_err("expected error for missing file");
945        assert_ne!(
946            err.identifier(),
947            IMREAD_ERROR_UNSUPPORTED_SCHEME.identifier,
948            "drive-letter path incorrectly rejected as unsupported URL scheme"
949        );
950    }
951
952    #[test]
953    fn imread_respects_explicit_format_hint() {
954        let dir = tempfile::tempdir().expect("tempdir");
955        let path = dir.path().join("image-no-extension");
956        std::fs::write(&path, rgb_png()).expect("write image");
957        let result = futures::executor::block_on(imread_builtin(
958            Value::from(path.to_string_lossy().to_string()),
959            vec![Value::from("png")],
960        ))
961        .expect("imread");
962        assert!(matches!(result, Value::Tensor(_)));
963    }
964
965    #[test]
966    fn imread_rejects_unknown_format_hint() {
967        let err = futures::executor::block_on(imread_builtin(
968            Value::from("missing"),
969            vec![Value::from("not-a-format")],
970        ))
971        .expect_err("expected error");
972        assert_eq!(err.identifier(), IMREAD_ERROR_UNSUPPORTED_FORMAT.identifier);
973    }
974
975    #[test]
976    fn imread_dispatcher_reports_builtin_error_directly() {
977        let url = spawn_repeating_server(
978            2,
979            b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".to_vec(),
980        );
981        let err = crate::call_builtin("imread", &[Value::from(format!("{url}/missing.jpg"))])
982            .expect_err("expected 404");
983        assert_eq!(err.identifier(), IMREAD_ERROR_HTTP_STATUS.identifier);
984        assert!(err.message().contains("HTTP status 404"));
985        assert!(!err.message().contains("No matching overload"));
986    }
987
988    #[test]
989    fn imread_descriptor_signatures_cover_surface() {
990        let labels: Vec<&str> = IMREAD_DESCRIPTOR
991            .signatures
992            .iter()
993            .map(|signature| signature.label)
994            .collect();
995        assert_eq!(
996            labels,
997            vec![
998                "I = imread(filename)",
999                "I = imread(filename, fmt)",
1000                "[I, map] = imread(filename)",
1001                "[I, map] = imread(filename, fmt)",
1002                "[I, map, alpha] = imread(filename)",
1003                "[I, map, alpha] = imread(filename, fmt)",
1004            ]
1005        );
1006    }
1007
1008    #[test]
1009    fn imread_descriptor_errors_have_stable_codes() {
1010        let codes: Vec<&str> = IMREAD_DESCRIPTOR
1011            .errors
1012            .iter()
1013            .map(|error| error.code)
1014            .collect();
1015        assert_eq!(
1016            codes,
1017            vec![
1018                "RM.IMREAD.INVALID_ARGUMENT",
1019                "RM.IMREAD.INVALID_FILENAME",
1020                "RM.IMREAD.INVALID_FORMAT",
1021                "RM.IMREAD.UNSUPPORTED_FORMAT",
1022                "RM.IMREAD.TOO_MANY_INPUTS",
1023                "RM.IMREAD.TOO_MANY_OUTPUTS",
1024                "RM.IMREAD.UNSUPPORTED_SCHEME",
1025                "RM.IMREAD.FILE_READ",
1026                "RM.IMREAD.INVALID_FILE_URL",
1027                "RM.IMREAD.TIMEOUT",
1028                "RM.IMREAD.NETWORK",
1029                "RM.IMREAD.HTTP_STATUS",
1030                "RM.IMREAD.INVALID_HEADER",
1031                "RM.IMREAD.DECODE",
1032                "RM.IMREAD.SHAPE",
1033            ]
1034        );
1035    }
1036
1037    #[test]
1038    fn imread_materializes_multi_outputs_with_empty_colormap() {
1039        let dir = tempfile::tempdir().expect("tempdir");
1040        let path = dir.path().join("rgb.png");
1041        std::fs::write(&path, rgb_png()).expect("write image");
1042        let _guard = crate::output_count::push_output_count(Some(2));
1043        let result = futures::executor::block_on(imread_builtin(
1044            Value::from(path.to_string_lossy().to_string()),
1045            Vec::new(),
1046        ))
1047        .expect("imread");
1048        let Value::OutputList(outputs) = result else {
1049            panic!("expected output list, got {result:?}");
1050        };
1051        assert_eq!(outputs.len(), 2);
1052        assert!(matches!(&outputs[0], Value::Tensor(_)));
1053        match &outputs[1] {
1054            Value::Tensor(map) => assert_eq!(map.shape, vec![0, 0]),
1055            other => panic!("expected map tensor, got {other:?}"),
1056        }
1057    }
1058
1059    #[test]
1060    fn imread_decodes_16_bit_grayscale() {
1061        let image: ImageBuffer<Luma<u16>, Vec<u16>> = ImageBuffer::from_fn(2, 2, |x, y| {
1062            let value = match (x, y) {
1063                (0, 0) => 1,
1064                (1, 0) => 2,
1065                (0, 1) => 300,
1066                (1, 1) => 65535,
1067                _ => unreachable!(),
1068            };
1069            Luma([value])
1070        });
1071        let bytes = encode_image(DynamicImage::ImageLuma16(image), ImageOutputFormat::Png);
1072        let result = run_imread(&bytes, "png", Vec::new());
1073        let Value::Tensor(tensor) = result else {
1074            panic!("expected tensor, got {result:?}");
1075        };
1076        assert_eq!(tensor.shape, vec![2, 2]);
1077        assert_eq!(tensor.dtype, NumericDType::U16);
1078        assert_eq!(tensor.data, vec![1.0, 300.0, 2.0, 65535.0]);
1079    }
1080
1081    #[test]
1082    fn imread_fetches_http_url() {
1083        let body = rgb_png();
1084        let response = http_response(200, "OK", "image/png", &body);
1085        let url = spawn_server(response);
1086        let result = futures::executor::block_on(imread_builtin(
1087            Value::from(format!("{url}/image.png")),
1088            Vec::new(),
1089        ))
1090        .expect("imread");
1091        let Value::Tensor(tensor) = result else {
1092            panic!("expected tensor, got {result:?}");
1093        };
1094        assert_eq!(tensor.shape, vec![2, 2, 3]);
1095        assert_eq!(tensor.dtype, NumericDType::U8);
1096    }
1097
1098    fn http_response(status: u16, reason: &str, content_type: &str, body: &[u8]) -> Vec<u8> {
1099        let mut response = format!(
1100            "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n\r\n",
1101            body.len()
1102        )
1103        .into_bytes();
1104        response.extend_from_slice(body);
1105        response
1106    }
1107
1108    fn spawn_server(response: Vec<u8>) -> String {
1109        spawn_repeating_server(1, response)
1110    }
1111
1112    fn spawn_repeating_server(limit: usize, response: Vec<u8>) -> String {
1113        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1114        let addr = listener.local_addr().expect("addr");
1115        let response = Arc::new(response);
1116        std::thread::spawn(move || {
1117            for stream in listener.incoming().take(limit) {
1118                let Ok(mut stream) = stream else {
1119                    continue;
1120                };
1121                write_response(&mut stream, &response);
1122            }
1123        });
1124        format!("http://{addr}")
1125    }
1126
1127    fn write_response(stream: &mut TcpStream, response: &[u8]) {
1128        let mut buffer = [0u8; 1024];
1129        let _ = stream.read(&mut buffer);
1130        stream.write_all(response).expect("write response");
1131        stream.flush().expect("flush response");
1132    }
1133}