esrf-data-compressor 0.2.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.
- esrf_data_compressor/__init__.py +0 -0
- esrf_data_compressor/checker/run_check.py +77 -0
- esrf_data_compressor/checker/ssim.py +87 -0
- esrf_data_compressor/cli.py +193 -0
- esrf_data_compressor/compressors/__init__.py +0 -0
- esrf_data_compressor/compressors/base.py +271 -0
- esrf_data_compressor/compressors/jp2k.py +149 -0
- esrf_data_compressor/finder/finder.py +203 -0
- esrf_data_compressor/tests/__init__.py +0 -0
- esrf_data_compressor/tests/test_cli.py +262 -0
- esrf_data_compressor/tests/test_finder.py +70 -0
- esrf_data_compressor/tests/test_hdf5_helpers.py +9 -0
- esrf_data_compressor/tests/test_jp2k.py +87 -0
- esrf_data_compressor/tests/test_paths.py +36 -0
- esrf_data_compressor/tests/test_run_check.py +125 -0
- esrf_data_compressor/tests/test_ssim.py +106 -0
- esrf_data_compressor/tests/test_utils.py +64 -0
- esrf_data_compressor/utils/hdf5_helpers.py +18 -0
- esrf_data_compressor/utils/paths.py +81 -0
- esrf_data_compressor/utils/utils.py +34 -0
- esrf_data_compressor-0.2.0.dist-info/METADATA +185 -0
- esrf_data_compressor-0.2.0.dist-info/RECORD +26 -0
- esrf_data_compressor-0.2.0.dist-info/WHEEL +5 -0
- esrf_data_compressor-0.2.0.dist-info/entry_points.txt +2 -0
- esrf_data_compressor-0.2.0.dist-info/licenses/LICENSE +20 -0
- esrf_data_compressor-0.2.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
3
|
+
from tqdm import tqdm
|
|
4
|
+
|
|
5
|
+
from esrf_data_compressor.checker.ssim import compute_ssim_for_file_pair
|
|
6
|
+
from esrf_data_compressor.utils.paths import resolve_compressed_path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run_ssim_check(
|
|
10
|
+
raw_files: list[str], method: str, report_path: str, layout: str = "sibling"
|
|
11
|
+
) -> None:
|
|
12
|
+
"""
|
|
13
|
+
Given a list of raw HDF5 file paths, partitions into:
|
|
14
|
+
to_check → those with an expected compressed counterpart according to `layout`
|
|
15
|
+
missing → those without one
|
|
16
|
+
|
|
17
|
+
Writes a report to `report_path`:
|
|
18
|
+
- '=== NOT COMPRESSED FILES ===' listing each missing
|
|
19
|
+
- then for each to_check pair, computes SSIM in parallel and appends
|
|
20
|
+
per‐dataset SSIM lines under '=== <stem> ===' with full paths
|
|
21
|
+
"""
|
|
22
|
+
to_check: list[tuple[str, str]] = []
|
|
23
|
+
missing: list[str] = []
|
|
24
|
+
|
|
25
|
+
# partition
|
|
26
|
+
for orig in raw_files:
|
|
27
|
+
comp_path = resolve_compressed_path(orig, method, layout=layout)
|
|
28
|
+
if os.path.exists(comp_path):
|
|
29
|
+
to_check.append((orig, comp_path))
|
|
30
|
+
else:
|
|
31
|
+
missing.append(orig)
|
|
32
|
+
print(
|
|
33
|
+
f"Found {len(to_check)} file pairs to check, {len(missing)} missing compressed files."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# write report
|
|
37
|
+
with open(report_path, "w") as rpt:
|
|
38
|
+
if missing:
|
|
39
|
+
rpt.write("=== NOT COMPRESSED FILES ===\n")
|
|
40
|
+
for orig in missing:
|
|
41
|
+
rpt.write(f"{orig} :: NO COMPRESSED DATASET FOUND\n")
|
|
42
|
+
rpt.write("\n")
|
|
43
|
+
|
|
44
|
+
if not to_check:
|
|
45
|
+
rpt.write("No file pairs to check (no compressed siblings found).\n")
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
# run SSIM in parallel
|
|
49
|
+
n_workers = min(len(to_check), os.cpu_count() or 1)
|
|
50
|
+
with ProcessPoolExecutor(max_workers=n_workers) as exe:
|
|
51
|
+
futures = {
|
|
52
|
+
exe.submit(compute_ssim_for_file_pair, orig, comp): (orig, comp)
|
|
53
|
+
for orig, comp in to_check
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for fut in tqdm(
|
|
57
|
+
as_completed(futures),
|
|
58
|
+
total=len(futures),
|
|
59
|
+
desc="Checking SSIM (files)",
|
|
60
|
+
unit="file",
|
|
61
|
+
):
|
|
62
|
+
orig, comp = futures[fut]
|
|
63
|
+
fname = os.path.basename(orig)
|
|
64
|
+
comp_name = os.path.basename(comp)
|
|
65
|
+
tqdm.write(f"Checking file: {fname} ↔ {comp_name}")
|
|
66
|
+
try:
|
|
67
|
+
# get results
|
|
68
|
+
basename, lines = fut.result()
|
|
69
|
+
# write section with both file paths
|
|
70
|
+
rpt.write(f"=== {basename} ===\n")
|
|
71
|
+
rpt.write(f"Uncompressed file: {orig}\n")
|
|
72
|
+
rpt.write(f"Compressed file: {comp}\n")
|
|
73
|
+
for line in lines:
|
|
74
|
+
rpt.write(line + "\n")
|
|
75
|
+
rpt.write("\n")
|
|
76
|
+
except Exception as e:
|
|
77
|
+
rpt.write(f"{orig} :: ERROR processing file pair: {e}\n\n")
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# src/esrf_data_compressor/checker/ssim.py
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import numpy as np
|
|
5
|
+
import h5py
|
|
6
|
+
from skimage.metrics import structural_similarity as ssim
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _select_win_size(H: int, W: int) -> int:
|
|
10
|
+
"""
|
|
11
|
+
Choose an odd, valid window size for SSIM given slice dimensions H×W.
|
|
12
|
+
win_size = min(H, W, 7), made odd, at least 3.
|
|
13
|
+
"""
|
|
14
|
+
win = min(H, W, 7)
|
|
15
|
+
if win % 2 == 0:
|
|
16
|
+
win -= 1
|
|
17
|
+
return max(win, 3)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compute_ssim_for_dataset_pair(
|
|
21
|
+
orig_path: str, comp_path: str, dataset_relpath: str
|
|
22
|
+
) -> tuple[float, float]:
|
|
23
|
+
"""
|
|
24
|
+
Given two HDF5 files and the relative 3D dataset path (e.g., 'entry_0000/ESRF-ID11/marana/data'),
|
|
25
|
+
compute SSIM on the first (z=0) and last (z=Z-1) slices.
|
|
26
|
+
Returns (ssim_first, ssim_last). If a slice is constant, SSIM = 1.0.
|
|
27
|
+
"""
|
|
28
|
+
with h5py.File(orig_path, "r") as fo, h5py.File(comp_path, "r") as fc:
|
|
29
|
+
ds_o = fo[dataset_relpath]
|
|
30
|
+
ds_c = fc[dataset_relpath]
|
|
31
|
+
|
|
32
|
+
# Ensure both datasets are 3D
|
|
33
|
+
if ds_o.ndim != 3 or ds_c.ndim != 3:
|
|
34
|
+
raise IndexError(
|
|
35
|
+
f"Dataset '{dataset_relpath}' is not 3D (orig: {ds_o.ndim}D, comp: {ds_c.ndim}D)"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
first_o = ds_o[0].astype(np.float64)
|
|
39
|
+
last_o = ds_o[-1].astype(np.float64)
|
|
40
|
+
first_c = ds_c[0].astype(np.float64)
|
|
41
|
+
last_c = ds_c[-1].astype(np.float64)
|
|
42
|
+
|
|
43
|
+
H, W = first_o.shape
|
|
44
|
+
win = _select_win_size(H, W)
|
|
45
|
+
|
|
46
|
+
def _slice_ssim(a: np.ndarray, b: np.ndarray) -> float:
|
|
47
|
+
amin, amax = a.min(), a.max()
|
|
48
|
+
if amax == amin:
|
|
49
|
+
return 1.0
|
|
50
|
+
dr = amax - amin
|
|
51
|
+
return ssim(a, b, data_range=dr, win_size=win)
|
|
52
|
+
|
|
53
|
+
s0 = _slice_ssim(first_o, first_c)
|
|
54
|
+
s1 = _slice_ssim(last_o, last_c)
|
|
55
|
+
return s0, s1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def compute_ssim_for_file_pair(orig_path: str, comp_path: str) -> tuple[str, list[str]]:
|
|
59
|
+
"""
|
|
60
|
+
Compute SSIM for every 3D dataset under `orig_path` vs. `comp_path`.
|
|
61
|
+
Returns (basename, [report_lines…]), where each line is either:
|
|
62
|
+
"<dataset_relpath>: SSIM_first=… SSIM_last=…" or an error message.
|
|
63
|
+
"""
|
|
64
|
+
basename = os.path.basename(orig_path)
|
|
65
|
+
report_lines: list[str] = []
|
|
66
|
+
|
|
67
|
+
with h5py.File(orig_path, "r") as fo:
|
|
68
|
+
ds_paths: list[str] = []
|
|
69
|
+
|
|
70
|
+
def visitor(name, obj):
|
|
71
|
+
if isinstance(obj, h5py.Dataset) and obj.ndim == 3:
|
|
72
|
+
ds_paths.append(name)
|
|
73
|
+
|
|
74
|
+
fo.visititems(visitor)
|
|
75
|
+
|
|
76
|
+
if not ds_paths:
|
|
77
|
+
report_lines.append(f"No 3D datasets found in {basename}")
|
|
78
|
+
return basename, report_lines
|
|
79
|
+
|
|
80
|
+
for ds in ds_paths:
|
|
81
|
+
try:
|
|
82
|
+
s0, s1 = compute_ssim_for_dataset_pair(orig_path, comp_path, ds)
|
|
83
|
+
report_lines.append(f"{ds}: SSIM_first={s0:.4f} SSIM_last={s1:.4f}")
|
|
84
|
+
except Exception as e:
|
|
85
|
+
report_lines.append(f"{ds}: ERROR computing SSIM: {e}")
|
|
86
|
+
|
|
87
|
+
return basename, report_lines
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import argparse
|
|
3
|
+
from esrf_data_compressor.finder.finder import find_vds_files, write_report
|
|
4
|
+
from esrf_data_compressor.compressors.base import CompressorManager
|
|
5
|
+
from esrf_data_compressor.checker.run_check import run_ssim_check
|
|
6
|
+
from esrf_data_compressor.utils.hdf5_helpers import exit_with_error
|
|
7
|
+
from esrf_data_compressor.utils.utils import parse_report
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_path_components(args):
|
|
11
|
+
comps = [args.experiment]
|
|
12
|
+
if args.beamline:
|
|
13
|
+
comps.append(args.beamline)
|
|
14
|
+
if args.session:
|
|
15
|
+
comps.append(args.session)
|
|
16
|
+
return comps
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def do_list(args):
|
|
20
|
+
"""
|
|
21
|
+
1) Discover datasets under RAW_DATA/<components...>
|
|
22
|
+
2) Apply dataset‑level filters (--filter key:val[,key2:val2...])
|
|
23
|
+
3) Extract VDS source files from every dataset
|
|
24
|
+
4) Write two‑section report:
|
|
25
|
+
## TO COMPRESS ## (sources from matching datasets)
|
|
26
|
+
## REMAINING ## (sources from non‑matching datasets)
|
|
27
|
+
"""
|
|
28
|
+
comps = get_path_components(args)
|
|
29
|
+
try:
|
|
30
|
+
to_c, rem = find_vds_files(comps, base_root=args.root, filter_expr=args.filter)
|
|
31
|
+
except SystemExit as e:
|
|
32
|
+
exit_with_error(str(e))
|
|
33
|
+
|
|
34
|
+
report_path = args.output or "file_list.txt"
|
|
35
|
+
write_report(to_c, rem, report_path)
|
|
36
|
+
print(f"Report written to {report_path}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def do_compress(args):
|
|
40
|
+
report = args.input
|
|
41
|
+
if not report:
|
|
42
|
+
exit_with_error("The --input report file must be specified for compress")
|
|
43
|
+
try:
|
|
44
|
+
files = parse_report(report)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
exit_with_error(f"Failed to read report '{report}': {e}")
|
|
47
|
+
|
|
48
|
+
if not files:
|
|
49
|
+
print("Nothing to compress (TO COMPRESS list is empty).")
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
print(
|
|
53
|
+
f"Compressing {len(files)} file(s) from '{report}' using '{args.method}' method, ratio {args.cratio}, layout '{args.layout}' …"
|
|
54
|
+
)
|
|
55
|
+
mgr = CompressorManager(cratio=args.cratio, method=args.method, layout=args.layout)
|
|
56
|
+
mgr.compress_files(files)
|
|
57
|
+
print("Compression complete.\n")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def do_check(args):
|
|
61
|
+
report = args.input or "file_list.txt"
|
|
62
|
+
try:
|
|
63
|
+
files = parse_report(report)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
exit_with_error(f"Failed to read report '{report}': {e}")
|
|
66
|
+
|
|
67
|
+
if not files:
|
|
68
|
+
print("Nothing to check (TO COMPRESS list is empty).")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
report_fname = f"{os.path.splitext(report)[0]}_{args.method}_ssim_report.txt"
|
|
72
|
+
report_path = os.path.abspath(report_fname)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
run_ssim_check(files, args.method, report_path, layout=args.layout)
|
|
76
|
+
except SystemExit as e:
|
|
77
|
+
exit_with_error(str(e))
|
|
78
|
+
|
|
79
|
+
print(f"SSIM report written to {report_path}\n")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def do_overwrite(args):
|
|
83
|
+
report = args.input or "file_list.txt"
|
|
84
|
+
try:
|
|
85
|
+
files = parse_report(report)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
exit_with_error(f"Failed to read report '{report}': {e}")
|
|
88
|
+
|
|
89
|
+
if not files:
|
|
90
|
+
print("Nothing to process (TO COMPRESS list is empty).")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
mgr = CompressorManager()
|
|
94
|
+
|
|
95
|
+
if args.final:
|
|
96
|
+
print(f"Finalizing overwrite for {len(files)} file(s) from '{report}' …")
|
|
97
|
+
mgr.remove_backups(files)
|
|
98
|
+
print("Finalize step complete.\n")
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
if args.undo:
|
|
102
|
+
print(f"Undoing overwrite for {len(files)} file(s) from '{report}' …")
|
|
103
|
+
mgr.restore_backups(files)
|
|
104
|
+
print("Undo step complete.\n")
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
print(f"Overwriting {len(files)} file(s) from '{report}' …")
|
|
108
|
+
mgr.overwrite_files(files)
|
|
109
|
+
print("Overwrite complete (backups kept).\n")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main():
|
|
113
|
+
parser = argparse.ArgumentParser(
|
|
114
|
+
description="List, compress, check or overwrite ESRF HDF5 VDS sources."
|
|
115
|
+
)
|
|
116
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
117
|
+
|
|
118
|
+
p = sub.add_parser("list", help="Report VDS sources → TO COMPRESS vs REMAINING")
|
|
119
|
+
p.add_argument("experiment", help="Experiment ID")
|
|
120
|
+
p.add_argument("beamline", nargs="?", help="Optional beamline")
|
|
121
|
+
p.add_argument("session", nargs="?", help="Optional session")
|
|
122
|
+
p.add_argument("--root", default="/data/visitor", help="Base directory")
|
|
123
|
+
p.add_argument(
|
|
124
|
+
"--filter",
|
|
125
|
+
metavar="KEY:VAL[,KEY2:VAL2...]",
|
|
126
|
+
help="Dataset-level attribute substring filters",
|
|
127
|
+
)
|
|
128
|
+
p.add_argument("--output", help="Report file (default = file_list.txt)")
|
|
129
|
+
p.set_defaults(func=do_list)
|
|
130
|
+
|
|
131
|
+
p = sub.add_parser("compress", help="Compress only the TO COMPRESS files")
|
|
132
|
+
p.add_argument(
|
|
133
|
+
"--input",
|
|
134
|
+
"-i",
|
|
135
|
+
required=True,
|
|
136
|
+
help="Report file to read (must be produced by `list`)",
|
|
137
|
+
)
|
|
138
|
+
p.add_argument("--cratio", type=int, default=10, help="Compression ratio")
|
|
139
|
+
p.add_argument(
|
|
140
|
+
"--method",
|
|
141
|
+
choices=["jp2k"],
|
|
142
|
+
default="jp2k",
|
|
143
|
+
help="Compression method",
|
|
144
|
+
)
|
|
145
|
+
p.add_argument(
|
|
146
|
+
"--layout",
|
|
147
|
+
choices=["sibling", "mirror"],
|
|
148
|
+
default="mirror",
|
|
149
|
+
help="Output layout: sibling (next to each source) or mirror (under RAW_DATA_COMPRESSED, preserving source names).",
|
|
150
|
+
)
|
|
151
|
+
p.set_defaults(func=do_compress)
|
|
152
|
+
|
|
153
|
+
p = sub.add_parser("check", help="Generate SSIM report for TO COMPRESS files")
|
|
154
|
+
p.add_argument(
|
|
155
|
+
"--input", "-i", help="Report file to read (default = file_list.txt)"
|
|
156
|
+
)
|
|
157
|
+
p.add_argument(
|
|
158
|
+
"--method", choices=["jp2k"], default="jp2k", help="Compression method"
|
|
159
|
+
)
|
|
160
|
+
p.add_argument(
|
|
161
|
+
"--layout",
|
|
162
|
+
choices=["sibling", "mirror"],
|
|
163
|
+
default="mirror",
|
|
164
|
+
help="Location of compressed files to check.",
|
|
165
|
+
)
|
|
166
|
+
p.set_defaults(func=do_check)
|
|
167
|
+
|
|
168
|
+
p = sub.add_parser(
|
|
169
|
+
"overwrite",
|
|
170
|
+
help="Swap in compressed files and keep backups; with --final or --undo, perform cleanup/restore only.",
|
|
171
|
+
)
|
|
172
|
+
p.add_argument(
|
|
173
|
+
"--input", "-i", help="Report file to read (default = file_list.txt)"
|
|
174
|
+
)
|
|
175
|
+
group = p.add_mutually_exclusive_group()
|
|
176
|
+
group.add_argument(
|
|
177
|
+
"--final",
|
|
178
|
+
action="store_true",
|
|
179
|
+
help="Cleanup only: delete existing *.h5.bak backups after confirmation (no overwrite).",
|
|
180
|
+
)
|
|
181
|
+
group.add_argument(
|
|
182
|
+
"--undo",
|
|
183
|
+
action="store_true",
|
|
184
|
+
help="Restore only: move <file>.h5.bak back to <file>.h5 and preserve the current file as <file>_<method>.h5 when needed.",
|
|
185
|
+
)
|
|
186
|
+
p.set_defaults(func=do_overwrite)
|
|
187
|
+
|
|
188
|
+
args = parser.parse_args()
|
|
189
|
+
args.func(args)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
4
|
+
from tqdm import tqdm
|
|
5
|
+
|
|
6
|
+
from esrf_data_compressor.compressors.jp2k import JP2KCompressorWrapper
|
|
7
|
+
from esrf_data_compressor.utils.paths import (
|
|
8
|
+
find_dataset_base_h5,
|
|
9
|
+
resolve_compressed_path,
|
|
10
|
+
resolve_mirror_path,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Compressor:
|
|
15
|
+
"""
|
|
16
|
+
Abstract base class. Subclasses must implement compress_file().
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def compress_file(self, input_path: str, output_path: str, **kwargs):
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CompressorManager:
|
|
24
|
+
"""
|
|
25
|
+
Manages parallel compression and overwrite.
|
|
26
|
+
|
|
27
|
+
Each worker process is given up to 4 Blosc2 threads (or fewer if the machine
|
|
28
|
+
has fewer than 4 cores). The number of worker processes is then
|
|
29
|
+
total_cores // threads_per_worker (at least 1). If the user explicitly
|
|
30
|
+
passes `workers`, we cap it to `total_cores`, then recompute threads_per_worker
|
|
31
|
+
= min(4, total_cores // workers).
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
mgr = CompressorManager(cratio=10, method='jp2k')
|
|
35
|
+
mgr.compress_files([...])
|
|
36
|
+
mgr.overwrite_files([...])
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
workers: int | None = None,
|
|
42
|
+
cratio: int = 10,
|
|
43
|
+
method: str = "jp2k",
|
|
44
|
+
layout: str = "sibling",
|
|
45
|
+
):
|
|
46
|
+
total_cores = os.cpu_count() or 1
|
|
47
|
+
default_nthreads = 4 if total_cores >= 4 else 1
|
|
48
|
+
default_workers = max(1, total_cores // default_nthreads)
|
|
49
|
+
|
|
50
|
+
if workers is None:
|
|
51
|
+
w = default_workers
|
|
52
|
+
nthreads = default_nthreads
|
|
53
|
+
else:
|
|
54
|
+
w = min(workers, total_cores)
|
|
55
|
+
possible = total_cores // w
|
|
56
|
+
nthreads = min(possible, 4) if possible >= 1 else 1
|
|
57
|
+
|
|
58
|
+
self.workers = max(1, w)
|
|
59
|
+
self.nthreads = max(1, nthreads)
|
|
60
|
+
self.cratio = cratio
|
|
61
|
+
self.method = method
|
|
62
|
+
self.layout = layout
|
|
63
|
+
|
|
64
|
+
if self.method == "jp2k":
|
|
65
|
+
self.compressor = JP2KCompressorWrapper(
|
|
66
|
+
cratio=cratio, nthreads=self.nthreads
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
raise ValueError(f"Unsupported compression method: {self.method}")
|
|
70
|
+
|
|
71
|
+
print(f"Compression method: {self.method}")
|
|
72
|
+
print(f"Output layout: {self.layout}")
|
|
73
|
+
print(f"Total CPU cores: {total_cores}")
|
|
74
|
+
print(f"Worker processes: {self.workers}")
|
|
75
|
+
print(f"Threads per worker: {self.nthreads}")
|
|
76
|
+
print(f"Total threads: {self.workers * self.nthreads}")
|
|
77
|
+
|
|
78
|
+
def _compress_worker(self, ipath: str) -> tuple[str, str]:
|
|
79
|
+
"""
|
|
80
|
+
Worker function for ProcessPoolExecutor: compress a single HDF5:
|
|
81
|
+
- sibling layout: <same_dir>/<basename>_<method>.h5
|
|
82
|
+
- mirror layout: mirror RAW_DATA tree under RAW_DATA_COMPRESSED
|
|
83
|
+
"""
|
|
84
|
+
outp = resolve_compressed_path(ipath, self.method, layout=self.layout)
|
|
85
|
+
os.makedirs(os.path.dirname(outp), exist_ok=True)
|
|
86
|
+
self.compressor.compress_file(
|
|
87
|
+
ipath, outp, cratio=self.cratio, nthreads=self.nthreads
|
|
88
|
+
)
|
|
89
|
+
return ipath, "success"
|
|
90
|
+
|
|
91
|
+
def _mirror_non_compressed_dataset_content(self, file_list: list[str]) -> None:
|
|
92
|
+
source_targets = {os.path.realpath(p) for p in file_list}
|
|
93
|
+
mirror_roots: set[str] = set()
|
|
94
|
+
for ipath in file_list:
|
|
95
|
+
base_h5 = find_dataset_base_h5(ipath)
|
|
96
|
+
dataset_dir = (
|
|
97
|
+
os.path.dirname(base_h5) if base_h5 else os.path.dirname(ipath)
|
|
98
|
+
)
|
|
99
|
+
# Mirror the parent sample folder too, so sidecar files next to
|
|
100
|
+
# dataset folders are preserved (e.g. RAW_DATA/<sample>/*.h5).
|
|
101
|
+
mirror_roots.add(os.path.dirname(dataset_dir))
|
|
102
|
+
|
|
103
|
+
for src_dir in sorted(mirror_roots):
|
|
104
|
+
try:
|
|
105
|
+
dst_dir = resolve_mirror_path(src_dir)
|
|
106
|
+
except ValueError:
|
|
107
|
+
print(f"WARNING: Cannot mirror folder outside RAW_DATA: '{src_dir}'")
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
for cur, dirs, files in os.walk(src_dir):
|
|
111
|
+
rel_cur = os.path.relpath(cur, src_dir)
|
|
112
|
+
target_cur = (
|
|
113
|
+
dst_dir if rel_cur == "." else os.path.join(dst_dir, rel_cur)
|
|
114
|
+
)
|
|
115
|
+
os.makedirs(target_cur, exist_ok=True)
|
|
116
|
+
|
|
117
|
+
for dname in dirs:
|
|
118
|
+
os.makedirs(os.path.join(target_cur, dname), exist_ok=True)
|
|
119
|
+
|
|
120
|
+
for fname in files:
|
|
121
|
+
src_file = os.path.join(cur, fname)
|
|
122
|
+
if os.path.realpath(src_file) in source_targets:
|
|
123
|
+
# Do not copy raw files that will be produced by compression.
|
|
124
|
+
continue
|
|
125
|
+
dst_file = os.path.join(target_cur, fname)
|
|
126
|
+
shutil.copy2(src_file, dst_file)
|
|
127
|
+
|
|
128
|
+
def compress_files(self, file_list: list[str]) -> None:
|
|
129
|
+
"""
|
|
130
|
+
Compress each .h5 in file_list in parallel.
|
|
131
|
+
- sibling layout: produce <basename>_<method>.h5 next to each source.
|
|
132
|
+
- mirror layout: write compressed files to RAW_DATA_COMPRESSED with same file names.
|
|
133
|
+
Does not overwrite originals. At the end, prints total elapsed time and data rate in MB/s.
|
|
134
|
+
"""
|
|
135
|
+
valid = [p for p in file_list if p.lower().endswith(".h5")]
|
|
136
|
+
if not valid:
|
|
137
|
+
print("No valid .h5 files to compress.")
|
|
138
|
+
return
|
|
139
|
+
if self.layout == "mirror":
|
|
140
|
+
print(
|
|
141
|
+
"Preparing RAW_DATA_COMPRESSED with non-compressed dataset content..."
|
|
142
|
+
)
|
|
143
|
+
self._mirror_non_compressed_dataset_content(valid)
|
|
144
|
+
|
|
145
|
+
total_bytes = 0
|
|
146
|
+
for f in valid:
|
|
147
|
+
try:
|
|
148
|
+
total_bytes += os.path.getsize(f)
|
|
149
|
+
except OSError:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
import time
|
|
153
|
+
|
|
154
|
+
t0 = time.time()
|
|
155
|
+
|
|
156
|
+
with ProcessPoolExecutor(max_workers=self.workers) as executor:
|
|
157
|
+
futures = {executor.submit(self._compress_worker, p): p for p in valid}
|
|
158
|
+
for fut in tqdm(
|
|
159
|
+
as_completed(futures),
|
|
160
|
+
total=len(futures),
|
|
161
|
+
desc=f"Compressing HDF5 files ({self.method})",
|
|
162
|
+
unit="file",
|
|
163
|
+
):
|
|
164
|
+
pth = futures[fut]
|
|
165
|
+
try:
|
|
166
|
+
fut.result()
|
|
167
|
+
except Exception as e:
|
|
168
|
+
print(f"Failed to compress '{pth}': {e}")
|
|
169
|
+
|
|
170
|
+
elapsed = time.time() - t0
|
|
171
|
+
total_mb = total_bytes / (1024 * 1024)
|
|
172
|
+
rate_mb_s = total_mb / elapsed if elapsed > 0 else float("inf")
|
|
173
|
+
print(f"\nTotal elapsed time: {elapsed:.3f}s")
|
|
174
|
+
print(f"Data processed: {total_mb:.2f} MB ({rate_mb_s:.2f} MB/s)\n")
|
|
175
|
+
|
|
176
|
+
def overwrite_files(self, file_list: list[str]) -> None:
|
|
177
|
+
"""
|
|
178
|
+
Overwrites files only if they have a compressed sibling:
|
|
179
|
+
|
|
180
|
+
1) Rename <file>.h5 → <file>.h5.bak
|
|
181
|
+
2) Rename <file>_<method>.h5 → <file>.h5
|
|
182
|
+
|
|
183
|
+
After processing all files, removes the backup .h5.bak files.
|
|
184
|
+
"""
|
|
185
|
+
for ipath in file_list:
|
|
186
|
+
if not ipath.lower().endswith(".h5"):
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
compressed_path = resolve_compressed_path(
|
|
190
|
+
ipath, self.method, layout=self.layout
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if os.path.exists(compressed_path):
|
|
194
|
+
backup = ipath + ".bak"
|
|
195
|
+
try:
|
|
196
|
+
os.replace(ipath, backup)
|
|
197
|
+
os.replace(compressed_path, ipath)
|
|
198
|
+
print(f"Overwritten '{ipath}' (backup at '{backup}').")
|
|
199
|
+
except Exception as e:
|
|
200
|
+
print(f"ERROR overwriting '{ipath}': {e}")
|
|
201
|
+
else:
|
|
202
|
+
print(f"SKIP (no compressed file): {ipath}")
|
|
203
|
+
|
|
204
|
+
def remove_backups(self, file_list: list[str]) -> None:
|
|
205
|
+
candidates = {p + ".bak" for p in file_list if p.lower().endswith(".h5")}
|
|
206
|
+
backups = [b for b in candidates if os.path.exists(b)]
|
|
207
|
+
if not backups:
|
|
208
|
+
print("No backup files to remove.")
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
total_bytes = 0
|
|
212
|
+
for b in backups:
|
|
213
|
+
try:
|
|
214
|
+
total_bytes += os.path.getsize(b)
|
|
215
|
+
except OSError:
|
|
216
|
+
pass
|
|
217
|
+
total_mb = total_bytes / (1024 * 1024)
|
|
218
|
+
|
|
219
|
+
print(
|
|
220
|
+
f"About to remove {len(backups)} backup file(s), ~{total_mb:.2f} MB total."
|
|
221
|
+
)
|
|
222
|
+
ans = input("Proceed? [y/N]: ").strip().lower()
|
|
223
|
+
if ans not in ("y", "yes"):
|
|
224
|
+
print("Backups kept.")
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
removed = 0
|
|
228
|
+
for b in backups:
|
|
229
|
+
try:
|
|
230
|
+
os.remove(b)
|
|
231
|
+
removed += 1
|
|
232
|
+
except Exception as e:
|
|
233
|
+
print(f"ERROR deleting backup '{b}': {e}")
|
|
234
|
+
|
|
235
|
+
print(f"Deleted {removed} backup file(s).")
|
|
236
|
+
|
|
237
|
+
def restore_backups(self, file_list: list[str]) -> None:
|
|
238
|
+
restored = 0
|
|
239
|
+
preserved = 0
|
|
240
|
+
for ipath in file_list:
|
|
241
|
+
if not ipath.lower().endswith(".h5"):
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
backup = ipath + ".bak"
|
|
245
|
+
method_path = resolve_compressed_path(
|
|
246
|
+
ipath, self.method, layout=self.layout
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if not os.path.exists(backup):
|
|
250
|
+
print(f"SKIP (no backup): {ipath}")
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
if os.path.exists(ipath) and not os.path.exists(method_path):
|
|
254
|
+
try:
|
|
255
|
+
os.replace(ipath, method_path)
|
|
256
|
+
preserved += 1
|
|
257
|
+
print(f"Preserved current file to '{method_path}'.")
|
|
258
|
+
except Exception as e:
|
|
259
|
+
print(f"ERROR preserving current '{ipath}' to '{method_path}': {e}")
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
os.replace(backup, ipath)
|
|
264
|
+
restored += 1
|
|
265
|
+
print(f"Restored '{ipath}' from backup.")
|
|
266
|
+
except Exception as e:
|
|
267
|
+
print(f"ERROR restoring '{ipath}' from '{backup}': {e}")
|
|
268
|
+
|
|
269
|
+
print(
|
|
270
|
+
f"Restore complete. Restored: {restored}, preserved compressed copies: {preserved}."
|
|
271
|
+
)
|