0xgr3y commited on
Commit
b98c929
·
verified ·
1 Parent(s): dc1f28a

Upload build_model.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. build_model.py +609 -0
build_model.py ADDED
@@ -0,0 +1,609 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Arch-Building-Image-Classification — Model Construction & Inference Module.
2
+
3
+ This module provides the architecture definition, custom layer implementations,
4
+ and inference utilities for the EfficientNetV2-S-based fine-grained visual
5
+ classification (FGIC) model trained on the World Architectural Buildings dataset.
6
+
7
+ Custom layers (GeMPooling, FocalLoss, DiscriminativeAdamW) are registered via
8
+ ``@register_keras_serializable`` so that ``tf.keras.models.load_model`` can
9
+ deserialize them without an explicit ``custom_objects`` dict — simply importing
10
+ this module is sufficient.
11
+
12
+ Usage — Clean load (no ProtectAI flag, recommended):
13
+ >>> from build_model import ArchBuildingClassifier
14
+ >>> clf = ArchBuildingClassifier.build()
15
+ >>> clf.load_weights('fine_tuning_swa.weights.h5')
16
+ >>> preds = clf.predict(image_array)
17
+
18
+ Usage — Load from .keras (flagged by ProtectAI but functionally correct):
19
+ >>> import build_model # registers custom classes
20
+ >>> import tensorflow as tf
21
+ >>> model = tf.keras.models.load_model('fine_tuning_swa.keras')
22
+
23
+ Usage — Inference with preprocessing:
24
+ >>> from build_model import ArchBuildingClassifier
25
+ >>> clf = ArchBuildingClassifier.from_weights('fine_tuning_swa.weights.h5')
26
+ >>> label, confidence, top3 = clf.predict(image_pil_or_array)
27
+
28
+ References:
29
+ - GeM Pooling: Radenovic et al., CVPR 2018
30
+ - Focal Loss: Lin et al., ICCV 2017
31
+ - DiscriminativeAdamW: Howard & Ruder, ACL 2018 (selective fine-tuning)
32
+ - Random Erasing: Zhong et al., AAAI 2020
33
+ - SWA: Izmailov et al., UAI 2018
34
+
35
+ License:
36
+ - Code: MIT
37
+ - Model weights: Apache-2.0
38
+ - Dataset: CC-BY-4.0
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import os
44
+ from typing import Dict, List, Optional, Tuple, Union
45
+
46
+ import numpy as np
47
+ import tensorflow as tf
48
+ from tensorflow.keras.applications import EfficientNetV2S
49
+ try:
50
+ from tensorflow.keras.applications.efficientnet_v2 import preprocess_input
51
+ except (ImportError, ModuleNotFoundError):
52
+ from tensorflow.keras.applications.efficientnet import preprocess_input
53
+ from tensorflow.keras.layers import (
54
+ BatchNormalization,
55
+ Conv2D,
56
+ Dense,
57
+ Dropout,
58
+ Layer,
59
+ MaxPooling2D,
60
+ )
61
+ from tensorflow.keras.layers import Input
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Compatibility shim — tf.keras.saving is not exposed in all TF/Keras setups.
65
+ # ---------------------------------------------------------------------------
66
+ try:
67
+ from tensorflow.keras.saving import register_keras_serializable
68
+ except (ImportError, AttributeError):
69
+ try:
70
+ from keras.saving import register_keras_serializable
71
+ except (ImportError, AttributeError):
72
+
73
+ def register_keras_serializable(package: Optional[str] = None):
74
+ """No-op fallback when Keras saving API is unavailable."""
75
+
76
+ def decorator(cls):
77
+ return cls
78
+
79
+ return decorator
80
+
81
+
82
+ __all__ = [
83
+ "ArchBuildingClassifier",
84
+ "GeMPooling",
85
+ "FocalLoss",
86
+ "DiscriminativeAdamW",
87
+ "CUSTOM_OBJECTS",
88
+ "LABELS",
89
+ "build_model",
90
+ ]
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Module-level constants
94
+ # ---------------------------------------------------------------------------
95
+
96
+ LABELS: List[str] = [
97
+ "barn",
98
+ "bridge",
99
+ "castle",
100
+ "mosque",
101
+ "skyscraper",
102
+ "stadium",
103
+ "temple",
104
+ "windmill",
105
+ ]
106
+
107
+ INPUT_SHAPE: Tuple[int, int, int] = (320, 320, 3)
108
+ NUM_CLASSES: int = len(LABELS)
109
+ PACKAGE: str = "ArchClassifier"
110
+
111
+
112
+ # ===========================================================================
113
+ # Custom Layers
114
+ # ===========================================================================
115
+
116
+
117
+ @register_keras_serializable(package=PACKAGE)
118
+ class GeMPooling(Layer):
119
+ """Generalized Mean Pooling layer for fine-grained visual recognition.
120
+
121
+ Replaces standard Global Average Pooling with a learnable generalized
122
+ mean that better preserves discriminative spatial features. The pooling
123
+ parameter ``p`` is trainable: ``p -> 1`` reduces to average pooling,
124
+ ``p -> inf`` approaches max pooling.
125
+
126
+ Args:
127
+ p: Initial value for the pooling power parameter (default: 3.0).
128
+ eps: Small constant for numerical stability when clamping inputs
129
+ (default: 1e-6).
130
+ **kwargs: Standard Keras layer keyword arguments (name, trainable, etc.).
131
+
132
+ Reference:
133
+ Radenovic, F., Tolias, G., & Chum, O. (2018). Fine-tuning CNN
134
+ Image Retrieval with No Human Annotation. IEEE TPAMI.
135
+ """
136
+
137
+ def __init__(self, p: float = 3.0, eps: float = 1e-6, **kwargs):
138
+ super().__init__(**kwargs)
139
+ self.p_init = p
140
+ self.eps = eps
141
+
142
+ def build(self, input_shape):
143
+ self.p = self.add_weight(
144
+ name="gem_p",
145
+ shape=(),
146
+ initializer=tf.keras.initializers.Constant(self.p_init),
147
+ trainable=True,
148
+ dtype=tf.float32,
149
+ )
150
+ super().build(input_shape)
151
+
152
+ def call(self, x: tf.Tensor) -> tf.Tensor:
153
+ x = tf.maximum(x, self.eps)
154
+ x = tf.pow(x, self.p)
155
+ x = tf.reduce_mean(x, axis=[1, 2], keepdims=False)
156
+ x = tf.pow(x, 1.0 / self.p)
157
+ return x
158
+
159
+ def get_config(self) -> dict:
160
+ config = super().get_config()
161
+ config.update({"p": self.p_init, "eps": self.eps})
162
+ return config
163
+
164
+
165
+ @register_keras_serializable(package=PACKAGE)
166
+ class FocalLoss(tf.keras.losses.Loss):
167
+ """Focal Loss for class imbalance and hard-example mining.
168
+
169
+ Down-weights well-classified examples via ``(1 - p)^gamma``, focusing
170
+ gradient updates on difficult samples. Combined with optional label
171
+ smoothing to prevent overconfidence.
172
+
173
+ Args:
174
+ gamma: Focusing parameter; higher values increase down-weighting
175
+ of easy examples (default: 2.0, per Lin et al.).
176
+ alpha: Optional per-class weighting factor. If None, no class
177
+ weighting is applied.
178
+ label_smoothing: Smoothing factor in [0, 1) to soft-target labels
179
+ (default: 0.0).
180
+ **kwargs: Standard Keras loss keyword arguments.
181
+
182
+ Reference:
183
+ Lin, T.-Y., Goyal, P., Girshick, R., He, K., & Dollar, P. (2017).
184
+ Focal Loss for Dense Object Detection. ICCV 2017.
185
+ """
186
+
187
+ def __init__(
188
+ self,
189
+ gamma: float = 2.0,
190
+ alpha: Optional[float] = None,
191
+ label_smoothing: float = 0.0,
192
+ **kwargs,
193
+ ):
194
+ super().__init__(**kwargs)
195
+ self.gamma = gamma
196
+ self.alpha = alpha
197
+ self.label_smoothing = label_smoothing
198
+
199
+ def call(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor:
200
+ y_pred = tf.clip_by_value(y_pred, 1e-7, 1.0 - 1e-7)
201
+ if self.label_smoothing > 0:
202
+ num_classes = tf.cast(tf.shape(y_true)[-1], tf.float32)
203
+ y_true = y_true * (1.0 - self.label_smoothing) + (
204
+ self.label_smoothing / num_classes
205
+ )
206
+ ce = -y_true * tf.math.log(y_pred)
207
+ weight = tf.pow(1.0 - y_pred, self.gamma)
208
+ fl = weight * ce
209
+ if self.alpha is not None:
210
+ alpha_t = y_true * self.alpha
211
+ fl = alpha_t * fl
212
+ return tf.reduce_mean(tf.reduce_sum(fl, axis=-1))
213
+
214
+ def get_config(self) -> dict:
215
+ config = super().get_config()
216
+ config.update(
217
+ {
218
+ "gamma": self.gamma,
219
+ "alpha": self.alpha,
220
+ "label_smoothing": self.label_smoothing,
221
+ }
222
+ )
223
+ return config
224
+
225
+
226
+ @register_keras_serializable(package=PACKAGE)
227
+ class DiscriminativeAdamW(tf.keras.optimizers.AdamW):
228
+ """AdamW with per-variable learning rate scaling for selective fine-tuning.
229
+
230
+ Overrides ``update_step`` to scale the learning rate per-variable based
231
+ on layer name patterns within the backbone network. Unlike gradient
232
+ scaling (which is scale-invariant in Adam), LR scaling produces truly
233
+ discriminative updates — block6 variables receive 10x smaller updates
234
+ than head variables.
235
+
236
+ Args:
237
+ lr_multipliers: Mapping from layer-name substrings to LR scale
238
+ factors. e.g. ``{'block6': 0.1}`` applies 0.1x learning rate
239
+ to all block6 variables.
240
+ backbone_layer_idx: Index of the backbone model within the
241
+ Functional model container (default: 0).
242
+ **kwargs: Standard AdamW keyword arguments (learning_rate,
243
+ weight_decay, etc.).
244
+
245
+ Note:
246
+ LR scaling is applied inside ``update_step`` by multiplying
247
+ ``learning_rate * mult`` before calling the parent AdamW update.
248
+ A variable cache is built via ``_build_var_cache(model)`` to map
249
+ ``id(variable) -> multiplier``.
250
+
251
+ Reference:
252
+ Howard, J., & Ruder, S. (2018). Universal Language Model
253
+ Fine-tuning for Text Classification. ACL 2018.
254
+ """
255
+
256
+ def __init__(
257
+ self,
258
+ lr_multipliers: Optional[Dict[str, float]] = None,
259
+ backbone_layer_idx: int = 0,
260
+ **kwargs,
261
+ ):
262
+ super().__init__(**kwargs)
263
+ self.lr_multipliers = lr_multipliers or {}
264
+ self.backbone_layer_idx = backbone_layer_idx
265
+ self._var_mult_cache: Dict[int, float] = {}
266
+
267
+ def _build_var_cache(self, model: tf.keras.Model) -> None:
268
+ """Build the variable-to-multiplier cache from the model's backbone."""
269
+ self._var_mult_cache = {}
270
+ base_model = next((l for l in model.layers if isinstance(l, tf.keras.Model)), None)
271
+ if base_model is None:
272
+ base_model = model.layers[self.backbone_layer_idx]
273
+ for layer in base_model.layers:
274
+ mult = 1.0
275
+ for pattern, m in self.lr_multipliers.items():
276
+ if pattern in layer.name:
277
+ mult = m
278
+ break
279
+ for var in layer.trainable_variables:
280
+ self._var_mult_cache[id(var)] = mult
281
+
282
+ def _get_multiplier(self, var: tf.Variable) -> float:
283
+ return self._var_mult_cache.get(id(var), 1.0)
284
+
285
+ def update_step(self, gradient, variable, learning_rate):
286
+ """Scale learning_rate per-variable — truly discriminative."""
287
+ mult = self._get_multiplier(variable)
288
+ effective_lr = learning_rate * mult
289
+ return super().update_step(gradient, variable, effective_lr)
290
+
291
+ def get_config(self) -> dict:
292
+ config = super().get_config()
293
+ config.update(
294
+ {
295
+ "lr_multipliers": self.lr_multipliers,
296
+ "backbone_layer_idx": self.backbone_layer_idx,
297
+ }
298
+ )
299
+ return config
300
+
301
+
302
+ # ---------------------------------------------------------------------------
303
+ # Custom objects registry (for explicit load_model custom_objects dict)
304
+ # ---------------------------------------------------------------------------
305
+
306
+ CUSTOM_OBJECTS: Dict[str, type] = {
307
+ "GeMPooling": GeMPooling,
308
+ "FocalLoss": FocalLoss,
309
+ "DiscriminativeAdamW": DiscriminativeAdamW,
310
+ }
311
+
312
+
313
+ # ===========================================================================
314
+ # Model Wrapper Class
315
+ # ===========================================================================
316
+
317
+
318
+ class ArchBuildingClassifier:
319
+ """High-level wrapper for the Arch-Building-Image-Classification model.
320
+
321
+ Encapsulates architecture construction, weight loading from multiple
322
+ formats, and single/batch inference with EfficientNetV2-S preprocessing.
323
+
324
+ The underlying architecture is a Functional model:
325
+ EfficientNetV2-S (frozen, training=False) -> Conv2D(256) -> BN -> MaxPool ->
326
+ GeMPooling(p=3.0) -> Dense(256) -> BN -> Dropout(0.4) ->
327
+ Dense(8, softmax, dtype=float32)
328
+
329
+ Attributes:
330
+ labels: List of class label strings (alphabetical order).
331
+ input_shape: Expected input tensor shape (H, W, C).
332
+ num_classes: Number of output classes.
333
+
334
+ Example:
335
+ >>> clf = ArchBuildingClassifier.from_weights('model.weights.h5')
336
+ >>> label, conf, top3 = clf.predict(image)
337
+ >>> print(f"Predicted: {label} ({conf:.1%})")
338
+ """
339
+
340
+ labels: List[str] = LABELS
341
+ input_shape: Tuple[int, int, int] = INPUT_SHAPE
342
+ num_classes: int = NUM_CLASSES
343
+
344
+ def __init__(self, model: Optional[tf.keras.Model] = None):
345
+ self._model = model
346
+
347
+ # ------------------------------------------------------------------
348
+ # Construction
349
+ # ------------------------------------------------------------------
350
+
351
+ @classmethod
352
+ def build(
353
+ cls,
354
+ input_shape: Optional[Tuple[int, int, int]] = None,
355
+ num_classes: Optional[int] = None,
356
+ ) -> "ArchBuildingClassifier":
357
+ """Construct the model architecture from scratch.
358
+
359
+ Creates a Functional model with EfficientNetV2-S backbone (ImageNet
360
+ weights, frozen) and a custom classification head featuring GeM
361
+ pooling. The output Dense layer uses dtype=float32 for mixed
362
+ precision stability.
363
+
364
+ Args:
365
+ input_shape: Input tensor shape (default: (320, 320, 3)).
366
+ num_classes: Number of output classes (default: 8).
367
+
368
+ Returns:
369
+ An ArchBuildingClassifier instance with an untrained model.
370
+ """
371
+ input_shape = input_shape or cls.input_shape
372
+ num_classes = num_classes or cls.num_classes
373
+
374
+ base_model = EfficientNetV2S(
375
+ weights="imagenet",
376
+ include_top=False,
377
+ include_preprocessing=True,
378
+ input_shape=input_shape,
379
+ )
380
+ base_model.trainable = False
381
+
382
+ inputs = Input(shape=input_shape)
383
+ x = base_model(inputs, training=False)
384
+ x = Conv2D(256, (3, 3), activation="relu", padding="same")(x)
385
+ x = BatchNormalization()(x)
386
+ x = MaxPooling2D(pool_size=(2, 2))(x)
387
+ x = GeMPooling(p=3.0, name="gem_pooling")(x)
388
+ x = Dense(256, activation="relu")(x)
389
+ x = BatchNormalization()(x)
390
+ x = Dropout(0.4)(x)
391
+ outputs = Dense(num_classes, activation="softmax", dtype="float32")(x)
392
+
393
+ model = tf.keras.Model(inputs, outputs)
394
+ return cls(model)
395
+
396
+ @classmethod
397
+ def from_keras(cls, path: str) -> "ArchBuildingClassifier":
398
+ """Load from a .keras checkpoint file.
399
+
400
+ Requires that custom classes are registered (importing this module
401
+ is sufficient) or passed via ``CUSTOM_OBJECTS``.
402
+
403
+ Args:
404
+ path: Path to the .keras file.
405
+
406
+ Returns:
407
+ An ArchBuildingClassifier with loaded weights and architecture.
408
+ """
409
+ model = tf.keras.models.load_model(
410
+ path, custom_objects=CUSTOM_OBJECTS, compile=False
411
+ )
412
+ return cls(model)
413
+
414
+ @classmethod
415
+ def from_weights(cls, weights_path: str) -> "ArchBuildingClassifier":
416
+ """Reconstruct architecture and load weights from .weights.h5.
417
+
418
+ This is the recommended loading path for production inference —
419
+ the .weights.h5 format does not carry custom class references and
420
+ is not flagged by ProtectAI Guardian (PAIT-KERAS-301).
421
+
422
+ Args:
423
+ weights_path: Path to the .weights.h5 file.
424
+
425
+ Returns:
426
+ An ArchBuildingClassifier with loaded weights.
427
+ """
428
+ clf = cls.build()
429
+ clf._model.load_weights(weights_path)
430
+ return clf
431
+
432
+ # ------------------------------------------------------------------
433
+ # Loading
434
+ # ------------------------------------------------------------------
435
+
436
+ def load_weights(self, weights_path: str) -> None:
437
+ """Load weights into the existing model.
438
+
439
+ Args:
440
+ weights_path: Path to the .weights.h5 file.
441
+ """
442
+ if self._model is None:
443
+ raise RuntimeError("Model not initialized. Call build() first.")
444
+ self._model.load_weights(weights_path)
445
+
446
+ # ------------------------------------------------------------------
447
+ # Inference
448
+ # ------------------------------------------------------------------
449
+
450
+ def _preprocess(self, image: Union[np.ndarray, "Image.Image"]) -> np.ndarray:
451
+ """Resize and apply EfficientNetV2-S preprocessing to a single image.
452
+
453
+ Args:
454
+ image: PIL Image or numpy array (H, W, C) in uint8 range.
455
+
456
+ Returns:
457
+ Preprocessed batch of shape (1, 320, 320, 3) as float32.
458
+ """
459
+ if hasattr(image, "resize"): # PIL Image
460
+ image = image.convert("RGB").resize(
461
+ (self.input_shape[1], self.input_shape[0])
462
+ )
463
+ image = np.array(image, dtype=np.float32)
464
+ elif image.shape[:2] != self.input_shape[:2]:
465
+ image = tf.image.resize(image, self.input_shape[:2]).numpy()
466
+
467
+ if image.ndim == 3:
468
+ image = np.expand_dims(image, axis=0)
469
+ image = preprocess_input(image)
470
+ return image
471
+
472
+ def predict(
473
+ self,
474
+ image: Union[np.ndarray, "Image.Image"],
475
+ top_k: int = 3,
476
+ ) -> Tuple[str, float, List[Tuple[str, float]]]:
477
+ """Run inference on a single image.
478
+
479
+ Args:
480
+ image: PIL Image or numpy array (H, W, C) in uint8 range.
481
+ top_k: Number of top predictions to return.
482
+
483
+ Returns:
484
+ Tuple of (predicted_label, confidence, top_k_list) where
485
+ top_k_list is a list of (label, probability) pairs.
486
+ """
487
+ if self._model is None:
488
+ raise RuntimeError("Model not initialized. Call build() first.")
489
+
490
+ x = self._preprocess(image)
491
+ probs = self._model.predict(x, verbose=0)[0]
492
+
493
+ idx = int(np.argmax(probs))
494
+ label = self.labels[idx]
495
+ confidence = float(probs[idx])
496
+
497
+ top_indices = np.argsort(probs)[::-1][:top_k]
498
+ top_k_list = [(self.labels[i], float(probs[i])) for i in top_indices]
499
+
500
+ return label, confidence, top_k_list
501
+
502
+ def predict_batch(
503
+ self,
504
+ images: List[Union[np.ndarray, "Image.Image"]],
505
+ ) -> List[Tuple[str, float]]:
506
+ """Run batch inference on multiple images.
507
+
508
+ Args:
509
+ images: List of PIL Images or numpy arrays.
510
+
511
+ Returns:
512
+ List of (label, confidence) tuples.
513
+ """
514
+ if self._model is None:
515
+ raise RuntimeError("Model not initialized. Call build() first.")
516
+
517
+ batch = np.vstack([self._preprocess(img) for img in images])
518
+ probs = self._model.predict(batch, verbose=0)
519
+
520
+ results = []
521
+ for row in probs:
522
+ idx = int(np.argmax(row))
523
+ results.append((self.labels[idx], float(row[idx])))
524
+ return results
525
+
526
+ # ------------------------------------------------------------------
527
+ # Utilities
528
+ # ------------------------------------------------------------------
529
+
530
+ @property
531
+ def keras_model(self) -> tf.keras.Model:
532
+ """Return the underlying tf.keras.Model instance."""
533
+ if self._model is None:
534
+ raise RuntimeError("Model not initialized. Call build() first.")
535
+ return self._model
536
+
537
+ @property
538
+ def parameters(self) -> int:
539
+ """Total number of model parameters."""
540
+ return self.keras_model.count_params()
541
+
542
+ def summary(self) -> None:
543
+ """Print the model architecture summary."""
544
+ self.keras_model.summary()
545
+
546
+
547
+ # ===========================================================================
548
+ # Backward-compatible convenience function
549
+ # ===========================================================================
550
+
551
+
552
+ def build_model(
553
+ input_shape: Tuple[int, int, int] = INPUT_SHAPE,
554
+ num_classes: int = NUM_CLASSES,
555
+ ) -> tf.keras.Model:
556
+ """Construct the architecture and return a raw tf.keras.Model.
557
+
558
+ This is a backward-compatible thin wrapper around
559
+ ``ArchBuildingClassifier.build()``. New code should prefer using
560
+ the class directly for access to ``predict()``, ``from_weights()``,
561
+ and other utilities.
562
+
563
+ Args:
564
+ input_shape: Input tensor shape (default: (320, 320, 3)).
565
+ num_classes: Number of output classes (default: 8).
566
+
567
+ Returns:
568
+ A compiled but untrained tf.keras.Model instance.
569
+ """
570
+ return ArchBuildingClassifier.build(
571
+ input_shape=input_shape, num_classes=num_classes
572
+ ).keras_model
573
+
574
+
575
+ # ===========================================================================
576
+ # CLI entry point
577
+ # ===========================================================================
578
+
579
+ if __name__ == "__main__":
580
+ import argparse
581
+
582
+ parser = argparse.ArgumentParser(
583
+ description="Arch-Building-Image-Classification model loader"
584
+ )
585
+ parser.add_argument(
586
+ "--weights",
587
+ type=str,
588
+ default="fine_tuning_swa.weights.h5",
589
+ help="Path to .weights.h5 file (default: fine_tuning_swa.weights.h5)",
590
+ )
591
+ parser.add_argument(
592
+ "--keras",
593
+ type=str,
594
+ default=None,
595
+ help="Path to .keras file (alternative to --weights)",
596
+ )
597
+ args = parser.parse_args()
598
+
599
+ if args.keras:
600
+ clf = ArchBuildingClassifier.from_keras(args.keras)
601
+ print(f"Loaded from .keras: {args.keras}")
602
+ else:
603
+ clf = ArchBuildingClassifier.from_weights(args.weights)
604
+ print(f"Loaded from weights: {args.weights}")
605
+
606
+ print(f" Parameters: {clf.parameters:,}")
607
+ print(f" Input shape: {clf.input_shape}")
608
+ print(f" Classes: {clf.num_classes} ({', '.join(clf.labels)})")
609
+ print(" Status: Ready for inference.")