dp-vit 0.1.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.
- dp_vit/__init__.py +34 -0
- dp_vit/checkpoint.py +147 -0
- dp_vit/config.py +116 -0
- dp_vit/ics.py +215 -0
- dp_vit/inference.py +132 -0
- dp_vit/model.py +80 -0
- dp_vit/patching.py +283 -0
- dp_vit/pruning.py +171 -0
- dp_vit-0.1.0.dist-info/METADATA +238 -0
- dp_vit-0.1.0.dist-info/RECORD +13 -0
- dp_vit-0.1.0.dist-info/WHEEL +5 -0
- dp_vit-0.1.0.dist-info/licenses/LICENSE +21 -0
- dp_vit-0.1.0.dist-info/top_level.txt +1 -0
dp_vit/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from .checkpoint import load_checkpoint, save_checkpoint
|
|
2
|
+
from .config import DPViTConfig, ICSCalibration, ICSConfig, PruningConfig
|
|
3
|
+
from .ics import (
|
|
4
|
+
compute_gradient_magnitude,
|
|
5
|
+
compute_ics,
|
|
6
|
+
compute_local_variance,
|
|
7
|
+
compute_patch_size_map_for_image,
|
|
8
|
+
determine_patch_size_map,
|
|
9
|
+
fit_ics_calibration,
|
|
10
|
+
)
|
|
11
|
+
from .inference import predict_tensor
|
|
12
|
+
from .model import DPViT
|
|
13
|
+
from .patching import AdaptivePatchEmbedding, adaptive_collate_fn
|
|
14
|
+
from .pruning import prune_mlp_by_magnitude
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AdaptivePatchEmbedding",
|
|
18
|
+
"DPViT",
|
|
19
|
+
"DPViTConfig",
|
|
20
|
+
"ICSCalibration",
|
|
21
|
+
"ICSConfig",
|
|
22
|
+
"PruningConfig",
|
|
23
|
+
"adaptive_collate_fn",
|
|
24
|
+
"compute_gradient_magnitude",
|
|
25
|
+
"compute_ics",
|
|
26
|
+
"compute_local_variance",
|
|
27
|
+
"compute_patch_size_map_for_image",
|
|
28
|
+
"determine_patch_size_map",
|
|
29
|
+
"fit_ics_calibration",
|
|
30
|
+
"load_checkpoint",
|
|
31
|
+
"predict_tensor",
|
|
32
|
+
"prune_mlp_by_magnitude",
|
|
33
|
+
"save_checkpoint",
|
|
34
|
+
]
|
dp_vit/checkpoint.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Checkpoint saving and loading for DP-ViT."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
from torch import nn
|
|
9
|
+
|
|
10
|
+
from .config import DPViTConfig, ICSCalibration
|
|
11
|
+
from .model import DPViT
|
|
12
|
+
|
|
13
|
+
CHECKPOINT_FORMAT_VERSION = 1
|
|
14
|
+
|
|
15
|
+
def _get_mlp_widths(model: DPViT) -> list[int]:
|
|
16
|
+
"""Return the hidden width of every encoder MLP."""
|
|
17
|
+
return [
|
|
18
|
+
int(getattr(getattr(layer, "linear1"), "out_features"))
|
|
19
|
+
for layer in model.encoder.layers
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
def _resize_mlp_layer(
|
|
23
|
+
layer: nn.TransformerEncoderLayer,
|
|
24
|
+
hidden_width: int,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Create MLP layers with a target width before leading checkpoint weights"""
|
|
27
|
+
if hidden_width <= 0:
|
|
28
|
+
raise ValueError("Each MLP hidden width must be greater than zero.")
|
|
29
|
+
|
|
30
|
+
old_linear1 = layer.linear1
|
|
31
|
+
old_linear2 = layer.linear2
|
|
32
|
+
|
|
33
|
+
new_linear1 = nn.Linear(
|
|
34
|
+
in_features=old_linear1.in_features,
|
|
35
|
+
out_features=hidden_width,
|
|
36
|
+
bias=old_linear1.bias is not None,
|
|
37
|
+
device=old_linear1.weight.device,
|
|
38
|
+
dtype=old_linear1.weight.dtype,
|
|
39
|
+
)
|
|
40
|
+
new_linear2 = nn.Linear(
|
|
41
|
+
in_features=hidden_width,
|
|
42
|
+
out_features=old_linear2.out_features,
|
|
43
|
+
bias=old_linear2.bias is not None,
|
|
44
|
+
device=old_linear2.weight.device,
|
|
45
|
+
dtype=old_linear2.weight.dtype,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
layer.linear1 = new_linear1
|
|
49
|
+
layer.linear2 = new_linear2
|
|
50
|
+
|
|
51
|
+
def save_checkpoint(
|
|
52
|
+
path: str | Path,
|
|
53
|
+
model: DPViT,
|
|
54
|
+
ics_calibration: ICSCalibration,
|
|
55
|
+
class_names: list[str] | None = None,
|
|
56
|
+
pruning_report: dict[str, Any] | None = None,
|
|
57
|
+
) -> Path:
|
|
58
|
+
"""Save a DP-ViT checkpoint and the metadata needed to rebuild it."""
|
|
59
|
+
if not isinstance(model, DPViT):
|
|
60
|
+
raise TypeError("model must be an instance of DPViT")
|
|
61
|
+
|
|
62
|
+
if not isinstance(ics_calibration, ICSCalibration):
|
|
63
|
+
raise TypeError("ics_calibration must be an ICSCalibration instance.")
|
|
64
|
+
|
|
65
|
+
if class_names is not None:
|
|
66
|
+
if len(class_names) != model.config.num_classes:
|
|
67
|
+
raise ValueError(
|
|
68
|
+
"The number of class names must match num_classes."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
checkpoint_path = Path(path).expanduser()
|
|
72
|
+
checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
payload = {
|
|
75
|
+
"format_version": CHECKPOINT_FORMAT_VERSION,
|
|
76
|
+
"model_config": asdict(model.config),
|
|
77
|
+
"ics_calibration": asdict(ics_calibration),
|
|
78
|
+
"class_names": class_names,
|
|
79
|
+
"mlp_widths": _get_mlp_widths(model),
|
|
80
|
+
"pruning_report": pruning_report,
|
|
81
|
+
"model_state_dict": {
|
|
82
|
+
name: value.detach().cpu()
|
|
83
|
+
for name, value in model.state_dict().items()
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
temporary_path = checkpoint_path.with_suffix(
|
|
88
|
+
checkpoint_path.suffix + ".tmp"
|
|
89
|
+
)
|
|
90
|
+
torch.save(payload, temporary_path)
|
|
91
|
+
temporary_path.replace(checkpoint_path)
|
|
92
|
+
|
|
93
|
+
return checkpoint_path
|
|
94
|
+
|
|
95
|
+
def load_checkpoint(
|
|
96
|
+
path: str | Path,
|
|
97
|
+
map_location: str | torch.device = "cpu",
|
|
98
|
+
) -> tuple[DPViT, ICSCalibration, dict[str, Any]]:
|
|
99
|
+
"""
|
|
100
|
+
Load a DP-ViT checkpoint and reconstruct its architecture.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
model: The reconstructured DP-ViT model.
|
|
104
|
+
ics_calibration: The ICS parameters saved with the model.
|
|
105
|
+
metadata: Class names, MLP widths, and pruning report.
|
|
106
|
+
"""
|
|
107
|
+
checkpoint_path = Path(path).expanduser()
|
|
108
|
+
|
|
109
|
+
if not checkpoint_path.is_file():
|
|
110
|
+
raise FileNotFoundError(f"Checkpoint file does not exist: {checkpoint_path}")
|
|
111
|
+
|
|
112
|
+
payload = torch.load(
|
|
113
|
+
checkpoint_path,
|
|
114
|
+
map_location=map_location,
|
|
115
|
+
weights_only=True,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if payload.get("format_version") != CHECKPOINT_FORMAT_VERSION:
|
|
119
|
+
raise ValueError("Unsupported or missing DP-ViT checkpoint format version.")
|
|
120
|
+
|
|
121
|
+
config = DPViTConfig(**payload["model_config"])
|
|
122
|
+
calibration = ICSCalibration(**payload["ics_calibration"])
|
|
123
|
+
|
|
124
|
+
model = DPViT(config)
|
|
125
|
+
|
|
126
|
+
mlp_widths = payload["mlp_widths"]
|
|
127
|
+
encoder_layers = list(model.encoder.layers)
|
|
128
|
+
|
|
129
|
+
if len(mlp_widths) != len(encoder_layers):
|
|
130
|
+
raise ValueError("Checkpoint MLP widths do not match the encoder depth.")
|
|
131
|
+
|
|
132
|
+
for layer, hidden_width in zip(encoder_layers, mlp_widths):
|
|
133
|
+
_resize_mlp_layer(
|
|
134
|
+
cast(nn.TransformerEncoderLayer, layer),
|
|
135
|
+
int(hidden_width),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
model.load_state_dict(payload["model_state_dict"])
|
|
139
|
+
model.to(map_location)
|
|
140
|
+
|
|
141
|
+
metadata = {
|
|
142
|
+
"class_names": payload.get("class_names"),
|
|
143
|
+
"mlp_widths": mlp_widths,
|
|
144
|
+
"pruning_report": payload.get("pruning_report"),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return model, calibration, metadata
|
dp_vit/config.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Configuration objects for the DP-ViT Library."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class DPViTConfig:
|
|
8
|
+
"""Architecture Dynamic Patch Vision Transformer"""
|
|
9
|
+
|
|
10
|
+
num_classes: int
|
|
11
|
+
|
|
12
|
+
image_size: int = 224
|
|
13
|
+
region_size: int = 32
|
|
14
|
+
patch_sizes: tuple[int, int, int] = (8, 16, 32)
|
|
15
|
+
|
|
16
|
+
embed_dim: int = 384
|
|
17
|
+
num_layers: int = 12
|
|
18
|
+
num_heads: int = 6
|
|
19
|
+
mlp_ratio: float = 4.0
|
|
20
|
+
dropout: float = 0.1
|
|
21
|
+
|
|
22
|
+
def __post_init__(self) -> None:
|
|
23
|
+
if self.num_classes <= 0:
|
|
24
|
+
raise ValueError("num_classes must be bigger than 0")
|
|
25
|
+
|
|
26
|
+
if self.image_size <= 0:
|
|
27
|
+
raise ValueError("image_size must be bigger than 0")
|
|
28
|
+
|
|
29
|
+
if self.region_size <= 0:
|
|
30
|
+
raise ValueError("region_size must be bigger than 0")
|
|
31
|
+
|
|
32
|
+
if self.image_size % self.region_size !=0:
|
|
33
|
+
raise ValueError("image_size must be divisible by region_size")
|
|
34
|
+
|
|
35
|
+
if self.patch_sizes != (8, 16, 32):
|
|
36
|
+
raise ValueError("patch_size must be (8, 16, 32)")
|
|
37
|
+
|
|
38
|
+
if any(self.region_size % size != 0 for size in self.patch_sizes):
|
|
39
|
+
raise ValueError("region_size must be divisible by all patch sizes")
|
|
40
|
+
|
|
41
|
+
if self.embed_dim <= 0 or self.num_layers <= 0 or self.num_heads <= 0:
|
|
42
|
+
raise ValueError("embed_dim, num_layers, and num_heads must be bigger than 0")
|
|
43
|
+
|
|
44
|
+
if self.embed_dim % self.num_heads != 0:
|
|
45
|
+
raise ValueError("embed_dim must be divisible by num_heads")
|
|
46
|
+
|
|
47
|
+
if not math.isfinite(self.mlp_ratio) or self.mlp_ratio <= 0:
|
|
48
|
+
raise ValueError("mlp_ratio must be positive number")
|
|
49
|
+
|
|
50
|
+
if not math.isfinite(self.dropout) or not 0 <= self.dropout < 1:
|
|
51
|
+
raise ValueError("dropout must be in range [0, 1].")
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def max_position_cell(self) -> int:
|
|
55
|
+
"""Maximum number of position cells."""
|
|
56
|
+
smallest_patch = min(self.patch_sizes)
|
|
57
|
+
return (self.image_size // smallest_patch) ** 2
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ICSConfig:
|
|
61
|
+
"Config percentile for ICS module"
|
|
62
|
+
|
|
63
|
+
low_percentile: float = 30.0
|
|
64
|
+
high_percentile: float = 70.0
|
|
65
|
+
|
|
66
|
+
def __post_init__(self) -> None:
|
|
67
|
+
if not 0 <= self.low_percentile <= 100:
|
|
68
|
+
raise ValueError("low_percentile must be in range [0, 100]")
|
|
69
|
+
|
|
70
|
+
if not 0 <= self.high_percentile <= 100:
|
|
71
|
+
raise ValueError("high_percentile must be in range [0, 100]")
|
|
72
|
+
|
|
73
|
+
if self.low_percentile >= self.high_percentile:
|
|
74
|
+
raise ValueError("low_percentile must be smaller than high_percentile.")
|
|
75
|
+
|
|
76
|
+
if ( self.low_percentile != 30.0 or self.high_percentile != 70.0):
|
|
77
|
+
raise ValueError("The current ICSCalibration supports only the 30th and 70th percentile.")
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class ICSCalibration:
|
|
81
|
+
"""Calibration ICS value used for mapping patch."""
|
|
82
|
+
|
|
83
|
+
alpha: float
|
|
84
|
+
beta: float
|
|
85
|
+
p30: float
|
|
86
|
+
p70: float
|
|
87
|
+
|
|
88
|
+
def __post_init__ (self) -> None:
|
|
89
|
+
for name, value in (
|
|
90
|
+
("alpha", self.alpha),
|
|
91
|
+
("beta", self.beta),
|
|
92
|
+
("p30", self.p30),
|
|
93
|
+
("p70", self.p70),
|
|
94
|
+
):
|
|
95
|
+
if not math.isfinite(value):
|
|
96
|
+
raise ValueError(f"{name} must be finite number"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if self.alpha <= 0 or self.beta <= 0:
|
|
100
|
+
raise ValueError("alpha and beta must be greater than 0.")
|
|
101
|
+
|
|
102
|
+
if self.p30 > self.p70:
|
|
103
|
+
raise ValueError("p30 must be smaller than p70.")
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class PruningConfig:
|
|
107
|
+
"""Configuration for neuron MLP structured magnitude-based pruning."""
|
|
108
|
+
|
|
109
|
+
target_ratio: float = 0.2
|
|
110
|
+
|
|
111
|
+
def __post_init__(self) -> None:
|
|
112
|
+
if not math.isfinite(self.target_ratio):
|
|
113
|
+
raise ValueError("target_ratio must be finite number")
|
|
114
|
+
|
|
115
|
+
if not 0 < self.target_ratio < 1:
|
|
116
|
+
raise ValueError("target_ratio must be greater than 0 and smaller than 1.")
|
dp_vit/ics.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""INformation Complexity Score (ICS) calculations for DP-ViT."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.ndimage import sobel
|
|
7
|
+
|
|
8
|
+
from .config import ICSCalibration, ICSConfig
|
|
9
|
+
|
|
10
|
+
def _to_grayscale(image: np.ndarray) -> np.ndarray:
|
|
11
|
+
"""Convert a grayscale or RGB image to a 2D float64 intensity array."""
|
|
12
|
+
image = np.asarray(image)
|
|
13
|
+
|
|
14
|
+
if image.ndim == 2:
|
|
15
|
+
gray = image.astype(np.float64)
|
|
16
|
+
elif image.ndim == 3 and image.shape[2] == 3:
|
|
17
|
+
image = image.astype(np.float64)
|
|
18
|
+
gray = (
|
|
19
|
+
0.299 * image[..., 0]
|
|
20
|
+
+ 0.587 * image[..., 1]
|
|
21
|
+
+ 0.114 * image[..., 2]
|
|
22
|
+
)
|
|
23
|
+
else:
|
|
24
|
+
raise ValueError("image must have shape (height, width) or (height, width, 3).")
|
|
25
|
+
|
|
26
|
+
if not np.isfinite(gray).all():
|
|
27
|
+
raise ValueError("image must contain only finite values.")
|
|
28
|
+
|
|
29
|
+
return gray
|
|
30
|
+
|
|
31
|
+
def _make_region_blocks(
|
|
32
|
+
image: np.ndarray,
|
|
33
|
+
region_size: int,
|
|
34
|
+
) -> np.ndarray:
|
|
35
|
+
"""Split a 2D image into non-overlapping square regions."""
|
|
36
|
+
if isinstance(region_size, bool) or not isinstance(region_size, int):
|
|
37
|
+
raise TypeError("region_size must be an integer.")
|
|
38
|
+
|
|
39
|
+
if region_size <= 0:
|
|
40
|
+
raise ValueError("region_size must be greater than zero.")
|
|
41
|
+
|
|
42
|
+
height, width = image.shape
|
|
43
|
+
|
|
44
|
+
if height % region_size != 0 or width % region_size != 0:
|
|
45
|
+
raise ValueError("Image height and width must be divisible by region_size.")
|
|
46
|
+
|
|
47
|
+
rows = height // region_size
|
|
48
|
+
cols = width // region_size
|
|
49
|
+
|
|
50
|
+
blocks = image.reshape(
|
|
51
|
+
rows, region_size, cols, region_size
|
|
52
|
+
)
|
|
53
|
+
blocks = blocks.transpose(0, 2, 1, 3)
|
|
54
|
+
|
|
55
|
+
return blocks.reshape(rows, cols, region_size * region_size)
|
|
56
|
+
|
|
57
|
+
def compute_local_variance(
|
|
58
|
+
image: np.ndarray,
|
|
59
|
+
region_size: int = 32,
|
|
60
|
+
) -> np.ndarray:
|
|
61
|
+
"""
|
|
62
|
+
Compute the population variance of grayscale intensity in each region.
|
|
63
|
+
|
|
64
|
+
The variance for each region is
|
|
65
|
+
Var(R) = mean((pixel - region_mean) ** 2)
|
|
66
|
+
"""
|
|
67
|
+
gray = _to_grayscale(image)
|
|
68
|
+
blocks = _make_region_blocks(gray, region_size)
|
|
69
|
+
|
|
70
|
+
region_means = blocks.mean(axis=-1, keepdims=True)
|
|
71
|
+
variance_map = np.mean((blocks - region_means) ** 2, axis=-1)
|
|
72
|
+
|
|
73
|
+
return variance_map
|
|
74
|
+
|
|
75
|
+
def compute_gradient_magnitude(
|
|
76
|
+
image: np.ndarray,
|
|
77
|
+
region_size: int = 32,
|
|
78
|
+
) -> np.ndarray:
|
|
79
|
+
"""
|
|
80
|
+
Compute the mean Sobel gradient magnitude in each region.
|
|
81
|
+
|
|
82
|
+
Sobel gradients are calculated on full image before region averaging.
|
|
83
|
+
"""
|
|
84
|
+
gray = _to_grayscale(image)
|
|
85
|
+
|
|
86
|
+
gradient_x = sobel(gray, axis=1)
|
|
87
|
+
gradient_y = sobel(gray, axis=0)
|
|
88
|
+
magnitude = np.sqrt(gradient_x ** 2 + gradient_y ** 2)
|
|
89
|
+
|
|
90
|
+
blocks = _make_region_blocks(magnitude, region_size)
|
|
91
|
+
return blocks.mean(axis=-1)
|
|
92
|
+
|
|
93
|
+
def compute_ics(
|
|
94
|
+
variance_map: np.ndarray,
|
|
95
|
+
gradient_map: np.ndarray,
|
|
96
|
+
alpha: float,
|
|
97
|
+
beta: float,
|
|
98
|
+
) -> np.ndarray:
|
|
99
|
+
"""Combine local variance and gradient magnitude into ICS map."""
|
|
100
|
+
variance_map = np.asarray(variance_map, dtype=np.float64)
|
|
101
|
+
gradient_map = np.asarray(gradient_map, dtype=np.float64)
|
|
102
|
+
|
|
103
|
+
if variance_map.shape != gradient_map.shape:
|
|
104
|
+
raise ValueError("variance_map and gradient_map must have the same shape.")
|
|
105
|
+
|
|
106
|
+
if not np.isfinite(alpha) or alpha <= 0:
|
|
107
|
+
raise ValueError("alpha must be a finite positive number.")
|
|
108
|
+
|
|
109
|
+
if not np.isfinite(beta) or beta <= 0:
|
|
110
|
+
raise ValueError("beta must be a finite positive number.")
|
|
111
|
+
|
|
112
|
+
return alpha * variance_map + beta * gradient_map
|
|
113
|
+
|
|
114
|
+
def fit_ics_calibration(
|
|
115
|
+
calibration_images: Sequence[np.ndarray],
|
|
116
|
+
region_size: int = 32,
|
|
117
|
+
config: ICSConfig | None = None,
|
|
118
|
+
) -> ICSCalibration:
|
|
119
|
+
"""
|
|
120
|
+
Fit ICS weights and global percentile thresholds using calibration images.
|
|
121
|
+
|
|
122
|
+
All images must use the same intensity range and preprocessing convention.
|
|
123
|
+
"""
|
|
124
|
+
if not calibration_images:
|
|
125
|
+
raise ValueError("calibration_images must not be empty.")
|
|
126
|
+
|
|
127
|
+
if config is None:
|
|
128
|
+
config = ICSConfig()
|
|
129
|
+
|
|
130
|
+
all_variances = []
|
|
131
|
+
all_gradients = []
|
|
132
|
+
|
|
133
|
+
for image in calibration_images:
|
|
134
|
+
all_variances.append(
|
|
135
|
+
compute_local_variance(image, region_size).ravel()
|
|
136
|
+
)
|
|
137
|
+
all_gradients.append(
|
|
138
|
+
compute_gradient_magnitude(image, region_size).ravel()
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
all_variances = np.concatenate(all_variances)
|
|
142
|
+
all_gradients = np.concatenate(all_gradients)
|
|
143
|
+
|
|
144
|
+
epsilon = 1e-8
|
|
145
|
+
alpha = 1.0 / (all_variances.std() + epsilon)
|
|
146
|
+
beta = 1.0 / (all_gradients.std() + epsilon)
|
|
147
|
+
|
|
148
|
+
all_ics = alpha * all_variances + beta * all_gradients
|
|
149
|
+
|
|
150
|
+
p30 = float(np.percentile(all_ics, config.low_percentile))
|
|
151
|
+
p70 = float(np.percentile(all_ics, config.high_percentile))
|
|
152
|
+
|
|
153
|
+
return ICSCalibration(
|
|
154
|
+
alpha=float(alpha),
|
|
155
|
+
beta=float(beta),
|
|
156
|
+
p30=p30,
|
|
157
|
+
p70=p70,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def determine_patch_size_map(
|
|
161
|
+
ics_map: np.ndarray,
|
|
162
|
+
calibration: ICSCalibration,
|
|
163
|
+
patch_sizes: tuple[int, int, int] = (8, 16, 32),
|
|
164
|
+
) -> np.ndarray:
|
|
165
|
+
"""
|
|
166
|
+
Map each ICS region to a patch size.
|
|
167
|
+
|
|
168
|
+
The patch_sizes tuple is ordewred as (small, medium, large).
|
|
169
|
+
"""
|
|
170
|
+
if len(patch_sizes) != 3:
|
|
171
|
+
raise ValueError("patch_sizes must contain exactly three values.")
|
|
172
|
+
|
|
173
|
+
small_patch, medium_patch, large_patch = patch_sizes
|
|
174
|
+
|
|
175
|
+
if not small_patch < medium_patch < large_patch:
|
|
176
|
+
raise ValueError("patch_sizes must be ordered from smallest to largest.")
|
|
177
|
+
|
|
178
|
+
size_map = np.full(ics_map.shape, large_patch, dtype=np.int32)
|
|
179
|
+
|
|
180
|
+
medium_mask = (
|
|
181
|
+
(ics_map >= calibration.p30)
|
|
182
|
+
& (ics_map < calibration.p70)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
small_mask = ics_map >= calibration.p70
|
|
186
|
+
|
|
187
|
+
size_map[medium_mask] = medium_patch
|
|
188
|
+
size_map[small_mask] = small_patch
|
|
189
|
+
|
|
190
|
+
return size_map
|
|
191
|
+
|
|
192
|
+
def compute_patch_size_map_for_image(
|
|
193
|
+
image: np.ndarray,
|
|
194
|
+
calibration: ICSCalibration,
|
|
195
|
+
region_size: int = 32,
|
|
196
|
+
patch_sizes: tuple[int, int, int] = (8, 16, 32),
|
|
197
|
+
) -> np.ndarray:
|
|
198
|
+
"""
|
|
199
|
+
Compute an iamge's ICS map and convert it to an adaptive patch map.
|
|
200
|
+
"""
|
|
201
|
+
variance_map = compute_local_variance(image, region_size)
|
|
202
|
+
gradient_map = compute_gradient_magnitude(image, region_size)
|
|
203
|
+
|
|
204
|
+
ics_map = compute_ics(
|
|
205
|
+
variance_map,
|
|
206
|
+
gradient_map,
|
|
207
|
+
alpha=calibration.alpha,
|
|
208
|
+
beta=calibration.beta,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return determine_patch_size_map(
|
|
212
|
+
ics_map,
|
|
213
|
+
calibration=calibration,
|
|
214
|
+
patch_sizes=patch_sizes
|
|
215
|
+
)
|
dp_vit/inference.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Single-image inference utilities for DP-ViT."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import torch
|
|
8
|
+
from torch import Tensor
|
|
9
|
+
|
|
10
|
+
from .config import ICSCalibration
|
|
11
|
+
from .ics import compute_patch_size_map_for_image
|
|
12
|
+
from .model import DPViT
|
|
13
|
+
from .patching import adaptive_collate_fn
|
|
14
|
+
|
|
15
|
+
def predict_tensor(
|
|
16
|
+
model: DPViT,
|
|
17
|
+
image_before_normalization: Tensor,
|
|
18
|
+
ics_calibration: ICSCalibration,
|
|
19
|
+
class_names: Sequence[str] | None = None,
|
|
20
|
+
normalize_fn : Callable[[Tensor], Tensor] | None = None,
|
|
21
|
+
) -> dict[str, Any]:
|
|
22
|
+
"""
|
|
23
|
+
Predict the class of one preprocessed image tensor.
|
|
24
|
+
|
|
25
|
+
The input must be an RGB tensor in [0, 1], before channel normalization.
|
|
26
|
+
Resizing and other user-selected preprocessing must be applied beforehand.
|
|
27
|
+
"""
|
|
28
|
+
if not isinstance(model, DPViT):
|
|
29
|
+
raise TypeError("model must be an instance of DPViT.")
|
|
30
|
+
|
|
31
|
+
if not isinstance(image_before_normalization, Tensor):
|
|
32
|
+
raise TypeError("image_before_normalization must be a torch.Tensor.")
|
|
33
|
+
|
|
34
|
+
if image_before_normalization.ndim != 3:
|
|
35
|
+
raise ValueError("The input image must have shape (channels, height, width).")
|
|
36
|
+
|
|
37
|
+
channels, height, width = image_before_normalization.shape
|
|
38
|
+
|
|
39
|
+
if channels != 3:
|
|
40
|
+
raise ValueError("DP-ViT currently expects an RGB image with 3 channels.")
|
|
41
|
+
|
|
42
|
+
if (height, width) != (model.config.image_size, model.config.image_size):
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"Expected an image of size {model.config.image_size}x"
|
|
45
|
+
f"{model.config.image_size}, got {height}x{width}."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if not torch.isfinite(image_before_normalization).all():
|
|
49
|
+
raise ValueError("The input image must contain only finite values.")
|
|
50
|
+
|
|
51
|
+
if (
|
|
52
|
+
image_before_normalization.min() <0
|
|
53
|
+
or image_before_normalization.max() > 1
|
|
54
|
+
):
|
|
55
|
+
raise ValueError(
|
|
56
|
+
"ICS input must be in [0, 1]. Apply channel normalization"
|
|
57
|
+
"after ICS patch-map generation."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if not isinstance(ics_calibration, ICSCalibration):
|
|
61
|
+
raise TypeError("ics_calibration must be an ICSCalibration instance.")
|
|
62
|
+
|
|
63
|
+
if class_names is not None and len(class_names) != model.config.num_classes:
|
|
64
|
+
raise ValueError("class_names length must match model.num_classes.")
|
|
65
|
+
|
|
66
|
+
image = image_before_normalization.detach().to(
|
|
67
|
+
device="cpu",
|
|
68
|
+
dtype=torch.float32,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
image_np = image.permute(1, 2, 0).numpy()
|
|
72
|
+
|
|
73
|
+
patch_size_map = compute_patch_size_map_for_image(
|
|
74
|
+
image=image_np,
|
|
75
|
+
calibration=ics_calibration,
|
|
76
|
+
region_size=model.config.region_size,
|
|
77
|
+
patch_sizes=model.config.patch_sizes,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if normalize_fn is None:
|
|
81
|
+
model_image = image
|
|
82
|
+
else:
|
|
83
|
+
model_image = normalize_fn(image)
|
|
84
|
+
|
|
85
|
+
if not isinstance(model_image, Tensor):
|
|
86
|
+
raise TypeError("normalize_fn must return a torch.Tensor.")
|
|
87
|
+
|
|
88
|
+
if model_image.shape != image.shape:
|
|
89
|
+
raise ValueError("normalize_fn must preserve the input image shape.")
|
|
90
|
+
|
|
91
|
+
batch = adaptive_collate_fn(
|
|
92
|
+
[(model_image, patch_size_map, 0)],
|
|
93
|
+
region_size=model.config.region_size,
|
|
94
|
+
patch_sizes=model.config.patch_sizes,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
device = next(model.parameters()).device
|
|
98
|
+
batch = {
|
|
99
|
+
key: value.to(device) if isinstance(value, Tensor) else value
|
|
100
|
+
for key, value in batch.items()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
was_training = model.training
|
|
104
|
+
model.eval()
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
with torch.inference_mode():
|
|
108
|
+
logits = model(batch)
|
|
109
|
+
probabilities = torch.softmax(logits, dim=1)[0]
|
|
110
|
+
predicted_index = int(probabilities.argmax())
|
|
111
|
+
finally:
|
|
112
|
+
model.train(was_training)
|
|
113
|
+
|
|
114
|
+
patch_counts = {
|
|
115
|
+
int(size): int(np.count_nonzero(patch_size_map == size))
|
|
116
|
+
for size in model.config.patch_sizes
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
predicted_name = (
|
|
120
|
+
class_names[predicted_index]
|
|
121
|
+
if class_names is not None
|
|
122
|
+
else None
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
"class_index": predicted_index,
|
|
127
|
+
"class_name": predicted_name,
|
|
128
|
+
"confidence": float(probabilities[predicted_index].cpu()),
|
|
129
|
+
"probabilities": probabilities.cpu().tolist(),
|
|
130
|
+
"patch_size_map": patch_size_map.tolist(),
|
|
131
|
+
"patch_counts": patch_counts,
|
|
132
|
+
}
|