Writing Imagery Assets

The Simple Path

For common write tasks — saving a NumPy array to a file with sensible defaults — imsave handles format detection, compression, and blocking for you:

from aws.osml.io import imsave
import numpy as np

data = np.random.randint(0, 255, (3, 512, 512), dtype=np.uint8)

# Format inferred from extension, compression and block size auto-selected
imsave("output.tif", data)   # GeoTIFF with Deflate compression, 256×256 tiles
imsave("output.ntf", data)   # NITF with JPEG 2000 lossless, 1024×1024 blocks
imsave("output.png", data)   # PNG

# 2D arrays are treated as single-band images
grayscale = np.zeros((256, 256), dtype=np.uint8)
imsave("gray.png", grayscale)

# Add georeferencing
imsave("geo.tif", data,
       corners=[(-77.0, 39.0), (-76.5, 39.0), (-76.5, 38.5), (-77.0, 38.5)],
       crs="EPSG:4326")

When you need full control — custom metadata, specific compression parameters, multi-asset datasets, COG overviews, R-set pyramids, or TRE preservation — the low-level write API described below gives you direct access to every encoding detail.

Choosing the Output Format

This library supports writing imagery in multiple formats. NITF 2.1, NSIF 1.0, GeoTIFF, PNG, JPEG 2000, and JPEG are supported. You can either specify the format explicitly or let IO.open auto-detect it from the file extension:

from aws.osml.io import IO

# Explicit format — always works
with IO.open(["output.ntf"], "w", "nitf") as writer:
    ...

# Auto-detected from extension — format parameter omitted
with IO.open(["output.ntf"], "w") as writer:
    ...  # Detected as NITF from .ntf extension

with IO.open(["output.tif"], "w") as writer:
    ...  # Detected as TIFF from .tif extension

When format is omitted, the extension of the first path determines the format. For R-set paths like output.ntf.r1, the .rN suffix is stripped before detection so the underlying .ntf extension is used. If the extension is not recognized, a ValueError is raised — pass the format explicitly in that case.

When format is provided, it takes precedence over the file extension.

Accepted format strings and auto-detected extensions:

Format string

Output

Auto-detected extensions

"nitf", "nitf21", "nitf2.1"

NITF 2.1

.ntf, .nitf

"nsif", "nsif10", "nsif1.0"

NSIF 1.0

.nsf, .nsif

"tiff", "tif", "geotiff", "gtiff"

GeoTIFF

.tif, .tiff, .gtif, .gtiff

"png"

PNG

.png

"j2k", "jp2"

JPEG 2000

.j2k, .jp2

"jpeg", "jpg"

JPEG

.jpg, .jpeg

For reading, IO.open auto-detects the format from the file extension or from magic bytes in the file header. You can also override detection with the format parameter.

Metadata Controls Encoding

Metadata drives how the image is encoded. The BufferedMetadataProvider is a mutable, in-memory key-value store that serves two roles:

  1. Create metadata from scratch when building new images.

  2. Copy and modify metadata from an existing image when transcoding or chipping.

The metadata values you set are highly dependent on the desired output format. A NITF file needs fields like IC, IMODE, and COMRAT; a GeoTIFF file uses TIFF tags and GeoKeys. The field names match what you see when reading files — no translation layer sits between you and the format.

from aws.osml.io import BufferedMetadataProvider

# Create from scratch
metadata = BufferedMetadataProvider()
metadata["IMODE"] = "B"
metadata["IC"] = "NC"

# Copy from an existing provider and modify
copied = BufferedMetadataProvider(source=existing_provider)
copied["IC"] = "C8"           # Switch to JPEG 2000
copied["COMRAT"] = "N001.0"   # Lossless

# Query
value = metadata.get("IMODE")          # "B" or None
all_pairs = metadata.entries()
filtered = metadata.entries("NPP")     # {"NPPBH": "256", "NPPBV": "256"}

Basic Write Workflow

