Visual Pattern Anomaly Detector

Detect defective patterns in image grids using the Confident Ravens algorithm.

Introduction

30 Second Summary

Imagine a factory floor where thousands of tiles roll off the production line every hour. A human inspector might catch an obvious crack, but subtle shifts in pattern alignment or missing elements slip through unnoticed.

In this project, you will build a visual anomaly detector that uses confidence-driven reasoning to identify defective patterns in image grids. Your detector applies principles from an academic AI paper to a practical quality inspection problem.

What You'll Build

A Python anomaly detection system that generates grids of geometric patterns, introduces controlled defects, then uses multi-resolution similarity analysis and statistical confidence to determine which pattern breaks the expected visual relationships.

By the end of this project, you'll have:

  • A pattern generator that creates grids of geometric shapes and injects controlled anomalies like shifted positions, missing elements, and size changes.
  • A multi-resolution feature extractor and Tversky similarity engine that compares visual relationships between grid cells at multiple abstraction levels.
  • A confidence-based detection engine that statistically identifies anomalous patterns and produces annotated heatmaps showing detected defects with confidence scores.
  • Secret Mission: Extend the detector to handle multi-defect grids where multiple anomalies exist simultaneously, requiring ambiguity resolution at different abstraction levels.

Are there any prerequisites?

You should be comfortable with Python basics like functions, loops, and data structures. Beginner-level image processing experience is fine. The project walks you through everything else.

Before We Start

Before writing any detection code, you need a working Python environment with the right libraries installed.

In this step, you will install three Python libraries, verify they work, and create the project file where all your code will live.

In this step, get ready to:
  • Install NumPy, Pillow, and Matplotlib.
  • Verify all three libraries import correctly.
  • Create the project folder and main Python file.
Check your Python version

This project requires Python 3.11 or higher because NumPy 2.4.6 depends on it.

  • Open a terminal in your code editor.
  • Check your Python version by running this command:

macOS

python3 --version

Windows

python --version

You should see a version number of 3.11 or higher in the output.

Seeing a version lower than 3.11?

NumPy 2.4.6 requires Python 3.11 or above. If you see an older version, download and install the latest Python from python.org.

On macOS, the system default is often an older version. After installing a newer Python, use python3.11 or python3.12 directly instead of python3.

help me upgrade Python to 3.11 or higher

Install the required libraries

Your anomaly detector uses three libraries. NumPy handles numerical arrays. Pillow creates and manipulates images. Matplotlib generates the final heatmap visualizations.

  • Install all three libraries by running this command:
pip install numpy==2.4.6 pillow==12.2.0 matplotlib==3.10.9

What does this command do?

The pip install command downloads and installs Python packages from PyPI (the Python Package Index).

The == pins each library to a specific version. This ensures your code behaves exactly as expected regardless of when you run the install.

You should see download progress bars followed by a success message for each package.

Seeing a permission error or command not found?

If you see a permissions error, try adding --user to the end of the command. If pip is not found, try pip3 instead.

help me fix my pip install error

Verify your installations

A quick import test confirms all three libraries are accessible from Python.

  • Verify all three libraries import correctly by running this command:

macOS

python3 -c "import numpy; import PIL; import matplotlib; print('All libraries installed successfully!')"

Windows

python -c "import numpy; import PIL; import matplotlib; print('All libraries installed successfully!')"

You should see All libraries installed successfully! printed in your terminal.

Seeing a ModuleNotFoundError?

This means the library did not install to the same Python that is running the command. Make sure the python3 (or python) that runs the check matches the pip you used for installation.

help me fix ModuleNotFoundError after pip install

Create the project folder and file

Now that your libraries are ready, create a dedicated folder for your project.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir anomaly-detector
cd anomaly-detector
  • Create the main Python file by running this command:

macOS

touch anomaly_detector.py

Windows

New-Item anomaly_detector.py
  • Confirm the file exists by running:
ls

You should see anomaly_detector.py in the output.

  • Open the anomaly-detector folder in your code editor so that anomaly_detector.py is visible in the sidebar.

Don't see the file?

Make sure you ran the touch (macOS) or New-Item (Windows) command from inside the anomaly-detector folder. Run pwd (macOS) or cd (Windows) to check your current directory.

help me find where my file was created

Your environment is ready. Next up, you will build the pattern generator that creates grids of geometric shapes for the detector to analyze.

Build the Pattern Generator

Your Python environment is ready with all the libraries installed. Now it's time to build the core of your anomaly detector: the pattern generator.

Think of a factory floor where identical tiles roll off a production line. Each tile should look the same. Your pattern generator creates these "perfect" tiles as geometric shapes, then introduces controlled defects to simulate real-world manufacturing errors.

In this step, get ready to:
  • Create a function that draws geometric patterns (circles, squares, crosses) using Pillow.
  • Build a grid generator that assembles patterns and introduces controlled anomalies.
  • Test your generator by saving a sample grid as a PNG.
Add the project imports

Before writing any functions, your script needs access to the libraries you installed in the previous step. Each import serves a specific role in the detection pipeline.

  • Add the following imports to the top of anomaly_detector.py by copying this code:
import numpy as np
from PIL import Image, ImageDraw
import matplotlib.pyplot as plt
from matplotlib.colors import Normalize
from matplotlib.cm import ScalarMappable

What do these imports do?

  • numpy handles numerical array operations when you convert images to pixel data later.
  • PIL.Image and PIL.ImageDraw let you create blank images and draw shapes on them.
  • matplotlib.pyplot powers the visualization heatmaps you'll build in a later step.
  • Normalize and ScalarMappable help scale color values in those heatmaps.
  • Save anomaly_detector.py.

You won't see a change yet. The next chunk uses these imports to draw shapes.

Create the geometric pattern function

Each cell in your inspection grid needs a single geometric shape drawn on a white background. This function acts like a stamp that prints one shape at a time.

  • In anomaly_detector.py, add the following function below your imports:
# =============================================================================
# Pattern Generator
# =============================================================================

