Skip to main content

vision-anomaly

Spot unusual images against a set of normal ones, without a single labelled defect - show it images you are happy with, then ask it about anything else.

No training, no model download, no OpenCV, no torch. Just numpy and Pillow.

Install

pip install vision-anomaly

Quickstart

import numpy as np
from vision_anomaly import detect_anomalies

rng = np.random.default_rng(0)
good = [rng.normal(120, 4, (96, 96, 3)).clip(0, 255).astype(np.uint8) for _ in range(8)]
odd = good[0].copy()
odd[20:60, 20:60] = [240, 30, 30]          # a red blotch that does not belong

print(detect_anomalies(good, [good[0], odd]).summary())
2 images checked against 8 known-good, threshold 3.00: 1 anomalous
the known-good images themselves score 0.00 typical, 0.02 at worst

<array>: ANOMALOUS (score 64.69, threshold 3.00, confidence 1.00)
compared against 8 known-good images, which themselves score 0.00 typical and 0.02 at worst
what moved:
  colour (64.7 sigmas): the mix of colours in the frame; furthest is colour.chroma.r1c1, 403.0 sigmas above normal at row 1, column 1
  contrast (9.6 sigmas): local contrast within each part of the frame; furthest is contrast.r2c1, 22.6 sigmas above normal at row 2, column 1
  orientation (2.2 sigmas): the directions the edges run in; furthest is orientation.r1c2[0-45deg], 10.3 sigmas above normal at row 1, column 2
where it moved most:
  row 1, column 1      126.5 sigmas  (analysis-grid box x=64 y=64 64x64)
  row 1, column 2      63.2 sigmas  (analysis-grid box x=128 y=64 64x64)
  row 2, column 1      63.1 sigmas  (analysis-grid box x=64 y=128 64x64)

An image is a path, a PIL image or a numpy array. Nothing you pass in is ever written to.

Any integer or float dtype works. The 0.0-1.0 scale an array is put on is read from the values it holds, never from the container they arrived in, so uint8, uint16 and int32 carrying the same pixels give one score. A profile remembers the scale it was fitted on and reads later images the same way; if a known-good set disagrees with itself about its own bit depth, fit says so rather than quietly building a profile that flags nothing.

What it does

fit reduces your known-good images to a profile: the median and a robust spread of a few hundred hand-built numbers. score turns any other image into the same numbers and asks how far outside that spread it lands, in robust sigmas. Nothing is learned, so there is nothing to train and nothing to download.

The numbers come in six families:

family what it measures
colour per-channel histograms of red, green and blue over the frame, plus the mean chroma of each grid cell
edges per cell, the mean gradient magnitude and the share of pixels sitting on an edge
orientation per cell, a gradient-orientation histogram, plus a finer one over the whole frame
brightness per cell, mean luminance
contrast per cell, the spread of luminance
texture per cell, the mean absolute Laplacian - fine detail, which separates a rough surface from a smooth one at the same brightness

216 numbers in all, at the default 4x4 grid. describe_features() prints the table with the counts.

Four decisions are worth knowing about, because they are what makes the scores usable rather than merely computable:

  • Median and MAD, not mean and standard deviation. A folder of good images nearly always has one in it that should not be there, and a single bad image moves a mean and inflates a standard deviation enough to hide every real anomaly behind it.
  • Histograms are interpolated, not hard-binned. A pixel a hair below a bin edge would otherwise land wholly in one bin, and one grey level brighter in the next, so two photographs of the same thing would differ by a whole bin.
  • Every feature has a floor on its spread - 5% of its own level, and an absolute floor below that. A dozen images never show all the variation a camera, a lamp and a hand placing a part will produce.
  • Only the distance past 3 sigmas counts. Summing up the wobble that every clean image has would give them all a floor of about one sigma and leave no room to say "this one is identical". It is why a copy of a fitted image scores near zero - usually 0.0, and never far from it. .fit_worst is the exact figure for your own set: it is the highest any of your known-good images scores against the profile they built.

The score is the worst of the six family scores, and each family's score is the root mean square of how far its features lie outside the band. Averaging the six instead would divide a real colour fault by the five families that are fine.

API

from vision_anomaly import Detector, detect_anomalies

Detector(sensitivity=3.0, *, grid=4, analysis_size=256, max_regions=3, max_reasons=3)

method does
.fit(images) learn normal; returns the detector, so Detector().fit(good) is one expression
.score(image) how far the image sits from normal, in robust sigmas. 0.0 means it landed entirely inside normal
.predict(image) an AnomalyResult
.predict_batch(images) a BatchReport
.save(path) / Detector.load(path) the fitted profile as JSON. Not a pickle: loading it executes nothing
.summary() what this detector was fitted on

images can be a list of paths, PIL images or arrays, a stacked NxHxWx3 array, or the path of a directory.

detect_anomalies(good_images, test_images, sensitivity=3.0, **kw) -> BatchReport does the fit and the check in one call.