Create a BufferedImageAssetProvider with tiled assets and a BufferedMetadataProvider with encoding hints, then write through the IO interface. This example shows a GeoTIFF workflow with Deflate compression and UTM georeferencing. The TIFF writer expects numeric tag IDs as metadata keys (per the TIFF 6.0 specification). Use TagNameResolver for convenient name-based access. GeoModelType, GeoRasterType, GeoProjectedCRS, GeoPixelScale, and GeoTiepoints are derived from GeoTIFF GeoKeys and coordinate transformation tags:

from aws.osml.io import IO, BufferedImageAssetProvider, BufferedMetadataProvider, PixelType
from aws.osml.io.tiff.utils import TagNameResolver
import numpy as np

# Set up TIFF encoding hints using TagNameResolver for readable names
metadata = BufferedMetadataProvider()
tag_dict = metadata.entries()
resolver = TagNameResolver(tag_dict)
resolver["Compression"] = "Deflate"      # Tag 259: Deflate compression (LZW, None also supported)
resolver["TileWidth"] = "256"            # Tag 322: 256-pixel tile width
resolver["TileLength"] = "256"           # Tag 323: 256-pixel tile height
resolver["Predictor"] = "Horizontal"     # Tag 317: Horizontal differencing predictor
# Write resolved numeric keys back into the metadata provider
for key, value in tag_dict.items():
    metadata[key] = value

# GeoTIFF coordinate reference system (EPSG:32618 = WGS 84 / UTM zone 18N)
metadata["GeoModelType"] = "Projected"
metadata["GeoRasterType"] = "PixelIsArea"
metadata["GeoProjectedCRS"] = "32618"

# Pixel-to-model coordinate transformation
# ModelPixelScaleTag: [scale_x, scale_y, scale_z] in CRS units (meters for UTM)
metadata["GeoPixelScale"] = "[0.5, 0.5, 0.0]"
# ModelTiepointTag: [pixel_x, pixel_y, pixel_z, geo_x, geo_y, geo_z]
metadata["GeoTiepoints"] = "[0, 0, 0, 300000.0, 4500000.0, 0.0]"

# Create a tiled image asset
image_data = np.random.randint(0, 255, (3, 512, 512), dtype=np.uint8)

provider = BufferedImageAssetProvider.create(
    key="output_image",
    num_columns=512,
    num_rows=512,
    num_bands=3,
    block_width=256,
    block_height=256,
    pixel_type=PixelType.UInt8,
    metadata=metadata,
)
provider.set_full_image(image_data)

# Write to disk
with IO.open(["output.tif"], "w", "geotiff") as writer:
    writer.add_asset("image_0", provider,
                     title="Synthetic RGB Image",
                     description="UTM-referenced test image",
                     roles=["data"])

The same pattern applies to NITF — only the metadata field names change. Where GeoTIFF uses Compression and TileWidth, NITF uses IC and NPPBH. See the Format-Specific Encoding Options section below for the full set of fields per format.

Copy-and-Modify Workflow

Read an existing file, modify metadata, and write a new file. This is the typical pattern for transcoding, chipping, or re-compressing imagery.

The writer needs two kinds of metadata:

  • File metadata (NITF only) — populates the file header (security markings, originator, etc.). Set this on the writer via writer.metadata.

  • Image metadata — controls per-image encoding (compression, blocking, GeoTIFF tags, etc.). Attach this to the BufferedImageAssetProvider.

For TIFF, all IFD content (encoding hints and GeoTIFF tags) comes from the provider’s metadata — writer.metadata is not used. For NITF, file-level metadata and image-level metadata are separate. Both can be copied from the original file and selectively overridden:

from aws.osml.io import IO, BufferedImageAssetProvider, BufferedMetadataProvider