def generate_pattern(size, pattern_type, offset=(0, 0), scale=1.0):
    """Generate a single geometric pattern on a white background."""
    img = Image.new("L", (size, size), 255)
    draw = ImageDraw.Draw(img)
    center_x = size // 2 + offset[0]
    center_y = size // 2 + offset[1]
    radius = int((size // 4) * scale)

    if pattern_type == "circle":
        draw.ellipse(
            [center_x - radius, center_y - radius,
             center_x + radius, center_y + radius],
            fill=0
        )
    elif pattern_type == "square":
        draw.rectangle(
            [center_x - radius, center_y - radius,
             center_x + radius, center_y + radius],
            fill=0
        )
    elif pattern_type == "cross":
        arm_width = radius // 3
        draw.rectangle(
            [center_x - arm_width, center_y - radius,
             center_x + arm_width, center_y + radius],
            fill=0
        )
        draw.rectangle(
            [center_x - radius, center_y - arm_width,
             center_x + radius, center_y + arm_width],
            fill=0
        )
    return img

What does this code do?

This function creates a single tile with one geometric shape drawn in black on a white background.

  • Image.new("L", (size, size), 255) creates a grayscale image filled with white (255 = white in grayscale).
  • ImageDraw.Draw(img) gives you a drawing surface to place shapes on.
  • The offset parameter shifts the shape away from center. This is how you'll simulate a "shifted" defect later.
  • The scale parameter shrinks or grows the shape. A scale of 0.5 makes a shape half its normal size, simulating a "scale" defect.
  • Save anomaly_detector.py.

You won't see a visual change yet. The next function uses generate_pattern to assemble a full grid.

Add the grid generator with anomaly injection

A single tile isn't enough for anomaly detection. You need a grid of tiles where most look identical but one or more contain a defect. This function assembles the grid and injects anomalies at positions you specify.

  • In anomaly_detector.py, add the following function below generate_pattern:
def generate_grid(grid_size, cell_size, pattern_type, anomaly_positions=None,
                  anomaly_type="shift"):
    """Generate a grid of patterns with optional anomalies."""
    grid_img = Image.new("L", (grid_size * cell_size, grid_size * cell_size), 255)
    cells = []

    for row in range(grid_size):
        row_cells = []
        for col in range(grid_size):
            is_anomaly = (anomaly_positions is not None and
                          (row, col) in anomaly_positions)

            if is_anomaly:
                if anomaly_type == "shift":
                    cell = generate_pattern(cell_size, pattern_type,
                                            offset=(cell_size // 6, cell_size // 6))
                elif anomaly_type == "scale":
                    cell = generate_pattern(cell_size, pattern_type, scale=0.5)
                elif anomaly_type == "missing":
                    cell = Image.new("L", (cell_size, cell_size), 255)
                else:
                    cell = generate_pattern(cell_size, pattern_type,
                                            offset=(cell_size // 6, 0))
            else:
                cell = generate_pattern(cell_size, pattern_type)

            grid_img.paste(cell, (col * cell_size, row * cell_size))
            row_cells.append(np.array(cell))
        cells.append(row_cells)

    return grid_img, cells

What does this code do?

This function builds a full inspection grid by stamping patterns into a larger image. It returns both the composite image and the raw pixel data for each cell.

  • For normal cells, it calls generate_pattern with default parameters (centered, full scale).
  • For anomaly cells, it applies a defect: "shift" moves the shape off-center, "scale" shrinks it to half size, and "missing" leaves the cell blank.
  • np.array(cell) converts each Pillow image to a NumPy array so later steps can do math on the pixel values.
  • Save anomaly_detector.py.
  • Test your generator by running this command in the terminal:
python -c "from anomaly_detector import generate_grid; img, _ = generate_grid(3, 64, 'circle', [(2,2)], 'shift'); img.save('test_grid.png'); print('Saved test_grid.png')"
  • Confirm the file was created by running:
ls test_grid.png

You should see test_grid.png listed in the output. Open it in your file browser or image viewer to see a 3x3 grid of circles where the bottom-right circle is shifted off-center.

Don't see the file?

  • Make sure you saved anomaly_detector.py before running the test command.
  • Check that your terminal is in the same folder as anomaly_detector.py. Run ls anomaly_detector.py to confirm.
  • If you see an ImportError, confirm your imports match exactly (especially from PIL import Image, ImageDraw).

Help me debug why my pattern generator isn't saving the PNG file.

✔️ Awesome, I've got everything!

Great. Double check you've saved anomaly_detector.py before moving on.

ⓧ I'd like to double check the full code

Here's what your anomaly_detector.py should look like at this point:

import numpy as np
from PIL import Image, ImageDraw
import matplotlib.pyplot as plt
from matplotlib.colors import Normalize
from matplotlib.cm import ScalarMappable


# =============================================================================
# Pattern Generator
# =============================================================================

def generate_pattern(size, pattern_type, offset=(0, 0), scale=1.0):
    """Generate a single geometric pattern on a white background."""
    img = Image.new("L", (size, size), 255)
    draw = ImageDraw.Draw(img)
    center_x = size // 2 + offset[0]
    center_y = size // 2 + offset[1]
    radius = int((size // 4) * scale)

    if pattern_type == "circle":
        draw.ellipse(
            [center_x - radius, center_y - radius,
             center_x + radius, center_y + radius],
            fill=0
        )
    elif pattern_type == "square":
        draw.rectangle(
            [center_x - radius, center_y - radius,
             center_x + radius, center_y + radius],
            fill=0
        )
    elif pattern_type == "cross":
        arm_width = radius // 3
        draw.rectangle(
            [center_x - arm_width, center_y - radius,
             center_x + arm_width, center_y + radius],
            fill=0
        )
        draw.rectangle(
            [center_x - radius, center_y - arm_width,
             center_x + radius, center_y + arm_width],
            fill=0
        )
    return img


def generate_grid(grid_size, cell_size, pattern_type, anomaly_positions=None,
                  anomaly_type="shift"):
    """Generate a grid of patterns with optional anomalies."""
    grid_img = Image.new("L", (grid_size * cell_size, grid_size * cell_size), 255)
    cells = []

    for row in range(grid_size):
        row_cells = []
        for col in range(grid_size):
            is_anomaly = (anomaly_positions is not None and
                          (row, col) in anomaly_positions)

            if is_anomaly:
                if anomaly_type == "shift":
                    cell = generate_pattern(cell_size, pattern_type,
                                            offset=(cell_size // 6, cell_size // 6))
                elif anomaly_type == "scale":
                    cell = generate_pattern(cell_size, pattern_type, scale=0.5)
                elif anomaly_type == "missing":
                    cell = Image.new("L", (cell_size, cell_size), 255)
                else:
                    cell = generate_pattern(cell_size, pattern_type,
                                            offset=(cell_size // 6, 0))
            else:
                cell = generate_pattern(cell_size, pattern_type)

            grid_img.paste(cell, (col * cell_size, row * cell_size))
            row_cells.append(np.array(cell))
        cells.append(row_cells)

    return grid_img, cells

Your pattern generator is working. Next up, you'll build the feature extractor that converts these visual patterns into mathematical sets the algorithm can reason about.

Implement Multi-Resolution Feature Extraction

Your grid of patterns is ready, but right now each cell is just a flat array of pixel values. To detect subtle anomalies, the algorithm needs to compare patterns at different scales.

The Confident Ravens paper examines images at progressively coarser resolutions, from full detail down to a handful of regions. A defect invisible at one scale may stand out at another. In this step, you will build the multi-resolution feature extractor that makes this possible.

In this step, get ready to:
  • Build a feature extractor that partitions grid cells into sub-grids and produces binary feature sets.
  • Define the abstraction level sequence that halves resolution from full-size down to 4 pixels.
  • Test the extractor on one grid cell and confirm feature counts at each level.
Build the binary feature extractor

The paper uses a "mutual fractal representation" that partitions images into gridded sections. Your implementation simplifies this into binary feature sets: each sub-region is either "dark" (below a brightness threshold) or "light" (above it).

  • In anomaly_detector.py, scroll to the bottom of the file (below the generate_grid function).
  • Add the multi-resolution feature extraction section by pasting this code:
# =============================================================================
# Multi-Resolution Feature Extraction
# =============================================================================

def extract_features_at_level(cell_array, grid_level):
    """Extract binary feature set from a cell at a given grid partitioning level."""
    h, w = cell_array.shape
    features = set()

    step_y = max(1, h // grid_level)
    step_x = max(1, w // grid_level)

    for gy in range(grid_level):
        for gx in range(grid_level):
            y_start = gy * step_y
            y_end = min((gy + 1) * step_y, h)
            x_start = gx * step_x
            x_end = min((gx + 1) * step_x, w)

            region = cell_array[y_start:y_end, x_start:x_end]
            if region.size > 0:
                mean_val = np.mean(region)
                if mean_val < 128:
                    features.add((gy, gx))

    return features

What does this code do?

This function takes a single cell (as a NumPy array) and a grid partitioning level (e.g. 32, 16, 8).

  • It divides the cell into a grid of grid_level × grid_level sub-regions.
  • For each sub-region, it computes the mean pixel brightness.
  • If the mean is below 128 (darker than mid-gray), that region's coordinates are added to the feature set.
  • The result is a Python set of (row, col) tuples representing where dark content exists at that resolution.
  • Save anomaly_detector.py.

Seeing an IndentationError?

Make sure the function body is indented with 4 spaces, not tabs. Also confirm there is a blank line between the section comment and the def line.

Help me fix my IndentationError

Define the abstraction level sequence

The algorithm needs to know which resolutions to examine. Following the paper's approach, you will halve the partition size at each step, starting at the cell's full dimension and stopping at 4 pixels.

  • Below extract_features_at_level, add this helper function:
def get_abstraction_levels(cell_size):
    """Determine abstraction levels by halving from max dimension down to 4."""
    levels = []
    level = cell_size
    while level >= 4:
        levels.append(level)
        level = level // 2
    return levels

What does this code do?

For a 64px cell, this returns [64, 32, 16, 8, 4]. Each value represents a grid partitioning level that the algorithm will examine.

Coarser levels (like 4) split the cell into just 4×4 = 16 regions. Finer levels (like 64) check every single pixel. The algorithm starts coarse and works toward fine detail, looking for the first level where it can confidently identify an anomaly.

  • Save anomaly_detector.py.

Function not showing up when you call it?

Make sure you placed get_abstraction_levels outside and below the extract_features_at_level function, not nested inside it. Both functions should start at column 0 (no indentation).

Help me debug my misplaced function

Test the extractor on one grid cell

Before building the rest of the pipeline, confirm that the feature extractor produces meaningful output at each resolution level.

  • Test the extractor by running this command in your terminal:
python -c "
import numpy as np
from anomaly_detector import generate_pattern, extract_features_at_level, get_abstraction_levels
cell = np.array(generate_pattern(64, 'circle'))
levels = get_abstraction_levels(64)
for l in levels:
    features = extract_features_at_level(cell, l)
    print(f'Level {l}px: {len(features)} features')
"

What should I see?

You should see 5 lines of output, one for each abstraction level (64, 32, 16, 8, 4). The feature count decreases at coarser levels because larger sub-regions average out fine detail.

At the finest level (64px), the count represents individual dark pixels in the circle. At the coarsest level (4px), the count shows how many of the 16 large regions are predominantly dark.

Seeing a ModuleNotFoundError?

Make sure your terminal is in the same folder as anomaly_detector.py. Run ls to confirm the file is listed in the current directory.

If you see a SyntaxError on Windows, the multiline string format may not work in your shell. Try opening a Python interactive session with python and pasting the lines one by one instead.

Help me troubleshoot my import or syntax error

✔️ Awesome, I've got everything!

Great. Double check you have saved anomaly_detector.py with both new functions.

ⓧ I'd like to double check the full code

Here is the full anomaly_detector.py up to this point:

import numpy as np
from PIL import Image, ImageDraw
import matplotlib.pyplot as plt
from matplotlib.colors import Normalize
from matplotlib.cm import ScalarMappable


# =============================================================================
# Pattern Generator
# =============================================================================

def generate_pattern(size, pattern_type, offset=(0, 0), scale=1.0):
    """Generate a single geometric pattern on a white background."""
    img = Image.new("L", (size, size), 255)
    draw = ImageDraw.Draw(img)
    center_x = size // 2 + offset[0]
    center_y = size // 2 + offset[1]
    radius = int((size // 4) * scale)

    if pattern_type == "circle":
        draw.ellipse(
            [center_x - radius, center_y - radius,
             center_x + radius, center_y + radius],
            fill=0
        )
    elif pattern_type == "square":
        draw.rectangle(
            [center_x - radius, center_y - radius,
             center_x + radius, center_y + radius],
            fill=0
        )
    elif pattern_type == "cross":
        arm_width = radius // 3
        draw.rectangle(
            [center_x - arm_width, center_y - radius,
             center_x + arm_width, center_y + radius],
            fill=0
        )
        draw.rectangle(
            [center_x - radius, center_y - arm_width,
             center_x + radius, center_y + arm_width],
            fill=0
        )
    return img


def generate_grid(grid_size, cell_size, pattern_type, anomaly_positions=None,
                  anomaly_type="shift"):
    """Generate a grid of patterns with optional anomalies."""
    grid_img = Image.new("L", (grid_size * cell_size, grid_size * cell_size), 255)
    cells = []

    for row in range(grid_size):
        row_cells = []
        for col in range(grid_size):
            is_anomaly = (anomaly_positions is not None and
                          (row, col) in anomaly_positions)

            if is_anomaly:
                if anomaly_type == "shift":
                    cell = generate_pattern(cell_size, pattern_type,
                                            offset=(cell_size // 6, cell_size // 6))
                elif anomaly_type == "scale":
                    cell = generate_pattern(cell_size, pattern_type, scale=0.5)
                elif anomaly_type == "missing":
                    cell = Image.new("L", (cell_size, cell_size), 255)
                else:
                    cell = generate_pattern(cell_size, pattern_type,
                                            offset=(cell_size // 6, 0))
            else:
                cell = generate_pattern(cell_size, pattern_type)

            grid_img.paste(cell, (col * cell_size, row * cell_size))
            row_cells.append(np.array(cell))
        cells.append(row_cells)

    return grid_img, cells


# =============================================================================
# Multi-Resolution Feature Extraction
# =============================================================================

def extract_features_at_level(cell_array, grid_level):
    """Extract binary feature set from a cell at a given grid partitioning level."""
    h, w = cell_array.shape
    features = set()

    step_y = max(1, h // grid_level)
    step_x = max(1, w // grid_level)

    for gy in range(grid_level):
        for gx in range(grid_level):
            y_start = gy * step_y
            y_end = min((gy + 1) * step_y, h)
            x_start = gx * step_x
            x_end = min((gx + 1) * step_x, w)

            region = cell_array[y_start:y_end, x_start:x_end]
            if region.size > 0:
                mean_val = np.mean(region)
                if mean_val < 128:
                    features.add((gy, gx))

    return features


def get_abstraction_levels(cell_size):
    """Determine abstraction levels by halving from max dimension down to 4."""
    levels = []
    level = cell_size
    while level >= 4:
        levels.append(level)
        level = level // 2
    return levels

Your feature extractor is ready. Next up, you will use these feature sets to compute Tversky similarity scores between neighboring cells and identify which patterns break the expected relationships.

Implement Tversky Similarity and Relationship Comparison

Your feature extractor can now describe any grid cell as a set of binary features at multiple resolutions. The next question is: how do you compare two cells to measure whether they belong to the same visual pattern?

The Confident Ravens paper uses Tversky similarity, an asymmetric measure that weights "what's missing" differently from "what's extra." In this step, you'll implement the similarity formula, build functions that capture horizontal and vertical relationships across the grid, and reduce those relationships to a single confidence-ready score.

In this step, get ready to:
  • Implement the Tversky similarity formula with the paper's asymmetric parameters.
  • Build relationship functions that mirror the paper's H1, H2, V1, V2 grid comparisons.
  • Reduce similarity vectors to a single score using Euclidean magnitude.
Implement the Tversky similarity formula

Standard similarity measures treat both sets symmetrically. The Confident Ravens paper uses an asymmetric version called the Tversky ratio, where missing features count fully but extra features count for nothing.

  • In anomaly_detector.py, scroll to the bottom of the file (below get_abstraction_levels).
  • Add the Tversky similarity function by pasting this code:
# =============================================================================
# Tversky Similarity
# =============================================================================

def tversky_similarity(set_x, set_y, alpha=1.0, beta=0.0):
    """Compute Tversky similarity between two sets.

    With alpha=1.0 and beta=0.0, this matches the asymmetric coincidence
    model used in the Confident Ravens paper (Bush & Mosteller, 1953).
    """
    intersection = set_x & set_y
    x_minus_y = set_x - set_y
    y_minus_x = set_y - set_x

    numerator = len(intersection)
    denominator = len(intersection) + alpha * len(x_minus_y) + beta * len(y_minus_x)

    if denominator == 0:
        return 0.0
    return numerator / denominator

What does this code do?

The formula is: S(X,Y) = |X∩Y| / (|X∩Y| + α|X-Y| + β|Y-X|).

  • intersection captures features shared by both sets.
  • With alpha=1.0, features in X but missing from Y are penalized fully.
  • With beta=0.0, extra features in Y that aren't in X are ignored completely.
  • This asymmetry means the measure asks "does the candidate contain what the pattern expects?" without caring about extras.

Why asymmetric similarity instead of Jaccard?

Jaccard similarity treats both directions equally. In anomaly detection, you care more about whether expected features are missing than whether unexpected features appeared. A defective tile missing half its pattern is clearly broken, even if it gained some noise pixels.

The paper's authors chose α=1.0 and β=0.0 specifically to conform to the coincidence model by Bush and Mosteller (1953), which prioritizes absence of expected features as the strongest signal.

  • Save anomaly_detector.py.
  • Verify the function works by running this quick test in your terminal:
python3 -c "from anomaly_detector import tversky_similarity; print(tversky_similarity({1,2,3}, {2,3,4}))"

You should see 0.6666666666666666 printed. Two of X's three features appear in Y, and the one missing feature is penalized.

Seeing a ModuleNotFoundError?

  • Make sure your terminal is in the same folder as your anomaly_detector.py file. Run ls (macOS/Linux) or dir (Windows) and confirm you can see the file listed.
  • Check that you saved the file after adding the function.

Why can't Python find my anomaly_detector module?

Build relationship functions for horizontal and vertical neighbors

In the Confident Ravens paper, a grid's "relationships" describe how neighboring cells relate to each other. For a 3x3 grid, there are four key relationships: H1 and H2 (horizontal rows) and V1 and V2 (vertical columns).

  • Below the tversky_similarity function, add the relationship helper by pasting this code:
# =============================================================================
# Relationship Computation
# =============================================================================

def compute_relationship(cell_a, cell_b, grid_level):
    """Compute the feature-set relationship between two cells at a given level."""
    features_a = extract_features_at_level(cell_a, grid_level)
    features_b = extract_features_at_level(cell_b, grid_level)
    return features_a | features_b

What does this code do?

This function extracts features from two neighboring cells and combines them using set union. The union captures "everything visually present across both cells" at a given resolution level. This combined set becomes the expected relationship that other cell pairs should match.

  • Now add the full similarity vector function directly below compute_relationship:
def compute_similarity_vector(cells, candidate_row, candidate_col, grid_level,
                              grid_size):
    """Compute similarity vector for a candidate cell against known relationships.

    For a 3x3 grid, evaluates H1, H2 (horizontal) and V1, V2 (vertical)
    relationships as described in the paper.
    """
    similarities = []

    if grid_size == 3:
        # Horizontal relationships (rows)
        h1 = compute_relationship(cells[0][0], cells[0][1], grid_level)
        h1_ext = compute_relationship(cells[0][1], cells[0][2], grid_level)
        h1_combined = h1 | h1_ext

        h2 = compute_relationship(cells[1][0], cells[1][1], grid_level)
        h2_ext = compute_relationship(cells[1][1], cells[1][2], grid_level)
        h2_combined = h2 | h2_ext

        # Vertical relationships (columns)
        v1 = compute_relationship(cells[0][0], cells[1][0], grid_level)
        v1_ext = compute_relationship(cells[1][0], cells[2][0], grid_level)
        v1_combined = v1 | v1_ext

        v2 = compute_relationship(cells[0][1], cells[1][1], grid_level)
        v2_ext = compute_relationship(cells[1][1], cells[2][1], grid_level)
        v2_combined = v2 | v2_ext

        # Candidate's relationships
        candidate_cell = cells[candidate_row][candidate_col]

        # Horizontal: relationship with left neighbor (or compute row relationship)
        if candidate_col > 0:
            h_candidate = compute_relationship(
                cells[candidate_row][candidate_col - 1],
                candidate_cell, grid_level
            )
        else:
            h_candidate = extract_features_at_level(candidate_cell, grid_level)

        # Vertical: relationship with top neighbor
        if candidate_row > 0:
            v_candidate = compute_relationship(
                cells[candidate_row - 1][candidate_col],
                candidate_cell, grid_level
            )
        else:
            v_candidate = extract_features_at_level(candidate_cell, grid_level)

        similarities.append(tversky_similarity(h1_combined, h_candidate))
        similarities.append(tversky_similarity(h2_combined, h_candidate))
        similarities.append(tversky_similarity(v1_combined, v_candidate))
        similarities.append(tversky_similarity(v2_combined, v_candidate))

    else:
        # For 2x2 or other sizes, use available neighbors
        candidate_cell = cells[candidate_row][candidate_col]
        candidate_features = extract_features_at_level(candidate_cell, grid_level)

        for row in range(grid_size):
            for col in range(grid_size):
                if (row, col) != (candidate_row, candidate_col):
                    other_features = extract_features_at_level(
                        cells[row][col], grid_level
                    )
                    sim = tversky_similarity(other_features, candidate_features)
                    similarities.append(sim)

    return similarities

What does this code do?

For a 3x3 grid, this function computes four reference relationships from the known cells:

  • H1 and H2 capture the combined features along the first and second rows (horizontal neighbors).
  • V1 and V2 capture the combined features along the first and second columns (vertical neighbors).
  • The candidate cell's own horizontal and vertical relationships are then compared to these references using Tversky similarity.

A normal cell will produce high similarity scores (its relationships match the established pattern). A defective cell will produce low scores because its features diverge from what the grid expects.

  • Save anomaly_detector.py.

You won't see a change yet. The next function uses these relationships to produce a single numeric score.

Seeing an IndentationError?

  • Make sure compute_similarity_vector is defined at the top level (no extra indentation). It should start at column 0, just like compute_relationship above it.
  • Check that you haven't accidentally pasted the function inside another function's body.

Why am I getting an IndentationError in my similarity vector function?

Reduce similarity vectors to a single score

Each candidate cell now produces a vector of similarity values. The paper reduces this vector to a single comparable number using Euclidean magnitude.

  • Below compute_similarity_vector, add the score reduction function:
def compute_cell_score(similarities):
    """Reduce similarity vector to single score via Euclidean magnitude."""
    if not similarities:
        return 0.0
    return np.sqrt(sum(s ** 2 for s in similarities))

What does this code do?

The paper defines the final score as t = √Σθ² for all θ in the similarity vector. This is the Euclidean magnitude. A cell that matches the grid's relationships will produce a high score (all similarity values near 1.0). A defective cell will produce a low score (some similarities drop toward 0).

  • Save anomaly_detector.py.
  • Verify the full pipeline works together by running this test in your terminal:
python3 -c "
from anomaly_detector import generate_grid, compute_similarity_vector, compute_cell_score
grid_img, cells = generate_grid(3, 64, 'circle', anomaly_positions=[(2,2)], anomaly_type='shift')
sim = compute_similarity_vector(cells, 0, 0, 32, 3)
print('Normal cell score:', compute_cell_score(sim))
sim2 = compute_similarity_vector(cells, 2, 2, 32, 3)
print('Anomaly cell score:', compute_cell_score(sim2))
"

You should see two scores printed. The normal cell score should be higher than the anomaly cell score. The exact values depend on how the geometric features partition, but the anomalous cell (2,2) should produce a noticeably lower number.

Both scores look identical?

  • Double check that tversky_similarity uses alpha=1.0 and beta=0.0 as defaults. Swapping these values would make the metric ignore missing features entirely.
  • Confirm compute_relationship returns features_a | features_b (union), not features_a & features_b (intersection).

Why are my normal and anomaly cell scores the same?

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

Here is the full anomaly_detector.py as it should look at the end of this step:

import numpy as np
from PIL import Image, ImageDraw
import matplotlib.pyplot as plt
from matplotlib.colors import Normalize
from matplotlib.cm import ScalarMappable


# =============================================================================
# Pattern Generator
# =============================================================================

def generate_pattern(size, pattern_type, offset=(0, 0), scale=1.0):
    """Generate a single geometric pattern on a white background."""
    img = Image.new("L", (size, size), 255)
    draw = ImageDraw.Draw(img)
    center_x = size // 2 + offset[0]
    center_y = size // 2 + offset[1]
    radius = int((size // 4) * scale)

    if pattern_type == "circle":
        draw.ellipse(
            [center_x - radius, center_y - radius,
             center_x + radius, center_y + radius],
            fill=0
        )
    elif pattern_type == "square":
        draw.rectangle(
            [center_x - radius, center_y - radius,
             center_x + radius, center_y + radius],
            fill=0
        )
    elif pattern_type == "cross":
        arm_width = radius // 3
        draw.rectangle(
            [center_x - arm_width, center_y - radius,
             center_x + arm_width, center_y + radius],
            fill=0
        )
        draw.rectangle(
            [center_x - radius, center_y - arm_width,
             center_x + radius, center_y + arm_width],
            fill=0
        )
    return img


def generate_grid(grid_size, cell_size, pattern_type, anomaly_positions=None,
                  anomaly_type="shift"):
    """Generate a grid of patterns with optional anomalies."""
    grid_img = Image.new("L", (grid_size * cell_size, grid_size * cell_size), 255)
    cells = []

    for row in range(grid_size):
        row_cells = []
        for col in range(grid_size):
            is_anomaly = (anomaly_positions is not None and
                          (row, col) in anomaly_positions)

            if is_anomaly:
                if anomaly_type == "shift":
                    cell = generate_pattern(cell_size, pattern_type,
                                            offset=(cell_size // 6, cell_size // 6))
                elif anomaly_type == "scale":
                    cell = generate_pattern(cell_size, pattern_type, scale=0.5)
                elif anomaly_type == "missing":
                    cell = Image.new("L", (cell_size, cell_size), 255)
                else:
                    cell = generate_pattern(cell_size, pattern_type,
                                            offset=(cell_size // 6, 0))
            else:
                cell = generate_pattern(cell_size, pattern_type)

            grid_img.paste(cell, (col * cell_size, row * cell_size))
            row_cells.append(np.array(cell))
        cells.append(row_cells)

    return grid_img, cells


# =============================================================================
# Multi-Resolution Feature Extraction
# =============================================================================

def extract_features_at_level(cell_array, grid_level):
    """Extract binary feature set from a cell at a given grid partitioning level."""
    h, w = cell_array.shape
    features = set()

    step_y = max(1, h // grid_level)
    step_x = max(1, w // grid_level)

    for gy in range(grid_level):
        for gx in range(grid_level):
            y_start = gy * step_y
            y_end = min((gy + 1) * step_y, h)
            x_start = gx * step_x
            x_end = min((gx + 1) * step_x, w)

            region = cell_array[y_start:y_end, x_start:x_end]
            if region.size > 0:
                mean_val = np.mean(region)
                if mean_val < 128:
                    features.add((gy, gx))

    return features


def get_abstraction_levels(cell_size):
    """Determine abstraction levels by halving from max dimension down to 4."""
    levels = []
    level = cell_size
    while level >= 4:
        levels.append(level)
        level = level // 2
    return levels


# =============================================================================
# Tversky Similarity
# =============================================================================

def tversky_similarity(set_x, set_y, alpha=1.0, beta=0.0):
    """Compute Tversky similarity between two sets.

    With alpha=1.0 and beta=0.0, this matches the asymmetric coincidence
    model used in the Confident Ravens paper (Bush & Mosteller, 1953).
    """
    intersection = set_x & set_y
    x_minus_y = set_x - set_y
    y_minus_x = set_y - set_x

    numerator = len(intersection)
    denominator = len(intersection) + alpha * len(x_minus_y) + beta * len(y_minus_x)

    if denominator == 0:
        return 0.0
    return numerator / denominator


# =============================================================================
# Relationship Computation
# =============================================================================

def compute_relationship(cell_a, cell_b, grid_level):
    """Compute the feature-set relationship between two cells at a given level."""
    features_a = extract_features_at_level(cell_a, grid_level)
    features_b = extract_features_at_level(cell_b, grid_level)
    return features_a | features_b


def compute_similarity_vector(cells, candidate_row, candidate_col, grid_level,
                              grid_size):
    """Compute similarity vector for a candidate cell against known relationships.

    For a 3x3 grid, evaluates H1, H2 (horizontal) and V1, V2 (vertical)
    relationships as described in the paper.
    """
    similarities = []

    if grid_size == 3:
        # Horizontal relationships (rows)
        h1 = compute_relationship(cells[0][0], cells[0][1], grid_level)
        h1_ext = compute_relationship(cells[0][1], cells[0][2], grid_level)
        h1_combined = h1 | h1_ext

        h2 = compute_relationship(cells[1][0], cells[1][1], grid_level)
        h2_ext = compute_relationship(cells[1][1], cells[1][2], grid_level)
        h2_combined = h2 | h2_ext

        # Vertical relationships (columns)
        v1 = compute_relationship(cells[0][0], cells[1][0], grid_level)
        v1_ext = compute_relationship(cells[1][0], cells[2][0], grid_level)
        v1_combined = v1 | v1_ext

        v2 = compute_relationship(cells[0][1], cells[1][1], grid_level)
        v2_ext = compute_relationship(cells[1][1], cells[2][1], grid_level)
        v2_combined = v2 | v2_ext

        # Candidate's relationships
        candidate_cell = cells[candidate_row][candidate_col]

        # Horizontal: relationship with left neighbor (or compute row relationship)
        if candidate_col > 0:
            h_candidate = compute_relationship(
                cells[candidate_row][candidate_col - 1],
                candidate_cell, grid_level
            )
        else:
            h_candidate = extract_features_at_level(candidate_cell, grid_level)

        # Vertical: relationship with top neighbor
        if candidate_row > 0:
            v_candidate = compute_relationship(
                cells[candidate_row - 1][candidate_col],
                candidate_cell, grid_level
            )
        else:
            v_candidate = extract_features_at_level(candidate_cell, grid_level)

        similarities.append(tversky_similarity(h1_combined, h_candidate))
        similarities.append(tversky_similarity(h2_combined, h_candidate))
        similarities.append(tversky_similarity(v1_combined, v_candidate))
        similarities.append(tversky_similarity(v2_combined, v_candidate))

    else:
        # For 2x2 or other sizes, use available neighbors
        candidate_cell = cells[candidate_row][candidate_col]
        candidate_features = extract_features_at_level(candidate_cell, grid_level)

        for row in range(grid_size):
            for col in range(grid_size):
                if (row, col) != (candidate_row, candidate_col):
                    other_features = extract_features_at_level(
                        cells[row][col], grid_level
                    )
                    sim = tversky_similarity(other_features, candidate_features)
                    similarities.append(sim)

    return similarities


def compute_cell_score(similarities):
    """Reduce similarity vector to single score via Euclidean magnitude."""
    if not similarities:
        return 0.0
    return np.sqrt(sum(s ** 2 for s in similarities))

You now have a complete similarity scoring pipeline. Each cell can be evaluated against the grid's expected visual relationships. Next up, you'll build the confidence engine that uses statistics to determine which low score is genuinely anomalous rather than just noise.

Build the Confidence Engine

Your Tversky similarity engine can now compare every cell in the grid against its neighbors and produce a score. The missing piece is deciding which scores are statistically significant enough to flag as defects.

The Confident Ravens paper solves this with a confidence-driven approach. Instead of using a fixed similarity cutoff, it computes how far each cell's score deviates from the group mean. If exactly one cell deviates beyond the 95% confidence threshold (1.96 standard deviations), that cell is the anomaly.

In this step, get ready to:
  • Compute mean and standard deviation of similarity scores at each abstraction level.
  • Implement deviation-based outlier detection with a 95% confidence threshold.
  • Iterate from coarsest to finest resolution, returning the first unambiguous detection.
Add the detect_anomalies function

The confidence engine ties together everything you've built so far. It loops through each abstraction level, computes a similarity score for every cell, then uses statistics to determine if any single cell is a clear outlier.

How the algorithm decides

At each resolution level, the engine calculates D = (score - mean) / (stdev / √n) for every cell. If exactly ONE cell's deviation falls below -1.96 (the 95% confidence threshold), that cell is flagged as anomalous.

If multiple cells exceed the threshold at one level, the result is "ambiguous" and the algorithm descends to a finer resolution. The first level that produces an unambiguous single outlier wins.

  • In anomaly_detector.py, scroll to the bottom of the file after the compute_cell_score function.
  • Add the confidence engine section by copying this code below it:
# =============================================================================
# Confidence Engine
# =============================================================================

def detect_anomalies(cells, grid_size, cell_size, confidence_threshold=1.96):
    """Run the Confident Ravens algorithm to detect anomalous cells."""
    levels = get_abstraction_levels(cell_size)
    results = {
        "levels": [],
        "final_detection": None,
        "final_confidence": 0.0,
        "final_level": None
    }

    for level in levels:
        # Compute similarity scores for all cells at this level
        scores = []
        for row in range(grid_size):
            for col in range(grid_size):
                similarities = compute_similarity_vector(
                    cells, row, col, level, grid_size
                )
                score = compute_cell_score(similarities)
                scores.append(((row, col), score))

        # Calculate mean and standard deviation across all cells
        score_values = [s[1] for s in scores]
        n = len(score_values)
        mean_score = np.mean(score_values)
        std_score = np.std(score_values)

        # If all scores are identical, skip this level
        if std_score == 0:
            level_result = {
                "level": level,
                "scores": scores,
                "mean": mean_score,
                "std": std_score,
                "deviations": [(pos, 0.0) for pos, _ in scores],
                "status": "homogeneous"
            }
            results["levels"].append(level_result)
            continue

        # Compute deviation score for each cell: D = (score - mean) / (stdev / sqrt(n))
        std_error = std_score / np.sqrt(n)
        deviations = []
        for pos, score in scores:
            deviation = (score - mean_score) / std_error
            deviations.append((pos, deviation))

        # Check for unambiguous detection: exactly one cell below -threshold
        min_dev = [(pos, dev) for pos, dev in deviations if dev < -confidence_threshold]

        level_result = {
            "level": level,
            "scores": scores,
            "mean": mean_score,
            "std": std_score,
            "deviations": deviations,
            "status": "ambiguous" if len(min_dev) != 1 else "detected"
        }
        results["levels"].append(level_result)

        # Lock in the first unambiguous detection
        if len(min_dev) == 1 and results["final_detection"] is None:
            results["final_detection"] = min_dev[0][0]
            results["final_confidence"] = abs(min_dev[0][1])
            results["final_level"] = level

    return results

What does this code do?

  • get_abstraction_levels(cell_size) generates the list of resolution levels (64, 32, 16, 8, 4 for a 64px cell). The loop processes from coarsest to finest.
  • For each level, the function uses your existing compute_similarity_vector and compute_cell_score to produce a single score per cell.
  • std_error = std_score / np.sqrt(n) computes the standard error. Dividing by √n accounts for sample size, making the threshold meaningful regardless of grid dimensions.
  • The min_dev filter looks for cells whose deviation is below -1.96. A negative deviation means the cell scored lower than average (less similar to its neighbors). If exactly one cell crosses this threshold, the detection is unambiguous.
  • The function stops at the first level that gives an unambiguous answer, storing the position, confidence score, and resolution level in the results dict.

Why 1.96 standard deviations?

The value 1.96 corresponds to the 95% confidence interval in a normal distribution. The Confident Ravens paper uses this as the primary threshold, noting a marked shift in detection accuracy between 99.9% and 95% confidence. This means the engine only flags a cell when it is statistically confident that the deviation is not random noise.

  • Save anomaly_detector.py.
  • Verify the function loads without errors by running this command in your terminal:
python -c "import anomaly_detector; print('Confidence engine loaded successfully')"

You should see Confidence engine loaded successfully printed in the terminal.

Seeing a NameError or ImportError?

  • Make sure you pasted the function below compute_cell_score, not inside it. The indentation of def detect_anomalies should be at the top level (no indentation).
  • Check that your terminal's working directory is the same folder where anomaly_detector.py lives. Use ls to confirm the file is visible.
  • If you see a SyntaxError, look for unmatched brackets or quotes inside the results dictionary or the list comprehensions.

Why won't my detect_anomalies function load?

Quick-test the detection on a sample grid

Before moving on, confirm the engine can actually detect an anomaly. You can run a quick inline test without modifying your file.

  • Run this test command in your terminal to generate a grid with one defect and check detection:
python -c "
from anomaly_detector import generate_grid, detect_anomalies
grid_img, cells = generate_grid(3, 64, 'circle', anomaly_positions=[(2,2)], anomaly_type='shift')
results = detect_anomalies(cells, 3, 64)
print('Detection:', results['final_detection'])
print('Confidence:', round(results['final_confidence'], 2), 'std devs')
print('Level:', results['final_level'], 'px')
"

You should see output showing the detected anomaly at position (2, 2) with a confidence score above 1.96 standard deviations.

Seeing Detection: None?

  • This means no single cell crossed the threshold unambiguously. Double check the confidence_threshold=1.96 default parameter in your function signature.
  • Make sure the deviation formula uses dev < -confidence_threshold (negative, not positive). Anomalous cells score LOWER than normal, so their deviation is negative.

My confidence engine returns None even though I have an anomaly in the grid.

✔️ Awesome, I've got everything!

Great. Double check you've saved anomaly_detector.py and your test returned a valid detection.

ⓧ I'd like to double check the full code

Here is the complete anomaly_detector.py up to this point:

import numpy as np
from PIL import Image, ImageDraw
import matplotlib.pyplot as plt
from matplotlib.colors import Normalize
from matplotlib.cm import ScalarMappable


# =============================================================================
# Pattern Generator
# =============================================================================

def generate_pattern(size, pattern_type, offset=(0, 0), scale=1.0):
    """Generate a single geometric pattern on a white background."""
    img = Image.new("L", (size, size), 255)
    draw = ImageDraw.Draw(img)
    center_x = size // 2 + offset[0]
    center_y = size // 2 + offset[1]
    radius = int((size // 4) * scale)

    if pattern_type == "circle":
        draw.ellipse(
            [center_x - radius, center_y - radius,
             center_x + radius, center_y + radius],
            fill=0
        )
    elif pattern_type == "square":
        draw.rectangle(
            [center_x - radius, center_y - radius,
             center_x + radius, center_y + radius],
            fill=0
        )
    elif pattern_type == "cross":
        arm_width = radius // 3
        draw.rectangle(
            [center_x - arm_width, center_y - radius,
             center_x + arm_width, center_y + radius],
            fill=0
        )
        draw.rectangle(
            [center_x - radius, center_y - arm_width,
             center_x + radius, center_y + arm_width],
            fill=0
        )
    return img


def generate_grid(grid_size, cell_size, pattern_type, anomaly_positions=None,
                  anomaly_type="shift"):
    """Generate a grid of patterns with optional anomalies."""
    grid_img = Image.new("L", (grid_size * cell_size, grid_size * cell_size), 255)
    cells = []

    for row in range(grid_size):
        row_cells = []
        for col in range(grid_size):
            is_anomaly = (anomaly_positions is not None and
                          (row, col) in anomaly_positions)

            if is_anomaly:
                if anomaly_type == "shift":
                    cell = generate_pattern(cell_size, pattern_type,
                                            offset=(cell_size // 6, cell_size // 6))
                elif anomaly_type == "scale":
                    cell = generate_pattern(cell_size, pattern_type, scale=0.5)
                elif anomaly_type == "missing":
                    cell = Image.new("L", (cell_size, cell_size), 255)
                else:
                    cell = generate_pattern(cell_size, pattern_type,
                                            offset=(cell_size // 6, 0))
            else:
                cell = generate_pattern(cell_size, pattern_type)

            grid_img.paste(cell, (col * cell_size, row * cell_size))
            row_cells.append(np.array(cell))
        cells.append(row_cells)

    return grid_img, cells


# =============================================================================
# Multi-Resolution Feature Extraction
# =============================================================================

def extract_features_at_level(cell_array, grid_level):
    """Extract binary feature set from a cell at a given grid partitioning level."""
    h, w = cell_array.shape
    features = set()

    step_y = max(1, h // grid_level)
    step_x = max(1, w // grid_level)

    for gy in range(grid_level):
        for gx in range(grid_level):
            y_start = gy * step_y
            y_end = min((gy + 1) * step_y, h)
            x_start = gx * step_x
            x_end = min((gx + 1) * step_x, w)

            region = cell_array[y_start:y_end, x_start:x_end]
            if region.size > 0:
                mean_val = np.mean(region)
                if mean_val < 128:
                    features.add((gy, gx))

    return features


def get_abstraction_levels(cell_size):
    """Determine abstraction levels by halving from max dimension down to 4."""
    levels = []
    level = cell_size
    while level >= 4:
        levels.append(level)
        level = level // 2
    return levels


# =============================================================================
# Tversky Similarity
# =============================================================================

def tversky_similarity(set_x, set_y, alpha=1.0, beta=0.0):
    """Compute Tversky similarity between two sets."""
    intersection = set_x & set_y
    x_minus_y = set_x - set_y
    y_minus_x = set_y - set_x

    numerator = len(intersection)
    denominator = len(intersection) + alpha * len(x_minus_y) + beta * len(y_minus_x)

    if denominator == 0:
        return 0.0
    return numerator / denominator


# =============================================================================
# Relationship Computation
# =============================================================================

def compute_relationship(cell_a, cell_b, grid_level):
    """Compute the feature-set relationship between two cells at a given level."""
    features_a = extract_features_at_level(cell_a, grid_level)
    features_b = extract_features_at_level(cell_b, grid_level)
    return features_a | features_b


def compute_similarity_vector(cells, candidate_row, candidate_col, grid_level,
                              grid_size):
    """Compute similarity vector for a candidate cell against known relationships."""
    similarities = []

    if grid_size == 3:
        h1 = compute_relationship(cells[0][0], cells[0][1], grid_level)
        h1_ext = compute_relationship(cells[0][1], cells[0][2], grid_level)
        h1_combined = h1 | h1_ext

        h2 = compute_relationship(cells[1][0], cells[1][1], grid_level)
        h2_ext = compute_relationship(cells[1][1], cells[1][2], grid_level)
        h2_combined = h2 | h2_ext

        v1 = compute_relationship(cells[0][0], cells[1][0], grid_level)
        v1_ext = compute_relationship(cells[1][0], cells[2][0], grid_level)
        v1_combined = v1 | v1_ext

        v2 = compute_relationship(cells[0][1], cells[1][1], grid_level)
        v2_ext = compute_relationship(cells[1][1], cells[2][1], grid_level)
        v2_combined = v2 | v2_ext

        candidate_cell = cells[candidate_row][candidate_col]

        if candidate_col > 0:
            h_candidate = compute_relationship(
                cells[candidate_row][candidate_col - 1],
                candidate_cell, grid_level
            )
        else:
            h_candidate = extract_features_at_level(candidate_cell, grid_level)

        if candidate_row > 0:
            v_candidate = compute_relationship(
                cells[candidate_row - 1][candidate_col],
                candidate_cell, grid_level
            )
        else:
            v_candidate = extract_features_at_level(candidate_cell, grid_level)

        similarities.append(tversky_similarity(h1_combined, h_candidate))
        similarities.append(tversky_similarity(h2_combined, h_candidate))
        similarities.append(tversky_similarity(v1_combined, v_candidate))
        similarities.append(tversky_similarity(v2_combined, v_candidate))

    else:
        candidate_cell = cells[candidate_row][candidate_col]
        candidate_features = extract_features_at_level(candidate_cell, grid_level)

        for row in range(grid_size):
            for col in range(grid_size):
                if (row, col) != (candidate_row, candidate_col):
                    other_features = extract_features_at_level(
                        cells[row][col], grid_level
                    )
                    sim = tversky_similarity(other_features, candidate_features)
                    similarities.append(sim)

    return similarities


def compute_cell_score(similarities):
    """Reduce similarity vector to single score via Euclidean magnitude."""
    if not similarities:
        return 0.0
    return np.sqrt(sum(s ** 2 for s in similarities))


# =============================================================================
# Confidence Engine
# =============================================================================

def detect_anomalies(cells, grid_size, cell_size, confidence_threshold=1.96):
    """Run the Confident Ravens algorithm to detect anomalous cells."""
    levels = get_abstraction_levels(cell_size)
    results = {
        "levels": [],
        "final_detection": None,
        "final_confidence": 0.0,
        "final_level": None
    }

    for level in levels:
        scores = []
        for row in range(grid_size):
            for col in range(grid_size):
                similarities = compute_similarity_vector(
                    cells, row, col, level, grid_size
                )
                score = compute_cell_score(similarities)
                scores.append(((row, col), score))

        score_values = [s[1] for s in scores]
        n = len(score_values)
        mean_score = np.mean(score_values)
        std_score = np.std(score_values)

        if std_score == 0:
            level_result = {
                "level": level,
                "scores": scores,
                "mean": mean_score,
                "std": std_score,
                "deviations": [(pos, 0.0) for pos, _ in scores],
                "status": "homogeneous"
            }
            results["levels"].append(level_result)
            continue

        std_error = std_score / np.sqrt(n)
        deviations = []
        for pos, score in scores:
            deviation = (score - mean_score) / std_error
            deviations.append((pos, deviation))

        min_dev = [(pos, dev) for pos, dev in deviations if dev < -confidence_threshold]

        level_result = {
            "level": level,
            "scores": scores,
            "mean": mean_score,
            "std": std_score,
            "deviations": deviations,
            "status": "ambiguous" if len(min_dev) != 1 else "detected"
        }
        results["levels"].append(level_result)

        if len(min_dev) == 1 and results["final_detection"] is None:
            results["final_detection"] = min_dev[0][0]
            results["final_confidence"] = abs(min_dev[0][1])
            results["final_level"] = level

    return results

Your confidence engine is complete. In the next step, you'll build the visualization module that turns these detection results into annotated heatmaps showing exactly where defects live and how confident the algorithm is about each one.

Visualize Results with a Confidence Heatmap

Your confidence engine can now identify anomalous cells and report their deviation scores. The missing piece is making those results visible.

In this step, you will build a visualization function that displays the original grid alongside a color-coded heatmap of deviation scores. You will also wire up a pipeline runner that ties every component together and runs the full detection across multiple test cases.

In this step, get ready to:
  • Create a Matplotlib visualization with a side-by-side display and confidence annotations.
  • Build a pipeline runner that orchestrates generation, detection, and visualization.
  • Run the full pipeline on three different test cases and verify the output.
Create the side-by-side visualization with confidence annotations

Raw deviation numbers are hard to interpret at a glance. A color-coded heatmap lets you instantly see which grid cell breaks the pattern and how confident the detection is.

  • In anomaly_detector.py, scroll to the bottom of the file (below the detect_anomalies function).
  • Add the visualization function by pasting the following code:
# =============================================================================
# Visualization
# =============================================================================

def visualize_results(grid_img, results, grid_size, title="Anomaly Detection"):
    """Create side-by-side visualization: original grid + confidence heatmap."""
    fig, axes = plt.subplots(1, 2, figsize=(12, 5))

    # Left: Original grid
    axes[0].imshow(np.array(grid_img), cmap="gray")
    axes[0].set_title("Original Pattern Grid")
    axes[0].axis("off")

    # Right: Confidence heatmap
    heatmap = np.zeros((grid_size, grid_size))

    if results["levels"]:
        # Use the level where detection occurred, or the finest level
        detection_level = None
        for level_result in results["levels"]:
            if level_result["status"] == "detected":
                detection_level = level_result
                break

        if detection_level is None:
            detection_level = results["levels"][-1]

        for pos, dev in detection_level["deviations"]:
            row, col = pos
            heatmap[row, col] = dev

    im = axes[1].imshow(heatmap, cmap="RdYlGn", interpolation="nearest")
    axes[1].set_title("Confidence Heatmap (Deviation Scores)")

    # Annotate cells with their deviation values
    for row in range(grid_size):
        for col in range(grid_size):
            val = heatmap[row, col]
            color = "white" if abs(val) > 1.5 else "black"
            axes[1].text(col, row, f"{val:.2f}", ha="center", va="center",
                         fontsize=9, color=color, fontweight="bold")

    plt.colorbar(im, ax=axes[1], label="Deviation from Mean")

    # Add detection annotation with confidence percentage
    if results["final_detection"]:
        det_row, det_col = results["final_detection"]
        confidence_pct = min(99.99, (1 - 2 * (1 - 0.5 * (
            1 + np.math.erf(results["final_confidence"] / np.sqrt(2))
        ))) * 100)
        axes[1].set_xlabel(
            f"Detected anomaly at ({det_row},{det_col}) | "
            f"Confidence: {confidence_pct:.1f}% | "
            f"Resolution: {results['final_level']}px grid",
            fontsize=10
        )

    plt.suptitle(title, fontsize=13, fontweight="bold")
    plt.tight_layout()
    plt.savefig("anomaly_detection_result.png", dpi=150, bbox_inches="tight")
    plt.show()

What does this code do?

This function produces a two-panel figure that tells the full story of a detection run.

  • The left panel shows the original pattern grid in grayscale so you can see the raw input.
  • The right panel builds a heatmap from the deviation scores at the resolution level where detection occurred. Green means normal. Red means anomalous.
  • Each cell is annotated with its numeric deviation score for precise comparison.
  • The x-axis label reports the detected anomaly position, confidence percentage (converted from the z-score using the error function), and the resolution level at which detection occurred.
  • Save anomaly_detector.py.

Seeing a NameError for plt or np?

Make sure your imports at the top of the file still include import matplotlib.pyplot as plt and import numpy as np. If you accidentally deleted them, add them back to the top of the file.

help me debug a NameError in my visualize_results function

Build the pipeline runner function

Each detection run needs to generate a grid, run the confidence engine, print a report, and visualize the output. A single orchestrating function makes it easy to test different scenarios without repeating yourself.

  • Below the visualize_results function, add the pipeline runner by pasting this code:
# =============================================================================
# Main Pipeline
# =============================================================================

def run_detection(grid_size=3, cell_size=64, pattern_type="circle",
                  anomaly_positions=None, anomaly_type="shift", title=None):
    """Run the full anomaly detection pipeline."""
    if anomaly_positions is None:
        anomaly_positions = [(2, 2)]

    if title is None:
        title = f"Anomaly Detection - {anomaly_type.capitalize()} Defect"

    # Generate grid
    grid_img, cells = generate_grid(
        grid_size, cell_size, pattern_type,
        anomaly_positions=anomaly_positions,
        anomaly_type=anomaly_type
    )

    # Run detection
    results = detect_anomalies(cells, grid_size, cell_size)

    # Report
    print(f"\n{'='*60}")
    print(f"  {title}")
    print(f"{'='*60}")
    print(f"  Grid: {grid_size}x{grid_size} | Cell: {cell_size}px | "
          f"Pattern: {pattern_type}")
    print(f"  Anomaly positions: {anomaly_positions}")
    print(f"  Anomaly type: {anomaly_type}")
    print(f"{'='*60}")

    if results["final_detection"]:
        print(f"\n  DETECTED: Anomaly at position {results['final_detection']}")
        print(f"  Confidence score: {results['final_confidence']:.3f} "
              f"standard deviations")
        print(f"  Detection level: {results['final_level']}px grid partitioning")
    else:
        print("\n  No unambiguous anomaly detected.")

    print(f"\n  Abstraction levels examined: {len(results['levels'])}")
    for level_result in results["levels"]:
        print(f"    Level {level_result['level']:>3}px: "
              f"mean={level_result['mean']:.4f}, "
              f"std={level_result['std']:.4f}, "
              f"status={level_result['status']}")

    # Visualize
    visualize_results(grid_img, results, grid_size, title)

    return results

What does this code do?

This function is the single entry point for running a detection. It ties together every component you have built so far.

  • generate_grid creates the pattern image and cell arrays with a controlled anomaly.
  • detect_anomalies runs the multi-resolution confidence engine across every cell.
  • The printed report shows the detection result, confidence score, and a level-by-level breakdown.
  • visualize_results produces the annotated heatmap and saves it as a PNG.
  • Save anomaly_detector.py.

Seeing an indentation or syntax error?

Check that run_detection is defined at the top level (no extra indentation), and that it appears after visualize_results. Python reads functions in order, so calling visualize_results inside run_detection only works if it is defined above.

help me fix a syntax or indentation error in run_detection

Run the pipeline on multiple test cases

A single test case is not enough to trust the detector. You will run three scenarios that stress different detection capabilities: an obvious positional shift, a subtle scale change, and a completely missing element.

  • At the very bottom of anomaly_detector.py, add the main execution block by pasting this code:
if __name__ == "__main__":
    # Test Case 1: Shifted circle (obvious defect)
    print("\n" + "=" * 60)
    print("  TEST CASE 1: Shifted Pattern (Obvious Defect)")
    run_detection(
        grid_size=3, cell_size=64, pattern_type="circle",
        anomaly_positions=[(2, 2)], anomaly_type="shift",
        title="Test 1: Shifted Circle Defect"
    )

    # Test Case 2: Scaled pattern (subtle defect)
    print("\n" + "=" * 60)
    print("  TEST CASE 2: Scaled Pattern (Subtle Defect)")
    run_detection(
        grid_size=3, cell_size=64, pattern_type="square",
        anomaly_positions=[(1, 1)], anomaly_type="scale",
        title="Test 2: Scaled Square Defect"
    )

    # Test Case 3: Missing element (severe defect)
    print("\n" + "=" * 60)
    print("  TEST CASE 3: Missing Pattern (Severe Defect)")
    run_detection(
        grid_size=3, cell_size=64, pattern_type="cross",
        anomaly_positions=[(0, 2)], anomaly_type="missing",
        title="Test 3: Missing Cross Defect"
    )

What do these test cases cover?

  • Test 1 shifts a circle to position (2,2). This is an obvious positional defect that should be detected at a coarse resolution level.
  • Test 2 shrinks a square at (1,1) to half size. This is subtle and may require a finer resolution level to confidently detect.
  • Test 3 removes the cross at (0,2) entirely. This is the most severe defect and should produce the highest confidence score.
  • Save anomaly_detector.py.
  • Run the script by executing this command in your terminal:
python anomaly_detector.py
  • You should see three printed reports in your terminal, each showing "DETECTED" with a confidence score and detection level. Three Matplotlib windows will open one after another showing the heatmap visualizations.
  • Close each Matplotlib window to proceed to the next test case.
  • After all three windows close, confirm that anomaly_detection_result.png has been saved in your project folder by running:
ls

You should see anomaly_detection_result.png listed alongside anomaly_detector.py.

Not seeing the heatmap window?

Some environments (remote servers, WSL without display forwarding) cannot show interactive Matplotlib windows. If you see a warning about the display backend, the script still saves the PNG file. Open anomaly_detection_result.png directly to verify the output.

If the script raises a ModuleNotFoundError, double-check that you installed matplotlib in the same Python environment you are running the script from.

help me fix Matplotlib display issues in my anomaly detector

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete anomaly_detector.py file at this point. Compare yours against it:

import numpy as np
from PIL import Image, ImageDraw
import matplotlib.pyplot as plt
from matplotlib.colors import Normalize
from matplotlib.cm import ScalarMappable


# =============================================================================
# Pattern Generator
# =============================================================================

def generate_pattern(size, pattern_type, offset=(0, 0), scale=1.0):
    """Generate a single geometric pattern on a white background."""
    img = Image.new("L", (size, size), 255)
    draw = ImageDraw.Draw(img)
    center_x = size // 2 + offset[0]
    center_y = size // 2 + offset[1]
    radius = int((size // 4) * scale)

    if pattern_type == "circle":
        draw.ellipse(
            [center_x - radius, center_y - radius,
             center_x + radius, center_y + radius],
            fill=0
        )
    elif pattern_type == "square":
        draw.rectangle(
            [center_x - radius, center_y - radius,
             center_x + radius, center_y + radius],
            fill=0
        )
    elif pattern_type == "cross":
        arm_width = radius // 3
        draw.rectangle(
            [center_x - arm_width, center_y - radius,
             center_x + arm_width, center_y + radius],
            fill=0
        )
        draw.rectangle(
            [center_x - radius, center_y - arm_width,
             center_x + radius, center_y + arm_width],
            fill=0
        )
    return img


def generate_grid(grid_size, cell_size, pattern_type, anomaly_positions=None,
                  anomaly_type="shift"):
    """Generate a grid of patterns with optional anomalies."""
    grid_img = Image.new("L", (grid_size * cell_size, grid_size * cell_size), 255)
    cells = []

    for row in range(grid_size):
        row_cells = []
        for col in range(grid_size):
            is_anomaly = (anomaly_positions is not None and
                          (row, col) in anomaly_positions)

            if is_anomaly:
                if anomaly_type == "shift":
                    cell = generate_pattern(cell_size, pattern_type,
                                            offset=(cell_size // 6, cell_size // 6))
                elif anomaly_type == "scale":
                    cell = generate_pattern(cell_size, pattern_type, scale=0.5)
                elif anomaly_type == "missing":
                    cell = Image.new("L", (cell_size, cell_size), 255)
                else:
                    cell = generate_pattern(cell_size, pattern_type,
                                            offset=(cell_size // 6, 0))
            else:
                cell = generate_pattern(cell_size, pattern_type)

            grid_img.paste(cell, (col * cell_size, row * cell_size))
            row_cells.append(np.array(cell))
        cells.append(row_cells)

    return grid_img, cells


# =============================================================================
# Multi-Resolution Feature Extraction
# =============================================================================

def extract_features_at_level(cell_array, grid_level):
    """Extract binary feature set from a cell at a given grid partitioning level."""
    h, w = cell_array.shape
    features = set()

    step_y = max(1, h // grid_level)
    step_x = max(1, w // grid_level)

    for gy in range(grid_level):
        for gx in range(grid_level):
            y_start = gy * step_y
            y_end = min((gy + 1) * step_y, h)
            x_start = gx * step_x
            x_end = min((gx + 1) * step_x, w)

            region = cell_array[y_start:y_end, x_start:x_end]
            if region.size > 0:
                mean_val = np.mean(region)
                if mean_val < 128:
                    features.add((gy, gx))

    return features


def get_abstraction_levels(cell_size):
    """Determine abstraction levels by halving from max dimension down to 4."""
    levels = []
    level = cell_size
    while level >= 4:
        levels.append(level)
        level = level // 2
    return levels


# =============================================================================
# Tversky Similarity
# =============================================================================

def tversky_similarity(set_x, set_y, alpha=1.0, beta=0.0):
    """Compute Tversky similarity between two sets.

    With alpha=1.0 and beta=0.0, this matches the asymmetric coincidence
    model used in the Confident Ravens paper (Bush & Mosteller, 1953).
    """
    intersection = set_x & set_y
    x_minus_y = set_x - set_y
    y_minus_x = set_y - set_x

    numerator = len(intersection)
    denominator = len(intersection) + alpha * len(x_minus_y) + beta * len(y_minus_x)

    if denominator == 0:
        return 0.0
    return numerator / denominator


# =============================================================================
# Relationship Computation
# =============================================================================

def compute_relationship(cell_a, cell_b, grid_level):
    """Compute the feature-set relationship between two cells at a given level."""
    features_a = extract_features_at_level(cell_a, grid_level)
    features_b = extract_features_at_level(cell_b, grid_level)
    return features_a | features_b


def compute_similarity_vector(cells, candidate_row, candidate_col, grid_level,
                              grid_size):
    """Compute similarity vector for a candidate cell against known relationships."""
    similarities = []

    if grid_size == 3:
        h1 = compute_relationship(cells[0][0], cells[0][1], grid_level)
        h1_ext = compute_relationship(cells[0][1], cells[0][2], grid_level)
        h1_combined = h1 | h1_ext

        h2 = compute_relationship(cells[1][0], cells[1][1], grid_level)
        h2_ext = compute_relationship(cells[1][1], cells[1][2], grid_level)
        h2_combined = h2 | h2_ext

        v1 = compute_relationship(cells[0][0], cells[1][0], grid_level)
        v1_ext = compute_relationship(cells[1][0], cells[2][0], grid_level)
        v1_combined = v1 | v1_ext

        v2 = compute_relationship(cells[0][1], cells[1][1], grid_level)
        v2_ext = compute_relationship(cells[1][1], cells[2][1], grid_level)
        v2_combined = v2 | v2_ext

        candidate_cell = cells[candidate_row][candidate_col]

        if candidate_col > 0:
            h_candidate = compute_relationship(
                cells[candidate_row][candidate_col - 1],
                candidate_cell, grid_level
            )
        else:
            h_candidate = extract_features_at_level(candidate_cell, grid_level)

        if candidate_row > 0:
            v_candidate = compute_relationship(
                cells[candidate_row - 1][candidate_col],
                candidate_cell, grid_level
            )
        else:
            v_candidate = extract_features_at_level(candidate_cell, grid_level)

        similarities.append(tversky_similarity(h1_combined, h_candidate))
        similarities.append(tversky_similarity(h2_combined, h_candidate))
        similarities.append(tversky_similarity(v1_combined, v_candidate))
        similarities.append(tversky_similarity(v2_combined, v_candidate))

    else:
        candidate_cell = cells[candidate_row][candidate_col]
        candidate_features = extract_features_at_level(candidate_cell, grid_level)

        for row in range(grid_size):
            for col in range(grid_size):
                if (row, col) != (candidate_row, candidate_col):
                    other_features = extract_features_at_level(
                        cells[row][col], grid_level
                    )
                    sim = tversky_similarity(other_features, candidate_features)
                    similarities.append(sim)

    return similarities


def compute_cell_score(similarities):
    """Reduce similarity vector to single score via Euclidean magnitude."""
    if not similarities:
        return 0.0
    return np.sqrt(sum(s ** 2 for s in similarities))


# =============================================================================
# Confidence Engine
# =============================================================================

def detect_anomalies(cells, grid_size, cell_size, confidence_threshold=1.96):
    """Run the Confident Ravens algorithm to detect anomalous cells."""
    levels = get_abstraction_levels(cell_size)
    results = {
        "levels": [],
        "final_detection": None,
        "final_confidence": 0.0,
        "final_level": None
    }

    for level in levels:
        scores = []
        for row in range(grid_size):
            for col in range(grid_size):
                similarities = compute_similarity_vector(
                    cells, row, col, level, grid_size
                )
                score = compute_cell_score(similarities)
                scores.append(((row, col), score))

        score_values = [s[1] for s in scores]
        n = len(score_values)
        mean_score = np.mean(score_values)
        std_score = np.std(score_values)

        if std_score == 0:
            level_result = {
                "level": level,
                "scores": scores,
                "mean": mean_score,
                "std": std_score,
                "deviations": [(pos, 0.0) for pos, _ in scores],
                "status": "homogeneous"
            }
            results["levels"].append(level_result)
            continue

        std_error = std_score / np.sqrt(n)
        deviations = []
        for pos, score in scores:
            deviation = (score - mean_score) / std_error
            deviations.append((pos, deviation))

        min_dev = [(pos, dev) for pos, dev in deviations if dev < -confidence_threshold]

        level_result = {
            "level": level,
            "scores": scores,
            "mean": mean_score,
            "std": std_score,
            "deviations": deviations,
            "status": "ambiguous" if len(min_dev) != 1 else "detected"
        }
        results["levels"].append(level_result)

        if len(min_dev) == 1 and results["final_detection"] is None:
            results["final_detection"] = min_dev[0][0]
            results["final_confidence"] = abs(min_dev[0][1])
            results["final_level"] = level

    return results


# =============================================================================
# Visualization
# =============================================================================

def visualize_results(grid_img, results, grid_size, title="Anomaly Detection"):
    """Create side-by-side visualization: original grid + confidence heatmap."""
    fig, axes = plt.subplots(1, 2, figsize=(12, 5))

    axes[0].imshow(np.array(grid_img), cmap="gray")
    axes[0].set_title("Original Pattern Grid")
    axes[0].axis("off")

    heatmap = np.zeros((grid_size, grid_size))

    if results["levels"]:
        detection_level = None
        for level_result in results["levels"]:
            if level_result["status"] == "detected":
                detection_level = level_result
                break

        if detection_level is None:
            detection_level = results["levels"][-1]

        for pos, dev in detection_level["deviations"]:
            row, col = pos
            heatmap[row, col] = dev

    im = axes[1].imshow(heatmap, cmap="RdYlGn", interpolation="nearest")
    axes[1].set_title("Confidence Heatmap (Deviation Scores)")

    for row in range(grid_size):
        for col in range(grid_size):
            val = heatmap[row, col]
            color = "white" if abs(val) > 1.5 else "black"
            axes[1].text(col, row, f"{val:.2f}", ha="center", va="center",
                         fontsize=9, color=color, fontweight="bold")

    plt.colorbar(im, ax=axes[1], label="Deviation from Mean")

    if results["final_detection"]:
        det_row, det_col = results["final_detection"]
        confidence_pct = min(99.99, (1 - 2 * (1 - 0.5 * (
            1 + np.math.erf(results["final_confidence"] / np.sqrt(2))
        ))) * 100)
        axes[1].set_xlabel(
            f"Detected anomaly at ({det_row},{det_col}) | "
            f"Confidence: {confidence_pct:.1f}% | "
            f"Resolution: {results['final_level']}px grid",
            fontsize=10
        )

    plt.suptitle(title, fontsize=13, fontweight="bold")
    plt.tight_layout()
    plt.savefig("anomaly_detection_result.png", dpi=150, bbox_inches="tight")
    plt.show()


# =============================================================================
# Main Pipeline
# =============================================================================

def run_detection(grid_size=3, cell_size=64, pattern_type="circle",
                  anomaly_positions=None, anomaly_type="shift", title=None):
    """Run the full anomaly detection pipeline."""
    if anomaly_positions is None:
        anomaly_positions = [(2, 2)]

    if title is None:
        title = f"Anomaly Detection - {anomaly_type.capitalize()} Defect"

    grid_img, cells = generate_grid(
        grid_size, cell_size, pattern_type,
        anomaly_positions=anomaly_positions,
        anomaly_type=anomaly_type
    )

    results = detect_anomalies(cells, grid_size, cell_size)

    print(f"\n{'='*60}")
    print(f"  {title}")
    print(f"{'='*60}")
    print(f"  Grid: {grid_size}x{grid_size} | Cell: {cell_size}px | "
          f"Pattern: {pattern_type}")
    print(f"  Anomaly positions: {anomaly_positions}")
    print(f"  Anomaly type: {anomaly_type}")
    print(f"{'='*60}")

    if results["final_detection"]:
        print(f"\n  DETECTED: Anomaly at position {results['final_detection']}")
        print(f"  Confidence score: {results['final_confidence']:.3f} "
              f"standard deviations")
        print(f"  Detection level: {results['final_level']}px grid partitioning")
    else:
        print("\n  No unambiguous anomaly detected.")

    print(f"\n  Abstraction levels examined: {len(results['levels'])}")
    for level_result in results["levels"]:
        print(f"    Level {level_result['level']:>3}px: "
              f"mean={level_result['mean']:.4f}, "
              f"std={level_result['std']:.4f}, "
              f"status={level_result['status']}")

    visualize_results(grid_img, results, grid_size, title)

    return results


if __name__ == "__main__":
    # Test Case 1: Shifted circle (obvious defect)
    print("\n" + "=" * 60)
    print("  TEST CASE 1: Shifted Pattern (Obvious Defect)")
    run_detection(
        grid_size=3, cell_size=64, pattern_type="circle",
        anomaly_positions=[(2, 2)], anomaly_type="shift",
        title="Test 1: Shifted Circle Defect"
    )

    # Test Case 2: Scaled pattern (subtle defect)
    print("\n" + "=" * 60)
    print("  TEST CASE 2: Scaled Pattern (Subtle Defect)")
    run_detection(
        grid_size=3, cell_size=64, pattern_type="square",
        anomaly_positions=[(1, 1)], anomaly_type="scale",
        title="Test 2: Scaled Square Defect"
    )

    # Test Case 3: Missing element (severe defect)
    print("\n" + "=" * 60)
    print("  TEST CASE 3: Missing Pattern (Severe Defect)")
    run_detection(
        grid_size=3, cell_size=64, pattern_type="cross",
        anomaly_positions=[(0, 2)], anomaly_type="missing",
        title="Test 3: Missing Cross Defect"
    )

Secret mission

Multi-Defect Ambiguity Resolution

Your detector handles single defects well. But real-world inspection surfaces often have multiple flaws at once. Can you extend the system to detect several anomalies simultaneously and resolve ambiguity across abstraction levels, just like Table 1 in the original paper?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally on your machine, so there are no ongoing costs or cloud resources to worry about.

Decide whether to keep your project files and installed packages, or remove them entirely.

Resources you used:

  • Project files: anomaly_detector.py, anomaly_detection_result.png, multi_level_detection.png.
  • Python packages: numpy, pillow, matplotlib.

Keep everything running

No action needed. Choose this if you plan to keep experimenting with the anomaly detector or want to extend it further.

  • Your project files stay in place and the installed Python packages remain available for future use.
  • You can re-run anomaly_detector.py at any time to regenerate the detection results.

Pause - I'll come back to this later

Since this project is entirely local with no running processes, there is nothing to pause. Your files will be right where you left them when you come back.

  • The installed packages take minimal disk space and do not consume memory when not in use.
  • You can safely close your IDE and terminal. Everything persists on disk.

Delete - I don't want to use this again

Remove all project files and uninstall the Python packages you added for this project.

  • Delete the project files by running this command from the folder containing anomaly_detector.py:

macOS

cd ~/Desktop
rm -rf anomaly_detector.py anomaly_detection_result.png multi_level_detection.png

Windows

cd ~\Desktop
Remove-Item anomaly_detector.py, anomaly_detection_result.png, multi_level_detection.png
  • Uninstall the Python packages by running:
pip uninstall numpy pillow matplotlib -y

Keep packages you use elsewhere?

If you use NumPy, Pillow, or Matplotlib in other projects, skip the uninstall step. These packages are common dependencies and removing them may break other scripts.

Nice Work!

Nice Work!

You just translated cutting-edge AI research into a working anomaly detection system. The Confident Ravens algorithm started as a method for solving intelligence tests, and you turned its core principles into a practical visual inspection tool.

You've learned how to:

  • Build a geometric pattern generator with controlled anomaly injection using Pillow and NumPy.
  • Implement the Tversky similarity metric with asymmetric parameters (α=1.0, β=0.0) from the Confident Ravens paper to compare visual relationships.
  • Create a multi-resolution confidence engine that extracts features at multiple abstraction levels and flags statistical outliers with 95% confidence.
  • Secret Mission: Extended the detector to handle multi-defect ambiguity resolution across abstraction levels, tracking how confidence shifts as the algorithm descends to finer resolutions.

Ready to quiz yourself?