From 3bfb43e83e31b0da476832067ada68a82b378b7b Mon Sep 17 00:00:00 2001 From: Micah Chambers Date: Tue, 4 Feb 2025 19:12:01 -0800 Subject: [PATCH] Add FP16 support (#257) --- .github/workflows/rust.yml | 8 +- Cargo.toml | 1 + src/bytecast.rs | 3 + src/decoder/image.rs | 8 +- src/decoder/mod.rs | 30 +++++ tests/decode_fp16_images.rs | 195 ++++++++++++++++++++++++++++ tests/images/random-fp16-pred2.tiff | Bin 0 -> 683 bytes tests/images/random-fp16-pred3.tiff | Bin 0 -> 698 bytes tests/images/random-fp16.pgm | Bin 0 -> 546 bytes tests/images/random-fp16.tiff | Bin 0 -> 621 bytes tests/images/single-black-fp16.tiff | Bin 0 -> 457 bytes tests/images/white-fp16-pred2.tiff | Bin 0 -> 1311 bytes tests/images/white-fp16-pred3.tiff | Bin 0 -> 1359 bytes tests/images/white-fp16.tiff | Bin 0 -> 131351 bytes 14 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 tests/decode_fp16_images.rs create mode 100644 tests/images/random-fp16-pred2.tiff create mode 100644 tests/images/random-fp16-pred3.tiff create mode 100644 tests/images/random-fp16.pgm create mode 100644 tests/images/random-fp16.tiff create mode 100644 tests/images/single-black-fp16.tiff create mode 100644 tests/images/white-fp16-pred2.tiff create mode 100644 tests/images/white-fp16-pred3.tiff create mode 100644 tests/images/white-fp16.tiff diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index a686be20..109e5fbf 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -9,14 +9,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - rust: ["1.61.0", stable, beta, nightly] + rust: ["1.70.0", stable, beta, nightly] steps: - uses: actions/checkout@v2 - uses: dtolnay/rust-toolchain@nightly - if: ${{ matrix.rust == '1.61.0' }} + if: ${{ matrix.rust == '1.70.0' }} - name: Generate Cargo.lock with minimal-version dependencies - if: ${{ matrix.rust == '1.61.0' }} + if: ${{ matrix.rust == '1.70.0' }} run: cargo -Zminimal-versions generate-lockfile - uses: dtolnay/rust-toolchain@v1 @@ -29,7 +29,7 @@ jobs: - name: build run: cargo build -v - name: test - if: ${{ matrix.rust != '1.61.0' }} + if: ${{ matrix.rust != '1.70.0' }} run: cargo test -v && cargo doc -v rustfmt: diff --git a/Cargo.toml b/Cargo.toml index a71fd27f..51c203c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ categories = ["multimedia::images", "multimedia::encoding"] exclude = ["tests/images/*", "tests/fuzz_images/*"] [dependencies] +half = { version = "2.4.1" } weezl = "0.1.0" jpeg = { package = "jpeg-decoder", version = "0.3.0", default-features = false } flate2 = "1.0.20" diff --git a/src/bytecast.rs b/src/bytecast.rs index 6e9d762a..88d562c7 100644 --- a/src/bytecast.rs +++ b/src/bytecast.rs @@ -12,6 +12,8 @@ //! TODO: Would like to use std-lib here. use std::{mem, slice}; +use half::f16; + macro_rules! integral_slice_as_bytes{($int:ty, $const:ident $(,$mut:ident)*) => { pub(crate) fn $const(slice: &[$int]) -> &[u8] { assert!(mem::align_of::<$int>() <= mem::size_of::<$int>()); @@ -31,4 +33,5 @@ integral_slice_as_bytes!(i32, i32_as_ne_bytes, i32_as_ne_mut_bytes); integral_slice_as_bytes!(u64, u64_as_ne_bytes, u64_as_ne_mut_bytes); integral_slice_as_bytes!(i64, i64_as_ne_bytes, i64_as_ne_mut_bytes); integral_slice_as_bytes!(f32, f32_as_ne_bytes, f32_as_ne_mut_bytes); +integral_slice_as_bytes!(f16, f16_as_ne_bytes, f16_as_ne_mut_bytes); integral_slice_as_bytes!(f64, f64_as_ne_bytes, f64_as_ne_mut_bytes); diff --git a/src/decoder/image.rs b/src/decoder/image.rs index 64f7c67f..6133fa1e 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -1,7 +1,7 @@ use super::ifd::{Directory, Value}; use super::stream::{ByteOrder, DeflateReader, LZWReader, PackBitsReader}; use super::tag_reader::TagReader; -use super::{predict_f32, predict_f64, Limits}; +use super::{predict_f16, predict_f32, predict_f64, Limits}; use super::{stream::SmartReader, ChunkType}; use crate::tags::{ CompressionMethod, PhotometricInterpretation, PlanarConfiguration, Predictor, SampleFormat, Tag, @@ -592,7 +592,10 @@ impl Image { // Validate that the predictor is supported for the sample type. match (self.predictor, self.sample_format) { - (Predictor::Horizontal, SampleFormat::Int | SampleFormat::Uint) => {} + ( + Predictor::Horizontal, + SampleFormat::Int | SampleFormat::Uint | SampleFormat::IEEEFP, + ) => {} (Predictor::Horizontal, _) => { return Err(TiffError::UnsupportedError( TiffUnsupportedError::HorizontalPredictor(color_type), @@ -672,6 +675,7 @@ impl Image { let row = &mut row[..data_row_bytes]; match color_type.bit_depth() { + 16 => predict_f16(&mut encoded, row, samples), 32 => predict_f32(&mut encoded, row, samples), 64 => predict_f64(&mut encoded, row, samples), _ => unreachable!(), diff --git a/src/decoder/mod.rs b/src/decoder/mod.rs index 2b8b0dd2..9cb92a43 100644 --- a/src/decoder/mod.rs +++ b/src/decoder/mod.rs @@ -8,6 +8,7 @@ use crate::tags::{ use crate::{ bytecast, ColorType, TiffError, TiffFormatError, TiffResult, TiffUnsupportedError, UsageError, }; +use half::f16; use self::ifd::Directory; use self::image::Image; @@ -29,6 +30,8 @@ pub enum DecodingResult { U32(Vec), /// A vector of 64 bit unsigned ints U64(Vec), + /// A vector of 16 bit IEEE floats (held in u16) + F16(Vec), /// A vector of 32 bit IEEE floats F32(Vec), /// A vector of 64 bit IEEE floats @@ -92,6 +95,14 @@ impl DecodingResult { } } + fn new_f16(size: usize, limits: &Limits) -> TiffResult { + if size > limits.decoding_buffer_size / std::mem::size_of::() { + Err(TiffError::LimitsExceeded) + } else { + Ok(DecodingResult::F16(vec![f16::ZERO; size])) + } + } + fn new_i8(size: usize, limits: &Limits) -> TiffResult { if size > limits.decoding_buffer_size / std::mem::size_of::() { Err(TiffError::LimitsExceeded) @@ -130,6 +141,7 @@ impl DecodingResult { DecodingResult::U16(ref mut buf) => DecodingBuffer::U16(&mut buf[start..]), DecodingResult::U32(ref mut buf) => DecodingBuffer::U32(&mut buf[start..]), DecodingResult::U64(ref mut buf) => DecodingBuffer::U64(&mut buf[start..]), + DecodingResult::F16(ref mut buf) => DecodingBuffer::F16(&mut buf[start..]), DecodingResult::F32(ref mut buf) => DecodingBuffer::F32(&mut buf[start..]), DecodingResult::F64(ref mut buf) => DecodingBuffer::F64(&mut buf[start..]), DecodingResult::I8(ref mut buf) => DecodingBuffer::I8(&mut buf[start..]), @@ -150,6 +162,8 @@ pub enum DecodingBuffer<'a> { U32(&'a mut [u32]), /// A slice of 64 bit unsigned ints U64(&'a mut [u64]), + /// A slice of 16 bit IEEE floats + F16(&'a mut [f16]), /// A slice of 32 bit IEEE floats F32(&'a mut [f32]), /// A slice of 64 bit IEEE floats @@ -175,6 +189,7 @@ impl<'a> DecodingBuffer<'a> { DecodingBuffer::I32(buf) => bytecast::i32_as_ne_mut_bytes(buf), DecodingBuffer::U64(buf) => bytecast::u64_as_ne_mut_bytes(buf), DecodingBuffer::I64(buf) => bytecast::i64_as_ne_mut_bytes(buf), + DecodingBuffer::F16(buf) => bytecast::f16_as_ne_mut_bytes(buf), DecodingBuffer::F32(buf) => bytecast::f32_as_ne_mut_bytes(buf), DecodingBuffer::F64(buf) => bytecast::f64_as_ne_mut_bytes(buf), } @@ -303,6 +318,19 @@ fn predict_f32(input: &mut [u8], output: &mut [u8], samples: usize) { } } +fn predict_f16(input: &mut [u8], output: &mut [u8], samples: usize) { + for i in samples..input.len() { + input[i] = input[i].wrapping_add(input[i - samples]); + } + + for (i, chunk) in output.chunks_mut(2).enumerate() { + chunk.copy_from_slice(&u16::to_ne_bytes(u16::from_be_bytes([ + input[i], + input[input.len() / 2 + i], + ]))); + } +} + fn predict_f64(input: &mut [u8], output: &mut [u8], samples: usize) { for i in samples..input.len() { input[i] = input[i].wrapping_add(input[i - samples]); @@ -340,6 +368,7 @@ fn fix_endianness_and_predict( Predictor::FloatingPoint => { let mut buffer_copy = buf.to_vec(); match bit_depth { + 16 => predict_f16(&mut buffer_copy, buf, samples), 32 => predict_f32(&mut buffer_copy, buf, samples), 64 => predict_f64(&mut buffer_copy, buf, samples), _ => unreachable!("Caller should have validated arguments. Please file a bug."), @@ -1004,6 +1033,7 @@ impl Decoder { )), }, SampleFormat::IEEEFP => match max_sample_bits { + 16 => DecodingResult::new_f16(buffer_size, &self.limits), 32 => DecodingResult::new_f32(buffer_size, &self.limits), 64 => DecodingResult::new_f64(buffer_size, &self.limits), n => Err(TiffError::UnsupportedError( diff --git a/tests/decode_fp16_images.rs b/tests/decode_fp16_images.rs new file mode 100644 index 00000000..ba6976c4 --- /dev/null +++ b/tests/decode_fp16_images.rs @@ -0,0 +1,195 @@ +extern crate tiff; + +use tiff::decoder::{Decoder, DecodingResult}; +use tiff::ColorType; + +use std::fs::File; +use std::path::PathBuf; + +const TEST_IMAGE_DIR: &str = "./tests/images/"; + +/// Test a basic all white image +#[test] +fn test_white_ieee_fp16() { + let filenames = ["white-fp16.tiff"]; + + for filename in filenames.iter() { + let path = PathBuf::from(TEST_IMAGE_DIR).join(filename); + let img_file = File::open(path).expect("Cannot find test image!"); + let mut decoder = Decoder::new(img_file).expect("Cannot create decoder"); + assert_eq!( + decoder.dimensions().expect("Cannot get dimensions"), + (256, 256) + ); + assert_eq!( + decoder.colortype().expect("Cannot get colortype"), + ColorType::Gray(16) + ); + if let DecodingResult::F16(img) = decoder.read_image().unwrap() { + for p in img { + assert!(p == half::f16::from_f32_const(1.0)); + } + } else { + panic!("Wrong data type"); + } + } +} + +/// Test a single black pixel, to make sure scaling is ok +#[test] +fn test_one_black_pixel_ieee_fp16() { + let filenames = ["single-black-fp16.tiff"]; + + for filename in filenames.iter() { + let path = PathBuf::from(TEST_IMAGE_DIR).join(filename); + let img_file = File::open(path).expect("Cannot find test image!"); + let mut decoder = Decoder::new(img_file).expect("Cannot create decoder"); + assert_eq!( + decoder.dimensions().expect("Cannot get dimensions"), + (256, 256) + ); + assert_eq!( + decoder.colortype().expect("Cannot get colortype"), + ColorType::Gray(16) + ); + if let DecodingResult::F16(img) = decoder.read_image().unwrap() { + for (i, p) in img.iter().enumerate() { + if i == 0 { + assert!(p < &half::f16::from_f32_const(0.001)); + } else { + assert!(p == &half::f16::from_f32_const(1.0)); + } + } + } else { + panic!("Wrong data type"); + } + } +} + +/// Test white with horizontal differencing predictor +#[test] +fn test_pattern_horizontal_differencing_ieee_fp16() { + let filenames = ["white-fp16-pred2.tiff"]; + + for filename in filenames.iter() { + let path = PathBuf::from(TEST_IMAGE_DIR).join(filename); + let img_file = File::open(path).expect("Cannot find test image!"); + let mut decoder = Decoder::new(img_file).expect("Cannot create decoder"); + assert_eq!( + decoder.dimensions().expect("Cannot get dimensions"), + (256, 256) + ); + assert_eq!( + decoder.colortype().expect("Cannot get colortype"), + ColorType::Gray(16) + ); + if let DecodingResult::F16(img) = decoder.read_image().unwrap() { + // 0, 2, 5, 8, 12, 16, 255 are black + let black = [0, 2, 5, 8, 12, 16, 255]; + for (i, p) in img.iter().enumerate() { + if black.contains(&i) { + assert!(p < &half::f16::from_f32_const(0.001)); + } else { + assert!(p == &half::f16::from_f32_const(1.0)); + } + } + } else { + panic!("Wrong data type"); + } + } +} + +/// Test white with floating point predictor +#[test] +fn test_pattern_predictor_ieee_fp16() { + let filenames = ["white-fp16-pred3.tiff"]; + + for filename in filenames.iter() { + let path = PathBuf::from(TEST_IMAGE_DIR).join(filename); + let img_file = File::open(path).expect("Cannot find test image!"); + let mut decoder = Decoder::new(img_file).expect("Cannot create decoder"); + assert_eq!( + decoder.dimensions().expect("Cannot get dimensions"), + (256, 256) + ); + assert_eq!( + decoder.colortype().expect("Cannot get colortype"), + ColorType::Gray(16) + ); + if let DecodingResult::F16(img) = decoder.read_image().unwrap() { + // 0, 2, 5, 8, 12, 16, 255 are black + let black = [0, 2, 5, 8, 12, 16, 255]; + for (i, p) in img.iter().enumerate() { + if black.contains(&i) { + assert!(p < &half::f16::from_f32_const(0.001)); + } else { + assert!(p == &half::f16::from_f32_const(1.0)); + } + } + } else { + panic!("Wrong data type"); + } + } +} + +/// Test several random images +/// we'rell compare against a pnm file, that scales from 0 (for 0.0) to 65767 (for 1.0) +#[test] +fn test_predictor_ieee_fp16() { + // first parse pnm, skip the first 4 \n + let pnm_path = PathBuf::from(TEST_IMAGE_DIR).join("random-fp16.pgm"); + let pnm_bytes = std::fs::read(pnm_path).expect("Failed to read expected PNM file"); + + // PGM looks like this: + // --- + // P5 + // #Created with GIMP + // 16 16 + // 65535 + // ... + // --- + // get index of 4th \n + let byte_start = pnm_bytes + .iter() + .enumerate() + .filter(|(_, &v)| v == b'\n') + .map(|(i, _)| i) + .nth(3) + .expect("Must be 4 \\n's"); + + let pnm_values: Vec = pnm_bytes[(byte_start + 1)..] + .chunks(2) + .map(|slice| { + let bts = [slice[0], slice[1]]; + (u16::from_be_bytes(bts) as f32) / (u16::MAX as f32) + }) + .collect(); + assert!(pnm_values.len() == 256); + + let filenames = [ + "random-fp16-pred2.tiff", + "random-fp16-pred3.tiff", + "random-fp16.tiff", + ]; + + for filename in filenames.iter() { + let path = PathBuf::from(TEST_IMAGE_DIR).join(filename); + let img_file = File::open(path).expect("Cannot find test image!"); + let mut decoder = Decoder::new(img_file).expect("Cannot create decoder"); + assert_eq!( + decoder.dimensions().expect("Cannot get dimensions"), + (16, 16) + ); + assert_eq!( + decoder.colortype().expect("Cannot get colortype"), + ColorType::Gray(16) + ); + if let DecodingResult::F16(img) = decoder.read_image().unwrap() { + for (exp, found) in std::iter::zip(pnm_values.iter(), img.iter()) { + assert!((exp - found.to_f32()).abs() < 0.0001); + } + } else { + panic!("Wrong data type"); + } + } +} diff --git a/tests/images/random-fp16-pred2.tiff b/tests/images/random-fp16-pred2.tiff new file mode 100644 index 0000000000000000000000000000000000000000..63e85db51a1d70d33c7c3b5e331bc5d1acda6f92 GIT binary patch literal 683 zcmebD)MDUZU|(8>E8`$_DA-V`O3w0@C+@SdftgY|aND zTNFu+7?L^SP&K=Onxz<7!RpTe)k{O!*MMvpB)u9?HWN_X7Kt4UWitcCK|lv+9>_f~ z+PNq-u_QG`p**uBL&4qCH-MpHj%|Ri$CN;kz2EPCw-mglbWn52B3Jt4_e6O1f3vcoyJLSOf(8h>j#m3Zt*W<&8O@u;76 zb3%(Z#6C>^eQdG6i!h_ZDU(-gESFr5(Ttfg=cOY@_W{;>%ab-_eZNruuPJIhARlz{pJ51m7!hB7)J!t-Xe4*pqjm}wNZLz#pZ}x<9uUYu~o7t2H2};ka zKDNn(6z=wLUHke^r0mAb6^b0`XN=UAcK!SLJ+Zh;j4L*mYwtXfedpJ++t9<2{gB^Q_v=kH^S3lt7|&!&Tzb!S>aR)1c}`^Z+~m!A zGUe1REzNiP6%A`rXO&D`-1OYGdUo@ME6eBhFY?hlEdM9v(W-0l+Pph6_imeeKKi@B zz1`KVC$0ZD+jhnUFOT{9ds=mU>OT3YE1M3l*ON76pY=W2`1<87^Ygdf{p7p9VE>&R Y|NT$<|DIj;FtL(TQ!;S&^F7-#0lyCR@Bjb+ literal 0 HcmV?d00001 diff --git a/tests/images/random-fp16-pred3.tiff b/tests/images/random-fp16-pred3.tiff new file mode 100644 index 0000000000000000000000000000000000000000..289b17ff046da9bda271840350ce49719a7eaba6 GIT binary patch literal 698 zcmebD)MDUZU|(8>E8`$_DA-V`O3w0@C+@SdftgY|aND zTNFu+7?L^SP&KE3nxz<7!RpTe)k{O!*MMvpB)u9?HWN_X7RqJ@vV)P>KrVw0&^(ZP zV6<~lYGO%hib8p2Nrr;Er*8m5#T?m*-X2#1C61o|`|j`gEgQr*eG~bkTmti|9DG0P zGie(faa9n}y`w6~Vk>Y+EJ($$TkK%Sl|?Vk^5}A9U0f!1`7qagt1b>Btwq{_?gj5o zzpr}#s7P;4b>01)8^50NvST+)4wO>dvhJ#DJ4RiFG}lS`I%}?p-uuOo$&|OzAnv}vQ}JYn$+&TMx9EHKEqQEGWTwmWh7*6Sh=*pBe;rZ~wQR@J;DQ#m6ZISHh)E*2Q1He~u~nuVi9ssG#@>VXybw zpUc{=v+umueDdnIoSlki-a9oj1{CeDS*P~CoF&%0dHu#jEA~q&3u{%nOuJid1)mgmPw(w=Zj|a+9yJdLka#g%~K{yraPi9r;JpB%h~!VvkW%Q_H>_ccgoEC^-RJ=}4c6 z9!@oYLVJfc`KF4opQ3Acwj*1j@7@UZYn-H8GH!^GoLRt9<h-y>l>MmTCIE4?We3jn+;HM{LrZIqEt!`XJN=~XRIz7`$=2s%pT?Z~V){#KPxt@o-n_v558g5CdbBp! z-?*4%c@XEp4O}L1m9xu#Z@sK@efm}YyC>c@eNoJ=4La0vb??c9iO(MT$rqp3JNNMp zud&8oPg~abGv8jd@NK*#Z=KS(l|hh!fsvU37~Bj3K+K55W}}XpyEI#Lok%h3}l0V4$wT1+hDYFQEFmIYKlU6W=V#EyQgmegHvL1c6w2M IXR8DP|~}1IT8BvO#+I7?~J^f%GaM zN05<)feFZ71Y`>#sS!nDi$U29Ky~6!^|OHLr5IVkZs-ECrJ?LeK(-8$URj`B+(3Q% zfZ`fZagcq%P&O<$bb#_e!~mlsf$VS~2Dv>E#LmbpNmWR#NKP#%QAo=yDlTDQa7s+h MPA|$Y%}Zeb0O5BgZ2$lO literal 0 HcmV?d00001 diff --git a/tests/images/white-fp16-pred2.tiff b/tests/images/white-fp16-pred2.tiff new file mode 100644 index 0000000000000000000000000000000000000000..bb055692f6bba5dcb6c857286e7aea5a5fa1bfd7 GIT binary patch literal 1311 zcmebD)MDUZU|*y4MkWSfARPzf2r{yO zOkRT0AX^&Bz6NB=AnDbBvYCM5wn*$?D4Q854gxwL zww$hk^Jn5Sz0wFcbpuY9M|J z#Nip4C8-Lj70IauB?@VoMa3ly6?5KRG!#5wz;M`snfnjZ>pR-bFX}fiX57BHd2{Wz z|93p@TK&EKCI9k%-~VP<8UEFbt@AirLFP09y~e|`kbSoOb%%8#w|b5K=l;9>XZz3m h&-b70KU+WhzZt5)E#~_lnGcTY91Wq-lt5fw0RYx{Utj*y4MkWSfARPzf2r{yO zOkRT0AX^&Bz6NB=AnDbBvYCM5woo=RkR6P~267p6 zKyF8(y@7073UDFNusuNh6Nt@#mKOu@1|WV5#9BZHqyzC1AbtqMax4rC(Lg){h_3>% z5Gw;ict&PPszPc-a%w?|LRw}~aS21koVS+@1rIpzFgr3E*D!lef2R26ZI8g5wEX8~ zdC%pau%$n(d&$0H|Jr}se%*eRzZyG(|IABKMxap_85tQ2cn&Y%n*E1+w?GqX-Zt~w rXRqyFTOa*@+rQkuw|{N_n*aJfionbN^6zy8Mm3KH*k~f5dd2_%@c?Il literal 0 HcmV?d00001 diff --git a/tests/images/white-fp16.tiff b/tests/images/white-fp16.tiff new file mode 100644 index 0000000000000000000000000000000000000000..95d4ff90c724032ddad3f6ffcffa394217e39a57 GIT binary patch literal 131351 zcmeIuy-EW?6b8^cyD9-iOh5&HAfcl+(L`K&2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FoG?7)F#fO615=dbz22FSpv7yS}Yn-Yrdxa_u*o7?zE!5kFZ+ zy`KGEKB)O4`wnaWclIBZ&Hr!s%zRw)SLT!6+0(phJJ0Rpd{)=H`|fIPGk4>n`;Xqd mek(D^wL$J&FBh}bVm^L