AI Hints
Home›Computer Vision Tutorial›Real-World Training
Computer Vision · Lesson 64 of 111

When the Light Changes

The failure that surprises everyone.

⏱ 2 min read▶ Video at 1:40:24🎓 Free
Watch this lesson in the Computer Vision courseWatch on YouTube ↗Full Computer Vision course

AppleEye passed every test in the lab. In the first week on the line, it was wonderful. Then the clocks changed, the evening shift started in darkness, and accuracy collapsed. Nothing had changed in the code. The world changed. That's called domain shift, and it's the number one killer of vision systems.

When the Light Changes
From the video: When the Light Changes
  • It passed every test
  • The clocks changed
  • The world changed, not the code

Testing other conditions

We train normally, then test the same model at different light levels. Run it. At full light, ninety eight percent. At eighty percent light, it's eighty two. At sixty percent light, it's fifty three, which is no better than flipping a coin. Yet a person would hardly notice that change. The model had only ever seen one kind of lighting, so it learned details that depend on brightness. Your test set can't warn you about conditions it doesn't include.

shift.pypython
import torch
from patch import make, net, train, accuracy

torch.manual_seed(0)
model = train(net(), *make(1500))

for light in [1.0, 0.8, 0.6, 1.3]:
    X, y = make(500, seed=9, light=light)
    print(f"Light x{light}: accuracy {accuracy(model, X, y):.3f}")
Output
Light x1.0: accuracy 0.976
Light x0.8: accuracy 0.820
Light x0.6: accuracy 0.526
Light x1.3: accuracy 0.990
Also uses patch.py
import numpy as np
import cv2
import torch

def make(n, seed=0, light=1.0, bruise_rate=0.5):
    """Simulated apple photos. Label 1 means bruised."""
    rng = np.random.default_rng(seed)
    X, y = [], []
    for _ in range(n):
        img = np.full((48, 48, 3), (70, 75, 80), np.uint8)
        c = tuple(int(v) for v in rng.integers(19, 30, 2))
        r = int(rng.integers(15, 20))
        cv2.circle(img, c, r, (35, 40, int(rng.integers(150, 225))), -1, cv2.LINE_AA)
        bad = rng.random() < bruise_rate
        if bad:
            at = (c[0] + int(rng.integers(-8, 9)), c[1] + int(rng.integers(-8, 9)))
            cv2.circle(img, at, int(rng.integers(3, 6)), (25, 45, 70), -1, cv2.LINE_AA)
        img = img * light + rng.normal(0, 8, img.shape)
        X.append(img.clip(0, 255) / 255)
        y.append(int(bad))
    X = torch.tensor(np.array(X), dtype=torch.float32).permute(0, 3, 1, 2)
    return X, torch.tensor(y)

def net():
    return torch.nn.Sequential(
        torch.nn.Conv2d(3, 16, 3, padding=1), torch.nn.ReLU(), torch.nn.MaxPool2d(2),
        torch.nn.Conv2d(16, 32, 3, padding=1), torch.nn.ReLU(), torch.nn.MaxPool2d(2),
        torch.nn.Flatten(), torch.nn.Linear(32 * 12 * 12, 2))

def train(model, X, y, epochs=8):
    opt = torch.optim.Adam(model.parameters(), lr=2e-3)
    for _ in range(epochs):
        for i in torch.randperm(len(X)).split(64):
            loss = torch.nn.functional.cross_entropy(model(X[i]), y[i])
            opt.zero_grad(); loss.backward(); opt.step()
    return model

def accuracy(model, X, y):
    return (model(X).argmax(1) == y).float().mean().item()

Augmentation fixes it

Now we train with varied brightness. Each image is copied, with a random brightness between half and one point four times the original, and half of them flipped. Same model, same data, with no new photos. Run it. Look at the dim light line. Fifty three percent has become ninety seven. And the normal accuracy has even gone up, to ninety nine. We taught it that lighting doesn't matter. Augment for the changes you expect in real life.

augment.pypython
import torch
from patch import make, net, train, accuracy

torch.manual_seed(0)
X, y = make(1500)

gains = torch.empty(len(X), 1, 1, 1).uniform_(0.5, 1.4)
flips = torch.rand(len(X)) < 0.5
Xa = (X * gains).clamp(0, 1)
Xa[flips] = Xa[flips].flip(-1)

model = train(net(), torch.cat([X, Xa]), torch.cat([y, y]))
for light in [1.0, 0.8, 0.6, 1.3]:
    Xt, yt = make(500, seed=9, light=light)
    print(f"Light x{light}: accuracy {accuracy(model, Xt, yt):.3f}")
Output
Light x1.0: accuracy 0.994
Light x0.8: accuracy 0.992
Light x0.6: accuracy 0.966
Light x1.3: accuracy 0.994
Also uses patch.py
import numpy as np
import cv2
import torch

def make(n, seed=0, light=1.0, bruise_rate=0.5):
    """Simulated apple photos. Label 1 means bruised."""
    rng = np.random.default_rng(seed)
    X, y = [], []
    for _ in range(n):
        img = np.full((48, 48, 3), (70, 75, 80), np.uint8)
        c = tuple(int(v) for v in rng.integers(19, 30, 2))
        r = int(rng.integers(15, 20))
        cv2.circle(img, c, r, (35, 40, int(rng.integers(150, 225))), -1, cv2.LINE_AA)
        bad = rng.random() < bruise_rate
        if bad:
            at = (c[0] + int(rng.integers(-8, 9)), c[1] + int(rng.integers(-8, 9)))
            cv2.circle(img, at, int(rng.integers(3, 6)), (25, 45, 70), -1, cv2.LINE_AA)
        img = img * light + rng.normal(0, 8, img.shape)
        X.append(img.clip(0, 255) / 255)
        y.append(int(bad))
    X = torch.tensor(np.array(X), dtype=torch.float32).permute(0, 3, 1, 2)
    return X, torch.tensor(y)

def net():
    return torch.nn.Sequential(
        torch.nn.Conv2d(3, 16, 3, padding=1), torch.nn.ReLU(), torch.nn.MaxPool2d(2),
        torch.nn.Conv2d(16, 32, 3, padding=1), torch.nn.ReLU(), torch.nn.MaxPool2d(2),
        torch.nn.Flatten(), torch.nn.Linear(32 * 12 * 12, 2))

def train(model, X, y, epochs=8):
    opt = torch.optim.Adam(model.parameters(), lr=2e-3)
    for _ in range(epochs):
        for i in torch.randperm(len(X)).split(64):
            loss = torch.nn.functional.cross_entropy(model(X[i]), y[i])
            opt.zero_grad(); loss.backward(); opt.step()
    return model

def accuracy(model, X, y):
    return (model(X).argmax(1) == y).float().mean().item()

Guarding against shift

Here's the habit. Before you collect data, list what might change. Time of day, season, fruit variety, dirty lenses, and a new camera. Collect examples of each one, if you can. Augment for the rest. Build separate test sets for each condition, and report them one by one. A single accuracy number hides exactly the failures that you care about.

List what changeslight, season, variety
Collect widelyacross conditions
Augment the restcheap variety
Test each conditionseparately

Summary

Test across conditions, and augment for reality. Next, another trap. What happens when defects are rare?

Key takeaways
  • Domain shift kills vision systems
  • Test every condition
  • Augmentation teaches invariance
Next up: Rare Defects — when ninety-seven percent means nothing