Equimo 0.5.0a1__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.
equimo/io.py ADDED
@@ -0,0 +1,313 @@
1
+ import json
2
+ import io
3
+ import tarfile
4
+ import tempfile
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import equinox as eqx
9
+ import jax
10
+ import jax.numpy as jnp
11
+ import lz4.frame
12
+ import requests
13
+ from loguru import logger
14
+ from semver import Version
15
+
16
+ import equimo.models as em
17
+ from equimo import __version__
18
+
19
+ DEFAULT_REPOSITORY_URL = (
20
+ "https://huggingface.co/poiretclement/equimo/resolve/main/models/default"
21
+ )
22
+
23
+
24
+ def save_model(
25
+ path: Path,
26
+ model: eqx.Module,
27
+ model_config: dict,
28
+ torch_hub_cfg: list[str] | dict = {},
29
+ timm_cfg: list = [],
30
+ compression: bool = True,
31
+ ):
32
+ """Save an Equinox model with its configuration and metadata to disk.
33
+
34
+ Args:
35
+ path (Path): Target path where the model will be saved. If compression is True
36
+ and path doesn't end with '.tar.lz4', it will be automatically appended.
37
+ model (eqx.Module): The Equinox model to be saved.
38
+ model_config (dict): Configuration dictionary containing model hyperparameters.
39
+ torch_hub_cfg (list[str]): List of torch hub configuration parameters.
40
+ timm_cfg (list[str]): List of timm configuration parameters.
41
+ compression (bool, optional): Whether to compress the saved model using LZ4.
42
+ Defaults to True.
43
+
44
+ The function saves:
45
+ - Model weights using Equinox serialization
46
+ - Metadata including model configuration, torch hub config, and version info
47
+ - If compression=True: Creates a .tar.lz4 archive containing both files
48
+ - If compression=False: Creates a directory containing both files
49
+ """
50
+
51
+ logger.info(f"Saving model to {path}...")
52
+
53
+ metadata = {
54
+ "model_config": model_config,
55
+ "torch_hub_cfg": torch_hub_cfg,
56
+ "timm": timm_cfg,
57
+ "jax_version": jax.__version__,
58
+ "equinox_version": eqx.__version__,
59
+ "equimo_version": __version__,
60
+ }
61
+
62
+ logger.debug(f"Metadata: {metadata}")
63
+
64
+ if compression:
65
+ logger.info("Compressing...")
66
+ if not path.name.endswith(".tar.lz4"):
67
+ path = path.with_name(path.name + ".tar.lz4")
68
+
69
+ with tempfile.TemporaryDirectory() as tmp_dir:
70
+ tmp_path = Path(tmp_dir)
71
+ with open(tmp_path / "metadata.json", "w") as f:
72
+ json.dump(metadata, f)
73
+
74
+ # Save model weights
75
+ eqx.tree_serialise_leaves(tmp_path / "weights.eqx", model)
76
+
77
+ # Create compressed archive
78
+ path.parent.mkdir(parents=True, exist_ok=True)
79
+ with lz4.frame.open(path, "wb") as f_out:
80
+ with tarfile.open(fileobj=f_out, mode="w") as tar:
81
+ tar.add(tmp_path / "metadata.json", arcname="metadata.json")
82
+ tar.add(tmp_path / "weights.eqx", arcname="weights.eqx")
83
+ else:
84
+ path.mkdir(parents=True, exist_ok=True)
85
+ with open(path / "metadata.json", "w") as f:
86
+ json.dump(metadata, f)
87
+ eqx.tree_serialise_leaves(path / "weights.eqx", model)
88
+
89
+ logger.info("Model succesfully saved.")
90
+
91
+
92
+ @logger.catch
93
+ def download(identifier: str, repository: str) -> Path:
94
+ """Download a model archive from a specified repository.
95
+
96
+ Args:
97
+ identifier (str): Unique identifier for the model to download.
98
+ repository (str): Base URL of the repository containing the model.
99
+
100
+ Returns:
101
+ Path: Local path to the downloaded model archive.
102
+
103
+ The function:
104
+ - Constructs the download URL using the repository and identifier
105
+ - Checks for existing cached file in ~/.cache/equimo/
106
+ - Downloads and saves the model if not cached
107
+ - Verifies the download using HTTPS
108
+ - Raises HTTP errors if download fails
109
+ """
110
+
111
+ logger.info(f"Downloading {identifier}...")
112
+
113
+ model = identifier.split("_")[0]
114
+ url = f"{repository}/{model}/{identifier}.tar.lz4"
115
+ path = Path(f"~/.cache/equimo/{model}/{identifier}.tar.lz4").expanduser()
116
+ path.parent.mkdir(parents=True, exist_ok=True)
117
+
118
+ if path.exists():
119
+ logger.info("Archive already downloaded, using cached file.")
120
+ return path
121
+
122
+ res = requests.get(url, verify=True)
123
+ res.raise_for_status()
124
+
125
+ with open(path.absolute(), "wb") as f:
126
+ f.write(res.content)
127
+
128
+ return path
129
+
130
+
131
+ @logger.catch
132
+ def load_model(
133
+ cls: str,
134
+ identifier: str | None = None,
135
+ path: Path | None = None,
136
+ repository: str = DEFAULT_REPOSITORY_URL,
137
+ inference_mode: bool = True,
138
+ **model_kwargs,
139
+ ) -> eqx.Module:
140
+ """Load an Equinox model from either a local path or remote repository.
141
+
142
+ Args:
143
+ cls (str): Model class identifier. Must be one of: 'vit', 'mlla', 'vssd',
144
+ 'shvit', 'fastervit', 'partialformer'.
145
+ identifier (str | None, optional): Remote model identifier for downloading.
146
+ Mutually exclusive with path. Defaults to None.
147
+ path (Path | None, optional): Local path to load model from.
148
+ Mutually exclusive with identifier. Defaults to None.
149
+ repository (str, optional): Base URL for model download.
150
+ Defaults to DEFAULT_REPOSITORY_URL.
151
+ inference_mode (bool): Disables dropouts if True.
152
+ Defaults to True.
153
+ model_kwargs: kwargs passed to model instanciation. Overrides metadatas.
154
+
155
+ Returns:
156
+ eqx.Module: Loaded and initialized model with weights.
157
+
158
+ Raises:
159
+ ValueError: If both identifier and path are None or if both are provided.
160
+ ValueError: If cls is not one of the supported model types.
161
+
162
+ The function:
163
+ - Downloads model if identifier is provided
164
+ - Handles both compressed (.tar.lz4) and uncompressed formats
165
+ - Loads model configuration and metadata
166
+ - Reconstructs model architecture and loads weights
167
+ - Supports caching of downloaded and decompressed files
168
+ """
169
+
170
+ if identifier is None and path is None:
171
+ raise ValueError(
172
+ "Both `identifier` and `path` are None. Please provide one of them."
173
+ )
174
+ if identifier and path:
175
+ raise ValueError(
176
+ "Both `identifier` and `path` are defined. Please provide only one of them."
177
+ )
178
+
179
+ if identifier:
180
+ path = download(identifier, repository)
181
+
182
+ load_path = path
183
+
184
+ logger.info(f"Loading a {cls} model...")
185
+
186
+ if path.suffixes == [".tar", ".lz4"]:
187
+ logger.info("Decompressing...")
188
+ # Handle compressed archive
189
+ decompressed_dir = path.with_suffix("").with_suffix("") # Remove .tar.lz4
190
+
191
+ # Check if we need to decompress
192
+ if not decompressed_dir.exists() or (
193
+ decompressed_dir.stat().st_mtime < path.stat().st_mtime
194
+ ):
195
+ decompressed_dir.mkdir(parents=True, exist_ok=True)
196
+ with lz4.frame.open(path, "rb") as f_in:
197
+ with tarfile.open(fileobj=f_in, mode="r") as tar:
198
+ tar.extractall(decompressed_dir)
199
+
200
+ load_path = decompressed_dir
201
+
202
+ # Load metadata and model
203
+ with open(load_path / "metadata.json", "r") as f:
204
+ metadata = json.load(f)
205
+
206
+ logger.debug(f"Metadata: {metadata}")
207
+
208
+ model_eqm_version = metadata.get("equimo_version", "0.2.0")
209
+ if Version.parse(model_eqm_version) > Version.parse(__version__):
210
+ logger.warning(
211
+ f"The model you are importing was packaged with equimo v{model_eqm_version}, but you have equimo v{__version__}. You may face unexpected errors. Please consider updating equimo."
212
+ )
213
+
214
+ # Class resolution
215
+ match cls:
216
+ case "vit":
217
+ model_cls = em.VisionTransformer
218
+ case "mlla":
219
+ model_cls = em.Mlla
220
+ case "vssd":
221
+ model_cls = em.Vssd
222
+ case "shvit":
223
+ model_cls = em.SHViT
224
+ case "fastervit":
225
+ model_cls = em.FasterViT
226
+ case "partialformer":
227
+ model_cls = em.PartialFormer
228
+ case "experimental.textencoder":
229
+ from equimo.experimental.text import TextEncoder
230
+
231
+ model_cls = TextEncoder
232
+ case _:
233
+ raise ValueError(f"Unknown model class: {cls}")
234
+
235
+ # Reconstruct model skeleton
236
+ kwargs = metadata["model_config"] | model_kwargs
237
+ model = model_cls(**kwargs, key=jax.random.PRNGKey(42))
238
+
239
+ # Load weights and set inference mode
240
+ model = eqx.tree_deserialise_leaves(load_path / "weights.eqx", model)
241
+ model = eqx.nn.inference_mode(model, inference_mode)
242
+
243
+ logger.info("Model loaded successfully.")
244
+
245
+ return model
246
+
247
+
248
+ def _center_crop_square(array: jnp.ndarray) -> jnp.ndarray:
249
+ """Center-crop a HxW(xC) array to a square of side min(H, W).
250
+
251
+ Args:
252
+ array: jnp.ndarray with shape (H, W) or (H, W, C).
253
+
254
+ Returns:
255
+ Center-cropped array with shape (M, M) or (M, M, C) where M = min(H, W).
256
+ """
257
+ if array.ndim < 2:
258
+ raise ValueError("Input array must have at least 2 dimensions (H, W[, C]).")
259
+ h, w = array.shape[:2]
260
+ if h == w:
261
+ return array
262
+ m = min(h, w)
263
+ top = (h - m) // 2
264
+ left = (w - m) // 2
265
+ return array[top : top + m, left : left + m, ...]
266
+
267
+
268
+ def load_image(
269
+ path: str,
270
+ mean: Optional[list[float]] = None,
271
+ std: Optional[list[float]] = None,
272
+ size: Optional[int] = None,
273
+ center_crop: bool = False,
274
+ ):
275
+ """Load an image and perform minor preprocessing.
276
+
277
+ Args:
278
+ path (str): Path of the image.
279
+ mean (list, optional): Channel mean for normalization.
280
+ std (list, optional): Channel std for normalization.
281
+ size (int, optional): Size to which resize the image.
282
+ center_crop (bool, optional): If True, center-crop to square prior to resizing.
283
+
284
+ Returns:
285
+ jnp.array: The loaded image.
286
+
287
+ Raises:
288
+ ImportError: If PIL can't be loaded.
289
+ """
290
+ try:
291
+ from PIL import Image
292
+ except ImportError:
293
+ raise ImportError("PIL is needed to be able to load images.")
294
+
295
+ with open(path, "rb") as fd:
296
+ image_bytes = io.BytesIO(fd.read())
297
+ pil_image = Image.open(image_bytes)
298
+
299
+ array = jnp.array(pil_image).astype(jnp.float32) / 255.0
300
+
301
+ if center_crop:
302
+ array = _center_crop_square(array)
303
+
304
+ if size is not None:
305
+ array = jax.image.resize(array, (size, size, 3), method="bilinear")
306
+
307
+ if mean is not None and std is not None:
308
+ mean = jnp.array(mean)[None, None, :]
309
+ std = jnp.array(std)[None, None, :]
310
+
311
+ array = (array - mean) / std
312
+
313
+ return array.transpose(2, 0, 1)
File without changes
@@ -0,0 +1,33 @@
1
+ from typing import Callable
2
+ import jax
3
+ from functools import partial
4
+
5
+
6
+ def get_act(activation: str | Callable) -> Callable:
7
+ """Get an activation function from its common name.
8
+
9
+ This is necessary because configs have to be stringified and stored as
10
+ json files to allow (de)serialization.
11
+ """
12
+ if not isinstance(activation, str):
13
+ return activation
14
+
15
+ match activation:
16
+ case "relu":
17
+ return jax.nn.relu
18
+ case "gelu":
19
+ return jax.nn.gelu
20
+ case "exactgelu":
21
+ return partial(jax.nn.gelu, approximate=False)
22
+ case "silu":
23
+ return jax.nn.silu
24
+ case "elu":
25
+ return jax.nn.elu
26
+ case "sigmoid":
27
+ return jax.nn.sigmoid
28
+ case "hard_sigmoid":
29
+ return jax.nn.hard_sigmoid
30
+ case "softmax":
31
+ return jax.nn.softmax
32
+ case _:
33
+ raise ValueError(f"Got an unknown activation string: {activation}")