Skip to content

Commit 2b86f80

Browse files
committed
fixup color image bugs
1 parent ddd0224 commit 2b86f80

3 files changed

Lines changed: 118 additions & 20 deletions

File tree

celestial-images/src/formats/image/fits.rs

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,40 +54,55 @@ impl Image {
5454

5555
let keywords = header.keywords().to_vec();
5656

57-
Ok(Self {
57+
let mut img = Self {
5858
pixels,
5959
dimensions,
6060
keywords,
6161
xisf_properties: Vec::new(),
62-
})
62+
};
63+
// FITS stores multi-channel images planar (all R, then all G,
64+
// then all B). Callers expect interleaved layout for display.
65+
img.planar_to_interleaved();
66+
Ok(img)
6367
}
6468

6569
pub(super) fn save_fits(&self, path: &Path) -> Result<()> {
6670
let mut writer = FitsWriter::create(path).map_err(ImageError::Fits)?;
6771

68-
match &self.pixels {
72+
// FITS expects multi-channel data in planar layout (NAXIS3
73+
// chunks each holding one full channel). In-memory we keep
74+
// it interleaved, so flip for the write.
75+
let source = if self.channels() == 3 {
76+
let mut planar = self.clone();
77+
planar.interleaved_to_planar();
78+
std::borrow::Cow::Owned(planar)
79+
} else {
80+
std::borrow::Cow::Borrowed(self)
81+
};
82+
83+
match &source.pixels {
6984
PixelData::U8(data) => writer
70-
.write_primary_image(data, &self.dimensions, &self.keywords)
85+
.write_primary_image(data, &source.dimensions, &source.keywords)
7186
.map_err(ImageError::Fits)?,
7287
PixelData::U16(data) => {
7388
let shifted: Vec<i16> =
7489
data.iter().map(|&v| (v as i32 - 32768) as i16).collect();
75-
let keywords = with_u16_scaling(&self.keywords);
90+
let keywords = with_u16_scaling(&source.keywords);
7691
writer
77-
.write_primary_image(&shifted, &self.dimensions, &keywords)
92+
.write_primary_image(&shifted, &source.dimensions, &keywords)
7893
.map_err(ImageError::Fits)?
7994
}
8095
PixelData::I16(data) => writer
81-
.write_primary_image(data, &self.dimensions, &self.keywords)
96+
.write_primary_image(data, &source.dimensions, &source.keywords)
8297
.map_err(ImageError::Fits)?,
8398
PixelData::I32(data) => writer
84-
.write_primary_image(data, &self.dimensions, &self.keywords)
99+
.write_primary_image(data, &source.dimensions, &source.keywords)
85100
.map_err(ImageError::Fits)?,
86101
PixelData::F32(data) => writer
87-
.write_primary_image(data, &self.dimensions, &self.keywords)
102+
.write_primary_image(data, &source.dimensions, &source.keywords)
88103
.map_err(ImageError::Fits)?,
89104
PixelData::F64(data) => writer
90-
.write_primary_image(data, &self.dimensions, &self.keywords)
105+
.write_primary_image(data, &source.dimensions, &source.keywords)
91106
.map_err(ImageError::Fits)?,
92107
}
93108

@@ -202,6 +217,27 @@ mod tests {
202217
assert_eq!(bscale.as_real(), Some(1.0));
203218
}
204219

220+
#[test]
221+
fn roundtrip_rgb_f32_stays_interleaved_in_memory() {
222+
// In-memory layout must be interleaved (RGB RGB RGB) both before
223+
// save and after load — the on-disk FITS layout is planar but
224+
// that conversion is handled transparently.
225+
let tmp = tmp_fits();
226+
let interleaved: Vec<f32> = vec![
227+
1.0, 2.0, 3.0, // pixel (0,0)
228+
4.0, 5.0, 6.0, // pixel (1,0)
229+
7.0, 8.0, 9.0, // pixel (0,1)
230+
10.0, 11.0, 12.0, // pixel (1,1)
231+
];
232+
let img = Image::new(PixelData::F32(interleaved.clone()), vec![2usize, 2, 3]);
233+
img.save(tmp.path()).unwrap();
234+
235+
let restored = Image::open(tmp.path()).unwrap();
236+
assert!(restored.is_rgb());
237+
assert_eq!(restored.dimensions, vec![2usize, 2, 3]);
238+
assert_eq!(restored.pixels.as_f32().unwrap(), &interleaved);
239+
}
240+
205241
#[test]
206242
fn keywords_roundtrip() {
207243
let tmp = tmp_fits();

celestial-images/src/formats/image/tiff.rs

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use crate::core::{ImageError, Result};
33
use crate::formats::pixel_data::PixelData;
44
use std::path::Path;
55
use tiff::encoder::{colortype, TiffEncoder};
6+
use tiff::ColorType;
67

78
impl Image {
89
pub fn open_tiff<P: AsRef<Path>>(path: P) -> Result<Self> {
@@ -16,12 +17,21 @@ impl Image {
1617
ImageError::FormatDetectionFailed(format!("TIFF dimensions error: {}", e))
1718
})?;
1819

20+
// Samples-per-pixel comes from the TIFF colortype, not from the
21+
// decoded buffer (which is always flat). Without this, RGB TIFFs
22+
// get reported as 1-channel and downstream code treats the
23+
// interleaved R/G/B samples as 3× as many grayscale pixels.
24+
let colortype = decoder.colortype().map_err(|e| {
25+
ImageError::FormatDetectionFailed(format!("TIFF colortype error: {}", e))
26+
})?;
27+
let samples = samples_per_pixel(colortype);
28+
1929
let image = decoder
2030
.read_image()
2131
.map_err(|e| ImageError::FormatDetectionFailed(format!("TIFF read error: {}", e)))?;
2232

23-
let (pixels, channels) = decode_tiff_image(image)?;
24-
let dimensions = build_tiff_dimensions(width, height, channels);
33+
let pixels = decode_tiff_image(image)?;
34+
let dimensions = build_tiff_dimensions(width, height, samples);
2535

2636
Ok(Self {
2737
pixels,
@@ -41,18 +51,30 @@ impl Image {
4151
}
4252
}
4353

44-
fn decode_tiff_image(image: tiff::decoder::DecodingResult) -> Result<(PixelData, usize)> {
54+
fn samples_per_pixel(colortype: ColorType) -> usize {
55+
match colortype {
56+
ColorType::Gray(_) | ColorType::Palette(_) => 1,
57+
ColorType::GrayA(_) => 2,
58+
ColorType::RGB(_) | ColorType::YCbCr(_) | ColorType::Lab(_) => 3,
59+
ColorType::RGBA(_) | ColorType::CMYK(_) => 4,
60+
ColorType::CMYKA(_) => 5,
61+
ColorType::Multiband { num_samples, .. } => num_samples as usize,
62+
_ => 1,
63+
}
64+
}
65+
66+
fn decode_tiff_image(image: tiff::decoder::DecodingResult) -> Result<PixelData> {
4567
use tiff::decoder::DecodingResult;
4668

4769
match image {
48-
DecodingResult::U8(data) => Ok((PixelData::U8(data), 1)),
49-
DecodingResult::U16(data) => Ok((PixelData::U16(data), 1)),
70+
DecodingResult::U8(data) => Ok(PixelData::U8(data)),
71+
DecodingResult::U16(data) => Ok(PixelData::U16(data)),
5072
DecodingResult::U32(data) => {
5173
let converted: Vec<i32> = data.iter().map(|&v| v as i32).collect();
52-
Ok((PixelData::I32(converted), 1))
74+
Ok(PixelData::I32(converted))
5375
}
54-
DecodingResult::F32(data) => Ok((PixelData::F32(data), 1)),
55-
DecodingResult::F64(data) => Ok((PixelData::F64(data), 1)),
76+
DecodingResult::F32(data) => Ok(PixelData::F32(data)),
77+
DecodingResult::F64(data) => Ok(PixelData::F64(data)),
5678
_ => Err(ImageError::UnsupportedFormat),
5779
}
5880
}
@@ -84,6 +106,9 @@ fn write_tiff_image(
84106
(PixelData::F32(data), 1) => {
85107
encoder.write_image::<colortype::Gray32Float>(width, height, data)
86108
}
109+
(PixelData::F32(data), 3) => {
110+
encoder.write_image::<colortype::RGB32Float>(width, height, data)
111+
}
87112
_ => return Err(ImageError::UnsupportedFormat),
88113
}
89114
.map_err(|e| ImageError::FormatDetectionFailed(format!("TIFF write error: {}", e)))
@@ -147,6 +172,35 @@ mod tests {
147172
img.save(tmp.path()).unwrap();
148173
}
149174

175+
#[test]
176+
fn roundtrip_rgb_u8_reports_three_channels() {
177+
let tmp = tmp_tiff();
178+
let img = Image::new(PixelData::U8((0u8..48).collect()), vec![4usize, 4, 3]);
179+
img.save(tmp.path()).unwrap();
180+
181+
let restored = Image::open_tiff(tmp.path()).unwrap();
182+
assert!(restored.is_rgb(), "RGB TIFF should round-trip with channels==3");
183+
assert_eq!(restored.channels(), 3);
184+
assert_eq!(restored.width(), 4);
185+
assert_eq!(restored.height(), 4);
186+
assert_eq!(restored.pixels.as_u8().unwrap().len(), 48);
187+
}
188+
189+
#[test]
190+
fn roundtrip_rgb_u16_reports_three_channels() {
191+
let tmp = tmp_tiff();
192+
let img = Image::new(
193+
PixelData::U16((0u16..48).map(|i| i * 1000).collect()),
194+
vec![4usize, 4, 3],
195+
);
196+
img.save(tmp.path()).unwrap();
197+
198+
let restored = Image::open_tiff(tmp.path()).unwrap();
199+
assert!(restored.is_rgb());
200+
assert_eq!(restored.channels(), 3);
201+
assert_eq!(restored.pixels.as_u16().unwrap().len(), 48);
202+
}
203+
150204
#[test]
151205
fn save_rejects_unsupported_combos() {
152206
let tmp = tmp_tiff();

celestial-images/src/formats/image/xisf.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,17 @@ impl Image {
5454

5555
let keywords = xisf.keywords().to_vec();
5656

57-
Ok(Self {
57+
let mut img = Self {
5858
pixels,
5959
dimensions,
6060
keywords,
6161
xisf_properties: Vec::new(),
62-
})
62+
};
63+
// XISF normal storage is read back in planar order
64+
// (`deinterleave_normal_storage` does the deinterleave). Callers
65+
// expect interleaved RGB for display, so reinterleave here.
66+
img.planar_to_interleaved();
67+
Ok(img)
6368
}
6469

6570
pub(super) fn save_xisf(&self, path: &Path) -> Result<()> {
@@ -211,6 +216,9 @@ mod tests {
211216
let restored = Image::open(tmp.path()).unwrap();
212217
assert_eq!(restored.dimensions, vec![4usize, 4, 3]);
213218
assert_eq!(restored.channels(), 3);
219+
// Pixel layout in memory must be interleaved (RGB RGB ...) so
220+
// display code can pass the buffer straight to a texture upload.
221+
assert_eq!(restored.pixels.as_u8().unwrap(), &original);
214222
}
215223

216224
#[test]

0 commit comments

Comments
 (0)