Need help with using custom classification model in sentis

I’m very new to neural networks and Sentis. I made a python training script with help of ai that uses pretrained MobileNetV2 on my custom dataset. This dataset only has 3 categories which is black drawings on white background with 100 images in each. Training works and Ive tested model succesfully on the dataset itself and with small python drawing script. But when using it with unity sentis I’m getting same results no matter what images I use. If someone knows what could be the reason for this please let me know, I’ve spent almost 5 days already trying different things without any success.
Here’s my training script:

import os
import json
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms, models
import numpy as np

DATASET_DIR = "dataset"
OUTPUT_DIR = "output"
IMAGE_SIZE = 224
BATCH_SIZE = 32
EPOCHS = 10
LEARNING_RATE = 0.0001

transform = transforms.Compose([
    transforms.Resize((IMAGE_SIZE, IMAGE_SIZE)),
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])

def train():
    os.makedirs(OUTPUT_DIR, exist_ok=True)
    
    dataset = datasets.ImageFolder(DATASET_DIR, transform=transform)
    loader = DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=True)
    
    num_classes = len(dataset.classes)
    print(f"Classes: {dataset.classes}")
    print(f"Samples: {len(dataset)}\n")
    
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    print(f"Device: {device}\n")
    
    model = models.mobilenet_v2(weights='IMAGENET1K_V1')
    model.classifier[1] = nn.Linear(model.classifier[1].in_features, num_classes)
    model = model.to(device)
    
    optimizer = optim.Adam(model.parameters(), lr=LEARNING_RATE)
    criterion = nn.CrossEntropyLoss()
    
    print("Training...")
    for epoch in range(EPOCHS):
        model.train()
        correct = 0
        total = 0
        
        for images, labels in loader:
            images, labels = images.to(device), labels.to(device)
            
            optimizer.zero_grad()
            outputs = model(images)
            loss = criterion(outputs, labels)
            loss.backward()
            optimizer.step()
            
            correct += (outputs.argmax(1) == labels).sum().item()
            total += labels.size(0)
        
        accuracy = correct / total
        print(f"Epoch {epoch+1}/{EPOCHS} - Accuracy: {accuracy:.3f}")
    
    torch.save(model.state_dict(), "model.pth")
    print("\n✓ Training complete")
    
    export_to_onnx(model, dataset.classes)

def export_to_onnx(model, class_names):
    print("\n" + "="*70)
    print("EXPORTING TO ONNX")
    print("="*70)
    
    # Load and prepare model
    model.load_state_dict(torch.load("model.pth", weights_only=True))
    model.eval()
    model.cpu()

    # =========================================================================
    # Wrap with normalization
    # =========================================================================
    print("Creating normalization wrapper...")
    
    class NormWrapper(nn.Module):
        def __init__(self, model):
            super().__init__()
            self.model = model
            self.register_buffer("mean", torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1))
            self.register_buffer("std", torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1))

        def forward(self, x):
            x = (x - self.mean) / self.std
            return self.model(x)

    wrapped = NormWrapper(model)
    wrapped.eval()

    print("✓ Model wrapped")
    
    # =========================================================================
    # Test PyTorch model
    # =========================================================================
    print("\nTesting PyTorch model...")
    
    test_input = torch.ones(1, 3, IMAGE_SIZE, IMAGE_SIZE)
    
    with torch.no_grad():
        pytorch_out = wrapped(test_input)
    
    print(f"PyTorch output: {pytorch_out[0].numpy()}")
    
    if torch.isnan(pytorch_out).any() or torch.isinf(pytorch_out).any():
        print("❌ ERROR: PyTorch output has NaN/Inf!")
        return
    
    if pytorch_out.abs().max() > 100:
        print(f"❌ ERROR: PyTorch has extreme values ({pytorch_out.abs().max():.2f})!")
        return
    
    print("✓ PyTorch test passed")
    
    # =========================================================================
    # Export to ONNX
    # =========================================================================
    print("\nExporting to ONNX...")
    
    dummy_input = torch.rand(1, 3, IMAGE_SIZE, IMAGE_SIZE)
    
    torch.onnx.export(
        wrapped,
        dummy_input,
        os.path.join(OUTPUT_DIR, "model.onnx"),
        input_names=["input"],
        output_names=["output"],
        opset_version=25,
        dynamo=True
    )
    
    print("✓ ONNX exported")
    
    # =========================================================================
    # Verify ONNX matches PyTorch
    # =========================================================================
    print("\nVerifying ONNX export...")
    
    try:
        import onnxruntime as ort
        
        session = ort.InferenceSession(
            os.path.join(OUTPUT_DIR, "model.onnx"),
            providers=['CPUExecutionProvider']
        )
        
        # Test with same input
        test_np = test_input.numpy()
        onnx_out = session.run(None, {'input': test_np})[0]
        
        pytorch_np = pytorch_out.numpy()
        diff = np.abs(pytorch_np - onnx_out).max()
        
        print(f"\nComparison:")
        print(f"  PyTorch: {pytorch_np[0]}")
        print(f"  ONNX:    {onnx_out[0]}")
        print(f"  Max diff: {diff:.8f}")
        
        if diff > 0.01:
            print(f"\n❌ ERROR: Outputs don't match (diff={diff})!")
            print("ONNX export is broken - DO NOT use in Unity!")
            return
        
        if np.abs(onnx_out).max() > 100:
            print(f"\n❌ ERROR: ONNX has extreme values!")
            return
        
        print("\n✅ ONNX verification PASSED")
        print("✅ Safe to import to Unity")
        
    except ImportError:
        print("\n⚠ WARNING: onnxruntime not installed")
        print("Cannot verify export - install with: pip install onnxruntime")
    except Exception as e:
        print(f"\n❌ ERROR during verification: {e}")
        return
    
    # Save labels
    with open(os.path.join(OUTPUT_DIR, "labels.json"), "w") as f:
        json.dump(class_names, f)
    
    print(f"\n{'='*70}")
    print("EXPORT COMPLETE")
    print(f"{'='*70}")
    print(f"✓ Model: {OUTPUT_DIR}/model.onnx")
    print(f"✓ Labels: {OUTPUT_DIR}/labels.json")
    print(f"✓ Model expects 0-1 input (normalization built-in)")
    print(f"{'='*70}")

