NRTK Overview

Introduction

NRTK consists of three main parts:

  1. Image Perturbation

  2. Perturbation Factories

  3. Model Evaluation

If you’re unfamiliar with these components, see the Getting Started page for brief descriptions before continuing.

The following sections will guide you through setting up and using an example perturber. By the end of this tutorial, you’ll have a working example that you can expand for your own projects.

To run this notebook in Colab, use the link below:

Open In Colab

Note for Colab users: After setting up the environment, you may need to “Restart Runtime” in order to resolve package version conflicts (see the README for more info).

Prerequisites

Before starting, ensure the following:

  • NRTK is installed (see Installation).

  • Software Requirements:

    • Python 3.9+ installed.

    • pip (Python package manager) installed.

  • Basic Skills: Familiarity with Python programming and using the terminal or command line.

The following sections will guide you through setting up and using an example perturber.

import sys  # noqa: F401

!{sys.executable} -m pip install -qU pip
print("Installing nrtk...")
!{sys.executable} -m pip install -q "nrtk[pybsm]"
print("Done")

Image Perturbation

The core of NRTK is based on image perturbation. NRTK offers a wide variety of ways to perturb images. Libraries such as scikit-image, Pillow, openCV, and pyBSM are used for various types of perturbation. The perturbation classes take an image and perform a transformation based on input parameters. The examples shown below focus on a pyBSM based perturber.

For this example, we are going to use the PybsmPerturber from pyBSM. This perturber is useful for creating new images based on existing parameters. The PybsmSensor and PybsmScenario classes contain the parameters for an existing sensor and environment, respectively.

We’ve preselected parameter values for the sensor and scenario objects for this tutorial, but this pyBSM explanation covers image formation concepts that provide insight into how these parameters are selected.

import numpy as np
from pybsm.otf import dark_current_from_density

from nrtk.impls.perturb_image.pybsm.pybsm_perturber import PybsmPerturber
from nrtk.impls.perturb_image.pybsm.scenario import PybsmScenario
from nrtk.impls.perturb_image.pybsm.sensor import PybsmSensor

opt_trans_wavelengths = np.array([0.58 - 0.08, 0.58 + 0.08]) * 1.0e-6
f = 4  # telescope focal length (m)
p = 0.008e-3  # detector pitch (m)
sensor = PybsmSensor(
    # required
    name="L32511x",
    D=275e-3,  # Telescope diameter (m)
    f=f,
    p_x=p,
    opt_trans_wavelengths=opt_trans_wavelengths,  # Optical system transmission, red  band first (m)
    # optional
    optics_transmission=0.5
    * np.ones(opt_trans_wavelengths.shape[0]),  # guess at the full system optical transmission (excluding obscuration)
    eta=0.4,  # guess
    w_x=p,  # detector width is assumed to be equal to the pitch
    w_y=p,  # detector width is assumed to be equal to the pitch
    int_time=30.0e-3,  # integration time (s) - this is a maximum, the actual integration time will be,
    # determined by the well fill percentage
    dark_current=dark_current_from_density(
        jd=1e-5,
        w_x=p,
        w_y=p,
    ),  # dark current density of 1 nA/cm2 guess, guess mid range for a silicon camera
    read_noise=25.0,  # rms read noise (rms electrons)
    max_n=96000,  # maximum ADC level (electrons)
    bit_depth=11.9,  # bit depth
    max_well_fill=0.6,  # maximum allowable well fill (see the paper for the logic behind this)
    s_x=0.25 * p / f,  # jitter (radians) - The Olson paper says that its "good" so we'll guess 1/4 ifov rms
    s_y=0.25 * p / f,  # jitter (radians) - The Olson paper says that its "good" so we'll guess 1/4 ifov rms
    da_x=100e-6,  # drift (radians/s) - again, we'll guess that it's really good
    da_y=100e-6,  # drift (radians/s) - again, we'll guess that it's really good
    qe_wavelengths=np.array([0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1]) * 1.0e-6,
    qe=np.array([0.05, 0.6, 0.75, 0.85, 0.85, 0.75, 0.5, 0.2, 0]),
)

