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