mlvbench 1.0.0__py3-none-any.whl

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.
mlvbench/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """MLV-Bench: Human vs. Machine Mid-Level Vision."""
mlvbench/cache.py ADDED
@@ -0,0 +1,508 @@
1
+ """Caching utilities for reusable files.
2
+
3
+ [`mlvbench.cache`][] provides utilities for caching files and reusing them across
4
+ experiments. The following example illustrates how the cache is typically used:
5
+
6
+ ```python
7
+ from pathlib import Path
8
+
9
+ from mlvbench.cache import (
10
+ get_cached_resource,
11
+ create_cached_resource,
12
+ finalize_cached_resource,
13
+ )
14
+
15
+ def my_data(seed: int = 2) -> Path:
16
+ # A cached resource is identified by a name, version and the parameters that where
17
+ # used to generate the resource:
18
+ name = "my_data"
19
+ version = "1"
20
+ parameters = { "seed": seed }
21
+
22
+ # If the resource with the given parameters already exists, get_cached_resource will
23
+ # return its path.
24
+ path = get_cached_resource(name, version, parameters)
25
+ if path is not None:
26
+ return path
27
+
28
+ # If the resource doesn't exist yet, create_cached_resource will create a temporary
29
+ # directory where we can prepare the data.
30
+ path = create_cached_resource(name, version, parameters)
31
+
32
+ # Prepare the data at the given path
33
+ ...
34
+
35
+ # Once all files have been generated, finalize_cached_resource will move the data to
36
+ # its final location. This method takes care of race conditions: If a different
37
+ # process has generated the same resource in the meantime, the newly created data
38
+ # will be discarded and the cached data is used.
39
+ path = finalize_cached_resource(path)
40
+
41
+ return path
42
+ ```
43
+
44
+ The location of the cache defaults to "~/.cache/mlvbench/cache" and can be configured
45
+ via the `MLVBENCH_CACHE_PATH` environment variable.
46
+ """
47
+
48
+ import hashlib
49
+ import json
50
+ import os
51
+ import shutil
52
+ import time
53
+ import uuid
54
+ import warnings
55
+ from pathlib import Path
56
+ from typing import Any
57
+
58
+ import numpy as np
59
+ import torch
60
+ from einops import rearrange
61
+ from torch.utils.data import Dataset
62
+ from tqdm import tqdm
63
+
64
+
65
+ def _get_cache_path() -> Path:
66
+ cache_path = Path(os.environ.get("MLVBENCH_CACHE_PATH", "~/.cache/mlvbench/cache"))
67
+ return cache_path.expanduser().resolve()
68
+
69
+
70
+ def get_cached_resource(
71
+ name: str,
72
+ version: str,
73
+ parameters: dict[str, Any],
74
+ ) -> Path | None:
75
+ """Return the data path of a complete cache entry, or None if not ready.
76
+
77
+ Args:
78
+ name: Name of the cached resource.
79
+ version: Version string of the resource.
80
+ parameters: Parameter dict that identifies this particular resource.
81
+
82
+ Returns:
83
+ Path to the `data/` subdirectory if the entry is complete, else None.
84
+ """
85
+ path = _get_cached_resource_path(name, version, parameters) / "data"
86
+ return path if path.exists() else None
87
+
88
+
89
+ def create_cached_resource(
90
+ name: str,
91
+ version: str,
92
+ parameters: dict[str, Any],
93
+ ) -> Path:
94
+ """Create a new cache entry and return a temporary data path.
95
+
96
+ This will create a new cached resource at a temporary location. Call
97
+ [`finalize_cached_resource`][mlvbench.cache.finalize_cached_resource] to move it to
98
+ its final location and make the data available for future runs.
99
+
100
+ Args:
101
+ name: Name of the cached resource.
102
+ version: Version string of the resource.
103
+ parameters: Parameter dict that identifies this particular resource.
104
+
105
+ Returns:
106
+ Path to the directory where resource data should be written.
107
+ """
108
+ final_root = _get_cached_resource_path(name, version, parameters)
109
+
110
+ if final_root.exists():
111
+ warnings.warn(
112
+ f"Cache entry '{name}' ({version}) already exists at {final_root}. " +
113
+ "Call get_cached_resource() first to reuse it.",
114
+ stacklevel=2,
115
+ )
116
+
117
+ tmp_id = uuid.uuid4().hex[:8]
118
+ tmp_root = final_root.parent / f"{final_root.name}_tmp_{tmp_id}"
119
+ data_path = tmp_root / "data"
120
+ data_path.mkdir(parents=True, exist_ok=True)
121
+
122
+ with open(tmp_root / "metadata.json", "w") as f:
123
+ json.dump({"parameters": parameters, "created_at": time.time()}, f, indent=2)
124
+
125
+ return data_path
126
+
127
+
128
+ def finalize_cached_resource(path: Path) -> Path:
129
+ """Atomically finalize a cached resource by moving it to its final path.
130
+
131
+ If the target already exists (e.g., another worker finalized first), this emits a
132
+ warning and deletes the newly created data.
133
+
134
+ Args:
135
+ path: The data path returned by
136
+ [`create_cached_resource`][mlvbench.cache.create_cached_resource].
137
+
138
+ Returns:
139
+ The updated data path.
140
+
141
+ Raises:
142
+ ValueError: If `path` is not located within the cache directory.
143
+ """
144
+ cache_path = _get_cache_path()
145
+
146
+ if not path.is_relative_to(cache_path):
147
+ raise ValueError(f"Path {path} is not within the cache directory {cache_path}.")
148
+
149
+ tmp_root = path.parent
150
+ hash_str = tmp_root.name.split("_tmp_")[0]
151
+ final_root = tmp_root.parent / hash_str
152
+
153
+ metadata_path = tmp_root / "metadata.json"
154
+ with open(metadata_path) as f:
155
+ metadata = json.load(f)
156
+
157
+ finalized_at = time.time()
158
+ data_files = list(path.rglob("*"))
159
+ metadata["finalized_at"] = finalized_at
160
+ metadata["duration"] = finalized_at - metadata["created_at"]
161
+ metadata["num_files"] = sum(1 for p in data_files if p.is_file())
162
+ metadata["total_size"] = sum(p.stat().st_size for p in data_files if p.is_file())
163
+
164
+ with open(metadata_path, "w") as f:
165
+ json.dump(metadata, f, indent=2)
166
+
167
+ try:
168
+ tmp_root.rename(final_root)
169
+ except OSError:
170
+ warnings.warn(
171
+ f"Cache entry at {final_root} already exists. " +
172
+ "Discarding the newly created resource.",
173
+ stacklevel=2,
174
+ )
175
+ shutil.rmtree(tmp_root)
176
+
177
+ return final_root / "data"
178
+
179
+
180
+ def _get_cached_resource_path(
181
+ name: str,
182
+ version: str,
183
+ parameters: dict[str, Any],
184
+ ) -> Path:
185
+ """Return the path to the cached resource."""
186
+ payload = {"name": name, "version": version, "parameters": parameters}
187
+ encoded = json.dumps(payload, sort_keys=True).encode()
188
+ hash_str = hashlib.sha256(encoded).hexdigest()[:8]
189
+ return _get_cache_path() / name / version / hash_str
190
+
191
+
192
+ def precompute_dataset(
193
+ dataset: Dataset,
194
+ output_path: Path,
195
+ progress_bar: bool = True,
196
+ ) -> "PrecomputedDataset":
197
+ """Precompute the dataset and store it as np.memmap.
198
+
199
+ Args:
200
+ dataset: The dataset to precompute.
201
+ output_path: The path to store the precomputed dataset.
202
+ progress_bar: Whether to show a progress bar.
203
+
204
+ Returns:
205
+ A PrecomputedDataset object.
206
+ """
207
+ output_path.mkdir(parents=True, exist_ok=True)
208
+
209
+ num_samples = len(dataset)
210
+
211
+ buffers = dict()
212
+ metadata = dict()
213
+ keys = []
214
+
215
+ for key, value in dataset[0].items():
216
+ if key == "__key__":
217
+ continue
218
+
219
+ buffers[key] = np.memmap(
220
+ output_path / f"{key}.memmap",
221
+ dtype=value.dtype,
222
+ mode="w+",
223
+ shape=(num_samples, *value.shape),
224
+ )
225
+
226
+ metadata[key] = {
227
+ "shape": buffers[key].shape,
228
+ "dtype": buffers[key].dtype.name,
229
+ }
230
+
231
+ with open(output_path / "metadata.json", "w") as f:
232
+ json.dump(metadata, f, indent=2)
233
+
234
+ if progress_bar:
235
+ dataset = tqdm(dataset, desc="Precomputing dataset", total=num_samples)
236
+
237
+ for sample_index, sample in enumerate(dataset):
238
+ for key, value in sample.items():
239
+ if key == "__key__":
240
+ keys.append(value)
241
+ else:
242
+ buffers[key][sample_index] = value
243
+
244
+ if len(keys) > 0:
245
+ with open(output_path / "keys", "w") as f:
246
+ f.write("\n".join(keys))
247
+
248
+ return PrecomputedDataset(output_path)
249
+
250
+
251
+ class PrecomputedDataset(Dataset):
252
+ """Precomputed dataset."""
253
+
254
+ def __init__(
255
+ self,
256
+ path: Path,
257
+ feature_map: dict[str, str] | None = None,
258
+ mmap: bool = True,
259
+ ):
260
+ """Initialize the dataset.
261
+
262
+ Args:
263
+ path: Path to the precomputed dataset directory.
264
+ feature_map: Optional mapping from stored feature names to output
265
+ feature names, e.g. `{"image_consistent": "image"}`. When
266
+ provided, only the keys present in the map are loaded; all
267
+ other stored features are ignored.
268
+ mmap: If True (default), buffers are memory-mapped from disk. If
269
+ False, the entire arrays are loaded into RAM at construction
270
+ time.
271
+ """
272
+ with open(path / "metadata.json", "r") as f:
273
+ metadata = json.load(f)
274
+
275
+ self.mmap = mmap
276
+ self.buffers = dict()
277
+ for key, info in metadata.items():
278
+ if feature_map is not None:
279
+ if key not in feature_map:
280
+ continue
281
+ out_key = feature_map[key]
282
+ else:
283
+ out_key = key
284
+ buffer = np.memmap(
285
+ path / f"{key}.memmap",
286
+ dtype=info["dtype"],
287
+ mode="r",
288
+ shape=info["shape"],
289
+ )
290
+ if not mmap:
291
+ buffer = torch.from_numpy(np.array(buffer))
292
+ buffer = rearrange(buffer, "B H W C -> B C H W")
293
+ self.buffers[out_key] = buffer
294
+
295
+ if (path / "keys").exists():
296
+ with open(path / "keys", "r") as f:
297
+ self.keys = f.read().splitlines()
298
+ else:
299
+ self.keys = None
300
+
301
+ def __len__(self):
302
+ """Return the number of samples in the dataset."""
303
+ return next(iter(self.buffers.values())).shape[0]
304
+
305
+ def __getitem__(self, index: int) -> dict[str, np.ndarray]:
306
+ """Return the sample at the given index.
307
+
308
+ Returns:
309
+ A dictionary mapping each feature name to its value for this sample. Each
310
+ value has the shape and dtype of the corresponding cached feature with
311
+ the leading sample dimension removed.
312
+ """
313
+ sample = dict()
314
+
315
+ for key, buffer in self.buffers.items():
316
+ if isinstance(buffer, torch.Tensor):
317
+ sample[key] = buffer[index]
318
+ else:
319
+ sample[key] = torch.from_numpy(buffer[index]).permute(2, 0, 1)
320
+
321
+ if self.keys is not None:
322
+ sample["__key__"] = self.keys[index]
323
+ else:
324
+ sample["__key__"] = str(index)
325
+
326
+ return sample
327
+
328
+
329
+ def _human_size(num_bytes: int) -> str:
330
+ """Return a human-readable string for a byte count."""
331
+ for unit in ("B", "KB", "MB", "GB", "TB"):
332
+ if num_bytes < 1024:
333
+ return f"{num_bytes:.1f} {unit}"
334
+ num_bytes /= 1024
335
+ return f"{num_bytes:.1f} PB"
336
+
337
+
338
+ def _human_duration(seconds: float) -> str:
339
+ """Return a human-readable string for a duration in seconds."""
340
+ if seconds < 60:
341
+ return f"{seconds:.1f}s"
342
+ minutes, secs = divmod(int(seconds), 60)
343
+ if minutes < 60:
344
+ return f"{minutes}m {secs}s"
345
+ hours, minutes = divmod(minutes, 60)
346
+ return f"{hours}h {minutes}m"
347
+
348
+
349
+ if __name__ == "__main__":
350
+ import argparse
351
+ import sys
352
+ from datetime import datetime
353
+
354
+ import tabulate
355
+
356
+ parser = argparse.ArgumentParser(description="Manage cached resources.")
357
+ subparsers = parser.add_subparsers(dest="command", required=True)
358
+
359
+ list_parser = subparsers.add_parser("list", help="List cached resources.")
360
+ list_parser.add_argument(
361
+ "--sort",
362
+ choices=["name", "size", "created", "duration", "files"],
363
+ default="name",
364
+ help="Sort order (default: name)",
365
+ )
366
+
367
+ remove_parser = subparsers.add_parser("remove", help="Remove a cached resource.")
368
+ remove_parser.add_argument("hash", help="Hash of the cache entry to remove.")
369
+
370
+ subparsers.add_parser("prune", help="Remove all incomplete resources.")
371
+
372
+ args = parser.parse_args()
373
+
374
+ cache_path = _get_cache_path()
375
+
376
+ if args.command == "prune":
377
+ incomplete_paths = (
378
+ [
379
+ path
380
+ for path in cache_path.rglob("*")
381
+ if path.is_dir() and "_tmp_" in path.name
382
+ ]
383
+ if cache_path.exists()
384
+ else []
385
+ )
386
+ for path in incomplete_paths:
387
+ shutil.rmtree(path)
388
+ print(f"Removed {path}") # noqa: T201
389
+ print(f"\nRemoved {len(incomplete_paths)} incomplete resource(s).") # noqa: T201
390
+
391
+ elif args.command == "remove":
392
+ if cache_path.exists():
393
+ matches = list(cache_path.glob(f"*/*/{args.hash}"))
394
+ else:
395
+ matches = []
396
+ if len(matches) == 0:
397
+ print(f"No cache entry found with hash '{args.hash}'.", file=sys.stderr) # noqa: T201
398
+ sys.exit(1)
399
+ for path in matches:
400
+ shutil.rmtree(path)
401
+ print(f"Removed {path}") # noqa: T201
402
+
403
+ elif args.command == "list":
404
+ rows = []
405
+
406
+ if cache_path.exists():
407
+ for name_dir in cache_path.iterdir():
408
+ if not name_dir.is_dir():
409
+ continue
410
+ for version_dir in name_dir.iterdir():
411
+ if not version_dir.is_dir():
412
+ continue
413
+ for hash_dir in version_dir.iterdir():
414
+ if not hash_dir.is_dir() or "_tmp_" in hash_dir.name:
415
+ continue
416
+ if not (hash_dir / "data").exists():
417
+ continue
418
+ metadata_file = hash_dir / "metadata.json"
419
+ if not metadata_file.exists():
420
+ continue
421
+ with open(metadata_file) as f:
422
+ meta = json.load(f)
423
+ rows.append(
424
+ {
425
+ "hash": hash_dir.name,
426
+ "name": name_dir.name,
427
+ "version": version_dir.name,
428
+ "parameters": json.dumps(
429
+ meta["parameters"], sort_keys=True
430
+ ),
431
+ "created": meta.get("created_at"),
432
+ "duration": meta.get("duration"),
433
+ "num_files": meta.get("num_files"),
434
+ "size": meta.get("total_size"),
435
+ }
436
+ )
437
+
438
+ sort_keys = {
439
+ "name": lambda row: (row["name"], row["version"], row["parameters"]),
440
+ "size": lambda row: row["size"],
441
+ "created": lambda row: row["created"],
442
+ "duration": lambda row: row["duration"],
443
+ "files": lambda row: row["num_files"],
444
+ }
445
+ rows.sort(key=sort_keys[args.sort])
446
+
447
+ table = [
448
+ [
449
+ row["hash"],
450
+ row["name"],
451
+ row["version"],
452
+ row["parameters"],
453
+ datetime.fromtimestamp(row["created"]).strftime("%Y-%m-%d %H:%M"),
454
+ _human_duration(row["duration"]),
455
+ row["num_files"],
456
+ _human_size(row["size"]),
457
+ ]
458
+ for row in rows
459
+ ]
460
+ headers = [
461
+ "Hash",
462
+ "Name",
463
+ "Version",
464
+ "Parameters",
465
+ "Created",
466
+ "Duration",
467
+ "Files",
468
+ "Size",
469
+ ]
470
+ total_files = sum(row["num_files"] for row in rows)
471
+ total_size = sum(row["size"] for row in rows)
472
+ footer = ["", "", "", "", "", "", total_files, _human_size(total_size)]
473
+ print( # noqa: T201
474
+ tabulate.tabulate(
475
+ table + [tabulate.SEPARATING_LINE, footer],
476
+ headers=headers,
477
+ tablefmt="simple",
478
+ )
479
+ )
480
+
481
+ incomplete_paths = (
482
+ [
483
+ path
484
+ for path in cache_path.rglob("*")
485
+ if path.is_dir() and "_tmp_" in path.name
486
+ ]
487
+ if cache_path.exists()
488
+ else []
489
+ )
490
+ if len(incomplete_paths) > 0:
491
+ incomplete_files = sum(
492
+ 1
493
+ for incomplete_path in incomplete_paths
494
+ for path in incomplete_path.rglob("*")
495
+ if path.is_file()
496
+ )
497
+ incomplete_size = sum(
498
+ path.stat().st_size
499
+ for incomplete_path in incomplete_paths
500
+ for path in incomplete_path.rglob("*")
501
+ if path.is_file()
502
+ )
503
+ print( # noqa: T201
504
+ f"\n{len(incomplete_paths)} incomplete resource(s): " +
505
+ f"{incomplete_files} files, {_human_size(incomplete_size)}"
506
+ )
507
+ else:
508
+ print("\nNo incomplete resources found.") # noqa: T201
@@ -0,0 +1,16 @@
1
+ """Datasets."""
2
+
3
+ from ._base import DataModule
4
+ from .figure_ground_convexity import FigureGroundConvexityDataModule
5
+ from .figure_ground_surroundedness import FigureGroundSurroundednessDataModule
6
+ from .figure_ground_symmetry import FigureGroundSymmetryDataModule
7
+ from .msra10k import MSRA10KDataModule, PrecomputedMSRA10KDataModule
8
+
9
+ __all__ = [
10
+ "DataModule",
11
+ "FigureGroundConvexityDataModule",
12
+ "FigureGroundSurroundednessDataModule",
13
+ "FigureGroundSymmetryDataModule",
14
+ "MSRA10KDataModule",
15
+ "PrecomputedMSRA10KDataModule",
16
+ ]