1use runmat_value::IntValue;
2use std::io::Cursor;
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use image::codecs::gif::{GifDecoder, GifEncoder, Repeat};
7use image::{AnimationDecoder, Delay, DynamicImage, Frame, ImageFormat, ImageOutputFormat};
8use image::{ImageBuffer, Luma, Rgb, Rgba, RgbaImage};
9use runmat_builtins::{
10 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinExtensionDescriptor,
11 BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
12 BuiltinIntegerClass, BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
13 BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
14 BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
15 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
16};
17use runmat_macros::runtime_builtin;
18use runmat_value::{IntegerStorage, LogicalArray, NumericDType, NumericScalar, Tensor, Value};
19
20use crate::builtins::common::spec::{
21 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
22 ReductionNaN, ResidencyPolicy, ShapeRequirements,
23};
24use crate::builtins::common::tensor;
25use crate::builtins::image::type_resolvers::imwrite_type;
26use crate::{build_runtime_error, BuiltinResult, RuntimeError};
27
28const BUILTIN_NAME: &str = "imwrite";
29
30const IMWRITE_INPUTS_IMAGE_FILENAME: [BuiltinParamDescriptor; 2] = [
31 BuiltinParamDescriptor {
32 name: "A",
33 ty: BuiltinParamType::NumericArray,
34 arity: BuiltinParamArity::Required,
35 default: None,
36 description: "Grayscale, truecolor, or RGBA image data.",
37 },
38 BuiltinParamDescriptor {
39 name: "filename",
40 ty: BuiltinParamType::StringScalar,
41 arity: BuiltinParamArity::Required,
42 default: None,
43 description: "Output image path.",
44 },
45];
46
47const IMWRITE_INPUTS_INDEXED: [BuiltinParamDescriptor; 3] = [
48 BuiltinParamDescriptor {
49 name: "X",
50 ty: BuiltinParamType::NumericArray,
51 arity: BuiltinParamArity::Required,
52 default: None,
53 description: "Indexed image data.",
54 },
55 BuiltinParamDescriptor {
56 name: "map",
57 ty: BuiltinParamType::NumericArray,
58 arity: BuiltinParamArity::Required,
59 default: None,
60 description: "Nx3 colormap.",
61 },
62 BuiltinParamDescriptor {
63 name: "filename",
64 ty: BuiltinParamType::StringScalar,
65 arity: BuiltinParamArity::Required,
66 default: None,
67 description: "Output image path.",
68 },
69];
70
71const IMWRITE_INPUTS_OPTIONS: [BuiltinParamDescriptor; 4] = [
72 BuiltinParamDescriptor {
73 name: "A",
74 ty: BuiltinParamType::NumericArray,
75 arity: BuiltinParamArity::Required,
76 default: None,
77 description: "Image data.",
78 },
79 BuiltinParamDescriptor {
80 name: "filename",
81 ty: BuiltinParamType::StringScalar,
82 arity: BuiltinParamArity::Required,
83 default: None,
84 description: "Output image path.",
85 },
86 BuiltinParamDescriptor {
87 name: "name",
88 ty: BuiltinParamType::StringScalar,
89 arity: BuiltinParamArity::Variadic,
90 default: None,
91 description: "Name-value option.",
92 },
93 BuiltinParamDescriptor {
94 name: "value",
95 ty: BuiltinParamType::Any,
96 arity: BuiltinParamArity::Variadic,
97 default: None,
98 description: "Name-value option value.",
99 },
100];
101
102const IMWRITE_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
103 BuiltinSignatureDescriptor {
104 label: "imwrite(A, filename)",
105 inputs: &IMWRITE_INPUTS_IMAGE_FILENAME,
106 outputs: &[],
107 },
108 BuiltinSignatureDescriptor {
109 label: "imwrite(A, filename, fmt)",
110 inputs: &IMWRITE_INPUTS_OPTIONS,
111 outputs: &[],
112 },
113 BuiltinSignatureDescriptor {
114 label: "imwrite(A, filename, name, value, ...)",
115 inputs: &IMWRITE_INPUTS_OPTIONS,
116 outputs: &[],
117 },
118 BuiltinSignatureDescriptor {
119 label: "imwrite(X, map, filename, ...)",
120 inputs: &IMWRITE_INPUTS_INDEXED,
121 outputs: &[],
122 },
123];
124
125const IMWRITE_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
126 code: "RM.IMWRITE.INVALID_ARGUMENT",
127 identifier: Some("RunMat:imwrite:InvalidArgument"),
128 when: "Arguments do not match a supported imwrite form.",
129 message: "imwrite: invalid argument",
130};
131const IMWRITE_ERROR_INVALID_FILENAME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
132 code: "RM.IMWRITE.INVALID_FILENAME",
133 identifier: Some("RunMat:imwrite:InvalidFilename"),
134 when: "Filename is missing or empty.",
135 message: "imwrite: invalid filename",
136};
137const IMWRITE_ERROR_INVALID_FORMAT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
138 code: "RM.IMWRITE.INVALID_FORMAT",
139 identifier: Some("RunMat:imwrite:InvalidFormat"),
140 when: "Image format cannot be inferred or is unsupported.",
141 message: "imwrite: invalid image format",
142};
143const IMWRITE_ERROR_INVALID_IMAGE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
144 code: "RM.IMWRITE.INVALID_IMAGE",
145 identifier: Some("RunMat:imwrite:InvalidImage"),
146 when: "Image data has unsupported type, shape, or values.",
147 message: "imwrite: invalid image data",
148};
149const IMWRITE_ERROR_INVALID_COLORMAP: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
150 code: "RM.IMWRITE.INVALID_COLORMAP",
151 identifier: Some("RunMat:imwrite:InvalidColormap"),
152 when: "Indexed-image colormap is not an Nx3 numeric array.",
153 message: "imwrite: invalid colormap",
154};
155const IMWRITE_ERROR_INVALID_OPTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
156 code: "RM.IMWRITE.INVALID_OPTION",
157 identifier: Some("RunMat:imwrite:InvalidOption"),
158 when: "Name-value option is malformed or unsupported for the requested format.",
159 message: "imwrite: invalid option",
160};
161const IMWRITE_ERROR_ENCODE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
162 code: "RM.IMWRITE.ENCODE",
163 identifier: Some("RunMat:imwrite:EncodeError"),
164 when: "Image data cannot be encoded.",
165 message: "imwrite: encode error",
166};
167const IMWRITE_ERROR_IO: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
168 code: "RM.IMWRITE.IO",
169 identifier: Some("RunMat:imwrite:Io"),
170 when: "Image file cannot be read for append or written.",
171 message: "imwrite: file I/O error",
172};
173
174const IMWRITE_ERRORS: [BuiltinErrorDescriptor; 8] = [
175 IMWRITE_ERROR_INVALID_ARGUMENT,
176 IMWRITE_ERROR_INVALID_FILENAME,
177 IMWRITE_ERROR_INVALID_FORMAT,
178 IMWRITE_ERROR_INVALID_IMAGE,
179 IMWRITE_ERROR_INVALID_COLORMAP,
180 IMWRITE_ERROR_INVALID_OPTION,
181 IMWRITE_ERROR_ENCODE,
182 IMWRITE_ERROR_IO,
183];
184
185pub const IMWRITE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
186 signatures: &IMWRITE_SIGNATURES,
187 output_mode: BuiltinOutputMode::Fixed,
188 completion_policy: BuiltinCompletionPolicy::Public,
189 errors: &IMWRITE_ERRORS,
190};
191
192const IMWRITE_SINGLE_GIF_TIFF_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
193 id: "imwrite-single-gif-tiff",
194 mode: BuiltinExtensionMode::RunMatOnly,
195 description: "imwrite accepts direct single image data for GIF and TIFF as a RunMat extension",
196 error_identifier: Some("RunMat:compatibility:ImwriteSingleGifTiffExtension"),
197};
198pub const IMWRITE_EXTENSIONS: [BuiltinExtensionDescriptor; 1] = [IMWRITE_SINGLE_GIF_TIFF_EXTENSION];
199
200const IMWRITE_DOCUMENTED_INTEGER_CLASSES: [BuiltinIntegerClass; 2] =
201 [BuiltinIntegerClass::Uint8, BuiltinIntegerClass::Uint16];
202const IMWRITE_REJECTED_INTEGER_CLASSES: [BuiltinIntegerClass; 6] = [
203 BuiltinIntegerClass::Int8,
204 BuiltinIntegerClass::Int16,
205 BuiltinIntegerClass::Int32,
206 BuiltinIntegerClass::Int64,
207 BuiltinIntegerClass::Uint32,
208 BuiltinIntegerClass::Uint64,
209];
210const IMWRITE_DOCUMENTED_INTEGER_INPUT: [BuiltinIntegerInputCapability; 1] =
211 [BuiltinIntegerInputCapability { name: "A_or_X", classes: &IMWRITE_DOCUMENTED_INTEGER_CLASSES, availability: BuiltinIntegerInputAvailability::Documented, scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable, notes: "Uint8 and uint16 direct or indexed images retain their native sample/index interpretation through the host encoder sink." }];
212const IMWRITE_REJECTED_INTEGER_INPUT: [BuiltinIntegerInputCapability; 1] =
213 [BuiltinIntegerInputCapability { name: "A_or_X", classes: &IMWRITE_REJECTED_INTEGER_CLASSES, availability: BuiltinIntegerInputAvailability::Rejected, scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable, notes: "Signed integer, uint32, and uint64 image arrays are outside the documented imwrite image-data surface and reject before file effects." }];
214const IMWRITE_ALPHA_INTEGER_CLASSES: [BuiltinIntegerClass; 2] =
215 [BuiltinIntegerClass::Uint8, BuiltinIntegerClass::Uint16];
216const IMWRITE_ALPHA_INTEGER_INPUT: [BuiltinIntegerInputCapability; 1] =
217 [BuiltinIntegerInputCapability { name: "Alpha", classes: &IMWRITE_ALPHA_INTEGER_CLASSES, availability: BuiltinIntegerInputAvailability::Documented, scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable, notes: "Documented uint8 and uint16 Alpha matrices are read from authoritative storage and scaled by their own full class range into the encoded channel." }];
218const IMWRITE_CONTROL_INTEGER_INPUT: [BuiltinIntegerInputCapability; 1] =
219 [BuiltinIntegerInputCapability { name: "integer-valued format control", classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES, availability: BuiltinIntegerInputAvailability::Documented, scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable, notes: "RowsPerStrip is documented for every integer class. Other integer-valued controls are format-specific; implemented controls read exact scalar storage before validation." }];
220pub const IMWRITE_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 4] = [
221 BuiltinIntegerCapabilityDescriptor { form: "imwrite(integer_A_or_X, ...)", inputs: &IMWRITE_DOCUMENTED_INTEGER_INPUT, computation_domain: BuiltinIntegerComputationDomain::ExactInteger, output_class: BuiltinIntegerOutputClassRule::NotApplicable, overflow: BuiltinIntegerOverflowRule::NotApplicable, backend: BuiltinIntegerBackendRule::GatherFallback, overload: BuiltinIntegerOverloadKind::Multiple, notes: "The documented host/file sink retains native uint8 or uint16 samples and indexed values through encoding. Resident image input is gathered non-destructively through its exact owner, and all validation/encoding completes before the write begins. Encoder limitations such as 16-bit JPEG and TIFF CMYK reject explicitly without changing the class contract or creating a file." },
222 BuiltinIntegerCapabilityDescriptor { form: "imwrite(unsupported_integer_A_or_X, ...)", inputs: &IMWRITE_REJECTED_INTEGER_INPUT, computation_domain: BuiltinIntegerComputationDomain::FunctionSpecific, output_class: BuiltinIntegerOutputClassRule::NotApplicable, overflow: BuiltinIntegerOverflowRule::NotApplicable, backend: BuiltinIntegerBackendRule::HostAndGpu, overload: BuiltinIntegerOverloadKind::Multiple, notes: "Unsupported image classes reject from authoritative host or resident metadata without a floating compatibility conversion or file effect." },
223 BuiltinIntegerCapabilityDescriptor { form: "imwrite(A, ..., Alpha=integer_alpha)", inputs: &IMWRITE_ALPHA_INTEGER_INPUT, computation_domain: BuiltinIntegerComputationDomain::ExactInteger, output_class: BuiltinIntegerOutputClassRule::NotApplicable, overflow: BuiltinIntegerOverflowRule::NotApplicable, backend: BuiltinIntegerBackendRule::GatherFallback, overload: BuiltinIntegerOverloadKind::SameSizeOrScalar, notes: "Documented uint8 and uint16 Alpha arrays preserve exact native samples until deterministic class-range scaling into the image bit depth; shape and format validation complete before file effects." },
224 BuiltinIntegerCapabilityDescriptor { form: "imwrite(A, ..., integer_control)", inputs: &IMWRITE_CONTROL_INTEGER_INPUT, computation_domain: BuiltinIntegerComputationDomain::Structural, output_class: BuiltinIntegerOutputClassRule::NotApplicable, overflow: BuiltinIntegerOverflowRule::Error, backend: BuiltinIntegerBackendRule::GatherFallback, overload: BuiltinIntegerOverloadKind::ScalarOnly, notes: "Typed scalar controls are read exactly and range-checked before encoding. Documented but not-yet-supported format controls, including RowsPerStrip and explicit BitDepth, reject with the stable invalid-option error before any file effect rather than silently coercing or ignoring the value." },
225];
226
227#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::image::imwrite")]
228pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
229 name: "imwrite",
230 op_kind: GpuOpKind::Custom("image-imwrite"),
231 supported_precisions: &[],
232 broadcast: BroadcastSemantics::None,
233 provider_hooks: &[],
234 constant_strategy: ConstantStrategy::InlineLiteral,
235 residency: ResidencyPolicy::GatherImmediately,
236 nan_mode: ReductionNaN::Include,
237 two_pass_threshold: None,
238 workgroup_size: None,
239 accepts_nan_mode: false,
240 notes: "Host image encoder sink; gpuArray inputs are gathered before writing.",
241};
242
243#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::image::imwrite")]
244pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
245 name: "imwrite",
246 shape: ShapeRequirements::Any,
247 constant_strategy: ConstantStrategy::InlineLiteral,
248 elementwise: None,
249 reduction: None,
250 emits_nan: false,
251 notes: "File I/O is not eligible for fusion.",
252};
253
254#[runtime_builtin(
255 name = "imwrite",
256 category = "image/io",
257 summary = "Write image data to a file.",
258 keywords = "image,write,imwrite,png,jpeg,gif,bmp,tiff",
259 sink = true,
260 suppress_auto_output = true,
261 type_resolver(imwrite_type),
262 descriptor(crate::builtins::image::imwrite::IMWRITE_DESCRIPTOR),
263 extensions(crate::builtins::image::imwrite::IMWRITE_EXTENSIONS),
264 integer_capabilities(crate::builtins::image::imwrite::IMWRITE_INTEGER_CAPABILITIES),
265 builtin_path = "crate::builtins::image::imwrite"
266)]
267async fn imwrite_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
268 if let Some(n) = crate::output_count::current_output_count() {
269 if n > 0 {
270 return Err(imwrite_error_with_detail(
271 &IMWRITE_ERROR_INVALID_ARGUMENT,
272 "imwrite does not return output arguments",
273 ));
274 }
275 }
276
277 preflight_resident_argument_roles(&args)?;
278 let mut host_args = Vec::with_capacity(args.len());
279 for arg in &args {
280 host_args.push(gather_imwrite_argument(arg).await?);
281 }
282
283 let invocation = parse_invocation(&host_args)?;
284 if matches!(invocation.format, ImageFormat::Gif | ImageFormat::Tiff)
285 && value_numeric_dtype(&invocation.image) == Some(NumericDType::F32)
286 {
287 crate::compatibility::ensure_builtin_extension_enabled(
288 &IMWRITE_SINGLE_GIF_TIFF_EXTENSION,
289 BUILTIN_NAME,
290 )?;
291 }
292 let image = materialize_image(
293 &invocation.image,
294 invocation.map.as_ref(),
295 invocation.alpha.as_ref(),
296 )?;
297 let bytes = encode_image(&image, &invocation).await?;
298 runmat_filesystem::write_async(&invocation.path, &bytes)
299 .await
300 .map_err(|err| {
301 imwrite_error_with_detail(
302 &IMWRITE_ERROR_IO,
303 format!("failed to write '{}': {err}", invocation.path.display()),
304 )
305 })?;
306
307 Ok(Value::OutputList(Vec::new()))
308}
309
310fn preflight_resident_argument_roles(args: &[Value]) -> BuiltinResult<()> {
311 for value in args {
312 if let Value::GpuTensor(handle) = value {
313 validate_resident_numeric_metadata(handle, &IMWRITE_ERROR_INVALID_ARGUMENT)?;
314 }
315 }
316 let Some(image) = args.first() else {
317 return Ok(());
318 };
319 preflight_resident_image_class(image)?;
320 if args.get(1).is_some_and(|value| !is_string_like(value)) {
321 if let Some(Value::GpuTensor(map)) = args.get(1) {
322 let owner = validate_resident_numeric_metadata(map, &IMWRITE_ERROR_INVALID_COLORMAP)?;
323 if runmat_accelerate_api::handle_integer_type(map).is_some()
324 || runmat_accelerate_api::handle_is_logical(map)
325 || runmat_accelerate_api::handle_precision(map)
326 != Some(runmat_accelerate_api::ProviderPrecision::F64)
327 || owner.precision() != runmat_accelerate_api::ProviderPrecision::F64
328 {
329 return Err(imwrite_error_with_detail(
330 &IMWRITE_ERROR_INVALID_COLORMAP,
331 "map must be a double Nx3 colormap",
332 ));
333 }
334 }
335 }
336 Ok(())
337}
338
339fn validate_resident_numeric_metadata(
340 handle: &runmat_accelerate_api::GpuTensorHandle,
341 error: &'static BuiltinErrorDescriptor,
342) -> BuiltinResult<&'static dyn runmat_accelerate_api::AccelProvider> {
343 let owner = crate::builtins::common::gpu_helpers::exact_provider_for_handle(handle)
344 .ok_or_else(|| {
345 imwrite_error_with_detail(error, "no acceleration provider owns the gpuArray handle")
346 })?;
347 if runmat_accelerate_api::handle_storage(handle)
348 != runmat_accelerate_api::GpuTensorStorage::Real
349 {
350 return Err(imwrite_error_with_detail(
351 error,
352 "gpuArray arguments must use real numeric storage",
353 ));
354 }
355 let precision = runmat_accelerate_api::handle_precision(handle);
356 let integer = runmat_accelerate_api::handle_integer_type(handle);
357 let logical = runmat_accelerate_api::handle_is_logical(handle);
358 if !crate::builtins::common::gpu_helpers::gpu_class_metadata_matches(
359 handle, precision, integer, logical,
360 ) {
361 return Err(imwrite_error_with_detail(
362 error,
363 "gpuArray class metadata contradicts its physical storage",
364 ));
365 }
366 if integer.is_none() && precision != Some(owner.precision()) {
367 return Err(imwrite_error_with_detail(
368 error,
369 "gpuArray precision metadata contradicts its owning provider",
370 ));
371 }
372 Ok(owner)
373}
374
375async fn gather_imwrite_argument(value: &Value) -> BuiltinResult<Value> {
376 let Value::GpuTensor(handle) = value else {
377 return Ok(value.clone());
378 };
379 let owner = validate_resident_numeric_metadata(handle, &IMWRITE_ERROR_INVALID_ARGUMENT)?;
380 let metadata = crate::builtins::common::gpu_helpers::snapshot_handle_metadata(handle);
381 let result = crate::builtins::common::gpu_helpers::download_value_preserving_residency_async(
382 owner, handle,
383 )
384 .await;
385 crate::builtins::common::gpu_helpers::restore_handle_metadata(handle, &metadata);
386 result.map_err(|err| imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_ARGUMENT, err.message()))
387}
388
389fn preflight_resident_image_class(value: &Value) -> BuiltinResult<()> {
390 let Value::GpuTensor(handle) = value else {
391 return Ok(());
392 };
393 validate_resident_numeric_metadata(handle, &IMWRITE_ERROR_INVALID_IMAGE)?;
394 if matches!(
395 runmat_accelerate_api::handle_integer_type(handle),
396 Some(
397 runmat_accelerate_api::IntegerElementType::I8
398 | runmat_accelerate_api::IntegerElementType::I16
399 | runmat_accelerate_api::IntegerElementType::I32
400 | runmat_accelerate_api::IntegerElementType::I64
401 | runmat_accelerate_api::IntegerElementType::U32
402 | runmat_accelerate_api::IntegerElementType::U64
403 )
404 ) {
405 return Err(imwrite_error_with_detail(
406 &IMWRITE_ERROR_INVALID_IMAGE,
407 "supported integer image classes are uint8 and uint16",
408 ));
409 }
410 Ok(())
411}
412
413fn value_numeric_dtype(value: &Value) -> Option<NumericDType> {
414 match value {
415 Value::Num(_) => Some(NumericDType::F64),
416 Value::Tensor(tensor) => Some(tensor.numeric_dtype()),
417 _ => None,
418 }
419}
420
421#[derive(Clone, Copy, Debug, PartialEq, Eq)]
422enum WriteMode {
423 Overwrite,
424 Append,
425}
426
427#[derive(Debug)]
428struct ImwriteOptions {
429 quality: u8,
430 delay_time: Option<f64>,
431 loop_count: Option<f64>,
432 write_mode: WriteMode,
433}
434
435impl Default for ImwriteOptions {
436 fn default() -> Self {
437 Self {
438 quality: 75,
439 delay_time: None,
440 loop_count: None,
441 write_mode: WriteMode::Overwrite,
442 }
443 }
444}
445
446#[derive(Debug)]
447struct Invocation {
448 image: Value,
449 map: Option<Value>,
450 alpha: Option<Tensor>,
451 path: PathBuf,
452 format: ImageFormat,
453 options: ImwriteOptions,
454}
455
456#[derive(Clone)]
457struct MaterializedImage {
458 rows: usize,
459 cols: usize,
460 channels: usize,
461 data: PixelData,
462 alpha_applied: bool,
463 indexed_source: bool,
464}
465
466#[derive(Clone)]
467enum PixelData {
468 U8(Vec<u8>),
469 U16(Vec<u16>),
470}
471
472fn parse_invocation(args: &[Value]) -> BuiltinResult<Invocation> {
473 if args.len() < 2 {
474 return Err(imwrite_error_with_detail(
475 &IMWRITE_ERROR_INVALID_ARGUMENT,
476 "expected image data and filename",
477 ));
478 }
479
480 let (image, map, filename_index) = if is_string_like(&args[1]) {
481 (args[0].clone(), None, 1usize)
482 } else {
483 if args.len() < 3 {
484 return Err(imwrite_error_with_detail(
485 &IMWRITE_ERROR_INVALID_ARGUMENT,
486 "indexed images require X, map, and filename",
487 ));
488 }
489 (args[0].clone(), Some(args[1].clone()), 2usize)
490 };
491
492 let filename = string_arg(
493 "filename",
494 &args[filename_index],
495 &IMWRITE_ERROR_INVALID_FILENAME,
496 )?;
497 if filename.trim().is_empty() {
498 return Err(imwrite_error_with_detail(
499 &IMWRITE_ERROR_INVALID_FILENAME,
500 "filename must not be empty",
501 ));
502 }
503 let path = PathBuf::from(filename);
504 let mut idx = filename_index + 1;
505
506 let mut explicit_format = None;
507 if idx < args.len() {
508 if let Some(text) = tensor::value_to_string(&args[idx]) {
509 if !is_option_name(&text) {
510 explicit_format = Some(parse_format_hint(&text)?);
511 idx += 1;
512 }
513 }
514 }
515
516 let mut options = ImwriteOptions::default();
517 let mut alpha = None;
518 while idx < args.len() {
519 let name = string_arg("option name", &args[idx], &IMWRITE_ERROR_INVALID_OPTION)?;
520 idx += 1;
521 if idx >= args.len() {
522 return Err(imwrite_error_with_detail(
523 &IMWRITE_ERROR_INVALID_OPTION,
524 format!("option '{name}' requires a value"),
525 ));
526 }
527 let value = &args[idx];
528 idx += 1;
529
530 match canonical_option_name(&name).as_str() {
531 "alpha" => {
532 let alpha_tensor = tensor_from_numeric_like(value, "Alpha")?;
533 ensure_documented_alpha_class(&alpha_tensor)?;
534 alpha = Some(alpha_tensor);
535 }
536 "quality" => {
537 let q = numeric_scalar(value, "Quality")?;
538 if !q.is_finite() || !(0.0..=100.0).contains(&q) {
539 return Err(imwrite_error_with_detail(
540 &IMWRITE_ERROR_INVALID_OPTION,
541 "Quality must be a scalar from 0 to 100",
542 ));
543 }
544 options.quality = q.round() as u8;
545 }
546 "writemode" => {
547 let mode = string_arg("WriteMode", value, &IMWRITE_ERROR_INVALID_OPTION)?;
548 options.write_mode = match mode.trim().to_ascii_lowercase().as_str() {
549 "overwrite" => WriteMode::Overwrite,
550 "append" => WriteMode::Append,
551 _ => {
552 return Err(imwrite_error_with_detail(
553 &IMWRITE_ERROR_INVALID_OPTION,
554 "WriteMode must be 'overwrite' or 'append'",
555 ))
556 }
557 };
558 }
559 "delaytime" => {
560 let delay = numeric_scalar(value, "DelayTime")?;
561 if !delay.is_finite() || delay < 0.0 {
562 return Err(imwrite_error_with_detail(
563 &IMWRITE_ERROR_INVALID_OPTION,
564 "DelayTime must be a finite non-negative scalar in seconds",
565 ));
566 }
567 options.delay_time = Some(delay);
568 }
569 "loopcount" => {
570 let count = numeric_scalar(value, "LoopCount")?;
571 if count.is_nan() || count < 0.0 {
572 return Err(imwrite_error_with_detail(
573 &IMWRITE_ERROR_INVALID_OPTION,
574 "LoopCount must be non-negative or Inf",
575 ));
576 }
577 options.loop_count = Some(count);
578 }
579 "compression" | "bitdepth" | "mode" | "disposalmethod" | "backgroundcolor"
580 | "comment" | "transparentcolor" => {
581 return Err(imwrite_error_with_detail(
582 &IMWRITE_ERROR_INVALID_OPTION,
583 format!("option '{name}' is not supported yet"),
584 ));
585 }
586 _ => {
587 return Err(imwrite_error_with_detail(
588 &IMWRITE_ERROR_INVALID_OPTION,
589 format!("unsupported option '{name}'"),
590 ))
591 }
592 }
593 }
594
595 let format = match explicit_format {
596 Some(format) => format,
597 None => infer_format_from_path(&path)?,
598 };
599
600 Ok(Invocation {
601 image,
602 map,
603 alpha,
604 path,
605 format,
606 options,
607 })
608}
609
610fn is_string_like(value: &Value) -> bool {
611 tensor::value_to_string(value).is_some()
612}
613
614fn string_arg(
615 label: &str,
616 value: &Value,
617 error: &'static BuiltinErrorDescriptor,
618) -> BuiltinResult<String> {
619 tensor::value_to_string(value).ok_or_else(|| {
620 imwrite_error_with_detail(
621 error,
622 format!("{label} must be a string scalar or char vector"),
623 )
624 })
625}
626
627fn numeric_scalar(value: &Value, label: &str) -> BuiltinResult<f64> {
628 let scalar = match value {
629 Value::Num(n) => return Ok(*n),
630 Value::Int(i) => NumericScalar::from(i.clone()),
631 Value::Bool(b) => return Ok(if *b { 1.0 } else { 0.0 }),
632 Value::Tensor(t) if tensor::is_scalar_tensor(t) => {
633 t.numeric_value_at(0).ok_or_else(|| {
634 imwrite_error_with_detail(
635 &IMWRITE_ERROR_INVALID_OPTION,
636 format!("{label} scalar storage is unavailable"),
637 )
638 })?
639 }
640 Value::LogicalArray(a) if a.data.len() == 1 => {
641 return Ok(if a.data[0] != 0 { 1.0 } else { 0.0 })
642 }
643 _ => Err(imwrite_error_with_detail(
644 &IMWRITE_ERROR_INVALID_OPTION,
645 format!("{label} must be a numeric scalar"),
646 ))?,
647 };
648 numeric_scalar_to_exact_f64(scalar, label)
649}
650
651fn numeric_scalar_to_exact_f64(value: NumericScalar, label: &str) -> BuiltinResult<f64> {
652 let converted = match value {
653 NumericScalar::F64(value) => return Ok(value),
654 NumericScalar::F32(value) => return Ok(f64::from(value)),
655 NumericScalar::I8(value) => f64::from(value),
656 NumericScalar::I16(value) => f64::from(value),
657 NumericScalar::I32(value) => f64::from(value),
658 NumericScalar::I64(value)
659 if crate::builtins::math::trigonometry::cos::integer_is_exact_f64(
660 &runmat_value::IntValue::I64(value),
661 ) =>
662 {
663 value as f64
664 }
665 NumericScalar::U8(value) => f64::from(value),
666 NumericScalar::U16(value) => f64::from(value),
667 NumericScalar::U32(value) => f64::from(value),
668 NumericScalar::U64(value)
669 if crate::builtins::math::trigonometry::cos::integer_is_exact_f64(
670 &runmat_value::IntValue::U64(value),
671 ) =>
672 {
673 value as f64
674 }
675 NumericScalar::I64(_) | NumericScalar::U64(_) => {
676 return Err(imwrite_error_with_detail(
677 &IMWRITE_ERROR_INVALID_OPTION,
678 format!("{label} integer value must be exactly representable as double"),
679 ))
680 }
681 };
682 Ok(converted)
683}
684
685fn canonical_option_name(name: &str) -> String {
686 name.chars()
687 .filter(|ch| !ch.is_whitespace() && *ch != '_' && *ch != '-')
688 .flat_map(char::to_lowercase)
689 .collect()
690}
691
692fn is_option_name(name: &str) -> bool {
693 matches!(
694 canonical_option_name(name).as_str(),
695 "alpha"
696 | "quality"
697 | "writemode"
698 | "delaytime"
699 | "loopcount"
700 | "compression"
701 | "bitdepth"
702 | "mode"
703 | "disposalmethod"
704 | "backgroundcolor"
705 | "comment"
706 | "transparentcolor"
707 )
708}
709
710fn parse_format_hint(value: &str) -> BuiltinResult<ImageFormat> {
711 let label = value.trim().trim_start_matches('.').to_ascii_lowercase();
712 if label.is_empty() {
713 return Err(imwrite_error_with_detail(
714 &IMWRITE_ERROR_INVALID_FORMAT,
715 "format hint must not be empty",
716 ));
717 }
718 match label.as_str() {
719 "jpg" | "jpeg" | "jpe" => Ok(ImageFormat::Jpeg),
720 "png" => Ok(ImageFormat::Png),
721 "bmp" => Ok(ImageFormat::Bmp),
722 "gif" => Ok(ImageFormat::Gif),
723 "tif" | "tiff" => Ok(ImageFormat::Tiff),
724 other => ImageFormat::from_extension(other)
725 .filter(is_supported_format)
726 .ok_or_else(|| {
727 imwrite_error_with_detail(
728 &IMWRITE_ERROR_INVALID_FORMAT,
729 format!("unsupported image format '{other}'"),
730 )
731 }),
732 }
733}
734
735fn infer_format_from_path(path: &Path) -> BuiltinResult<ImageFormat> {
736 ImageFormat::from_path(path)
737 .ok()
738 .filter(is_supported_format)
739 .ok_or_else(|| {
740 imwrite_error_with_detail(
741 &IMWRITE_ERROR_INVALID_FORMAT,
742 format!(
743 "could not infer supported image format from '{}'",
744 path.display()
745 ),
746 )
747 })
748}
749
750fn is_supported_format(format: &ImageFormat) -> bool {
751 matches!(
752 format,
753 ImageFormat::Png
754 | ImageFormat::Jpeg
755 | ImageFormat::Bmp
756 | ImageFormat::Gif
757 | ImageFormat::Tiff
758 )
759}
760
761fn tensor_from_numeric_like(value: &Value, label: &str) -> BuiltinResult<Tensor> {
762 match value {
763 Value::Tensor(t) => Ok(t.clone()),
764 Value::LogicalArray(a) => logical_to_tensor(a),
765 Value::Num(n) => Tensor::new(vec![*n], vec![1, 1]).map_err(|err| {
766 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, format!("{label}: {err}"))
767 }),
768 Value::Int(i) => Tensor::new_integer(IntegerStorage::from_scalar(i.clone()), vec![1, 1])
769 .map_err(|err| {
770 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, format!("{label}: {err}"))
771 }),
772 Value::Bool(b) => {
773 Tensor::new(vec![if *b { 1.0 } else { 0.0 }], vec![1, 1]).map_err(|err| {
774 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, format!("{label}: {err}"))
775 })
776 }
777 _ => Err(imwrite_error_with_detail(
778 &IMWRITE_ERROR_INVALID_IMAGE,
779 format!("{label} must be numeric or logical"),
780 )),
781 }
782}
783
784fn logical_to_tensor(value: &LogicalArray) -> BuiltinResult<Tensor> {
785 let data = value
786 .data
787 .iter()
788 .map(|&b| if b != 0 { 1.0 } else { 0.0 })
789 .collect::<Vec<_>>();
790 Tensor::new(data, value.shape.clone())
791 .map_err(|err| imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, err))
792}
793
794fn materialize_image(
795 image: &Value,
796 map: Option<&Value>,
797 alpha: Option<&Tensor>,
798) -> BuiltinResult<MaterializedImage> {
799 ensure_documented_image_class(image, map.is_some(), "image")?;
800 if let Some(map) = map {
801 ensure_double_colormap(map)?;
802 }
803 let tensor = tensor_from_numeric_like(image, "image")?;
804 let mut out = if let Some(map_value) = map {
805 materialize_indexed_image(&tensor, &tensor_from_numeric_like(map_value, "map")?)?
806 } else {
807 materialize_direct_image(&tensor)?
808 };
809
810 if let Some(alpha) = alpha {
811 apply_alpha(&mut out, alpha)?;
812 }
813 Ok(out)
814}
815
816fn ensure_documented_image_class(value: &Value, indexed: bool, label: &str) -> BuiltinResult<()> {
817 let supported = match value {
818 Value::Num(_) | Value::Bool(_) | Value::LogicalArray(_) => true,
819 Value::Int(IntValue::U8(_) | IntValue::U16(_)) => true,
820 Value::Tensor(tensor) => matches!(
821 tensor.numeric_dtype(),
822 NumericDType::F32 | NumericDType::F64 | NumericDType::U8 | NumericDType::U16
823 ),
824 _ => false,
825 };
826 if !supported {
827 return Err(imwrite_error_with_detail(
828 &IMWRITE_ERROR_INVALID_IMAGE,
829 format!(
830 "{label} class is unsupported; expected double, single, uint8, uint16, or logical{}",
831 if indexed { " indexed data" } else { "" }
832 ),
833 ));
834 }
835 Ok(())
836}
837
838fn ensure_double_colormap(value: &Value) -> BuiltinResult<()> {
839 let valid = matches!(value, Value::Num(_))
840 || matches!(value, Value::Tensor(tensor) if tensor.numeric_dtype() == NumericDType::F64);
841 if valid {
842 Ok(())
843 } else {
844 Err(imwrite_error_with_detail(
845 &IMWRITE_ERROR_INVALID_COLORMAP,
846 "map must be a double Nx3 colormap",
847 ))
848 }
849}
850
851fn ensure_documented_alpha_class(alpha: &Tensor) -> BuiltinResult<()> {
852 if matches!(
853 alpha.numeric_dtype(),
854 NumericDType::F32 | NumericDType::F64 | NumericDType::U8 | NumericDType::U16
855 ) {
856 Ok(())
857 } else {
858 Err(imwrite_error_with_detail(
859 &IMWRITE_ERROR_INVALID_OPTION,
860 "Alpha class must be double, single, uint8, or uint16",
861 ))
862 }
863}
864
865fn image_dimensions(tensor: &Tensor) -> BuiltinResult<(usize, usize, usize)> {
866 match tensor.shape.len() {
867 0 => Ok((1, 1, 1)),
868 1 => Ok((1, tensor.shape[0], 1)),
869 2 => Ok((tensor.shape[0], tensor.shape[1], 1)),
870 3 if matches!(tensor.shape[2], 1 | 3 | 4) => {
871 Ok((tensor.shape[0], tensor.shape[1], tensor.shape[2]))
872 }
873 _ => Err(imwrite_error_with_detail(
874 &IMWRITE_ERROR_INVALID_IMAGE,
875 "image must be MxN, MxNx3, or MxNx4",
876 )),
877 }
878}
879
880fn materialize_direct_image(tensor: &Tensor) -> BuiltinResult<MaterializedImage> {
881 let (rows, cols, channels) = image_dimensions(tensor)?;
882 let pixels = rows.checked_mul(cols).ok_or_else(|| {
883 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image dimensions overflow")
884 })?;
885 if tensor.len() != pixels * channels {
886 return Err(imwrite_error_with_detail(
887 &IMWRITE_ERROR_INVALID_IMAGE,
888 "image data length does not match shape",
889 ));
890 }
891
892 let mut data = if tensor.numeric_dtype() == NumericDType::U16 {
893 PixelData::U16(vec![0u16; pixels * channels])
894 } else {
895 PixelData::U8(vec![0u8; pixels * channels])
896 };
897 for row in 0..rows {
898 for col in 0..cols {
899 for channel in 0..channels {
900 let src = row + rows * col + pixels * channel;
901 let dst = (row * cols + col) * channels + channel;
902 let value = tensor_numeric_value(tensor, src, "image")?;
903 match &mut data {
904 PixelData::U8(data) => data[dst] = value_to_u8(value),
905 PixelData::U16(data) => data[dst] = value_to_u16(value),
906 }
907 }
908 }
909 }
910 Ok(MaterializedImage {
911 rows,
912 cols,
913 channels,
914 data,
915 alpha_applied: false,
916 indexed_source: false,
917 })
918}
919
920fn materialize_indexed_image(indexed: &Tensor, map: &Tensor) -> BuiltinResult<MaterializedImage> {
921 let (rows, cols, channels) = image_dimensions(indexed)?;
922 if channels != 1 {
923 return Err(imwrite_error_with_detail(
924 &IMWRITE_ERROR_INVALID_IMAGE,
925 "indexed image X must be a 2-D array",
926 ));
927 }
928 if map.shape.len() != 2 || map.shape[1] != 3 || map.shape[0] == 0 {
929 return Err(imwrite_error_with_detail(
930 &IMWRITE_ERROR_INVALID_COLORMAP,
931 "map must be an Nx3 colormap",
932 ));
933 }
934 let map_values = tensor::tensor_values_f64_cow(map);
935 if !map_values
936 .iter()
937 .all(|value| value.is_finite() && (0.0..=1.0).contains(value))
938 {
939 return Err(imwrite_error_with_detail(
940 &IMWRITE_ERROR_INVALID_COLORMAP,
941 "map values must be finite and in the range [0, 1]",
942 ));
943 }
944
945 let pixels = rows.checked_mul(cols).ok_or_else(|| {
946 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image dimensions overflow")
947 })?;
948 let byte_len = pixels.checked_mul(3).ok_or_else(|| {
949 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image dimensions overflow")
950 })?;
951 if indexed.len() != pixels || map.len() != map.shape[0] * 3 {
952 return Err(imwrite_error_with_detail(
953 &IMWRITE_ERROR_INVALID_IMAGE,
954 "indexed image or colormap data length does not match shape",
955 ));
956 }
957 let mut data = vec![0u8; byte_len];
958 for row in 0..rows {
959 for col in 0..cols {
960 let pixel = row + rows * col;
961 let map_idx = map_index(
962 tensor_numeric_value(indexed, pixel, "indexed image")?,
963 map.shape[0],
964 )?;
965 let dst = (row * cols + col) * 3;
966 for channel in 0..3 {
967 let src = map_idx + map.shape[0] * channel;
968 data[dst + channel] = value_to_u8(tensor_numeric_value(map, src, "colormap")?);
969 }
970 }
971 }
972 Ok(MaterializedImage {
973 rows,
974 cols,
975 channels: 3,
976 data: PixelData::U8(data),
977 alpha_applied: false,
978 indexed_source: true,
979 })
980}
981
982fn tensor_numeric_value(
983 tensor: &Tensor,
984 index: usize,
985 label: &str,
986) -> BuiltinResult<NumericScalar> {
987 tensor.numeric_value_at(index).ok_or_else(|| {
988 imwrite_error_with_detail(
989 &IMWRITE_ERROR_INVALID_IMAGE,
990 format!(
991 "{label} {} storage is unavailable at element {index}",
992 tensor.numeric_dtype().class_name()
993 ),
994 )
995 })
996}
997
998fn map_index(value: NumericScalar, map_rows: usize) -> BuiltinResult<usize> {
999 let index = match value {
1000 NumericScalar::F64(value) => floating_map_index(value)?,
1001 NumericScalar::F32(value) => floating_map_index(f64::from(value))?,
1002 NumericScalar::U8(value) => usize::from(value),
1003 NumericScalar::U16(value) => usize::from(value),
1004 NumericScalar::I8(value) => one_based_signed_index(i128::from(value))?,
1005 NumericScalar::I16(value) => one_based_signed_index(i128::from(value))?,
1006 NumericScalar::I32(value) => one_based_signed_index(i128::from(value))?,
1007 NumericScalar::I64(value) => one_based_signed_index(i128::from(value))?,
1008 NumericScalar::U32(value) => one_based_unsigned_index(u128::from(value))?,
1009 NumericScalar::U64(value) => one_based_unsigned_index(u128::from(value))?,
1010 };
1011 if index >= map_rows {
1012 return Err(imwrite_error_with_detail(
1013 &IMWRITE_ERROR_INVALID_IMAGE,
1014 format!(
1015 "indexed image value {} is outside the colormap",
1016 numeric_scalar_text(value)
1017 ),
1018 ));
1019 }
1020 Ok(index)
1021}
1022
1023fn floating_map_index(value: f64) -> BuiltinResult<usize> {
1024 if !value.is_finite() {
1025 return Err(imwrite_error_with_detail(
1026 &IMWRITE_ERROR_INVALID_IMAGE,
1027 "indexed image values must be finite",
1028 ));
1029 }
1030 let rounded = value.round();
1031 if rounded < 1.0 || rounded > usize::MAX as f64 {
1032 return Err(imwrite_error_with_detail(
1033 &IMWRITE_ERROR_INVALID_IMAGE,
1034 format!("indexed image value {value} is outside the colormap"),
1035 ));
1036 }
1037 Ok(rounded as usize - 1)
1038}
1039
1040fn one_based_signed_index(value: i128) -> BuiltinResult<usize> {
1041 if value < 1 {
1042 return Err(imwrite_error_with_detail(
1043 &IMWRITE_ERROR_INVALID_IMAGE,
1044 format!("indexed image value {value} is outside the colormap"),
1045 ));
1046 }
1047 usize::try_from(value - 1).map_err(|_| {
1048 imwrite_error_with_detail(
1049 &IMWRITE_ERROR_INVALID_IMAGE,
1050 format!("indexed image value {value} is outside the colormap"),
1051 )
1052 })
1053}
1054
1055fn one_based_unsigned_index(value: u128) -> BuiltinResult<usize> {
1056 if value == 0 {
1057 return Err(imwrite_error_with_detail(
1058 &IMWRITE_ERROR_INVALID_IMAGE,
1059 "indexed image value 0 is outside the colormap",
1060 ));
1061 }
1062 usize::try_from(value - 1).map_err(|_| {
1063 imwrite_error_with_detail(
1064 &IMWRITE_ERROR_INVALID_IMAGE,
1065 format!("indexed image value {value} is outside the colormap"),
1066 )
1067 })
1068}
1069
1070fn value_to_u8(value: NumericScalar) -> u8 {
1071 match value {
1072 NumericScalar::F64(value) => normalized_float_to_u8(value),
1073 NumericScalar::F32(value) => normalized_float_to_u8(f64::from(value)),
1074 integer => scaled_integer(integer, u8::MAX as u128) as u8,
1075 }
1076}
1077
1078fn value_to_u16(value: NumericScalar) -> u16 {
1079 match value {
1080 NumericScalar::F64(value) => normalized_float_to_u16(value),
1081 NumericScalar::F32(value) => normalized_float_to_u16(f64::from(value)),
1082 integer => scaled_integer(integer, u16::MAX as u128) as u16,
1083 }
1084}
1085
1086fn normalized_float_to_u8(value: f64) -> u8 {
1087 let scaled = value.clamp(0.0, 1.0) * u8::MAX as f64;
1088 if scaled.is_nan() {
1089 0
1090 } else {
1091 scaled.round() as u8
1092 }
1093}
1094
1095fn normalized_float_to_u16(value: f64) -> u16 {
1096 let scaled = value.clamp(0.0, 1.0) * u16::MAX as f64;
1097 if scaled.is_nan() {
1098 0
1099 } else {
1100 scaled.round() as u16
1101 }
1102}
1103
1104fn scaled_integer(value: NumericScalar, output_max: u128) -> u128 {
1105 let (offset, input_max) = match value {
1106 NumericScalar::I8(value) => (
1107 (i128::from(value) - i128::from(i8::MIN)) as u128,
1108 u128::from(u8::MAX),
1109 ),
1110 NumericScalar::I16(value) => (
1111 (i128::from(value) - i128::from(i16::MIN)) as u128,
1112 u128::from(u16::MAX),
1113 ),
1114 NumericScalar::I32(value) => (
1115 (i128::from(value) - i128::from(i32::MIN)) as u128,
1116 u128::from(u32::MAX),
1117 ),
1118 NumericScalar::I64(value) => (
1119 (i128::from(value) - i128::from(i64::MIN)) as u128,
1120 u128::from(u64::MAX),
1121 ),
1122 NumericScalar::U8(value) => (u128::from(value), u128::from(u8::MAX)),
1123 NumericScalar::U16(value) => (u128::from(value), u128::from(u16::MAX)),
1124 NumericScalar::U32(value) => (u128::from(value), u128::from(u32::MAX)),
1125 NumericScalar::U64(value) => (u128::from(value), u128::from(u64::MAX)),
1126 NumericScalar::F64(_) | NumericScalar::F32(_) => {
1127 unreachable!("floating image samples use normalized conversion")
1128 }
1129 };
1130 (offset * output_max + input_max / 2) / input_max
1131}
1132
1133fn numeric_scalar_text(value: NumericScalar) -> String {
1134 match value {
1135 NumericScalar::F64(value) => value.to_string(),
1136 NumericScalar::F32(value) => value.to_string(),
1137 NumericScalar::I8(value) => value.to_string(),
1138 NumericScalar::I16(value) => value.to_string(),
1139 NumericScalar::I32(value) => value.to_string(),
1140 NumericScalar::I64(value) => value.to_string(),
1141 NumericScalar::U8(value) => value.to_string(),
1142 NumericScalar::U16(value) => value.to_string(),
1143 NumericScalar::U32(value) => value.to_string(),
1144 NumericScalar::U64(value) => value.to_string(),
1145 }
1146}
1147
1148fn apply_alpha(image: &mut MaterializedImage, alpha: &Tensor) -> BuiltinResult<()> {
1149 if alpha.shape.len() != 2 || alpha.shape[0] != image.rows || alpha.shape[1] != image.cols {
1150 return Err(imwrite_error_with_detail(
1151 &IMWRITE_ERROR_INVALID_OPTION,
1152 "Alpha must be an MxN array matching the image dimensions",
1153 ));
1154 }
1155 if alpha.len() != image.rows * image.cols {
1156 return Err(imwrite_error_with_detail(
1157 &IMWRITE_ERROR_INVALID_OPTION,
1158 "Alpha data length does not match shape",
1159 ));
1160 }
1161
1162 let pixels = image.rows * image.cols;
1163 image.data = match &image.data {
1164 PixelData::U8(data) => {
1165 let mut rgba = vec![0u8; pixels * 4];
1166 for row in 0..image.rows {
1167 for col in 0..image.cols {
1168 let pixel = row * image.cols + col;
1169 let alpha_idx = row + image.rows * col;
1170 let dst = pixel * 4;
1171 match image.channels {
1172 1 => {
1173 let gray = data[pixel];
1174 rgba[dst] = gray;
1175 rgba[dst + 1] = gray;
1176 rgba[dst + 2] = gray;
1177 }
1178 3 | 4 => {
1179 let src = pixel * image.channels;
1180 rgba[dst] = data[src];
1181 rgba[dst + 1] = data[src + 1];
1182 rgba[dst + 2] = data[src + 2];
1183 }
1184 _ => unreachable!(),
1185 }
1186 rgba[dst + 3] = value_to_u8(tensor_numeric_value(alpha, alpha_idx, "Alpha")?);
1187 }
1188 }
1189 PixelData::U8(rgba)
1190 }
1191 PixelData::U16(data) => {
1192 let mut rgba = vec![0u16; pixels * 4];
1193 for row in 0..image.rows {
1194 for col in 0..image.cols {
1195 let pixel = row * image.cols + col;
1196 let alpha_idx = row + image.rows * col;
1197 let dst = pixel * 4;
1198 match image.channels {
1199 1 => {
1200 let gray = data[pixel];
1201 rgba[dst] = gray;
1202 rgba[dst + 1] = gray;
1203 rgba[dst + 2] = gray;
1204 }
1205 3 | 4 => {
1206 let src = pixel * image.channels;
1207 rgba[dst] = data[src];
1208 rgba[dst + 1] = data[src + 1];
1209 rgba[dst + 2] = data[src + 2];
1210 }
1211 _ => unreachable!(),
1212 }
1213 rgba[dst + 3] = value_to_u16(tensor_numeric_value(alpha, alpha_idx, "Alpha")?);
1214 }
1215 }
1216 PixelData::U16(rgba)
1217 }
1218 };
1219 image.channels = 4;
1220 image.alpha_applied = true;
1221 Ok(())
1222}
1223
1224async fn encode_image(
1225 image: &MaterializedImage,
1226 invocation: &Invocation,
1227) -> BuiltinResult<Vec<u8>> {
1228 if image.channels == 4 && !image.alpha_applied {
1229 let detail = if invocation.format == ImageFormat::Tiff {
1230 "CMYK TIFF encoding is not supported by this RunMat encoder"
1231 } else {
1232 "direct four-channel input is documented only as TIFF CMYK"
1233 };
1234 return Err(imwrite_error_with_detail(
1235 &IMWRITE_ERROR_INVALID_IMAGE,
1236 detail,
1237 ));
1238 }
1239 if invocation.options.write_mode == WriteMode::Append && invocation.format != ImageFormat::Gif {
1240 return Err(imwrite_error_with_detail(
1241 &IMWRITE_ERROR_INVALID_OPTION,
1242 "WriteMode 'append' is supported for GIF files only",
1243 ));
1244 }
1245
1246 match invocation.format {
1247 ImageFormat::Gif => {
1248 if (image.channels == 3 && !image.indexed_source)
1249 || matches!(&image.data, PixelData::U16(_))
1250 {
1251 return Err(imwrite_error_with_detail(
1252 &IMWRITE_ERROR_INVALID_IMAGE,
1253 "GIF direct input must be grayscale/indexed 8-bit data",
1254 ));
1255 }
1256 encode_gif(image, invocation).await
1257 }
1258 ImageFormat::Jpeg => {
1259 if image.channels == 4 {
1260 return Err(imwrite_error_with_detail(
1261 &IMWRITE_ERROR_INVALID_OPTION,
1262 "JPEG does not support alpha channels",
1263 ));
1264 }
1265 if matches!(&image.data, PixelData::U16(_)) {
1266 return Err(imwrite_error_with_detail(
1267 &IMWRITE_ERROR_INVALID_IMAGE,
1268 "16-bit JPEG encoding is not supported by this RunMat encoder",
1269 ));
1270 }
1271 write_dynamic_image(
1272 image_to_dynamic(&image_as_8bit(image), false)?,
1273 ImageOutputFormat::Jpeg(invocation.options.quality),
1274 )
1275 }
1276 ImageFormat::Bmp => {
1277 if image.channels == 4 {
1278 return Err(imwrite_error_with_detail(
1279 &IMWRITE_ERROR_INVALID_OPTION,
1280 "BMP alpha output is not supported",
1281 ));
1282 }
1283 if matches!(&image.data, PixelData::U16(_)) {
1284 return Err(imwrite_error_with_detail(
1285 &IMWRITE_ERROR_INVALID_IMAGE,
1286 "BMP does not support this 16-bit image input",
1287 ));
1288 }
1289 write_dynamic_image(
1290 image_to_dynamic(&image_as_8bit(image), false)?,
1291 ImageOutputFormat::Bmp,
1292 )
1293 }
1294 ImageFormat::Png => {
1295 write_dynamic_image(image_to_dynamic(image, true)?, ImageOutputFormat::Png)
1296 }
1297 ImageFormat::Tiff => {
1298 if image.channels == 4 {
1299 return Err(imwrite_error_with_detail(
1300 &IMWRITE_ERROR_INVALID_IMAGE,
1301 "CMYK TIFF encoding is not supported by this RunMat encoder",
1302 ));
1303 }
1304 write_dynamic_image(image_to_dynamic(image, true)?, ImageOutputFormat::Tiff)
1305 }
1306 _ => Err(imwrite_error_with_detail(
1307 &IMWRITE_ERROR_INVALID_FORMAT,
1308 "unsupported image format",
1309 )),
1310 }
1311}
1312
1313fn image_to_dynamic(image: &MaterializedImage, keep_alpha: bool) -> BuiltinResult<DynamicImage> {
1314 let width = u32::try_from(image.cols).map_err(|_| {
1315 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image width is too large")
1316 })?;
1317 let height = u32::try_from(image.rows).map_err(|_| {
1318 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image height is too large")
1319 })?;
1320
1321 match image.channels {
1322 1 => match &image.data {
1323 PixelData::U8(data) => {
1324 ImageBuffer::<Luma<u8>, _>::from_raw(width, height, data.clone())
1325 .map(DynamicImage::ImageLuma8)
1326 .ok_or_else(|| {
1327 imwrite_error_with_detail(
1328 &IMWRITE_ERROR_INVALID_IMAGE,
1329 "invalid grayscale image buffer",
1330 )
1331 })
1332 }
1333 PixelData::U16(data) => {
1334 ImageBuffer::<Luma<u16>, _>::from_raw(width, height, data.clone())
1335 .map(DynamicImage::ImageLuma16)
1336 .ok_or_else(|| {
1337 imwrite_error_with_detail(
1338 &IMWRITE_ERROR_INVALID_IMAGE,
1339 "invalid grayscale image buffer",
1340 )
1341 })
1342 }
1343 },
1344 3 => match &image.data {
1345 PixelData::U8(data) => ImageBuffer::<Rgb<u8>, _>::from_raw(width, height, data.clone())
1346 .map(DynamicImage::ImageRgb8)
1347 .ok_or_else(|| {
1348 imwrite_error_with_detail(
1349 &IMWRITE_ERROR_INVALID_IMAGE,
1350 "invalid RGB image buffer",
1351 )
1352 }),
1353 PixelData::U16(data) => {
1354 ImageBuffer::<Rgb<u16>, _>::from_raw(width, height, data.clone())
1355 .map(DynamicImage::ImageRgb16)
1356 .ok_or_else(|| {
1357 imwrite_error_with_detail(
1358 &IMWRITE_ERROR_INVALID_IMAGE,
1359 "invalid RGB image buffer",
1360 )
1361 })
1362 }
1363 },
1364 4 if keep_alpha => match &image.data {
1365 PixelData::U8(data) => {
1366 ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, data.clone())
1367 .map(DynamicImage::ImageRgba8)
1368 .ok_or_else(|| {
1369 imwrite_error_with_detail(
1370 &IMWRITE_ERROR_INVALID_IMAGE,
1371 "invalid RGBA image buffer",
1372 )
1373 })
1374 }
1375 PixelData::U16(data) => {
1376 ImageBuffer::<Rgba<u16>, _>::from_raw(width, height, data.clone())
1377 .map(DynamicImage::ImageRgba16)
1378 .ok_or_else(|| {
1379 imwrite_error_with_detail(
1380 &IMWRITE_ERROR_INVALID_IMAGE,
1381 "invalid RGBA image buffer",
1382 )
1383 })
1384 }
1385 },
1386 4 => match &image.data {
1387 PixelData::U8(data) => {
1388 let mut rgb = Vec::with_capacity(image.rows * image.cols * 3);
1389 for chunk in data.chunks_exact(4) {
1390 rgb.extend_from_slice(&chunk[..3]);
1391 }
1392 ImageBuffer::<Rgb<u8>, _>::from_raw(width, height, rgb)
1393 .map(DynamicImage::ImageRgb8)
1394 .ok_or_else(|| {
1395 imwrite_error_with_detail(
1396 &IMWRITE_ERROR_INVALID_IMAGE,
1397 "invalid RGB image buffer",
1398 )
1399 })
1400 }
1401 PixelData::U16(data) => {
1402 let mut rgb = Vec::with_capacity(image.rows * image.cols * 3);
1403 for chunk in data.chunks_exact(4) {
1404 rgb.extend_from_slice(&chunk[..3]);
1405 }
1406 ImageBuffer::<Rgb<u16>, _>::from_raw(width, height, rgb)
1407 .map(DynamicImage::ImageRgb16)
1408 .ok_or_else(|| {
1409 imwrite_error_with_detail(
1410 &IMWRITE_ERROR_INVALID_IMAGE,
1411 "invalid RGB image buffer",
1412 )
1413 })
1414 }
1415 },
1416 _ => Err(imwrite_error_with_detail(
1417 &IMWRITE_ERROR_INVALID_IMAGE,
1418 "image must have 1, 3, or 4 channels",
1419 )),
1420 }
1421}
1422
1423fn write_dynamic_image(image: DynamicImage, format: ImageOutputFormat) -> BuiltinResult<Vec<u8>> {
1424 let mut cursor = Cursor::new(Vec::new());
1425 image.write_to(&mut cursor, format).map_err(|err| {
1426 imwrite_error_with_detail(
1427 &IMWRITE_ERROR_ENCODE,
1428 format!("unable to encode image: {err}"),
1429 )
1430 })?;
1431 Ok(cursor.into_inner())
1432}
1433
1434async fn encode_gif(image: &MaterializedImage, invocation: &Invocation) -> BuiltinResult<Vec<u8>> {
1435 let mut frames = Vec::new();
1436 let mut existing_repeat = None;
1437 if invocation.options.write_mode == WriteMode::Append {
1438 let existing = runmat_filesystem::read_async(&invocation.path)
1441 .await
1442 .map_err(|err| {
1443 imwrite_error_with_detail(
1444 &IMWRITE_ERROR_IO,
1445 format!(
1446 "failed to read GIF for append '{}': {err}",
1447 invocation.path.display()
1448 ),
1449 )
1450 })?;
1451 existing_repeat = gif_repeat_from_bytes(&existing);
1452 let decoder = GifDecoder::new(Cursor::new(existing)).map_err(|err| {
1453 imwrite_error_with_detail(
1454 &IMWRITE_ERROR_ENCODE,
1455 format!("failed to decode GIF: {err}"),
1456 )
1457 })?;
1458 for frame in decoder.into_frames() {
1459 frames.push(frame.map_err(|err| {
1460 imwrite_error_with_detail(
1461 &IMWRITE_ERROR_ENCODE,
1462 format!("failed to decode GIF frame: {err}"),
1463 )
1464 })?);
1465 }
1466 }
1467 frames.push(gif_frame_from_image(image, invocation.options.delay_time)?);
1468
1469 let mut bytes = Vec::new();
1470 {
1471 let mut encoder = GifEncoder::new(&mut bytes);
1472 let repeat = if let Some(loop_count) = invocation.options.loop_count {
1473 Some(loop_count_to_repeat(loop_count)?)
1474 } else {
1475 existing_repeat
1476 };
1477 if let Some(repeat) = repeat {
1478 encoder.set_repeat(repeat).map_err(|err| {
1479 imwrite_error_with_detail(
1480 &IMWRITE_ERROR_ENCODE,
1481 format!("failed to set GIF repeat: {err}"),
1482 )
1483 })?;
1484 }
1485 for frame in frames {
1486 encoder.encode_frame(frame).map_err(|err| {
1487 imwrite_error_with_detail(
1488 &IMWRITE_ERROR_ENCODE,
1489 format!("failed to encode GIF frame: {err}"),
1490 )
1491 })?;
1492 }
1493 }
1494 Ok(bytes)
1495}
1496
1497fn gif_repeat_from_bytes(bytes: &[u8]) -> Option<Repeat> {
1498 const APP_EXT_PREFIX: &[u8] = b"\x21\xFF\x0BNETSCAPE2.0\x03\x01";
1499 bytes.windows(APP_EXT_PREFIX.len() + 3).find_map(|window| {
1500 if !window.starts_with(APP_EXT_PREFIX) || window[APP_EXT_PREFIX.len() + 2] != 0 {
1501 return None;
1502 }
1503 let lo = window[APP_EXT_PREFIX.len()];
1504 let hi = window[APP_EXT_PREFIX.len() + 1];
1505 let count = u16::from_le_bytes([lo, hi]);
1506 if count == 0 {
1507 Some(Repeat::Infinite)
1508 } else {
1509 Some(Repeat::Finite(count))
1510 }
1511 })
1512}
1513
1514fn loop_count_to_repeat(loop_count: f64) -> BuiltinResult<Repeat> {
1515 if loop_count.is_infinite() {
1516 return Ok(Repeat::Infinite);
1517 }
1518 let rounded = loop_count.round();
1519 if (rounded - loop_count).abs() > 1e-6 || rounded > u16::MAX as f64 {
1520 return Err(imwrite_error_with_detail(
1521 &IMWRITE_ERROR_INVALID_OPTION,
1522 "LoopCount must be an integer between 0 and 65535, or Inf",
1523 ));
1524 }
1525 Ok(Repeat::Finite(rounded as u16))
1526}
1527
1528fn gif_frame_from_image(
1529 image: &MaterializedImage,
1530 delay_time: Option<f64>,
1531) -> BuiltinResult<Frame> {
1532 let width = u32::try_from(image.cols).map_err(|_| {
1533 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image width is too large")
1534 })?;
1535 let height = u32::try_from(image.rows).map_err(|_| {
1536 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image height is too large")
1537 })?;
1538
1539 let mut rgba = vec![0u8; image.rows * image.cols * 4];
1540 let data = image_data_as_u8(image);
1541 for pixel in 0..image.rows * image.cols {
1542 let dst = pixel * 4;
1543 match image.channels {
1544 1 => {
1545 let gray = data[pixel];
1546 rgba[dst] = gray;
1547 rgba[dst + 1] = gray;
1548 rgba[dst + 2] = gray;
1549 rgba[dst + 3] = 255;
1550 }
1551 3 => {
1552 let src = pixel * 3;
1553 rgba[dst..dst + 3].copy_from_slice(&data[src..src + 3]);
1554 rgba[dst + 3] = 255;
1555 }
1556 4 => {
1557 let src = pixel * 4;
1558 rgba[dst..dst + 4].copy_from_slice(&data[src..src + 4]);
1559 }
1560 _ => unreachable!(),
1561 }
1562 }
1563 let image = RgbaImage::from_raw(width, height, rgba).ok_or_else(|| {
1564 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "invalid GIF frame buffer")
1565 })?;
1566 let delay = delay_time
1567 .map(|seconds| Delay::from_saturating_duration(Duration::from_secs_f64(seconds)))
1568 .unwrap_or_else(|| Delay::from_numer_denom_ms(0, 1));
1569 Ok(Frame::from_parts(image, 0, 0, delay))
1570}
1571
1572fn image_data_as_u8(image: &MaterializedImage) -> Vec<u8> {
1573 match &image.data {
1574 PixelData::U8(data) => data.clone(),
1575 PixelData::U16(data) => data
1576 .iter()
1577 .map(|value| ((*value as f64) / 257.0).round().clamp(0.0, 255.0) as u8)
1578 .collect(),
1579 }
1580}
1581
1582fn image_as_8bit(image: &MaterializedImage) -> MaterializedImage {
1583 MaterializedImage {
1584 rows: image.rows,
1585 cols: image.cols,
1586 channels: image.channels,
1587 data: PixelData::U8(image_data_as_u8(image)),
1588 alpha_applied: image.alpha_applied,
1589 indexed_source: image.indexed_source,
1590 }
1591}
1592
1593fn imwrite_error_with_detail(
1594 error: &'static BuiltinErrorDescriptor,
1595 message: impl Into<String>,
1596) -> RuntimeError {
1597 let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
1598 if let Some(identifier) = error.identifier {
1599 builder = builder.with_identifier(identifier);
1600 }
1601 builder.build()
1602}
1603
1604#[cfg(test)]
1605mod tests {
1606 use super::*;
1607 use futures::executor::block_on;
1608 use image::io::Reader as ImageReader;
1609 use runmat_value::IntegerStorage;
1610 use std::fs;
1611 use tempfile::tempdir;
1612
1613 fn tensor(data: Vec<f64>, shape: Vec<usize>, dtype: NumericDType) -> Tensor {
1614 Tensor::new_with_dtype(data, shape, dtype).expect("tensor")
1615 }
1616
1617 fn typed_tensor(storage: IntegerStorage, shape: Vec<usize>) -> Tensor {
1618 Tensor::new_integer(storage, shape).expect("integer tensor")
1619 }
1620
1621 fn call(args: Vec<Value>) -> BuiltinResult<Value> {
1622 block_on(imwrite_builtin(args))
1623 }
1624
1625 #[test]
1626 fn writes_png_rgb_and_round_trips_layout() {
1627 let dir = tempdir().unwrap();
1628 let path = dir.path().join("rgb.png");
1629 let rgb = tensor(
1630 vec![255.0, 0.0, 0.0, 0.0, 0.0, 255.0],
1631 vec![1, 2, 3],
1632 NumericDType::U8,
1633 );
1634
1635 call(vec![
1636 Value::Tensor(rgb),
1637 Value::from(path.to_string_lossy().as_ref()),
1638 ])
1639 .unwrap();
1640
1641 let decoded = ImageReader::open(&path)
1642 .unwrap()
1643 .decode()
1644 .unwrap()
1645 .to_rgb8();
1646 assert_eq!(decoded.dimensions(), (2, 1));
1647 assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0]);
1648 assert_eq!(decoded.get_pixel(1, 0).0, [0, 0, 255]);
1649 }
1650
1651 #[test]
1652 fn writes_typed_integer_png_rgb_from_exact_storage() {
1653 let dir = tempdir().unwrap();
1654 let path = dir.path().join("typed-rgb.png");
1655 let rgb = typed_tensor(
1656 IntegerStorage::U8(vec![255, 0, 0, 0, 0, 255]),
1657 vec![1, 2, 3],
1658 );
1659
1660 call(vec![
1661 Value::Tensor(rgb),
1662 Value::from(path.to_string_lossy().as_ref()),
1663 ])
1664 .unwrap();
1665
1666 let decoded = ImageReader::open(&path)
1667 .unwrap()
1668 .decode()
1669 .unwrap()
1670 .to_rgb8();
1671 assert_eq!(decoded.dimensions(), (2, 1));
1672 assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0]);
1673 assert_eq!(decoded.get_pixel(1, 0).0, [0, 0, 255]);
1674 }
1675
1676 #[test]
1677 fn writes_png_alpha_option() {
1678 let dir = tempdir().unwrap();
1679 let path = dir.path().join("alpha.png");
1680 let image = tensor(vec![1.0, 0.0, 0.0], vec![1, 1, 3], NumericDType::F64);
1681 let alpha = tensor(vec![0.5], vec![1, 1], NumericDType::F64);
1682
1683 call(vec![
1684 Value::Tensor(image),
1685 Value::from(path.to_string_lossy().as_ref()),
1686 Value::from("Alpha"),
1687 Value::Tensor(alpha),
1688 ])
1689 .unwrap();
1690
1691 let decoded = ImageReader::open(&path)
1692 .unwrap()
1693 .decode()
1694 .unwrap()
1695 .to_rgba8();
1696 assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0, 128]);
1697 }
1698
1699 #[test]
1700 fn writes_typed_integer_png_alpha_from_exact_storage() {
1701 let dir = tempdir().unwrap();
1702 let path = dir.path().join("typed-alpha.png");
1703 let image = tensor(vec![1.0, 0.0, 0.0], vec![1, 1, 3], NumericDType::F64);
1704 let alpha = typed_tensor(IntegerStorage::U8(vec![128]), vec![1, 1]);
1705
1706 call(vec![
1707 Value::Tensor(image),
1708 Value::from(path.to_string_lossy().as_ref()),
1709 Value::from("Alpha"),
1710 Value::Tensor(alpha),
1711 ])
1712 .unwrap();
1713
1714 let decoded = ImageReader::open(&path)
1715 .unwrap()
1716 .decode()
1717 .unwrap()
1718 .to_rgba8();
1719 assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0, 128]);
1720 }
1721
1722 #[test]
1723 fn imwrite_numeric_scalar_reads_typed_integer_tensor_exactly() {
1724 let scalar = Tensor::new_integer(
1725 runmat_value::IntegerStorage::U64(vec![u64::MAX]),
1726 vec![1, 1],
1727 )
1728 .expect("scalar");
1729 assert!(numeric_scalar(&Value::Tensor(scalar), "LoopCount").is_err());
1730 assert!(numeric_scalar(
1731 &Value::Int(runmat_value::IntValue::I64(i64::MAX)),
1732 "LoopCount"
1733 )
1734 .is_err());
1735 assert_eq!(
1736 numeric_scalar(
1737 &Value::Int(runmat_value::IntValue::I64(i64::MIN)),
1738 "LoopCount"
1739 )
1740 .unwrap(),
1741 i64::MIN as f64
1742 );
1743 let largest_exact_u64_below_max = u64::MAX - 2047;
1744 assert_eq!(
1745 numeric_scalar(
1746 &Value::Int(runmat_value::IntValue::U64(largest_exact_u64_below_max)),
1747 "LoopCount"
1748 )
1749 .unwrap(),
1750 largest_exact_u64_below_max as f64
1751 );
1752
1753 let vector = Tensor::new_integer(runmat_value::IntegerStorage::U16(vec![1, 2]), vec![1, 2])
1754 .expect("vector");
1755 assert!(numeric_scalar(&Value::Tensor(vector), "LoopCount").is_err());
1756
1757 for storage in [
1758 runmat_value::IntegerStorage::I8(vec![1]),
1759 runmat_value::IntegerStorage::I16(vec![1]),
1760 runmat_value::IntegerStorage::I32(vec![1]),
1761 runmat_value::IntegerStorage::I64(vec![1]),
1762 runmat_value::IntegerStorage::U8(vec![1]),
1763 runmat_value::IntegerStorage::U16(vec![1]),
1764 runmat_value::IntegerStorage::U32(vec![1]),
1765 runmat_value::IntegerStorage::U64(vec![1]),
1766 ] {
1767 let scalar = Tensor::new_integer(storage, vec![1, 1]).expect("scalar");
1768 assert_eq!(
1769 numeric_scalar(&Value::Tensor(scalar), "LoopCount").unwrap(),
1770 1.0
1771 );
1772 }
1773 }
1774
1775 #[test]
1776 fn integer_image_scaling_is_exact_for_every_native_class() {
1777 for (minimum, midpoint, maximum) in [
1778 (
1779 NumericScalar::I8(i8::MIN),
1780 NumericScalar::I8(0),
1781 NumericScalar::I8(i8::MAX),
1782 ),
1783 (
1784 NumericScalar::I16(i16::MIN),
1785 NumericScalar::I16(0),
1786 NumericScalar::I16(i16::MAX),
1787 ),
1788 (
1789 NumericScalar::I32(i32::MIN),
1790 NumericScalar::I32(0),
1791 NumericScalar::I32(i32::MAX),
1792 ),
1793 (
1794 NumericScalar::I64(i64::MIN),
1795 NumericScalar::I64(0),
1796 NumericScalar::I64(i64::MAX),
1797 ),
1798 (
1799 NumericScalar::U8(0),
1800 NumericScalar::U8(128),
1801 NumericScalar::U8(u8::MAX),
1802 ),
1803 (
1804 NumericScalar::U16(0),
1805 NumericScalar::U16(32768),
1806 NumericScalar::U16(u16::MAX),
1807 ),
1808 (
1809 NumericScalar::U32(0),
1810 NumericScalar::U32(1 << 31),
1811 NumericScalar::U32(u32::MAX),
1812 ),
1813 (
1814 NumericScalar::U64(0),
1815 NumericScalar::U64((1_u64 << 63) + 1),
1816 NumericScalar::U64(u64::MAX),
1817 ),
1818 ] {
1819 assert_eq!(value_to_u8(minimum), 0);
1820 assert_eq!(value_to_u8(midpoint), 128);
1821 assert_eq!(value_to_u8(maximum), u8::MAX);
1822 assert_eq!(value_to_u16(minimum), 0);
1823 assert!((32768..=32896).contains(&value_to_u16(midpoint)));
1824 assert_eq!(value_to_u16(maximum), u16::MAX);
1825 }
1826 }
1827
1828 #[test]
1829 fn indexed_integer_values_are_checked_without_floating_conversion() {
1830 assert_eq!(map_index(NumericScalar::U8(0), 2).unwrap(), 0);
1831 assert_eq!(map_index(NumericScalar::U16(1), 2).unwrap(), 1);
1832 assert_eq!(map_index(NumericScalar::I64(1), 2).unwrap(), 0);
1833 assert_eq!(map_index(NumericScalar::U64(2), 2).unwrap(), 1);
1834
1835 let error = map_index(NumericScalar::U64(u64::MAX), 2).unwrap_err();
1836 assert!(error.message.contains(&u64::MAX.to_string()));
1837 assert!(map_index(NumericScalar::I64(i64::MIN), 2).is_err());
1838 }
1839
1840 #[test]
1841 fn writes_uint16_png_without_downcasting() {
1842 let dir = tempdir().unwrap();
1843 let path = dir.path().join("gray16.png");
1844 let image = tensor(
1845 vec![0.0, 65535.0, 12345.0, 40000.0],
1846 vec![2, 2],
1847 NumericDType::U16,
1848 );
1849
1850 call(vec![
1851 Value::Tensor(image),
1852 Value::from(path.to_string_lossy().as_ref()),
1853 ])
1854 .unwrap();
1855
1856 let decoded = ImageReader::open(&path).unwrap().decode().unwrap();
1857 let gray = decoded.as_luma16().expect("expected 16-bit grayscale PNG");
1858 assert_eq!(gray.dimensions(), (2, 2));
1859 assert_eq!(gray.get_pixel(0, 0).0, [0]);
1860 assert_eq!(gray.get_pixel(0, 1).0, [65535]);
1861 assert_eq!(gray.get_pixel(1, 0).0, [12345]);
1862 assert_eq!(gray.get_pixel(1, 1).0, [40000]);
1863 }
1864
1865 #[test]
1866 fn resident_image_metadata_is_validated_and_preserved_before_file_effect() {
1867 crate::builtins::common::test_support::with_test_provider(|provider| {
1868 runmat_accelerate::ensure_residency_hooks();
1869 let dir = tempdir().unwrap();
1870 let path = dir.path().join("resident.png");
1871 let image = typed_tensor(IntegerStorage::U8(vec![255, 0, 0]), vec![1, 1, 3]);
1872 let handle = crate::builtins::common::gpu_helpers::upload_tensor(provider, &image)
1873 .expect("upload image");
1874 let handle =
1875 handle.with_provenance(runmat_accelerate_api::GpuHandleProvenance::Explicit);
1876 runmat_accelerate_api::mark_residency(&handle);
1877 runmat_accelerate_api::record_handle_transpose(&handle, 1, 3);
1878 call(vec![
1879 Value::GpuTensor(handle.clone()),
1880 Value::from(path.to_string_lossy().as_ref()),
1881 ])
1882 .expect("resident write");
1883 assert!(path.exists());
1884 assert!(runmat_accelerate::fusion_residency::is_resident(&handle));
1885 assert_eq!(
1886 runmat_accelerate_api::handle_transpose_info(&handle),
1887 Some(runmat_accelerate_api::TransposeInfo {
1888 base_rows: 1,
1889 base_cols: 3,
1890 })
1891 );
1892
1893 let bad_path = dir.path().join("bad.png");
1894 runmat_accelerate_api::set_handle_class_name(&handle, "uint16");
1895 let err = call(vec![
1896 Value::GpuTensor(handle),
1897 Value::from(bad_path.to_string_lossy().as_ref()),
1898 ])
1899 .expect_err("contradictory class must reject");
1900 assert!(err.message().contains("class metadata contradicts"));
1901 assert!(!bad_path.exists());
1902 });
1903 }
1904
1905 #[test]
1906 fn single_gif_extension_rejects_before_file_effect_in_matlab_mode() {
1907 let _compat = crate::compatibility::push_runmat_extensions_enabled(false);
1908 let dir = tempdir().unwrap();
1909 let path = dir.path().join("single.gif");
1910 let image = tensor(vec![0.5], vec![1, 1], NumericDType::F32);
1911 let err = call(vec![
1912 Value::Tensor(image),
1913 Value::from(path.to_string_lossy().as_ref()),
1914 ])
1915 .expect_err("single GIF extension must be gated");
1916 assert_eq!(
1917 err.identifier(),
1918 Some("RunMat:compatibility:ImwriteSingleGifTiffExtension")
1919 );
1920 assert!(!path.exists());
1921 }
1922
1923 #[test]
1924 fn writes_indexed_gif_with_zero_based_uint8_indices() {
1925 let dir = tempdir().unwrap();
1926 let path = dir.path().join("indexed.gif");
1927 let x = tensor(vec![0.0, 1.0], vec![1, 2], NumericDType::U8);
1928 let map = tensor(
1929 vec![1.0, 0.0, 0.0, 0.0, 0.0, 1.0],
1930 vec![2, 3],
1931 NumericDType::F64,
1932 );
1933
1934 call(vec![
1935 Value::Tensor(x),
1936 Value::Tensor(map),
1937 Value::from(path.to_string_lossy().as_ref()),
1938 ])
1939 .unwrap();
1940
1941 let decoded = ImageReader::open(&path)
1942 .unwrap()
1943 .decode()
1944 .unwrap()
1945 .to_rgb8();
1946 assert_eq!(decoded.dimensions(), (2, 1));
1947 assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0]);
1948 assert_eq!(decoded.get_pixel(1, 0).0, [0, 0, 255]);
1949 }
1950
1951 #[test]
1952 fn writes_typed_integer_indexed_gif_from_exact_storage() {
1953 let dir = tempdir().unwrap();
1954 let path = dir.path().join("typed-indexed.gif");
1955 let x = typed_tensor(IntegerStorage::U8(vec![0, 1]), vec![1, 2]);
1956 let map = tensor(
1957 vec![1.0, 0.0, 0.0, 0.0, 0.0, 1.0],
1958 vec![2, 3],
1959 NumericDType::F64,
1960 );
1961
1962 call(vec![
1963 Value::Tensor(x),
1964 Value::Tensor(map),
1965 Value::from(path.to_string_lossy().as_ref()),
1966 ])
1967 .unwrap();
1968
1969 let decoded = ImageReader::open(&path)
1970 .unwrap()
1971 .decode()
1972 .unwrap()
1973 .to_rgb8();
1974 assert_eq!(decoded.dimensions(), (2, 1));
1975 assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0]);
1976 assert_eq!(decoded.get_pixel(1, 0).0, [0, 0, 255]);
1977 }
1978
1979 #[test]
1980 fn appends_gif_frame() {
1981 let dir = tempdir().unwrap();
1982 let path = dir.path().join("animated.gif");
1983 let first = typed_tensor(IntegerStorage::U8(vec![0]), vec![1, 1]);
1984 let second = typed_tensor(IntegerStorage::U8(vec![1]), vec![1, 1]);
1985 let map = tensor(
1986 vec![1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
1987 vec![2, 3],
1988 NumericDType::F64,
1989 );
1990
1991 call(vec![
1992 Value::Tensor(first),
1993 Value::Tensor(map.clone()),
1994 Value::from(path.to_string_lossy().as_ref()),
1995 Value::from("LoopCount"),
1996 Value::Num(f64::INFINITY),
1997 Value::from("DelayTime"),
1998 Value::Num(0.25),
1999 ])
2000 .unwrap();
2001 call(vec![
2002 Value::Tensor(second),
2003 Value::Tensor(map),
2004 Value::from(path.to_string_lossy().as_ref()),
2005 Value::from("WriteMode"),
2006 Value::from("append"),
2007 Value::from("DelayTime"),
2008 Value::Num(0.25),
2009 ])
2010 .unwrap();
2011
2012 let bytes = fs::read(&path).unwrap();
2013 assert!(matches!(
2014 gif_repeat_from_bytes(&bytes),
2015 Some(Repeat::Infinite)
2016 ));
2017 let decoder = GifDecoder::new(Cursor::new(bytes)).unwrap();
2018 let frames = decoder.into_frames().collect_frames().unwrap();
2019 assert_eq!(frames.len(), 2);
2020 }
2021
2022 #[test]
2023 fn rejects_alpha_for_jpeg() {
2024 let dir = tempdir().unwrap();
2025 let path = dir.path().join("bad.jpg");
2026 let image = tensor(vec![1.0, 0.0, 0.0], vec![1, 1, 3], NumericDType::F64);
2027 let alpha = tensor(vec![1.0], vec![1, 1], NumericDType::F64);
2028
2029 let err = call(vec![
2030 Value::Tensor(image),
2031 Value::from(path.to_string_lossy().as_ref()),
2032 Value::from("Alpha"),
2033 Value::Tensor(alpha),
2034 ])
2035 .unwrap_err();
2036 assert_eq!(err.identifier(), Some("RunMat:imwrite:InvalidOption"));
2037 }
2038
2039 #[test]
2040 fn descriptor_has_stable_errors() {
2041 let codes: Vec<&str> = IMWRITE_DESCRIPTOR
2042 .errors
2043 .iter()
2044 .map(|error| error.code)
2045 .collect();
2046 assert!(codes.contains(&"RM.IMWRITE.INVALID_IMAGE"));
2047 assert!(codes.contains(&"RM.IMWRITE.ENCODE"));
2048 }
2049}