with IO.open(["input.ntf"], "r") as reader:
    image = reader.get_asset("image:0")
    block = image.get_block(0, 0, resolution_level=0)

    # Copy file-level metadata from the original dataset
    file_metadata = BufferedMetadataProvider(source=reader.metadata)

    # Copy image-level metadata and override encoding hints
    image_metadata = BufferedMetadataProvider(source=image.metadata)
    image_metadata["IC"] = "C8"
    image_metadata["COMRAT"] = "00.5"
    image_metadata["J2K_DECOMPOSITION_LEVELS"] = "5"
    image_metadata["NPPBH"] = "1024"
    image_metadata["NPPBV"] = "1024"

    provider = BufferedImageAssetProvider.create(
        key="compressed",
        num_columns=image.num_columns,
        num_rows=image.num_rows,
        num_bands=image.num_bands,
        pixel_type=image.pixel_value_type,
        metadata=image_metadata,
    )
    provider.set_full_image(block)

with IO.open(["compressed.ntf"], "w", "nitf") as writer:
    writer.metadata = file_metadata
    writer.add_asset("image_0", provider,
                     title="Re-compressed image",
                     description="JPEG 2000 compressed copy",
                     roles=["data"])

Custom Python Image Providers

add_asset() also accepts plain Python objects that implement the image provider interface via duck typing. This enables lazy per-block processing pipelines — your get_block() is called during encoding, so you only need O(block_size) memory instead of materializing the entire image up front.

import numpy as np
from aws.osml.io import IO, BufferedMetadataProvider

class InvertProvider:
    """Wraps a source image and inverts pixel values on the fly."""

    def __init__(self, source, metadata):
        self.key = source.key
        self.title = source.title
        self.description = source.description
        self.num_rows = source.num_rows
        self.num_columns = source.num_columns
        self.num_bands = source.num_bands
        self.num_bits_per_pixel = source.num_bits_per_pixel
        self.actual_bits_per_pixel = source.actual_bits_per_pixel
        self.pixel_value_type = source.pixel_value_type
        self.num_pixels_per_block_horizontal = source.num_pixels_per_block_horizontal
        self.num_pixels_per_block_vertical = source.num_pixels_per_block_vertical
        self.num_resolution_levels = source.num_resolution_levels
        self.pad_pixel_value = source.pad_pixel_value
        self._source = source
        self._metadata = metadata

    def get_block(self, block_row, block_col, resolution_level, bands=None):
        block = self._source.get_block(block_row, block_col, resolution_level)
        return np.iinfo(block.dtype).max - block  # invert

    @property
    def metadata(self):
        return self._metadata

with IO.open(["input.ntf"], "r") as reader:
    source = reader.get_asset("image:0")

    # Copy source metadata and set encoding hints
    metadata = BufferedMetadataProvider(source=source.metadata)
    metadata["IC"] = "NC"

    provider = InvertProvider(source, metadata)

    with IO.open(["inverted.ntf"], "w", "nitf") as writer:
        writer.add_asset("image:0", provider,
                         "Inverted", "Lazy inversion", ["data"])

A few things to keep in mind:

  • GIL on every block access. The adapter acquires the Python GIL each time it calls get_block() or has_block(). All other properties (num_rows, pixel_value_type, etc.) are cached in Rust at construction time and never re-acquire the GIL.

  • Optional methods have safe defaults. If your object has no has_block method, all blocks are assumed present. If it has no metadata property, empty metadata is used.

  • Required attributes. The object must have all 14 attributes: key, title, description, get_block, num_rows, num_columns, num_bands, num_bits_per_pixel, actual_bits_per_pixel, pixel_value_type, num_pixels_per_block_horizontal, num_pixels_per_block_vertical, num_resolution_levels, and pad_pixel_value. A missing attribute produces a TypeError.

  • Exception propagation. If get_block() raises, the exception surfaces as a RuntimeError containing the original message.

Asset Roles and COG Writing

The roles parameter on add_asset() assigns semantic labels to each asset. Roles affect how the writer encodes the asset — most importantly, the TIFF writer uses roles to set NewSubfileType and control GeoTIFF tag propagation:

  • Assets with role "data" get NewSubfileType = 0 (full-resolution image) and GeoTIFF tags from the provider’s metadata are written to the IFD.

  • Assets with role "overview" get NewSubfileType = 1 (reduced-resolution image) and GeoTIFF tags are suppressed on that IFD, per the OGC COG standard.

