Adding a New Analysis Module

This guide provides step-by-step instructions for creating a new analysis module in Granny. Analysis modules are pluggable algorithms that process fruit images and generate quality ratings or measurements.

Overview

An analysis module in Granny:

  • Inherits from the Analysis abstract base class

  • Defines input parameters using the Value system

  • Implements the performAnalysis() method

  • Returns a list of processed Image objects

  • Automatically integrates with all Granny interfaces (CLI, GUI)

  • Can be chained with other analyses using the Scheduler

The Value System

Granny uses a type-safe Value system for parameters. Each parameter is represented by a Value object that provides:

  • Type checking and validation

  • Automatic CLI argument generation

  • Default values and required/optional flags

  • Help text for users

  • Min/max constraints for numeric values

Available Value Types:

  • IntValue - Integer parameters (e.g., thresholds, kernel sizes)

  • FloatValue - Floating-point parameters (e.g., confidence scores, alpha values)

  • StringValue - String parameters (e.g., model names, labels)

  • BoolValue - Boolean flags

  • FileNameValue - File paths

  • FileDirValue - Directory paths

  • ImageListValue - Lists of images (typically input/output directories)

  • MetaDataValue - Metadata storage for results

Step-by-Step Guide

Step 1: Create Your Analysis File

Create a new Python file in Granny/Analyses/ with a descriptive name:

touch Granny/Analyses/MyNewAnalysis.py

Step 2: Import Required Modules

Start your file with necessary imports:

"""
Brief description of what this analysis does.

Author: Your Name
Date: YYYY-MM-DD
"""

import os
from datetime import datetime
from typing import List

import cv2
import numpy as np
from numpy.typing import NDArray

from Granny.Analyses.Analysis import Analysis
from Granny.Models.Images.Image import Image
from Granny.Models.Images.RGBImage import RGBImage
from Granny.Models.IO.ImageIO import ImageIO
from Granny.Models.IO.RGBImageFile import RGBImageFile
from Granny.Models.Values.IntValue import IntValue
from Granny.Models.Values.FloatValue import FloatValue
from Granny.Models.Values.ImageListValue import ImageListValue
from Granny.Models.Values.MetaDataValue import MetaDataValue

Step 3: Define Your Analysis Class

Create your class inheriting from Analysis:

class MyNewAnalysis(Analysis):
    """
    Detailed description of your analysis.

    This analysis processes fruit images to [describe what it does].

    Attributes:
        images (List[Image]): List of loaded images for processing
        input_images (ImageListValue): Input directory parameter
        output_images (ImageListValue): Output directory parameter
        output_results (MetaDataValue): Results directory parameter
        my_threshold (IntValue): Example threshold parameter
    """

    __analysis_name__ = "myanalysis"  # Used in CLI: --analysis myanalysis

Step 4: Implement the Constructor

Initialize your analysis with parameters:

def __init__(self):
    """
    Initialize the analysis with default parameters.
    """
    super().__init__()

    self.images: List[Image] = []

    # Required: Input images parameter
    self.input_images = ImageListValue(
        "input",                                    # Machine-readable name
        "input",                                    # CLI argument name (--input)
        "The directory where input images are located."  # Help text
    )
    self.input_images.setIsRequired(True)          # Make it required
    self.addInParam(self.input_images)             # Register as input parameter

    # Output directory for analyzed images
    self.output_images = ImageListValue(
        "output",
        "output",
        "The output directory where analyzed images are written."
    )
    result_dir = os.path.join(
        os.curdir,
        "results",
        self.__analysis_name__,
        datetime.now().strftime("%Y-%m-%d-%H-%M")
    )
    self.output_images.setValue(result_dir)        # Set default value
    self.addInParam(self.output_images)

    # Output directory for CSV results
    self.output_results = MetaDataValue(
        "results",
        "results",
        "The output directory where analysis results are written."
    )
    self.output_results.setValue(result_dir)

    # Analysis parameter: threshold
    self.my_threshold = IntValue(
        "threshold",                                # Machine-readable name
        "threshold",                                # CLI argument (--threshold)
        "Threshold value for detection. Range 0-255, default 128."
    )
    self.my_threshold.setMin(0)                    # Set constraints
    self.my_threshold.setMax(255)
    self.my_threshold.setValue(128)                # Set default
    self.my_threshold.setIsRequired(False)         # Make it optional
    self.addInParam(self.my_threshold)             # Register parameter

    # Visualization parameter: mask alpha
    self.mask_alpha = FloatValue(
        "alpha",
        "mask_alpha",
        "Transparency of mask overlay (0.0-1.0, default 0.5)."
    )
    self.mask_alpha.setMin(0.0)
    self.mask_alpha.setMax(1.0)
    self.mask_alpha.setValue(0.5)
    self.mask_alpha.setIsRequired(False)
    self.addInParam(self.mask_alpha)