if __name__ == "__main__":
    train()

And unity script:

    [SerializeField] private Texture2D _testImage1 = default!;
    [SerializeField] private Texture2D _testImage2 = default!;
    [SerializeField] private Texture2D _testImage3 = default!;

    [SerializeField]
    private ModelAsset _modelAsset = default!;

    private Worker _worker = default!;
    private Tensor<float> _inputTensor = default!;
    private readonly string[] _classNames = ["earth", "fire", "water"];

    private const int IMAGE_SIZE = 224;

    private void Start()
    {
        var sourceModel = ModelLoader.Load(_modelAsset);
        _worker = new Worker(sourceModel, BackendType.GPUCompute);
        _inputTensor = new Tensor<float>(new TensorShape(1, 3, IMAGE_SIZE, IMAGE_SIZE));

        TestImage(_testImage1, "Image 1");
        TestImage(_testImage2, "Image 2");
        TestImage(_testImage3, "Image 3");
    }

    private void TestImage(Texture2D texture, string name)
    {
        TextureConverter.ToTensor(texture, _inputTensor);
        _worker.Schedule(_inputTensor);

        var output = _worker.PeekOutput("output") as Tensor<float>;
        var logits = output!.DownloadToArray();
        output.Dispose();

        Debug.Log($"=== Testing {name} === \nLogits: {string.Join(", ", logits.Select(v => v.ToString("F4")))} \nPredicted: {_classNames[Array.IndexOf(logits, logits.Max())]}");
    }

This gives almost identical logits on 3 different images (imported to unity from dataset)

Might not be your Unity code, it seems correct in terms of normalization / input ranges.

Switching from this:
torch.onnx.export(..., opset_version=25, dynamo=True)

to:
torch.onnx.export(..., opset_version=19, dynamo=False)

seemed to fixed it.

Not sure if it’s a Sentis limitation or some issue with the onnx export using the more modern dynamo exporter.

(tested with Sentis v2.6.1)

this was chosen arbitrarily, I didn’t check which would be the newest onnx opset version that the legacy TorchScript-based exporter would support. 25 did not work for me.

Can’t believe it but it’s working. Thank you @julienkay. I actually tried 15, 18, 25 with old and new exporter and nothing was working and at some point I just started to think that problem is somewhere else. Thank you again. Btw can I ask you a few questions about classification nn while you here? (if you have the some experience with them)

i actually don’t.
but feel free to go ahead, can’t hurt to try. maybe someone else can chime in as well

When I was asking ai about my usecase it said that it’s better to use model with pretrained weights and just retrain latest layers on my dataset than training it from zero. And I was wondering if it still would make learning better even if my training data is very different from what it was pretrained with. If I understand correctly these various classification models like MobileNet or ResNet are trained on IMAGENET1K_V1 dataset which contains colored photos with categories like car, boat, mountain etc. My dataset it black&white drawings of simple symbols like digits or geometrical figures. Whould pretrained model still perform better on the drawings?

My surface-level understanding is that this is basically the Bitter Lesson
general methods + more compute/data beat handcrafted assumptions. Pretraining is just leveraging compute/data someone else already paid for.

So if your end goal is to have a model to practically use, you’ll have a hard time training a model from scratch that is equal or better in all dimensions that you care about compared to using a pretrained model (quality (accuracy) / model size / inference speed / compute & data requirements for training).

I feel like going down the rabbit hole of training from scratch is valid if your interest is more academic and you want to learn more about NN architectures, training pipelines, machine learning in general, etc., or optimize for one specific dimension.

Essentially the same as using Unity vs writing your own engine (do you want to make a game or be an engine programmer? :grinning_face_with_smiling_eyes:)

Earlier layers in pre-trained models learn features like edges, corners and shapes which might be beneficial for the dataset you are trying to train on. IMAGENET contains images with everyday objects in it and is usually used for benchmarking vision models or training object detection models so the dataset domain is slightly different from what you are trying to train on. You might need to re-train (fine-tune) more layers than just the last few layers but fine-tuning is surely the way to go in my opinion.

Cheers.