Custom model architectures
Ais loads its network architectures at startup — the ones that ship with it, plus any you add yourself. Your own architectures go in the user models directory:
- Windows:
C:\Users\<you>\.Ais\models - Linux:
~/.Ais/models
(The built-in models live inside the installed package, which is often read-only — on a cluster, for example — so your own models go in this user directory instead.) You can drop a .py file there directly, or install one through the GUI: Settings → Model settings → Model library → Install a model opens a file browser and copies the .py in for you. The same menu lists the models you have installed and lets you reload or delete them.
Every model file defines three things:
title— the name shown in the model dropdown.include— a boolean; set it toFalseto keep the model out of the GUI.create(input_shape)— a function returning the model object.input_shapeis(box_size, box_size, box_depth).
Keras models
Most built-in models are plain tensorflow.keras models, which is what Ais expects by default. create builds one and returns it, compiled:
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Input, Conv2D
from tensorflow.keras.optimizers import Adam
title = "My model" # shown in the model dropdown
include = True # set to False to hide it
def create(input_shape):
inputs = Input(input_shape)
x = Conv2D(32, 3, activation="relu", padding="same")(inputs)
outputs = Conv2D(1, 1, activation="sigmoid", padding="same")(x)
model = Model(inputs, outputs)
model.compile(optimizer=Adam(), loss="binary_crossentropy")
return model
The output must be a single-channel map with the same width and height as the input, and values in 0–1 (a sigmoid activation). The built-in architectures are written the same way — you can read them in the Ais/models directory of the source.
Non-Keras models
create can return any object, not just a Keras model. Ais drives it exactly as it drives a Keras model, so the object has to implement the methods Ais calls, with matching signatures:
count_params()— the parameter count (inference parameters only; a GAN, for instance, would exclude its discriminator).fit(...)— called askeras.Model.fitis: a data generator plussteps_per_epoch,validation_data,epochs, andcallbacks.predict(images)— inference on a batch of boxes.save(path)— writes the model.
For loading a saved model back, there are two options. By default Ais reloads it with Keras's load_model, so save must produce a Keras-loadable file. pix2pix.py works this way: it wraps a generator and a discriminator in one class for training, and saves the generator as a plain Keras model for inference. Alternatively, the model file can define a module-level load_model(path) function. If it does, Ais calls it instead, and hands it whatever file your object's save(path) wrote — which frees the model from Keras entirely and makes other machine learning backends possible.
Two things to be aware of:
- A model saved by an architecture with its own
load_modelonly loads on machines where that.py(and its backend) is installed — unlike Keras models, the.scnmis not self-contained. - Ais passes Keras callback objects to
fitfor the training progress readout and the stop button. Afitthat ignores thecallbacksargument trains fine, but those GUI elements will not respond; to support them, call the callbacks'on_batch_end/on_epoch_endfrom your training loop and stop whenstop_traininghas been set on your model object.
Example: VGGNet M in PyTorch
The code below implements the same VGGNet M network that is already available in Ais, but in PyTorch instead of tensorflow.
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
title = "VGGNet M torch"
include = True
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
class VGGNetM(nn.Module):
# the same layers as the Keras VGGNet M, in NCHW
def __init__(self, in_channels):
super().__init__()
self.enc = nn.Sequential(
nn.Conv2d(in_channels, 128, 3, padding=1), nn.ReLU(),
nn.Conv2d(128, 128, 3, padding=1), nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(128, 256, 3, padding=1), nn.ReLU(),
nn.Conv2d(256, 256, 3, padding=1), nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(256, 512, 3, padding=1), nn.ReLU(),
nn.Conv2d(512, 512, 3, padding=1), nn.ReLU(),
nn.MaxPool2d(2),
)
self.dec = nn.Sequential(
nn.ConvTranspose2d(512, 128, 2, stride=2),
nn.Conv2d(128, 128, 3, padding=1), nn.ReLU(),
nn.ConvTranspose2d(128, 64, 2, stride=2),
nn.Conv2d(64, 64, 3, padding=1), nn.ReLU(),
nn.ConvTranspose2d(64, 1, 2, stride=2),
nn.Conv2d(1, 1, 1),
)
def forward(self, x):
return torch.sigmoid(self.dec(self.enc(x)))
def masked_bce(y_pred, y_true, ignore_label=2.0, epsilon=1e-6):
mask = (y_true != ignore_label).float()
y_clean = torch.where(y_true == ignore_label, torch.zeros_like(y_true), y_true)
y_pred = y_pred.clamp(1e-7, 1.0 - 1e-7)
bce = F.binary_cross_entropy(y_pred, y_clean, reduction="none")
return (bce * mask).sum() / (mask.sum() + epsilon)
class _LearningRate:
# Ais sets the rate via model.optimizer.learning_rate.assign(value)
def __init__(self, opt):
self._opt = opt
def assign(self, value):
for g in self._opt.param_groups:
g["lr"] = float(value)
class _Optimizer:
def __init__(self, opt):
self.learning_rate = _LearningRate(opt)
class TorchWrapper:
def __init__(self, input_shape):
h, w, d = input_shape
self.input_shape = (None, h, w, d) # rank 4, channels-last, as Ais expects
self.output_shape = (None, h, w, 1)
self.net = VGGNetM(d).to(DEVICE)
self._opt = torch.optim.Adam(self.net.parameters(), lr=1e-3)
self.optimizer = _Optimizer(self._opt)
self.loss = masked_bce
self.stop_training = False
def compile(self, **kwargs):
pass # Ais re-compiles with its own metrics; nothing to do here
def count_params(self):
return sum(p.numel() for p in self.net.parameters())
def _to_torch(self, x):
if x.ndim == 3:
x = x[..., np.newaxis]
return torch.from_numpy(np.ascontiguousarray(x, dtype=np.float32)).permute(0, 3, 1, 2).to(DEVICE)
def fit(self, generator, steps_per_epoch=1, validation_data=None, validation_steps=None,
epochs=1, callbacks=None, **kwargs):
callbacks = callbacks or []
self.stop_training = False
for cb in callbacks:
cb.set_model(self)
cb.set_params({"steps": steps_per_epoch, "epochs": epochs})
cb.on_train_begin()
it = iter(generator)
for epoch in range(epochs):
self.net.train()
for cb in callbacks:
cb.on_epoch_begin(epoch)
loss_value = 0.0
for step in range(steps_per_epoch):
try:
x, y = next(it)
except StopIteration:
it = iter(generator)
x, y = next(it)
x, y = self._to_torch(x), self._to_torch(y)
self._opt.zero_grad()
loss = masked_bce(self.net(x), y)
loss.backward()
self._opt.step()
loss_value = loss.item()
for cb in callbacks:
cb.on_batch_end(step, logs={"loss": loss_value})
if self.stop_training:
return
logs = {"loss": loss_value}
if validation_data is not None:
self.net.eval()
val_losses = []
val_it = iter(validation_data)
with torch.no_grad():
for _ in range(validation_steps or 1):
try:
x, y = next(val_it)
except StopIteration:
break
val_losses.append(masked_bce(self.net(self._to_torch(x)), self._to_torch(y)).item())
if val_losses:
logs["val_loss"] = float(np.mean(val_losses))
for cb in callbacks:
cb.on_epoch_end(epoch, logs=logs)
def predict(self, images, verbose=0):
self.net.eval()
with torch.no_grad():
out = self.net(self._to_torch(np.asarray(images)))
return out.permute(0, 2, 3, 1).cpu().numpy()
# weight round-trip for Ais' continue-training path
def get_weights(self):
return [v.detach().cpu().numpy() for v in self.net.state_dict().values()]
def set_weights(self, weights):
sd = self.net.state_dict()
self.net.load_state_dict({k: torch.as_tensor(w) for k, w in zip(sd.keys(), weights)})
def save(self, path):
torch.save({"input_shape": self.input_shape[1:], "state_dict": self.net.state_dict()}, path)
@classmethod
def load(cls, path):
data = torch.load(path, map_location="cpu", weights_only=True)
wrapper = cls(tuple(data["input_shape"]))
wrapper.net.load_state_dict(data["state_dict"])
wrapper.net.to(DEVICE).eval()
return wrapper
def create(input_shape):
return TorchWrapper(input_shape)
def load_model(path):
return TorchWrapper.load(path)