Important Notes:

  • The first argument (name) is the machine-readable identifier

  • The second argument (label) becomes the CLI argument: --label

  • Always call super().__init__() first to initialize the base class

  • Use addInParam() for parameters that users can set

  • Use addRetValue() for values that other analyses can use

  • Set setIsRequired(True) for mandatory parameters

Step 5: Implement performAnalysis()

This is the core method that executes your analysis:

def performAnalysis(self) -> List[Image]:
    """
    Perform the analysis on all input images.

    Returns:
        List[Image]: List of processed Image objects with analysis results
    """
    # Step 1: Load input images
    input_dir = self.input_images.getValue()
    output_dir = self.output_images.getValue()
    results_dir = self.output_results.getValue()

    print(f"Loading images from: {input_dir}")
    imageIO = ImageIO()
    self.images = imageIO.load(input_dir, RGBImageFile)

    if not self.images:
        print("No images found to analyze.")
        return []

    print(f"Processing {len(self.images)} images...")

    # Step 2: Process each image
    result_images = []
    results_data = []  # For CSV output

    for idx, image in enumerate(self.images, 1):
        print(f"  Processing image {idx}/{len(self.images)}: {image.getFileName()}")

        # Get the numpy array
        img_array = image.getImageFile().getImage()

        # Perform your analysis
        result_array, metric_value = self._analyze_image(img_array)

        # Create result image
        result_image = RGBImage()
        result_file = RGBImageFile()
        result_file.setImage(result_array)
        result_file.setFileName(image.getFileName())
        result_file.setFilePath(output_dir)
        result_image.setImageFile(result_file)

        # Add metadata to the image
        result_image.addMetadata(self.metadata)
        result_image.addMetadata([
            {"name": "metric_value", "value": metric_value}
        ])

        result_images.append(result_image)
        results_data.append({
            "filename": image.getFileName(),
            "metric_value": metric_value
        })

    # Step 3: Save results
    print(f"Saving results to: {output_dir}")
    imageIO.save(result_images)

    # Save CSV
    self._save_csv(results_data, results_dir)

    print("Analysis complete!")
    return result_images

def _analyze_image(self, img: NDArray) -> tuple[NDArray, float]:
    """
    Perform analysis on a single image.

    Args:
        img: Input image as numpy array (BGR format)

    Returns:
        Tuple of (processed_image, metric_value)
    """
    # Get parameter values
    threshold = self.my_threshold.getValue()
    alpha = self.mask_alpha.getValue()

    # Your image processing logic here
    # This is a simple example - replace with your actual algorithm

    # Convert to grayscale
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

    # Apply threshold
    _, binary = cv2.threshold(gray, threshold, 255, cv2.THRESH_BINARY)

    # Calculate metric (e.g., percentage of pixels above threshold)
    metric = (np.sum(binary == 255) / binary.size) * 100

    # Create visualization
    mask_color = np.zeros_like(img)
    mask_color[binary == 255] = [0, 255, 0]  # Green overlay

    # Blend with original
    result = cv2.addWeighted(img, 1.0, mask_color, alpha, 0)

    # Add text annotation
    text = f"Metric: {metric:.2f}%"
    cv2.putText(result, text, (20, 50), cv2.FONT_HERSHEY_SIMPLEX,
                1.0, (255, 255, 255), 2, cv2.LINE_AA)

    return result, metric

def _save_csv(self, data: List[dict], output_dir: str):
    """
    Save analysis results to CSV file.

    Args:
        data: List of dictionaries containing results
        output_dir: Directory to save CSV
    """
    import csv

    os.makedirs(output_dir, exist_ok=True)
    csv_path = os.path.join(output_dir, f"{self.__analysis_name__}_results.csv")

    if not data:
        return

    with open(csv_path, 'w', newline='') as f:
        writer = csv.DictWriter(f, fieldnames=data[0].keys())
        writer.writeheader()
        writer.writerows(data)

    print(f"Results saved to: {csv_path}")

Step 6: Add Your Analysis to the Import Path

Edit Granny/Analyses/__init__.py to include your new analysis:

from .MyNewAnalysis import MyNewAnalysis

This makes your analysis discoverable by the CLI interface.

Step 7: Update the CLI Interface

Edit Granny/Interfaces/UI/GrannyCLI.py to add your analysis to the choices list:

