ziptif 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ziptif-0.1.0/.gitignore +14 -0
- ziptif-0.1.0/PKG-INFO +23 -0
- ziptif-0.1.0/README.md +9 -0
- ziptif-0.1.0/pyproject.toml +39 -0
- ziptif-0.1.0/src/ziptif/__init__.py +5 -0
- ziptif-0.1.0/src/ziptif/buffer.py +50 -0
- ziptif-0.1.0/src/ziptif/cache.py +295 -0
- ziptif-0.1.0/src/ziptif/fs.py +324 -0
- ziptif-0.1.0/src/ziptif/loader.py +285 -0
- ziptif-0.1.0/src/ziptif/xarray_backend.py +635 -0
- ziptif-0.1.0/src/ziptif/ziptif.py +1223 -0
ziptif-0.1.0/.gitignore
ADDED
ziptif-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ziptif
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ZipTif file format for Satellite Image Time Series.
|
|
5
|
+
Author: Centre Borelli
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: fire>=0.7.1
|
|
9
|
+
Requires-Dist: polars>=1.33.1
|
|
10
|
+
Requires-Dist: tifffile>=2025.9.20
|
|
11
|
+
Requires-Dist: xarray>=2024.0.0
|
|
12
|
+
Requires-Dist: zstd>=1.5.7.2
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
File format to store multimodal Satellite Image Time Series.
|
|
16
|
+
|
|
17
|
+
Reading a partial subset (site, time, space, spectral, modality) is optimized for training neural networks.
|
|
18
|
+
The format is composed of a zip containing tiffs (sorted for optimal reading speed), and a binary index file (`.ziptif`).
|
|
19
|
+
|
|
20
|
+
The reader supports lazy concatenation datasets along the 'site' and 'modality' axis, thus allowing arbitrarly large training dataset.
|
|
21
|
+
|
|
22
|
+
Tested with Sentinel-1 and Sentinel-2 for now. Functions to create a dataset make a few assumptions that are not yet documented here.
|
|
23
|
+
|
ziptif-0.1.0/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
File format to store multimodal Satellite Image Time Series.
|
|
2
|
+
|
|
3
|
+
Reading a partial subset (site, time, space, spectral, modality) is optimized for training neural networks.
|
|
4
|
+
The format is composed of a zip containing tiffs (sorted for optimal reading speed), and a binary index file (`.ziptif`).
|
|
5
|
+
|
|
6
|
+
The reader supports lazy concatenation datasets along the 'site' and 'modality' axis, thus allowing arbitrarly large training dataset.
|
|
7
|
+
|
|
8
|
+
Tested with Sentinel-1 and Sentinel-2 for now. Functions to create a dataset make a few assumptions that are not yet documented here.
|
|
9
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ziptif"
|
|
3
|
+
license = "MIT"
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
description = "ZipTif file format for Satellite Image Time Series."
|
|
6
|
+
requires-python = ">= 3.11"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"tifffile>=2025.9.20"
|
|
10
|
+
, "zstd>=1.5.7.2"
|
|
11
|
+
, "polars>=1.33.1"
|
|
12
|
+
, "fire>=0.7.1"
|
|
13
|
+
, "xarray>=2024.0.0"]
|
|
14
|
+
authors = [
|
|
15
|
+
{ name = "Centre Borelli" },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
build-backend = "hatchling.build"
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.sdist]
|
|
23
|
+
only-include = ["src/ziptif"]
|
|
24
|
+
|
|
25
|
+
[tool.pixi.workspace]
|
|
26
|
+
channels = ["conda-forge"]
|
|
27
|
+
platforms = ["linux-64", "osx-arm64"]
|
|
28
|
+
|
|
29
|
+
[tool.pixi.pypi-dependencies]
|
|
30
|
+
ziptif = { path = ".", editable = true }
|
|
31
|
+
|
|
32
|
+
[tool.pixi.tasks]
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = ["pytest"]
|
|
36
|
+
|
|
37
|
+
[tool.pixi.environments]
|
|
38
|
+
default = { features = [] }
|
|
39
|
+
dev = { features = ["dev"] }
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Protocol, override
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class IBuffer(Protocol):
|
|
7
|
+
@abc.abstractmethod
|
|
8
|
+
def read_at(self, offset: int, length: int) -> bytes: ...
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class OffsetedBuffer(IBuffer):
|
|
13
|
+
buffer: bytes
|
|
14
|
+
offset: int
|
|
15
|
+
|
|
16
|
+
@override
|
|
17
|
+
def read_at(self, offset: int, length: int) -> bytes:
|
|
18
|
+
offset_in_buffer = offset - self.offset
|
|
19
|
+
return self.buffer[offset_in_buffer : offset_in_buffer + length]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class MultiOffsetedBuffer(IBuffer):
|
|
24
|
+
buffers: list[OffsetedBuffer]
|
|
25
|
+
|
|
26
|
+
def __post_init__(self):
|
|
27
|
+
# assert that buffers are sorted by offset
|
|
28
|
+
sorted_buffers = sorted(self.buffers, key=lambda b: b.offset)
|
|
29
|
+
if sorted_buffers != self.buffers:
|
|
30
|
+
raise ValueError("Buffers must be sorted by offset")
|
|
31
|
+
|
|
32
|
+
@override
|
|
33
|
+
def read_at(self, offset: int, length: int) -> bytes:
|
|
34
|
+
result = b""
|
|
35
|
+
remaining = length
|
|
36
|
+
current_offset = offset
|
|
37
|
+
|
|
38
|
+
for buf in self.buffers:
|
|
39
|
+
if buf.offset <= current_offset < buf.offset + len(buf.buffer):
|
|
40
|
+
to_read = min(
|
|
41
|
+
remaining, len(buf.buffer) - (current_offset - buf.offset)
|
|
42
|
+
)
|
|
43
|
+
result += buf.read_at(current_offset, to_read)
|
|
44
|
+
remaining -= to_read
|
|
45
|
+
current_offset += to_read
|
|
46
|
+
|
|
47
|
+
if remaining == 0:
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
raise ValueError(f"Offset {offset} out of range for length {length}")
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Buffer caching for ziptif format.
|
|
3
|
+
|
|
4
|
+
Implements LRU caching at the byte-range level to avoid redundant disk reads
|
|
5
|
+
when accessing overlapping or repeated data regions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from ziptif.buffer import OffsetedBuffer
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ziptif.fs import ZipsReader
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class CachedRange:
|
|
23
|
+
"""A cached byte range from a zip file."""
|
|
24
|
+
|
|
25
|
+
offset: int
|
|
26
|
+
buffer: bytes
|
|
27
|
+
last_access: float
|
|
28
|
+
size: int
|
|
29
|
+
|
|
30
|
+
def __post_init__(self):
|
|
31
|
+
assert self.size == len(self.buffer)
|
|
32
|
+
|
|
33
|
+
def contains(self, offset: int, length: int) -> bool:
|
|
34
|
+
"""Check if this cached range fully contains the requested range."""
|
|
35
|
+
return self.offset <= offset and offset + length <= self.offset + self.size
|
|
36
|
+
|
|
37
|
+
def overlaps(self, offset: int, length: int) -> bool:
|
|
38
|
+
"""Check if this cached range overlaps with the requested range."""
|
|
39
|
+
return not (offset + length <= self.offset or offset >= self.offset + self.size)
|
|
40
|
+
|
|
41
|
+
def get_overlap(self, offset: int, length: int) -> tuple[int, int] | None:
|
|
42
|
+
"""
|
|
43
|
+
Get the overlapping portion of this cache with the requested range.
|
|
44
|
+
Returns (overlap_offset, overlap_length) or None if no overlap.
|
|
45
|
+
"""
|
|
46
|
+
if not self.overlaps(offset, length):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
overlap_start = max(self.offset, offset)
|
|
50
|
+
overlap_end = min(self.offset + self.size, offset + length)
|
|
51
|
+
return overlap_start, overlap_end - overlap_start
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BufferCache:
|
|
55
|
+
"""
|
|
56
|
+
LRU cache for byte ranges read from zip files.
|
|
57
|
+
|
|
58
|
+
The cache works at the (zip_id, offset, length) level. When a read is requested,
|
|
59
|
+
the cache determines which parts are already available and only reads missing
|
|
60
|
+
byte ranges from disk.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, max_size_bytes: int = 1_000_000_000):
|
|
64
|
+
"""
|
|
65
|
+
Initialize buffer cache.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
max_size_bytes : int
|
|
70
|
+
Maximum cache size in bytes (default: 1GB)
|
|
71
|
+
"""
|
|
72
|
+
self._cached_ranges: dict[str, list[CachedRange]] = {}
|
|
73
|
+
self._max_size = max_size_bytes
|
|
74
|
+
self._current_size = 0
|
|
75
|
+
self._lock = threading.Lock()
|
|
76
|
+
|
|
77
|
+
def _evict_lru(self) -> None:
|
|
78
|
+
"""Evict least recently used cache entries until under size limit."""
|
|
79
|
+
while self._current_size > self._max_size:
|
|
80
|
+
# Find the LRU entry across all zip files
|
|
81
|
+
oldest_zip_id = None
|
|
82
|
+
oldest_idx = None
|
|
83
|
+
oldest_time = float("inf")
|
|
84
|
+
|
|
85
|
+
for zip_id, ranges in self._cached_ranges.items():
|
|
86
|
+
for idx, cached_range in enumerate(ranges):
|
|
87
|
+
if cached_range.last_access < oldest_time:
|
|
88
|
+
oldest_time = cached_range.last_access
|
|
89
|
+
oldest_zip_id = zip_id
|
|
90
|
+
oldest_idx = idx
|
|
91
|
+
|
|
92
|
+
if oldest_zip_id is None:
|
|
93
|
+
break
|
|
94
|
+
|
|
95
|
+
# Remove the oldest entry
|
|
96
|
+
removed = self._cached_ranges[oldest_zip_id].pop(oldest_idx)
|
|
97
|
+
self._current_size -= removed.size
|
|
98
|
+
|
|
99
|
+
# Clean up empty lists
|
|
100
|
+
if not self._cached_ranges[oldest_zip_id]:
|
|
101
|
+
del self._cached_ranges[oldest_zip_id]
|
|
102
|
+
|
|
103
|
+
def _add_to_cache(self, zip_id: str, offset: int, buffer: bytes) -> None:
|
|
104
|
+
"""Add a new range to the cache."""
|
|
105
|
+
cached_range = CachedRange(
|
|
106
|
+
offset=offset, buffer=buffer, last_access=time.time(), size=len(buffer)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if zip_id not in self._cached_ranges:
|
|
110
|
+
self._cached_ranges[zip_id] = []
|
|
111
|
+
|
|
112
|
+
self._cached_ranges[zip_id].append(cached_range)
|
|
113
|
+
self._current_size += len(buffer)
|
|
114
|
+
|
|
115
|
+
# Evict if over limit
|
|
116
|
+
if self._current_size > self._max_size:
|
|
117
|
+
self._evict_lru()
|
|
118
|
+
|
|
119
|
+
def _get_cached_buffer(self, zip_id: str, offset: int, length: int) -> bytes | None:
|
|
120
|
+
"""
|
|
121
|
+
Try to get a buffer from cache that fully contains the requested range.
|
|
122
|
+
Updates last_access time if found.
|
|
123
|
+
"""
|
|
124
|
+
if zip_id not in self._cached_ranges:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
current_time = time.time()
|
|
128
|
+
for cached_range in self._cached_ranges[zip_id]:
|
|
129
|
+
if cached_range.contains(offset, length):
|
|
130
|
+
# Update access time
|
|
131
|
+
cached_range.last_access = current_time
|
|
132
|
+
# Extract the requested portion
|
|
133
|
+
start_in_cache = offset - cached_range.offset
|
|
134
|
+
return cached_range.buffer[start_in_cache : start_in_cache + length]
|
|
135
|
+
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
def _compute_missing_ranges(
|
|
139
|
+
self, zip_id: str, requested_ranges: list[tuple[int, int]]
|
|
140
|
+
) -> list[tuple[int, int]]:
|
|
141
|
+
"""
|
|
142
|
+
Compute which byte ranges need to be read from disk.
|
|
143
|
+
|
|
144
|
+
For each requested range, subtract any overlapping cached ranges
|
|
145
|
+
to find the missing parts.
|
|
146
|
+
"""
|
|
147
|
+
if zip_id not in self._cached_ranges:
|
|
148
|
+
return requested_ranges
|
|
149
|
+
|
|
150
|
+
missing_ranges: list[tuple[int, int]] = []
|
|
151
|
+
|
|
152
|
+
for req_offset, req_length in requested_ranges:
|
|
153
|
+
# Start with the full requested range
|
|
154
|
+
uncovered: list[tuple[int, int]] = [(req_offset, req_length)]
|
|
155
|
+
|
|
156
|
+
# Subtract each overlapping cached range
|
|
157
|
+
for cached_range in self._cached_ranges[zip_id]:
|
|
158
|
+
overlap = cached_range.get_overlap(req_offset, req_length)
|
|
159
|
+
if overlap is None:
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
overlap_offset, overlap_length = overlap
|
|
163
|
+
overlap_end = overlap_offset + overlap_length
|
|
164
|
+
|
|
165
|
+
# Update last access time for this cached range
|
|
166
|
+
cached_range.last_access = time.time()
|
|
167
|
+
|
|
168
|
+
# Split uncovered ranges around the overlap
|
|
169
|
+
new_uncovered: list[tuple[int, int]] = []
|
|
170
|
+
for unc_offset, unc_length in uncovered:
|
|
171
|
+
unc_end = unc_offset + unc_length
|
|
172
|
+
|
|
173
|
+
# Part before overlap
|
|
174
|
+
if unc_offset < overlap_offset:
|
|
175
|
+
new_uncovered.append(
|
|
176
|
+
(unc_offset, min(overlap_offset, unc_end) - unc_offset)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Part after overlap
|
|
180
|
+
if unc_end > overlap_end:
|
|
181
|
+
new_uncovered.append(
|
|
182
|
+
(
|
|
183
|
+
max(overlap_end, unc_offset),
|
|
184
|
+
unc_end - max(overlap_end, unc_offset),
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
uncovered = new_uncovered
|
|
189
|
+
|
|
190
|
+
missing_ranges.extend(uncovered)
|
|
191
|
+
|
|
192
|
+
return missing_ranges
|
|
193
|
+
|
|
194
|
+
def _assemble_from_cache(self, zip_id: str, offset: int, length: int) -> bytes:
|
|
195
|
+
"""
|
|
196
|
+
Try to assemble the requested buffer from potentially multiple cached ranges.
|
|
197
|
+
"""
|
|
198
|
+
result = bytearray(length)
|
|
199
|
+
current_time = time.time()
|
|
200
|
+
|
|
201
|
+
for cached_range in self._cached_ranges[zip_id]:
|
|
202
|
+
overlap = cached_range.get_overlap(offset, length)
|
|
203
|
+
if overlap is None:
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
overlap_offset, overlap_length = overlap
|
|
207
|
+
# Update access time
|
|
208
|
+
cached_range.last_access = current_time
|
|
209
|
+
|
|
210
|
+
# Copy the overlapping portion
|
|
211
|
+
start_in_result = overlap_offset - offset
|
|
212
|
+
start_in_cache = overlap_offset - cached_range.offset
|
|
213
|
+
result[start_in_result : start_in_result + overlap_length] = (
|
|
214
|
+
cached_range.buffer[start_in_cache : start_in_cache + overlap_length]
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
return bytes(result)
|
|
218
|
+
|
|
219
|
+
def get_buffers(
|
|
220
|
+
self,
|
|
221
|
+
zip_id: str,
|
|
222
|
+
requested_ranges: list[tuple[int, int]],
|
|
223
|
+
zips_reader: ZipsReader,
|
|
224
|
+
) -> list[OffsetedBuffer]:
|
|
225
|
+
"""
|
|
226
|
+
Get buffers covering all requested ranges.
|
|
227
|
+
|
|
228
|
+
Uses cached data where available and reads missing ranges from disk.
|
|
229
|
+
All newly read data is added to the cache.
|
|
230
|
+
|
|
231
|
+
Parameters
|
|
232
|
+
----------
|
|
233
|
+
zip_id : str
|
|
234
|
+
Identifier for the zip file
|
|
235
|
+
requested_ranges : list[tuple[int, int]]
|
|
236
|
+
List of (offset, length) tuples to read. Must be sorted by offset.
|
|
237
|
+
zips_reader : ZipsReader
|
|
238
|
+
Reader to use for fetching missing data from disk
|
|
239
|
+
|
|
240
|
+
Returns
|
|
241
|
+
-------
|
|
242
|
+
list[OffsetedBuffer]
|
|
243
|
+
List of buffers covering all requested ranges, sorted by offset
|
|
244
|
+
"""
|
|
245
|
+
with self._lock:
|
|
246
|
+
# Compute what needs to be read from disk
|
|
247
|
+
missing_ranges = self._compute_missing_ranges(zip_id, requested_ranges)
|
|
248
|
+
|
|
249
|
+
# Read missing ranges from disk using batched read
|
|
250
|
+
if missing_ranges:
|
|
251
|
+
# TODO: unlock here?
|
|
252
|
+
read_result = zips_reader.batch_read_at({zip_id: missing_ranges})
|
|
253
|
+
multi_buffer = read_result[zip_id]
|
|
254
|
+
|
|
255
|
+
# Extract and cache individual ranges from the result
|
|
256
|
+
for offset, length in missing_ranges:
|
|
257
|
+
buffer = multi_buffer.read_at(offset, length)
|
|
258
|
+
self._add_to_cache(zip_id, offset, buffer)
|
|
259
|
+
|
|
260
|
+
# Now assemble the result from cache
|
|
261
|
+
result_buffers: list[OffsetedBuffer] = []
|
|
262
|
+
for req_offset, req_length in requested_ranges:
|
|
263
|
+
# Try to assemble from cache (possibly multiple ranges)
|
|
264
|
+
cached_buffer = self._assemble_from_cache(
|
|
265
|
+
zip_id, req_offset, req_length
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Cache the assembled buffer for faster future access
|
|
269
|
+
if req_offset not in [
|
|
270
|
+
cr.offset for cr in self._cached_ranges.get(zip_id, [])
|
|
271
|
+
]:
|
|
272
|
+
self._add_to_cache(zip_id, req_offset, cached_buffer)
|
|
273
|
+
|
|
274
|
+
result_buffers.append(
|
|
275
|
+
OffsetedBuffer(buffer=cached_buffer, offset=req_offset)
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
return result_buffers
|
|
279
|
+
|
|
280
|
+
def clear(self) -> None:
|
|
281
|
+
"""Clear all cached data."""
|
|
282
|
+
with self._lock:
|
|
283
|
+
self._cached_ranges.clear()
|
|
284
|
+
self._current_size = 0
|
|
285
|
+
|
|
286
|
+
def get_stats(self) -> dict[str, int]:
|
|
287
|
+
"""Get cache statistics."""
|
|
288
|
+
with self._lock:
|
|
289
|
+
total_ranges = sum(len(ranges) for ranges in self._cached_ranges.values())
|
|
290
|
+
return {
|
|
291
|
+
"current_size_bytes": self._current_size,
|
|
292
|
+
"max_size_bytes": self._max_size,
|
|
293
|
+
"num_zip_files": len(self._cached_ranges),
|
|
294
|
+
"total_cached_ranges": total_ranges,
|
|
295
|
+
}
|