To write a COG with overviews, add the full-resolution image with role "data" and each overview with role "overview":

from aws.osml.io import IO, BufferedImageAssetProvider, PixelType
import numpy as np

# Attach GeoTIFF tags to the full-res provider's metadata.
# Overview providers should NOT carry GeoTIFF tags — the writer
# suppresses them automatically for overview IFDs.
with IO.open(["output_cog.tif"], "w", "tiff") as writer:
    # Full-resolution image (GeoTIFF tags come from full_res_provider.metadata)
    writer.add_asset("image:0", full_res_provider,
                     title="Full Resolution", description="Primary image",
                     roles=["data"])

    # First overview (2x downsampled)
    writer.add_asset("image:0:overview:1", overview_1_provider,
                     title="Overview 1", description="2x reduced",
                     roles=["overview"])

    # Second overview (4x downsampled)
    writer.add_asset("image:0:overview:2", overview_2_provider,
                     title="Overview 2", description="4x reduced",
                     roles=["overview"])

If roles are empty but the key contains :overview:, the writer falls back to inferring NewSubfileType = 1 from the key pattern. Explicit roles are preferred.

Writing Multi-File R-Set Pyramids

To write a multi-file R-set pyramid (separate files per resolution level), pass multiple output paths to IO.open() — the same pattern used for reading. The first path is the base file, and additional paths must follow the .rN naming convention:

from aws.osml.io import IO, BufferedImageAssetProvider, PixelType
import numpy as np

# Create providers for each resolution level
full_res = np.random.randint(0, 255, (3, 1024, 1024), dtype=np.uint8)
overview_1 = np.random.randint(0, 255, (3, 512, 512), dtype=np.uint8)
overview_2 = np.random.randint(0, 255, (3, 256, 256), dtype=np.uint8)

base_provider = BufferedImageAssetProvider.create(
    key="image:0", num_columns=1024, num_rows=1024, num_bands=3,
    block_width=256, block_height=256, pixel_type=PixelType.UInt8,
)
base_provider.set_full_image(full_res)

ovr1_provider = BufferedImageAssetProvider.create(
    key="image:0", num_columns=512, num_rows=512, num_bands=3,
    block_width=256, block_height=256, pixel_type=PixelType.UInt8,
)
ovr1_provider.set_full_image(overview_1)

ovr2_provider = BufferedImageAssetProvider.create(
    key="image:0", num_columns=256, num_rows=256, num_bands=3,
    block_width=256, block_height=256, pixel_type=PixelType.UInt8,
)
ovr2_provider.set_full_image(overview_2)

# Write — each overview is routed to its own file
with IO.open(
    ["output.ntf", "output.ntf.r1", "output.ntf.r2"], "w", "nitf"
) as writer:
    writer.add_asset("image:0", base_provider,
                     title="Full Resolution", description="Base image",
                     roles=["data"])
    writer.add_asset("image:0:overview:1", ovr1_provider,
                     title="Overview 1", description="2x reduced",
                     roles=["overview"])
    writer.add_asset("image:0:overview:2", ovr2_provider,
                     title="Overview 2", description="4x reduced",
                     roles=["overview"])

The overview level in the asset key (image:0:overview:1) determines which output file receives the data — it is matched to the .r1 suffix in the path list. The key is re-mapped to image:0 before writing to the inner file, so each R-set file contains a standard single-image dataset.

Reading the pyramid back uses the same multi-path pattern:

from aws.osml.io import IO, AssetType

with IO.open(
    ["output.ntf", "output.ntf.r1", "output.ntf.r2"], "r"
) as dataset:
    for key in dataset.get_asset_keys(asset_type=AssetType.Image):
        asset = dataset.get_asset(key)
        print(f"{key}: {asset.num_columns}x{asset.num_rows}")
    # image:0: 1024x1024
    # image:0:overview:1: 512x512
    # image:0:overview:2: 256x256