iface_grp.add_argument(
    "--analysis",
    dest="analysis",
    type=str,
    required=True,
    choices=["segmentation", "blush", "color", "scald", "starch", "myanalysis"],  # Add here
    help="Indicates the analysis to run.",
)

Also import your analysis class at the top of the file:

from Granny.Analyses.MyNewAnalysis import MyNewAnalysis

Step 8: Create Tests

Create a test file tests/test_Analyses/test_MyNewAnalysis.py:

from Granny.Analyses.MyNewAnalysis import MyNewAnalysis

def test_init():
    """Test that the analysis initializes correctly."""
    analysis = MyNewAnalysis()
    assert analysis.__analysis_name__ == "myanalysis"
    assert analysis.my_threshold.getValue() == 128

def test_performAnalysis():
    """Test the analysis with sample data."""
    analysis = MyNewAnalysis()

    # Set test parameters
    analysis.input_images.setValue("test-assets/sample_images")
    analysis.my_threshold.setValue(100)

    # Run analysis
    results = analysis.performAnalysis()

    # Verify results
    assert isinstance(results, list)
    # Add more specific assertions based on your analysis

Step 9: Test Your Analysis

Run your analysis from the command line:

granny -i cli --analysis myanalysis --input ./demo/images/ --threshold 150 --mask_alpha 0.7

Run the test suite:

python -m pytest tests/test_Analyses/test_MyNewAnalysis.py -v

Best Practices

Parameter Naming

  • Use clear, descriptive names for parameters

  • Distinguish between analysis parameters (affect results) and visualization parameters (affect output only)

  • Use consistent naming across similar parameters (e.g., blur_kernel, morph_kernel)

Documentation

  • Provide comprehensive docstrings for the class and all methods

  • Document parameter ranges, defaults, and units

  • Include examples in the module docstring

  • Explain the scientific/algorithmic basis of your analysis

Performance

  • Process images efficiently using NumPy operations

  • Consider multiprocessing for independent image processing (see BlushColor for example)

  • Minimize memory usage for large image sets

  • Provide progress feedback via print statements

Error Handling

  • Validate input parameters in performAnalysis()

  • Handle cases where no images are found

  • Provide clear error messages to users

  • Gracefully handle edge cases (empty images, invalid formats)

Result Storage

  • Always save both visual results (images) and numerical results (CSV)

  • Include metadata in output images (timestamps, parameters, analysis ID)

  • Use consistent directory structures for results

  • Generate timestamped result directories to avoid overwriting

Advanced Topics

Chaining Analyses

Your analysis can depend on outputs from other analyses using the compatibility system:

def __init__(self):
    super().__init__()

    # Define compatibility with segmentation analysis
    self.compatibility = {
        "segmentation": {
            "input": "output"  # Map our input to segmentation's output
        }
    }

This allows the Scheduler to automatically chain analyses together.

Return Values for Other Analyses

If your analysis produces values that other analyses might use:

# In __init__
self.fruit_coordinates = MetaDataValue(
    "coordinates",
    "coordinates",
    "Bounding box coordinates of detected fruit"
)
self.addRetValue(self.fruit_coordinates)

# In performAnalysis()
self.fruit_coordinates.setValue([(x1, y1, x2, y2), ...])

Custom Image Types

If you need a specialized image type beyond RGBImage:

  1. Create a new class inheriting from Image in Granny/Models/Images/

  2. Create a corresponding file class inheriting from ImageFile in Granny/Models/IO/

  3. Implement required methods for loading and saving

Troubleshooting

Analysis not appearing in CLI:

  • Verify __analysis_name__ is set correctly

  • Check that you added the analysis to GrannyCLI’s choices list

  • Ensure the import statement is in Granny/Analyses/__init__.py

Parameters not showing in help:

  • Verify you called addInParam() for each parameter

  • Check that the parameter’s label doesn’t conflict with existing parameters

  • Ensure you’re setting parameters before calling addInParam()

Images not loading:

  • Verify the input directory path is correct

  • Check that images are in a supported format (JPG, PNG)

  • Ensure ImageIO is initialized correctly

  • Use RGBImageFile for standard RGB images

Results not saving:

  • Verify the output directory is writable

  • Check that imageIO.save() is called with the result images

  • Ensure each image has both an ImageFile and metadata set

  • Verify directory paths are created with os.makedirs(path, exist_ok=True)

Next Steps

  • Review Complete Example Analysis for a complete working example

  • Study existing analyses in Granny/Analyses/ for patterns

  • Refer to API Reference for detailed API documentation

  • Consider contributing your analysis back to the project