Skip to content

Commit fa2c86b

Browse files
committed
chore: update dependencies and regenerate lock file
Updated the dependency resolution to include new packages and removed outdated ones following the recent migration to torch. This ensures compatibility and optimizes the project's dependency management.
1 parent 124d4bf commit fa2c86b

1 file changed

Lines changed: 118 additions & 0 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""
2+
convert.py
3+
4+
Convert a Keras `.h5` U-Net weights file into a PyTorch `.pt` state-dict
5+
compatible with :class:`unet_torch.UNet`.
6+
7+
Usage
8+
-----
9+
python -m nd2_analyzer.analysis.segmentation.convert \\
10+
/path/to/weights.h5 [--out /path/to/weights.pt] [--levels 5]
11+
12+
Reads weights directly with ``h5py`` — TensorFlow / Keras are NOT required.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import argparse
18+
from pathlib import Path
19+
from typing import Dict
20+
21+
import h5py
22+
import numpy as np
23+
import torch
24+
25+
from .unet_torch import UNet
26+
27+
28+
def _keras_to_torch_kernel(arr: np.ndarray) -> torch.Tensor:
29+
# Keras Conv2D kernel: (kH, kW, in_ch, out_ch)
30+
# PyTorch Conv2d weight: (out_ch, in_ch, kH, kW)
31+
return torch.from_numpy(arr.transpose(3, 2, 0, 1).copy())
32+
33+
34+
def _layer_map(levels: int) -> Dict[str, str]:
35+
"""Map Keras layer name → PyTorch parameter prefix (without `.weight`/`.bias`)."""
36+
mapping: Dict[str, str] = {
37+
"Level0_Conv2D_1": "level0_conv1.0",
38+
"Level0_Conv2D_2": "level0_conv2.0",
39+
"true_output": "final_conv",
40+
}
41+
42+
# Contracting: Keras Level{1..levels-1} → PyTorch contracting[{level-1}]
43+
for level in range(1, levels):
44+
idx = level - 1
45+
mapping[f"Level{level}_Contracting_Conv2D_1"] = f"contracting.{idx}.conv1.0"
46+
mapping[f"Level{level}_Contracting_Conv2D_2"] = f"contracting.{idx}.conv2.0"
47+
48+
# Expanding: Keras Level{levels-2..0} → PyTorch expanding[0..levels-2]
49+
# i.e. PyTorch index = (levels - 2) - keras_level
50+
for level in range(levels - 1):
51+
idx = (levels - 2) - level
52+
# Conv2D_1 is the 2x2 conv after upsample — Sequential[Pad, Conv, ReLU], conv at index 1
53+
mapping[f"Level{level}_Expanding_Conv2D_1"] = f"expanding.{idx}.conv1.1"
54+
mapping[f"Level{level}_Expanding_Conv2D_2"] = f"expanding.{idx}.conv2.0"
55+
mapping[f"Level{level}_Expanding_Conv2D_3"] = f"expanding.{idx}.conv3.0"
56+
57+
return mapping
58+
59+
60+
def convert(h5_path: Path, pt_path: Path, levels: int = 5, in_channels: int = 1) -> None:
61+
mapping = _layer_map(levels)
62+
model = UNet(in_channels=in_channels, output_classes=1, dropout=0.0, levels=levels)
63+
state_dict = model.state_dict()
64+
65+
copied: set[str] = set()
66+
with h5py.File(h5_path, "r") as f:
67+
for keras_name, torch_prefix in mapping.items():
68+
if keras_name not in f:
69+
raise KeyError(f"Layer '{keras_name}' missing from {h5_path}")
70+
# Keras nests one more group with the same name.
71+
grp = f[keras_name][keras_name]
72+
kernel = np.asarray(grp["kernel:0"])
73+
bias = np.asarray(grp["bias:0"])
74+
75+
weight_key = f"{torch_prefix}.weight"
76+
bias_key = f"{torch_prefix}.bias"
77+
if weight_key not in state_dict or bias_key not in state_dict:
78+
raise KeyError(
79+
f"Target params '{weight_key}'/'{bias_key}' not in PyTorch model "
80+
f"(check `levels` matches the Keras model)."
81+
)
82+
83+
target_weight = state_dict[weight_key]
84+
converted_weight = _keras_to_torch_kernel(kernel)
85+
if converted_weight.shape != target_weight.shape:
86+
raise ValueError(
87+
f"Shape mismatch for {keras_name}{weight_key}: "
88+
f"got {tuple(converted_weight.shape)}, expected {tuple(target_weight.shape)}"
89+
)
90+
state_dict[weight_key] = converted_weight
91+
state_dict[bias_key] = torch.from_numpy(bias.copy())
92+
copied.add(weight_key)
93+
copied.add(bias_key)
94+
95+
expected = set(state_dict.keys())
96+
missing = expected - copied
97+
if missing:
98+
raise RuntimeError(f"Did not copy weights for {len(missing)} params: {sorted(missing)[:5]}…")
99+
100+
pt_path.parent.mkdir(parents=True, exist_ok=True)
101+
torch.save(state_dict, pt_path)
102+
print(f"Wrote {pt_path} ({sum(v.numel() for v in state_dict.values()):,} params)")
103+
104+
105+
def main() -> None:
106+
p = argparse.ArgumentParser(description="Convert Keras .h5 U-Net weights → PyTorch .pt state-dict")
107+
p.add_argument("h5", type=Path, help="Input Keras .h5 weights file")
108+
p.add_argument("--out", type=Path, default=None, help="Output .pt path (default: alongside .h5)")
109+
p.add_argument("--levels", type=int, default=5, help="U-Net depth (must match training)")
110+
p.add_argument("--in-channels", type=int, default=1, help="Input channels (1 for grayscale)")
111+
args = p.parse_args()
112+
113+
out = args.out or args.h5.with_suffix(".pt")
114+
convert(args.h5, out, levels=args.levels, in_channels=args.in_channels)
115+
116+
117+
if __name__ == "__main__":
118+
main()

0 commit comments

Comments
 (0)