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