Skip to content

Commit e898b0d

Browse files
committed
Handle planar configuration (separate band planes) on read
TIFF PlanarConfiguration=2 stores each band as a separate set of strips or tiles (RRR...GGG...BBB) instead of interleaved (RGBRGB...). The reader now detects this from the IFD and iterates band-by-band through the strip/tile offset array, placing each single-band chunk into the correct slice of the output. Both strip and tile layouts are handled. Windowed reads and single- band selection work correctly with planar files. 6 new tests: planar strips (RGB, 2-band), planar tiles, windowed read, band selection, and public API.
1 parent 1421cae commit e898b0d

File tree

2 files changed

+376
-86
lines changed

2 files changed

+376
-86
lines changed

xrspatial/geotiff/_reader.py

Lines changed: 130 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ def _read_strips(data: bytes, ifd: IFD, header: TIFFHeader,
142142
if offsets is None or byte_counts is None:
143143
raise ValueError("Missing strip offsets or byte counts")
144144

145+
planar = ifd.planar_config # 1=chunky (interleaved), 2=planar (separate)
146+
145147
# Determine output region
146148
if window is not None:
147149
r0, c0, r1, c1 = window
@@ -154,47 +156,85 @@ def _read_strips(data: bytes, ifd: IFD, header: TIFFHeader,
154156

155157
out_h = r1 - r0
156158
out_w = c1 - c0
157-
row_bytes = width * samples * bytes_per_sample
158159

159160
if samples > 1:
160161
result = np.empty((out_h, out_w, samples), dtype=dtype)
161162
else:
162163
result = np.empty((out_h, out_w), dtype=dtype)
163164

164-
# Only decompress strips that overlap the requested row range
165-
first_strip = r0 // rps
166-
last_strip = min((r1 - 1) // rps, len(offsets) - 1)
167-
168-
for strip_idx in range(first_strip, last_strip + 1):
169-
strip_row = strip_idx * rps
170-
strip_rows = min(rps, height - strip_row)
171-
if strip_rows <= 0:
172-
continue
173-
174-
strip_data = data[offsets[strip_idx]:offsets[strip_idx] + byte_counts[strip_idx]]
175-
expected = strip_rows * width * samples * bytes_per_sample
176-
chunk = decompress(strip_data, compression, expected,
177-
width=width, height=strip_rows, samples=samples)
178-
179-
if pred in (2, 3):
180-
if not chunk.flags.writeable:
181-
chunk = chunk.copy()
182-
chunk = _apply_predictor(chunk, pred, width, strip_rows, bytes_per_sample * samples)
183-
184-
# Reshape the decompressed strip to (strip_rows, width[, samples])
185-
if samples > 1:
186-
strip_pixels = chunk.view(dtype).reshape(strip_rows, width, samples)
187-
else:
188-
strip_pixels = chunk.view(dtype).reshape(strip_rows, width)
165+
if planar == 2 and samples > 1:
166+
# Planar configuration: each band stored as separate strips.
167+
# Strip offsets are laid out as [band0_strip0, band0_strip1, ...,
168+
# band1_strip0, band1_strip1, ..., band2_strip0, ...].
169+
strips_per_band = math.ceil(height / rps)
170+
first_strip = r0 // rps
171+
last_strip = min((r1 - 1) // rps, strips_per_band - 1)
172+
173+
for band_idx in range(samples):
174+
band_offset = band_idx * strips_per_band
175+
176+
for strip_idx in range(first_strip, last_strip + 1):
177+
global_idx = band_offset + strip_idx
178+
if global_idx >= len(offsets):
179+
continue
180+
181+
strip_row = strip_idx * rps
182+
strip_rows = min(rps, height - strip_row)
183+
if strip_rows <= 0:
184+
continue
185+
186+
strip_data = data[offsets[global_idx]:offsets[global_idx] + byte_counts[global_idx]]
187+
expected = strip_rows * width * bytes_per_sample
188+
chunk = decompress(strip_data, compression, expected,
189+
width=width, height=strip_rows, samples=1)
190+
191+
if pred in (2, 3):
192+
if not chunk.flags.writeable:
193+
chunk = chunk.copy()
194+
chunk = _apply_predictor(chunk, pred, width, strip_rows, bytes_per_sample)
195+
196+
strip_pixels = chunk.view(dtype).reshape(strip_rows, width)
197+
198+
src_r0 = max(r0 - strip_row, 0)
199+
src_r1 = min(r1 - strip_row, strip_rows)
200+
dst_r0 = max(strip_row - r0, 0)
201+
dst_r1 = dst_r0 + (src_r1 - src_r0)
202+
203+
if dst_r1 > dst_r0:
204+
result[dst_r0:dst_r1, :, band_idx] = strip_pixels[src_r0:src_r1, c0:c1]
205+
else:
206+
# Chunky (interleaved) -- default path
207+
first_strip = r0 // rps
208+
last_strip = min((r1 - 1) // rps, len(offsets) - 1)
209+
210+
for strip_idx in range(first_strip, last_strip + 1):
211+
strip_row = strip_idx * rps
212+
strip_rows = min(rps, height - strip_row)
213+
if strip_rows <= 0:
214+
continue
215+
216+
strip_data = data[offsets[strip_idx]:offsets[strip_idx] + byte_counts[strip_idx]]
217+
expected = strip_rows * width * samples * bytes_per_sample
218+
chunk = decompress(strip_data, compression, expected,
219+
width=width, height=strip_rows, samples=samples)
220+
221+
if pred in (2, 3):
222+
if not chunk.flags.writeable:
223+
chunk = chunk.copy()
224+
chunk = _apply_predictor(chunk, pred, width, strip_rows, bytes_per_sample * samples)
189225

190-
# Compute the overlap between this strip and the output window
191-
src_r0 = max(r0 - strip_row, 0)
192-
src_r1 = min(r1 - strip_row, strip_rows)
193-
dst_r0 = max(strip_row - r0, 0)
194-
dst_r1 = dst_r0 + (src_r1 - src_r0)
226+
if samples > 1:
227+
strip_pixels = chunk.view(dtype).reshape(strip_rows, width, samples)
228+
else:
229+
strip_pixels = chunk.view(dtype).reshape(strip_rows, width)
195230

196-
if dst_r1 > dst_r0:
197-
result[dst_r0:dst_r1] = strip_pixels[src_r0:src_r1, c0:c1]
231+
src_r0 = max(r0 - strip_row, 0)
232+
src_r1 = min(r1 - strip_row, strip_rows)
233+
dst_r0 = max(strip_row - r0, 0)
234+
dst_r1 = dst_r0 + (src_r1 - src_r0)
235+
236+
if dst_r1 > dst_r0:
237+
result[dst_r0:dst_r1] = strip_pixels[src_r0:src_r1, c0:c1]
198238

199239
return result
200240

@@ -241,6 +281,7 @@ def _read_tiles(data: bytes, ifd: IFD, header: TIFFHeader,
241281
if offsets is None or byte_counts is None:
242282
raise ValueError("Missing tile offsets or byte counts")
243283

284+
planar = ifd.planar_config
244285
tiles_across = math.ceil(width / tw)
245286
tiles_down = math.ceil(height / th)
246287

@@ -257,70 +298,73 @@ def _read_tiles(data: bytes, ifd: IFD, header: TIFFHeader,
257298
out_h = r1 - r0
258299
out_w = c1 - c0
259300

260-
# Use np.empty for full-image reads (every pixel written by tile placement),
261-
# np.zeros for windowed reads (edge regions may not be covered).
262301
_alloc = np.zeros if window is not None else np.empty
263302
if samples > 1:
264303
result = _alloc((out_h, out_w, samples), dtype=dtype)
265304
else:
266305
result = _alloc((out_h, out_w), dtype=dtype)
267306

268-
# Which tiles overlap the window
269307
tile_row_start = r0 // th
270308
tile_row_end = min(math.ceil(r1 / th), tiles_down)
271309
tile_col_start = c0 // tw
272310
tile_col_end = min(math.ceil(c1 / tw), tiles_across)
273311

274-
for tr in range(tile_row_start, tile_row_end):
275-
for tc in range(tile_col_start, tile_col_end):
276-
tile_idx = tr * tiles_across + tc
277-
if tile_idx >= len(offsets):
278-
continue
279-
280-
tile_data = data[offsets[tile_idx]:offsets[tile_idx] + byte_counts[tile_idx]]
281-
expected = tw * th * samples * bytes_per_sample
282-
chunk = decompress(tile_data, compression, expected,
283-
width=tw, height=th, samples=samples)
284-
285-
if pred in (2, 3):
286-
if not chunk.flags.writeable:
287-
chunk = chunk.copy()
288-
chunk = _apply_predictor(chunk, pred, tw, th, bytes_per_sample * samples)
289-
290-
# Reshape tile
291-
if samples > 1:
292-
tile_pixels = chunk.view(dtype).reshape(th, tw, samples)
293-
else:
294-
tile_pixels = chunk.view(dtype).reshape(th, tw)
295-
296-
# Compute overlap between tile and window
297-
tile_r0 = tr * th
298-
tile_c0 = tc * tw
299-
tile_r1 = tile_r0 + th
300-
tile_c1 = tile_c0 + tw
301-
302-
# Source region within the tile
303-
src_r0 = max(r0 - tile_r0, 0)
304-
src_c0 = max(c0 - tile_c0, 0)
305-
src_r1 = min(r1 - tile_r0, th)
306-
src_c1 = min(c1 - tile_c0, tw)
307-
308-
# Dest region within the output
309-
dst_r0 = max(tile_r0 - r0, 0)
310-
dst_c0 = max(tile_c0 - c0, 0)
311-
dst_r1 = dst_r0 + (src_r1 - src_r0)
312-
dst_c1 = dst_c0 + (src_c1 - src_c0)
313-
314-
# Clip to actual image bounds within tile
315-
actual_tile_h = min(th, height - tile_r0)
316-
actual_tile_w = min(tw, width - tile_c0)
317-
src_r1 = min(src_r1, actual_tile_h)
318-
src_c1 = min(src_c1, actual_tile_w)
319-
dst_r1 = dst_r0 + (src_r1 - src_r0)
320-
dst_c1 = dst_c0 + (src_c1 - src_c0)
321-
322-
if dst_r1 > dst_r0 and dst_c1 > dst_c0:
323-
result[dst_r0:dst_r1, dst_c0:dst_c1] = tile_pixels[src_r0:src_r1, src_c0:src_c1]
312+
# Number of bands to iterate (1 for chunky, samples for planar)
313+
band_count = samples if (planar == 2 and samples > 1) else 1
314+
tiles_per_band = tiles_across * tiles_down
315+
316+
for band_idx in range(band_count):
317+
band_tile_offset = band_idx * tiles_per_band if band_count > 1 else 0
318+
# For planar, each tile has 1 sample; for chunky, samples per tile
319+
tile_samples = 1 if band_count > 1 else samples
320+
321+
for tr in range(tile_row_start, tile_row_end):
322+
for tc in range(tile_col_start, tile_col_end):
323+
tile_idx = band_tile_offset + tr * tiles_across + tc
324+
if tile_idx >= len(offsets):
325+
continue
326+
327+
tile_data = data[offsets[tile_idx]:offsets[tile_idx] + byte_counts[tile_idx]]
328+
expected = tw * th * tile_samples * bytes_per_sample
329+
chunk = decompress(tile_data, compression, expected,
330+
width=tw, height=th, samples=tile_samples)
331+
332+
if pred in (2, 3):
333+
if not chunk.flags.writeable:
334+
chunk = chunk.copy()
335+
chunk = _apply_predictor(chunk, pred, tw, th,
336+
bytes_per_sample * tile_samples)
337+
338+
if tile_samples > 1:
339+
tile_pixels = chunk.view(dtype).reshape(th, tw, tile_samples)
340+
else:
341+
tile_pixels = chunk.view(dtype).reshape(th, tw)
342+
343+
tile_r0 = tr * th
344+
tile_c0 = tc * tw
345+
346+
src_r0 = max(r0 - tile_r0, 0)
347+
src_c0 = max(c0 - tile_c0, 0)
348+
src_r1 = min(r1 - tile_r0, th)
349+
src_c1 = min(c1 - tile_c0, tw)
350+
351+
dst_r0 = max(tile_r0 - r0, 0)
352+
dst_c0 = max(tile_c0 - c0, 0)
353+
354+
actual_tile_h = min(th, height - tile_r0)
355+
actual_tile_w = min(tw, width - tile_c0)
356+
src_r1 = min(src_r1, actual_tile_h)
357+
src_c1 = min(src_c1, actual_tile_w)
358+
dst_r1 = dst_r0 + (src_r1 - src_r0)
359+
dst_c1 = dst_c0 + (src_c1 - src_c0)
360+
361+
if dst_r1 > dst_r0 and dst_c1 > dst_c0:
362+
src_slice = tile_pixels[src_r0:src_r1, src_c0:src_c1]
363+
if band_count > 1:
364+
# Planar: place single-band tile into the band slice
365+
result[dst_r0:dst_r1, dst_c0:dst_c1, band_idx] = src_slice
366+
else:
367+
result[dst_r0:dst_r1, dst_c0:dst_c1] = src_slice
324368

325369
return result
326370

0 commit comments

Comments
 (0)