Some things to keep in mind:

  • All files in the R-set use the same format. The format is specified once and applied to every path.

  • Additional paths must have a .rN suffix. Paths without it raise a ValueError.

  • File-level metadata set via writer.metadata is forwarded to all output files.

  • The writer closes files in order: base first, then R-set files in ascending level.

Format-Specific Encoding Options

NITF

The NITF writer reads encoding hints from the asset’s metadata. The IC field selects the compression algorithm and determines which other fields are relevant. The IMODE field controls how bands are interleaved within each block.

Uncompressed (IC=NC, NM)

No compression is applied. NM enables the blocked mask table, allowing sparse images where some blocks contain no data.

Field

Values

Description

IC

NC or NM

No compression / no compression with mask

IMODE

B, P, R, S

Band interleave mode

NPPBH

1–8192

Pixels per block horizontal

NPPBV

1–8192

Pixels per block vertical

metadata = BufferedMetadataProvider()
metadata["IC"] = "NC"
metadata["IMODE"] = "B"
metadata["NPPBH"] = "256"
metadata["NPPBV"] = "256"

For sparse images, use NM and only set the blocks that contain data. Missing blocks are treated as masked:

metadata = BufferedMetadataProvider()
metadata["IC"] = "NM"

provider = BufferedImageAssetProvider.create(
    key="sparse",
    num_columns=1024, num_rows=1024, num_bands=3,
    block_width=256, block_height=256,
    pixel_type=PixelType.UInt8,
    metadata=metadata,
)

# Only populate blocks that have data
provider.set_block(0, 0, block_data)
provider.set_block(1, 2, block_data)

JPEG 2000 (IC=C8, M8, CD, MD)

JPEG 2000 compression. C8 is standard J2K, CD is HTJ2K (Part 15) for faster encode/decode. M8 and MD are the masked variants that support sparse images.

Field

Values

Description

IC

C8, M8, CD, MD

J2K / J2K masked / HTJ2K / HTJ2K masked

IMODE

B (required)

Band interleave mode. Must be B (block interleaved) for JPEG 2000 per BPJ2K01.20

NPPBH

1–8192

Pixels per block horizontal

NPPBV

1–8192

Pixels per block vertical

NBPP

1–38

Number of bits per pixel. Must be 1–38 for JPEG 2000 per BPJ2K01.20

ABPP

equals NBPP

Actual bits per pixel. Must equal NBPP for JPEG 2000 per BPJ2K01.20

COMRAT

Nnnn.n, Vnnn.n, or nn.n

Compression ratio indicator representing bits-per-pixel-per-band. Nnnn.n = numerically lossless, where nnn.n is the achieved post-compression bpp (e.g. N001.0). Vnnn.n = visually lossless, where nnn.n is the target bpp (e.g. V020.0). nn.n = lossy target bpp (e.g. 01.0 for 1.0 bpp)

JPEG 2000 Encoder Parameters (J2K_ fields)

The J2K_ prefixed metadata fields are unique: they do not get written into the NITF image subheader or any TRE. They exist only as encoding hints that guide the JPEG 2000 compression algorithm itself. When you read a NITF file, you will not see these fields in the metadata — they are write-only parameters consumed by the encoder and discarded afterward.

Field

Values

Default

Description

J2K_DECOMPOSITION_LEVELS

1–32

5

Wavelet decomposition levels (resolution levels)

J2K_QUALITY_LAYERS

1–255

1

Quality layers for progressive refinement

HTJ2K mode (IC=CD or MD) is determined by the IC code — you do not set it separately. Lossless vs lossy encoding and the compression ratio are derived from the COMRAT field: Nnnn.n selects numerically lossless, Vnnn.n selects visually lossless at the given bpp, and nn.n selects lossy at the target bpp.