scenario = PybsmScenario(
    name="niceday",
    ihaze=1,  # weather model
    altitude=9000.0,  # sensor altitude
    ground_range=0.0,  # range to target
    aircraft_speed=100.0,
)

perturber = PybsmPerturber(sensor=sensor, scenario=scenario, ground_range=10000)

In the example above, we have created a pyBSM perturber where the output image will have a ground_range of 10000m instead of 0m. The image below is the original image we will use for future perturbations.

Note: ground_range refers to the projection of line of sight between the camera and target along on the ground (in meters).

import os
import urllib.request

import cv2
import matplotlib.pyplot as plt

data_dir = "./pybsm/data"
os.makedirs(data_dir, exist_ok=True)

url = "https://data.kitware.com/api/v1/item/6596fde89c30d6f4e17c9efc/download"

img_path = os.path.join(data_dir, "M-41 Walker Bulldog (USA) width 319cm height 272cm.tiff")
if not os.path.isfile(img_path):
    _ = urllib.request.urlretrieve(url, img_path)  # noqa: S310

img = cv2.imread(img_path)

fig, ax = plt.subplots()
ax.imshow(img)
ax.set_title("Original Image")
ax.set_axis_off()
../_images/6c056cf3516d31401ded4b394c785749a2b6721e34694ae4241b6f85b53257b3.png

The code block below shows the loading of the image above and the calling of the perturber. It is important to note that the Ground Sample Distance (or img_gsd) is another parameter the user will have to provide. The resulting image is displayed below the code block.

Note: GSD (Ground Sample Distance) is the distance between the centers of two adjacent pixels in an image, measured on the ground. A smaller GSD (more pixels per ground area) means a higher resolution image, capturing more detail. Conversely, a larger GSD (fewer pixels per ground area) means a lower resolution image, capturing less detail.

img_gsd = 3.19 / 165.0  # the width of the tank is 319 cm and it spans ~165 pixels in the image
perturbed_image, _ = perturber.perturb(img, additional_params={"img_gsd": img_gsd})

fig, ax = plt.subplots()
ax.imshow(perturbed_image)
ax.set_title("Perturbed Image")
ax.set_axis_off()
../_images/bb118e04dc81cb58362ac94afb7223ed86a39f14220a7d1d7e1d6a3a2d0cd306.png

From the perturbed image above, we can observe a blurred output image. It is important to note that this is simulated by a sensor with a telescope focal length (f) of 4 metres, detector pitch (p) of 8 millimeters and telescope diameter (D) of 275 millimeters. In the given scenario, the sensor is at 9000 meters above ground level (altitude) mounted on an aircraft moving at 100 meters per second (aircraft_speed).

Any of the parameters in either PybsmSensor or PybsmScenario can be modified; however, the PybsmPerturber basic perturber can modify only a single parameter for each instance call. The next section will cover modifying multiple parameters and multiple values using perturber factories.

Perturbation Factories

Continuing on from the previous example, the snippet below shows the initialization of a CustomPybsmPerturbImageFactory. The theta_keys variable controls which parameter(s) we are modifying and thetas are the actual values of the parameter(s). In this example, we are modifying the focal length (f) with the values of 1, 2, and 3. The modified images are displayed below the code block.

from nrtk.impls.perturb_image_factory.pybsm import CustomPybsmPerturbImageFactory

focal_length_pf = CustomPybsmPerturbImageFactory(
    sensor=sensor,
    scenario=scenario,
    theta_keys=["f"],
    thetas=[[1, 2, 3]],
)