AnomalyResult

attribute is
.anomalous True when score >= sensitivity
.score the distance, in robust sigmas
.confidence 0.0 to 1.0, how decisively the score sits on its side of the threshold. 0.5 means it landed on the line - and that is confidence in the verdict, so a clean image is a confident normal
.regions the grid cells that departed most, worst first, as Box objects - so you can see where
.reasons plain sentences naming the families that moved and the single feature that moved furthest
.group_scores every family's score, including the quiet ones
.fit_typical, .fit_worst what the known-good images score against their own profile. The honest yardstick: nothing below fit_worst was stranger than an image you called normal
.notes, .warnings how the image was read, and anything that makes the verdict less trustworthy
.summary(), .to_dict() human text, and JSON-safe data

Box carries .row, .col, .x, .y, .width, .height and .score. The coordinates are pixels on the square analysis grid, so use box.scaled(image.width, image.height) to draw it on your own picture.

BatchReport holds .results, .anomalous (worst first), .normal, .n_anomalous, .scores, .worst, .summary(limit=5), .to_dict() and .to_json(). It iterates and indexes, so for result in report works.

Edge cases, and what they do

you do it does
fit on fewer than 5 images warns that the profile is weak, and still works
fit on 1 image ValueError - one image says nothing about how much variation is normal
mix image sizes resamples everything onto one square grid, and says so in .notes
pass greyscale, RGB or RGBA all fine; grey is carried in three channels, alpha is composited over white
point at a corrupt file ValueError naming the path and what Pillow objected to
score() before fit() RuntimeError telling you to fit or load first
score a copy of a fitted image near zero - 0.0 for most, never above .fit_worst
pass the same pixels as uint8, uint16 or int32 one score; the scale is read from the values, not the container
mix 8-bit and 16-bit images in one fit set warns that the set disagrees about its own depth, and re-reads it all on one scale
run it twice identical numbers; there is no randomness anywhere

CLI

vision-anomaly --good known_good/ today/            # check a folder against another
vision-anomaly --good known_good/ --save-profile belt.json    # fit once, keep the profile
vision-anomaly --profile belt.json today/ --json    # reuse it, as JSON
vision-anomaly --good known_good/ today/ -s 5 -q    # less sensitive, one line per image
vision-anomaly --good known_good/ today/ -r -o report.txt
vision-anomaly --describe-features

Exit codes: 0 nothing flagged, 1 at least one image flagged, 2 the arguments or inputs could not be used. Output is UTF-8 whatever the console claims, so non-ASCII file names do not crash it.

Limits

Read this before trusting a number.

  • It compares hand-built statistics, not learned features. It catches gross departures - the wrong colour, the wrong texture, a missing part, a different scene, a lens change - and it will miss a subtle defect that a trained model would see. A hairline crack across a part occupies a few hundred pixels out of sixty thousand, moves no histogram and barely moves one grid cell's texture. If that is the defect you care about, this package is the wrong tool and no amount of turning sensitivity down will fix it - you will only buy false alarms.
  • The profile only knows what you showed it. If every good image you fitted has the part in the left half and a real good image has it in the right half, that is an anomaly as far as the profile is concerned, and correctly so. Fit on a set that spans the variation you are willing to accept.
  • Regions are grid cells, not outlines. At the default 4x4 they say "the middle right of the picture". Raise grid for a finer answer, at the cost of tolerating less camera movement.
  • sensitivity is a threshold on a robust distance, not a probability. 3.0 is a starting point. Check .fit_worst first: if your own known-good images score 2.5 against their own profile, a threshold of 3.0 is already tight.
  • Images are stretched onto a square. Layout is compared as proportions, so a 16:9 frame and a 4:3 frame of the same scene line up cell for cell. If your set is all one shape, nothing is lost.

License

MIT

Metadata

Release files for vision-anomaly 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for vision-anomaly 0.1.0
File Size Uploaded
vision_anomaly-0.1.0.tar.gz 56.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for vision-anomaly 0.1.0
File Interpreter ABI Platform
vision_anomaly-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 100.1 kB

Release files / vision_anomaly-0.1.0.tar.gz

Download URL vision_anomaly-0.1.0.tar.gz
Size 56.1 kB
Tags Source
SHA-256 checksum
How to use checksums
70524892a09d7bd504b7f751a3c97b1803274129c7b70947a363dc4e8d3458d8
BLAKE2b-256 checksum
How to use checksums
249b38eed435677cd9920add6a5efd2f832c45da800160cf7f28bea33614fd33
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / vision_anomaly-0.1.0-py3-none-any.whl

Download URL vision_anomaly-0.1.0-py3-none-any.whl
Size 44.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8f4ea15795951ae43d274a4402de42330b3ead9cfa3e525ebad71c7a1659ce33
BLAKE2b-256 checksum
How to use checksums
1ce23c8473132d5de1b1dbca3a924e7fed2138bf3e9aae7d337bdc6f969633eb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page