# Lossless JPEG 2000
metadata = BufferedMetadataProvider()
metadata["IC"] = "C8"
metadata["IMODE"] = "B"
metadata["COMRAT"] = "N001.0"
metadata["J2K_DECOMPOSITION_LEVELS"] = "5"
metadata["NPPBH"] = "1024"
metadata["NPPBV"] = "1024"

# Lossy JPEG 2000 at ~1.0 bpp (approximately 8:1 compression)
metadata = BufferedMetadataProvider()
metadata["IC"] = "C8"
metadata["IMODE"] = "B"
metadata["COMRAT"] = "01.0"
metadata["J2K_DECOMPOSITION_LEVELS"] = "5"
metadata["J2K_QUALITY_LAYERS"] = "1"
metadata["NPPBH"] = "1024"
metadata["NPPBV"] = "1024"

# HTJ2K (faster encode/decode)
metadata = BufferedMetadataProvider()
metadata["IC"] = "CD"
metadata["IMODE"] = "B"
metadata["COMRAT"] = "01.0"
metadata["NPPBH"] = "1024"
metadata["NPPBV"] = "1024"

JPEG DCT (IC=C3, M3)

JPEG DCT compression. C3 is standard JPEG, M3 is the masked variant. I1 is downsampled JPEG with a 2048×2048 dimension limit.

Field

Values

Description

IC

C3, M3, I1

JPEG / JPEG masked / downsampled JPEG. I1 is limited to images ≤ 2048×2048

IMODE

B, P, S

Band interleave mode. R (row interleaved) is not supported for JPEG DCT

NPPBH

1–8192

Pixels per block horizontal

NPPBV

1–8192

Pixels per block vertical

NBPP

8

Number of bits per pixel. Must be 8 (8-bit pixels only) for JPEG DCT

COMRAT

00.099.9

Quality factor (default: 75.0)

# JPEG at quality 85
metadata = BufferedMetadataProvider()
metadata["IC"] = "C3"
metadata["IMODE"] = "B"
metadata["COMRAT"] = "85.0"
metadata["NPPBH"] = "256"
metadata["NPPBV"] = "256"

GeoTIFF

The GeoTIFF writer reads encoding hints from the asset’s metadata using numeric TIFF tag IDs as keys. Use TagNameResolver for convenient name-based access. The writer supports uncompressed, LZW, Deflate, and PackBits compression.

TIFF Encoding Hints

Standard TIFF tags that control how the image is stored on disk:

Field

Values

Default

Description

Compression

None, LZW, Deflate

Deflate

Compression algorithm

TileWidth

multiple of 16

256

Tile width in pixels

TileHeight

multiple of 16

256

Tile height in pixels

Predictor

None, Horizontal

Horizontal (for LZW/Deflate)

Differencing predictor

GeoTIFF Metadata

GeoKeys and coordinate transformation tags from the OGC GeoTIFF 1.1 standard. These control the georeferencing of the image:

Field

Values

Description

GeoModelType

Projected, Geographic

GTModelTypeGeoKey — model coordinate type

GeoRasterType

PixelIsArea, PixelIsPoint

GTRasterTypeGeoKey — raster space interpretation

GeoProjectedCRS

EPSG code (e.g. 32618)

ProjectedCRSGeoKey — projected CRS

GeoGeographicCRS

EPSG code (e.g. 4326)

GeodeticCRSGeoKey — geographic CRS

GeoPixelScale

[sx, sy, sz]

ModelPixelScaleTag — pixel size in CRS units

GeoTiepoints

[px, py, pz, gx, gy, gz]

ModelTiepointTag — raster-to-model tie points

GeoTransform

[ox, pw, rx, oy, ry, ph]

6-element affine transform (GDAL convention)

# Deflate-compressed GeoTIFF with UTM Zone 18N georeferencing
from aws.osml.io.tiff.utils import TagNameResolver