_, ax = plt.subplots(1, 3, figsize=(10, 4))
for idx, perturber in enumerate(focal_length_pf):
    perturbed_img, _ = perturber(img, additional_params={"img_gsd": img_gsd})
    ax[idx].set_title(f"focal_length: {idx + 1}")
    ax[idx].imshow(perturbed_img)
    _ = ax[idx].axis("off")
plt.tight_layout()
../_images/5194bc54f3aebaeef6f9ba43bf0ad0e55ded4684931bb2c7cc0f80b96a3fb456.png

From the generated output images above, we can observe that as focal length increases from 1 to 3 meters, a sharper image of the tank is generated indicating that, comparatively, a focal length of 3 meters is preferable to get a sharper image of the tank under the given PybsmSensor and PybsmScenario configuration as defined in the Image Perturbation section.

Using the CustomPybsmPerturbImageFactory, it is possible to assign series of values to one or more parameters simultaneously. Each CustomPybsmPerturbImageFactory instance consists of a series of individual perturber instances based on the number of parameter-value combinations.

The code block below shows the focal length and ground range variables being modified. The resulting images are displayed below the code block.

import itertools

f_groung_range_pf = CustomPybsmPerturbImageFactory(
    sensor=sensor,
    scenario=scenario,
    theta_keys=["f", "ground_range"],
    thetas=[[1, 2], [10000, 20000]],
)
perturber_factory_config = f_groung_range_pf.get_config()
if "theta_keys" in perturber_factory_config:  # pyBSM doesn't follow interface rules
    perturb_factory_keys = perturber_factory_config["theta_keys"]
    thetas = f_groung_range_pf.thetas
else:
    perturb_factory_keys = [f_groung_range_pf.theta_key]
    thetas = [f_groung_range_pf.thetas]
perturber_combinations = [dict(zip(perturb_factory_keys, v, strict=False)) for v in itertools.product(*thetas)]

_, ax = plt.subplots(1, 4, figsize=(10, 4))
for idx, perturber in enumerate(f_groung_range_pf):
    perturbed_img, _ = perturber(img, additional_params={"img_gsd": img_gsd})
    ax[idx].set_title(
        f"focal_length: {perturber_combinations[idx]['f']}\n"
        f"ground_range: {perturber_combinations[idx]['ground_range']}",
    )
    ax[idx].imshow(perturbed_img)
    _ = ax[idx].axis("off")
plt.tight_layout()
../_images/bb3cf0260f5e16ad3f16687329e0fd7cb02ebbba0ebb0da6f688cedc0c840b81.png

In the above images, it is important to note that the simulation does not follow a linear progression as seen in the prior example. When modifying multiple parameters simultaneously, we can observe drastic changes in the image texture (blurring and subsampling effects) as we sweep through to find the optimal combination for the f (focal_length) and ground_range parameters.

Model Evaluation

To see examples of image classification and object detection, the coco_scorer notebook from the examples directory shows different scoring techniques. For examples of model response to image degradations, there are two notebooks to check out. The simple_generic_generator notebook shows model response to image degradation through perturbers based on scikit-image, Pillow, and openCV. The simple_pybsm_generator notebook shows model response to image degradation through pyBSM-based perturbers.

Model Evaluation with MAITE

NRTK’s image perturbation tools are often used in conjuction with evaluation workflows such as Modular AI Trustworthy Engineering (MAITE), which support modular test configuration and scoring. If you’re working within a JATIC ecosystem or evaluating robustness as scale, tools like MAITE help integrate perturbations into end-to-end pipelines. See our Testing & Evaluation (T&E) Guides for tutorials on evaluating models against operational risks using NRTK and MAITE.

Next Steps

Now that you’ve completed this tutorial, you can:

  • Explore Advanced Features: Try different perturbation methods.

  • Use Larger Datasets: Test NRTK on real-world datasets.

See How-To Guides for more details on applying various perturbations.

See pages under the Reference section for code documentation.

See the pyBSM documentation for explanatory information regarding the theory behind perturbations, jitter effects, and the significance of certain parameters.