metadata = BufferedMetadataProvider()
tag_dict = metadata.entries()
resolver = TagNameResolver(tag_dict)
resolver["Compression"] = "Deflate"   # Tag 259
resolver["TileWidth"] = "256"         # Tag 322
resolver["TileLength"] = "256"        # Tag 323
for key, value in tag_dict.items():
    metadata[key] = value
metadata["GeoModelType"] = "Projected"
metadata["GeoRasterType"] = "PixelIsArea"
metadata["GeoProjectedCRS"] = "32618"
metadata["GeoPixelScale"] = "[0.5, 0.5, 0.0]"
metadata["GeoTiepoints"] = "[0, 0, 0, 300000.0, 4500000.0, 0.0]"

Example: NITF Chip with TRE Preservation

Create a NITF chip from an arbitrary pixel region — not just a single block — while carrying forward all metadata including TREs. The chip region may span multiple blocks, so you need to find the overlapping blocks, read each one, and assemble the relevant portions into a single output array. An ICHIPB TRE is added to record where the chip came from in the original image, which is required for downstream mensuration and geopositioning tools to work correctly.

import numpy as np
from aws.osml.io import IO, BufferedImageAssetProvider, BufferedMetadataProvider

# Define the chip region in pixel coordinates (column/row)
x_min, y_min = 100, 200   # top-left corner
x_max, y_max = 612, 456   # bottom-right corner (exclusive)

with IO.open(["input.ntf"], "r") as reader:
    image = reader.get_asset("image:0")

    # Get image and block dimensions
    img_width = image.num_columns
    img_height = image.num_rows
    block_width = image.num_pixels_per_block_horizontal
    block_height = image.num_pixels_per_block_vertical

    # Handle non-blocked images (block size 0 means single block = full image)
    if block_width == 0:
        block_width = img_width
    if block_height == 0:
        block_height = img_height

    # Clamp bounds to image dimensions
    x_min = max(0, x_min)
    y_min = max(0, y_min)
    x_max = min(img_width, x_max)
    y_max = min(img_height, y_max)

    chip_width = x_max - x_min
    chip_height = y_max - y_min

    # Determine which blocks overlap the chip region
    block_col_start = x_min // block_width
    block_col_end = (x_max - 1) // block_width + 1
    block_row_start = y_min // block_height
    block_row_end = (y_max - 1) // block_height + 1

    # Allocate the output chip array (bands, height, width)
    dtype = np.dtype(image.pixel_value_type.to_numpy_dtype())
    chip = np.zeros((image.num_bands, chip_height, chip_width), dtype=dtype)

    # Read overlapping blocks and assemble the chip
    for block_row in range(block_row_start, block_row_end):
        for block_col in range(block_col_start, block_col_end):
            if not image.has_block(block_row, block_col, resolution_level=0):
                continue

            block = image.get_block(block_row, block_col, resolution_level=0)

            # Block's pixel coordinates in image space
            bx = block_col * block_width
            by = block_row * block_height

            # Overlap between this block and the chip region
            src_x0 = max(0, x_min - bx)
            src_y0 = max(0, y_min - by)
            src_x1 = min(block.shape[2], x_max - bx)
            src_y1 = min(block.shape[1], y_max - by)

            # Corresponding destination in the chip array
            dst_x0 = max(0, bx - x_min)
            dst_y0 = max(0, by - y_min)
            dst_x1 = dst_x0 + (src_x1 - src_x0)
            dst_y1 = dst_y0 + (src_y1 - src_y0)

            chip[:, dst_y0:dst_y1, dst_x0:dst_x1] = \
                block[:, src_y0:src_y1, src_x0:src_x1]

    # Preserve file-level metadata (security markings, originator, etc.)
    file_metadata = BufferedMetadataProvider(source=reader.metadata)

    # Preserve image-level metadata (TREs, etc.) and set chip encoding
    image_metadata = BufferedMetadataProvider(source=image.metadata)
    image_metadata["IC"] = "NC"
    image_metadata["IMODE"] = "B"

    # Add ICHIPB TRE to record the chip's origin in the full image.
    # This is required by STDI-0002 Vol 1 App B so that mensuration and
    # geopositioning tools can map chip coordinates back to the original image.
    #
    # Grid point layout (row, col):
    #   (1,1) = top-left       (1,2) = top-right
    #   (2,1) = bottom-left    (2,2) = bottom-right
    #
    # OP_ fields are the output product (chip) corner coordinates.
    # FI_ fields are the corresponding full image coordinates.
    # The .500 offset places the coordinate at the center of the pixel (grid
    # convention per ICHIPB spec Annex A).

    image_metadata["ICHIPB"] = {
        "XFRM_FLAG": "00",                                          # Non-dewarped imagery
        "SCALE_FACTOR": "0001.00000",                               # Full resolution (R0)
        "ANAMRPH_CORR": "00",                                       # No anamorphic correction
        "SCANBLK_NUM": "00",                                        # No scan blocks
        # Output product corner coordinates (chip space)
        "OP_ROW_11": "00000000.500",                                # top-left row
        "OP_COL_11": "00000000.500",                                # top-left col
        "OP_ROW_12": "00000000.500",                                # top-right row
        "OP_COL_12": f"{chip_width - 1:08d}.500",                   # top-right col
        "OP_ROW_21": f"{chip_height - 1:08d}.500",                  # bottom-left row
        "OP_COL_21": "00000000.500",                                # bottom-left col
        "OP_ROW_22": f"{chip_height - 1:08d}.500",                  # bottom-right row
        "OP_COL_22": f"{chip_width - 1:08d}.500",                   # bottom-right col
        # Full image corner coordinates (where the chip came from)
        "FI_ROW_11": f"{y_min:08d}.500",                            # top-left row
        "FI_COL_11": f"{x_min:08d}.500",                            # top-left col
        "FI_ROW_12": f"{y_min:08d}.500",                            # top-right row
        "FI_COL_12": f"{x_max - 1:08d}.500",                        # top-right col
        "FI_ROW_21": f"{y_max - 1:08d}.500",                        # bottom-left row
        "FI_COL_21": f"{x_min:08d}.500",                            # bottom-left col
        "FI_ROW_22": f"{y_max - 1:08d}.500",                        # bottom-right row
        "FI_COL_22": f"{x_max - 1:08d}.500",                        # bottom-right col
        # Full image dimensions
        "FI_ROW": f"{img_height:08d}",
        "FI_COL": f"{img_width:08d}",
    }

    provider = BufferedImageAssetProvider.create(
        key="chip",
        num_columns=chip_width,
        num_rows=chip_height,
        num_bands=image.num_bands,
        pixel_type=image.pixel_value_type,
        metadata=image_metadata,
    )
    provider.set_full_image(chip)

with IO.open(["chip.ntf"], "w", "nitf") as writer:
    writer.metadata = file_metadata
    writer.add_asset("image_0", provider,
                     title="Chipped image",
                     description="Region chip with ICHIPB provenance",
                     roles=["data"])

The ICHIPB fields follow the STDI-0002 Volume 1, Appendix B specification. The key relationships:

Field group

Purpose

XFRM_FLAG

00 for non-dewarped (linear) imagery. Set to 01 if the image has been dewarped, in which case remaining fields are zero-filled.

SCALE_FACTOR

Scale relative to full resolution R0. 0001.00000 = R0, 0002.00000 = R1, etc.

OP_ROW/COL_*

The four corner grid points in the output chip’s coordinate space.

FI_ROW/COL_*

The same four corners mapped to the original full image coordinate space.

FI_ROW, FI_COL

Total rows and columns of the original full image (the extent to which the SDEs apply).

The .500 offset on all coordinates places the point at the center of the pixel, following the ICHIPB grid convention (see Annex A of the ICHIPB spec). For a chip starting at pixel (100, 200) in the full image, FI_COL_11 is 00000100.500 and FI_ROW_11 is 00000200.500.