Repository: aws-solutions-library-samples/osml-model-runner (language: python)

Issue:
Release/v2.0.0
*Issue #, if available:*

*Description of changes:*
Release branch to dev

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.



Discussion / hints:

Fix patch:
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 00000000..3b25cb87
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,18 @@
+**Issue #, if available:** n/a
+
+### Notes
+
+
+### Checklist
+
+Before you submit a pull request, please make sure you have the following:
+- [ ] Code changes are compact and well-structured to facilitate easy review
+- [ ] Changes are documented in the README.md and other relevant documentation pages
+- [ ] PR title and description accurately reflect the changes and are detailed enough for historical tracking
+- [ ] PR contains tests that cover all new code and the code has been manual tested
+- [ ] All new dependencies are declared (if any), and no unnecessary libraries are added
+- [ ] Performance impacts (if any) of the changes are evaluated and documented
+- [ ] Security implications of the changes (if any) are reviewed and addressed
+- [ ] I have read the [Contributing Guidelines](../CONTRIBUTING.md) and agree to follow the [Code of Conduct](../CODE_OF_CONDUCT.md)
+
+By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
diff --git a/.github/copyright.yaml b/.github/copyright.yaml
new file mode 100644
index 00000000..6b2513d2
--- /dev/null
+++ b/.github/copyright.yaml
@@ -0,0 +1,5 @@
+one_date_re: ' Copyright (?P<year>[0-9]{4})'
+two_date_re: ' Copyright (?P<from>[0-9]{4})-(?P<to>[0-9]{4})'
+one_date_format: ' Copyright {year}'
+two_date_format: ' Copyright {from}-{to}'
+license_file: LICENSE
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 00000000..ee60a97f
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,13 @@
+name: build
+
+on:
+  pull_request:
+    branches: ["main"]
+
+jobs:
+  tox:
+    uses: ./.github/workflows/tox.yml
+    secrets: inherit
+  docker-build:
+    uses: ./.github/workflows/docker-build.yml
+    secrets: inherit
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 651c70b5..e93201ca 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -1,14 +1,14 @@
-name: Build Docker Container
+name: docker build
 
 on:
   workflow_call:
 
 jobs:
-  build:
+  docker-build:
     runs-on: ubuntu-latest
     steps:
     - uses: actions/checkout@v4
       with:
         lfs: 'true'
-    - name: Build the Docker image
+    - name: Build the Docker Container
       run: docker build . --file Dockerfile --tag osml-model-runner:$(date +%s)
diff --git a/.github/workflows/docker-publish-github.yml b/.github/workflows/docker-publish-github.yml
deleted file mode 100644
index dc70059e..00000000
--- a/.github/workflows/docker-publish-github.yml
+++ /dev/null
@@ -1,38 +0,0 @@
-name: Publish OSML Model Runner GitHub Container
-
-on:
-  workflow_call:
-
-env:
-  REGISTRY: ghcr.io
-  IMAGE_NAME: ${{ github.repository }}
-
-jobs:
-  build-and-push-image:
-    runs-on: ubuntu-latest
-    permissions:
-      contents: read
-      packages: write
-    steps:
-      - name: Checkout repository
-        uses: actions/checkout@v4
-        with:
-          lfs: 'true'
-      - name: Log in to the Container registry
-        uses: docker/login-action@v3
-        with:
-          registry: ${{ env.REGISTRY }}
-          username: ${{ github.actor }}
-          password: ${{ secrets.GITHUB_TOKEN }}
-      - name: Extract metadata (tags, labels) for Docker
-        id: meta
-        uses: docker/metadata-action@v5
-        with:
-          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
-      - name: Push to Github
-        uses: docker/build-push-action@v5
-        with:
-          context: .
-          push: true
-          tags: ${{ steps.meta.outputs.tags }}
-          labels: ${{ steps.meta.outputs.labels }}
diff --git a/.github/workflows/docker-publish-dockerhub.yml b/.github/workflows/docker-publish.yml
similarity index 56%
rename from .github/workflows/docker-publish-dockerhub.yml
rename to .github/workflows/docker-publish.yml
index de47b404..354be7ff 100644
--- a/.github/workflows/docker-publish-dockerhub.yml
+++ b/.github/workflows/docker-publish.yml
@@ -1,4 +1,4 @@
-name: Publish OSML Model Runner DockerHub Container
+name: docker publish
 
 on:
   workflow_call:
@@ -8,26 +8,21 @@ env:
   IMAGE_NAME: ${{ github.event.repository.name }}
 
 jobs:
-  push_to_registry:
-    name: Push Docker image to Docker Hub
+  docker-publish:
     runs-on: ubuntu-latest
     steps:
-      - name: Check out the repo
-        uses: actions/checkout@v4
+      - uses: actions/checkout@v4
         with:
           lfs: 'true'
-      - name: Log in to Docker Hub
-        uses: docker/login-action@v3
+      - uses: docker/login-action@v3
         with:
           username: ${{ secrets.DOCKER_USERNAME }}
           password: ${{ secrets.DOCKER_PASSWORD }}
-      - name: Extract metadata (tags, labels) for Docker
+      - uses: docker/metadata-action@v5
         id: meta
-        uses: docker/metadata-action@v5
         with:
           images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
-      - name: Publish Container to Dockerhub
-        uses: docker/build-push-action@v5
+      - uses: docker/build-push-action@v5
         with:
           context: .
           file: ./Dockerfile
diff --git a/.github/workflows/documentation-publish.yml b/.github/workflows/docs.yml
similarity index 61%
rename from .github/workflows/documentation-publish.yml
rename to .github/workflows/docs.yml
index 9847301b..1ccf9001 100644
--- a/.github/workflows/documentation-publish.yml
+++ b/.github/workflows/docs.yml
@@ -1,4 +1,4 @@
-name: Generate and Publish Documentation for OSML Model Runner
+name: docs
 
 on:
   workflow_call:
@@ -8,22 +8,20 @@ permissions:
 
 jobs:
   docs:
-    if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v4
-      - uses: actions/setup-python@v3
-      - name: Install dependencies
+      - uses: actions/setup-python@v5
+        with:
+          python-version: '3.10'
+      - name: Build Docs
         run: |
           pip install sphinx
           python -m pip install sphinx-autoapi
           python -m pip install sphinx_rtd_theme
           python -m pip install tox tox-gh-actions
-      - name: Sphinx build
-        run: |
           tox -e docs
-      - name: Deploy
-        uses: peaceiris/actions-gh-pages@v3.9.3
+      - uses: peaceiris/actions-gh-pages@v3.9.3
         with:
           publish_branch: gh-pages
           github_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/notify-guidance-repo.yml b/.github/workflows/notify.yml
similarity index 64%
rename from .github/workflows/notify-guidance-repo.yml
rename to .github/workflows/notify.yml
index 8f3bc47b..32b10287 100644
--- a/.github/workflows/notify-guidance-repo.yml
+++ b/.github/workflows/notify.yml
@@ -1,16 +1,15 @@
-name: Notify Guidance Overhead Imagery Repo
+name: notify
 
 on:
-  workflow_call:
+  push:
+    branches: ["main"]
 
 jobs:
   notify:
     runs-on: ubuntu-latest
     steps:
-    - name: Short Sha
-      uses: benjlevesque/short-sha@v2.2
-    - name: Invoke Guidance OSML Submodule Workflow
-      uses: benc-uk/workflow-dispatch@v1.2.2
+    - uses: benjlevesque/short-sha@v2.2
+    - uses: benc-uk/workflow-dispatch@v1.2.2
       with:
         repo: aws-solutions-library-samples/guidance-for-overhead-imagery-inference-on-aws
         workflow: osml_update_submodules.yml
diff --git a/.github/workflows/osml-model-runner-build.yml b/.github/workflows/osml-model-runner-build.yml
deleted file mode 100644
index 3b69b95d..00000000
--- a/.github/workflows/osml-model-runner-build.yml
+++ /dev/null
@@ -1,13 +0,0 @@
-name: "OSML Model Runner Build Workflow"
-
-on:
-  pull_request:
-    branches: ["main", "dev"]
-
-jobs:
-  Build_Validate_Tox:
-    uses: ./.github/workflows/python-tox.yml
-    secrets: inherit
-  Build_Docker_Container:
-    uses: ./.github/workflows/docker-build.yml
-    secrets: inherit
diff --git a/.github/workflows/osml-model-runner-publish.yml b/.github/workflows/osml-model-runner-publish.yml
deleted file mode 100644
index 1ef7f00f..00000000
--- a/.github/workflows/osml-model-runner-publish.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-name: "OSML Model Runner Build and Publish Workflow"
-
-on:
-  push:
-    branches: ["main", "dev"]
-
-jobs:
-  Build_Validate_Tox:
-    uses: ./.github/workflows/python-tox.yml
-    secrets: inherit
-  Build_Docker_Container:
-    uses: ./.github/workflows/docker-build.yml
-    secrets: inherit
-  Publish_Python:
-    needs: [Build_Validate_Tox, Build_Docker_Container]
-    uses: ./.github/workflows/python-publish.yml
-    secrets: inherit
-  Publish_Docker_Dockerhub:
-    needs: [Build_Validate_Tox, Build_Docker_Container]
-    uses: ./.github/workflows/docker-publish-dockerhub.yml
-    secrets: inherit
-  Publish_Docker_Github:
-    needs: [Build_Validate_Tox, Build_Docker_Container]
-    uses: ./.github/workflows/docker-publish-github.yml
-    secrets: inherit
-  Publish_Documentation:
-    needs: [Publish_Python, Publish_Docker_Github, Publish_Docker_Dockerhub]
-    uses: ./.github/workflows/documentation-publish.yml
-    secrets: inherit
-  Notify_Guidance_Repo:
-    needs: [Publish_Docker_Github, Publish_Docker_Dockerhub]
-    uses: ./.github/workflows/notify-guidance-repo.yml
-    secrets: inherit
diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml
new file mode 100644
index 00000000..4ad39a68
--- /dev/null
+++ b/.github/workflows/pypi.yml
@@ -0,0 +1,29 @@
+# This workflow will upload a Python Package using Twine when a release is created
+# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
+
+name: pypi
+
+on:
+  workflow_call:
+
+permissions:
+  contents: read
+  id-token: write
+
+jobs:
+  deploy:
+    if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
+    runs-on: ubuntu-latest
+    environment:
+      name: release
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: '3.10'
+      - name: Build Package
+        run: |
+          python -m pip install --upgrade pip
+          pip install build
+          python -m build
+      - uses: pypa/gh-action-pypi-publish@v1.8.11
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
deleted file mode 100644
index f3b91793..00000000
--- a/.github/workflows/python-publish.yml
+++ /dev/null
@@ -1,34 +0,0 @@
-# This workflow will upload a Python Package using Twine when a release is created
-# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
-
-name: Publish Python Package
-
-on:
-  workflow_call:
-  release:
-    types: [published]
-
-permissions:
-  contents: read
-
-jobs:
-  deploy:
-    if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
-    runs-on: ubuntu-latest
-    steps:
-    - uses: actions/checkout@v4
-    - name: Set up Python
-      uses: actions/setup-python@v3
-      with:
-        python-version: '3.x'
-    - name: Install dependencies
-      run: |
-        python -m pip install --upgrade pip
-        pip install build
-    - name: Build package
-      run: python -m build
-    - name: Publish package
-      uses: pypa/gh-action-pypi-publish@v1.8.10
-      with:
-        user: __token__
-        password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.github/workflows/python-tox.yml b/.github/workflows/python-tox.yml
deleted file mode 100644
index 82f17e69..00000000
--- a/.github/workflows/python-tox.yml
+++ /dev/null
@@ -1,26 +0,0 @@
-name: Build/Validation with Tox
-
-on:
-  workflow_call:
-
-permissions:
-  contents: read
-
-jobs:
-  build:
-    runs-on: ubuntu-latest
-    steps:
-    - uses: actions/checkout@v4
-      with:
-        lfs: 'true'
-    - name: Set up Python 3.11
-      uses: actions/setup-python@v5
-      with:
-        python-version: "3.11"
-    - name: Install dependencies
-      run: |
-        python -m pip install --upgrade pip tox
-    - name: Validate package with tox
-      run: |
-        # stop the build if there are Python syntax errors or undefined names
-        tox
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..5bec15bb
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,25 @@
+name: release
+
+on:
+  release:
+    types: [published]
+
+jobs:
+  tox:
+    uses: ./.github/workflows/tox.yml
+    secrets: inherit
+  docker-build:
+    uses: ./.github/workflows/docker-build.yml
+    secrets: inherit
+  pypi:
+    needs: [tox, docker-build]
+    uses: ./.github/workflows/pypi.yml
+    secrets: inherit
+  docker-publish:
+    needs: [tox, docker-build]
+    uses: ./.github/workflows/docker-publish.yml
+    secrets: inherit
+  docs:
+    needs: [pypi, docker-publish]
+    uses: ./.github/workflows/docs.yml
+    secrets: inherit
diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml
new file mode 100644
index 00000000..14d7ff61
--- /dev/null
+++ b/.github/workflows/tox.yml
@@ -0,0 +1,25 @@
+# This workflow will install Python dependencies, run tests and lint with a single version of Python
+# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
+
+name: tox
+
+on:
+  workflow_call:
+
+permissions:
+  contents: read
+
+jobs:
+  unit-test:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v4
+      with:
+        lfs: 'true'
+    - uses: actions/setup-python@v5
+      with:
+        python-version: "3.10"
+    - name: Run Tox
+      run: |
+        python -m pip install --upgrade pip tox
+        tox
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 05b63310..31ff7ac5 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -40,3 +40,37 @@ repos:
           stages: [ manual ]
           # uncomment the line below if using "finch" on MacOS
           # args: [ "-f" ]
+
+  - repo: https://github.com/sbrunner/pre-commit-copyright
+    rev: 0.7.0
+    hooks:
+      - id: copyright
+        name: update copyright
+        exclude: |
+          (?x)^(
+            doc/.*|
+            ^\..+|
+            .+\.md|
+            test/data/.*|
+            LICENSE|
+            tox.ini|
+            pyproject.toml|
+            setup.cfg|
+            setup.py|
+            py.typed
+          )$
+      - id: copyright-required
+        name: require copyright
+        exclude: |
+          (?x)^(
+            doc/.*|
+            ^\..+|
+            .+\.md|
+            test/data/.*|
+            LICENSE|
+            tox.ini|
+            pyproject.toml|
+            setup.cfg|
+            setup.py|
+            py.typed
+          )$
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b879f170..eed8b0a0 100755
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,235 +1,190 @@
 # OSML Contributing Guidelines
 
 Thank you for your interest in contributing to our project! This document will guide you through the process of
-contributing to our repository using a release-based GitFlow workflow. Please read the guidelines carefully in order to
+contributing to our repository using a Trunk-Based Development workflow. Please read the guidelines carefully to
 submit your pull requests effectively.
 
 ## Table of Contents
 
-- [Release-based GitFlow](#release-based-gitflow)
-- [Branches](#branches)
+- [Trunk-Based Development](#trunk-based-development)
 - [Linting](#linting)
-- [Use Cases](#use-cases)
-   - [Develop a new feature](#develop-a-new-feature)
-   - [Develop multiple features in parallel](#develop-multiple-features-in-parallel)
-   - [Create and deploy a release](#create-and-deploy-a-release)
-   - [Production hot fix](#production-hot-fix)
 - [Code Style](#code-style)
 - [Commit Messages](#commit-messages)
 - [Issue Tracking](#issue-tracking)
 
-## Release-based GitFlow
+## Trunk-Based Development
 
-We follow a release-based GitFlow branching model to manage our codebase. It involves different types of branches to
-ensure proper version control and collaboration. The primary branches in this workflow are:
+![Trunk-Based Development](https://github.com/aws-solutions-library-samples/osml-imagery-toolkit/assets/4109909/0b3c03ae-4518-471e-9331-da850f0d2e22)
 
-note- `main`: This branch represents the latest production-ready state of the project code.
-- `dev`: This branch is used as the integration branch, combining all feature branches for a specific release.
-- `release/vX.Y.Z`: Branches in the format of `release/vX.Y.Z` are used to prepare a specific release.
-- `feature/*`: Feature branches are created for the development of new features or significant enhancements.
-- `hotfix/*`: Hotfix branches are created for bug fixes against production code.
+We follow a trunk-based development model to manage our codebase.
+This guide provides a step-by-step example of implementing a Trunk-Based Development (TBD) workflow for development.
+The branches developers should create directly off of `main` (our trunk) as part of this workflow are:
 
+- `feature/*`: `feature` branches are created for the development of new features or significant enhancements.
 
-## Branches
-
-Here's a brief description of the supported branches in our repository:
-
-![Release Deployment workflow](images/release-deployment.png)
-
-| Branch           | Protected? | Base Branch        | Description                                                                                                                                                                                        |
-|:-----------------|:-----------|:-------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `main`           | YES        | N/A                | What is live in production (**stable**).<br/>A pull request is required to merge code into `main`.                                                                                                 |
-| `dev`            | YES        | `main`             | The latest state of development (**unstable**).<br/>A pull request is required to merge code into `dev`                                                                                            |
-| `feature/*`      | NO         | `dev`              | Cutting-edge features (**unstable**). These branches are used for any maintenance features / active development.                                                                                   |
-| `release/vX.Y.Z` | NO         | `dev`              | A temporary release branch that follows the [semver](http://semver.org/) versioning. This is what is sent to UAT.<br/>A pull request is required to merge code into any `release/vX.Y.Z` branch.   |
-| `hotfix/*`       | NO         | `main`             | These are bug fixes against production.<br/>This is used because develop might have moved on from the last published state.<br/>Remember to merge this back into develop and any release branches. |
-
-
-## Linting
-
-This package uses a number of tools to enforce formatting, linting, and general best practices:
-* [eslint](https://github.com/pre-commit/mirrors-eslint) to check pep8 compliance and logical errors in code
-* [prettier](https://github.com/pre-commit/mirrors-prettier) to check pep8 compliance and logical errors in code
-* [pre-commit](https://github.com/pre-commit/pre-commit-hooks) to install and control linters in githooks
-
-After pulling the repository, you should enable auto linting git commit hooks by running:
+### 1. Creating Feature (Internal Developer)
 
+#### Step 1: Clone the Repository
+Clone the repository to your local machine.
 ```bash
-python3 -m pip install pre-commit
-pre-commit install
+git clone git@github.com:aws-solutions-library-samples/osml-model-runner.git
 ```
 
-In addition to running the linters against files included in a commit, you can perform linting on all the files
-in the package by running:
+#### Step 2: Create a New Branch
+Create a new branch off `main`, naming it related to the work being done, in this case a feature.
 ```bash
-pre-commit run --all-files --show-diff-on-failure
+git checkout -b feature/<feature_id>
 ```
-or if using tox
-```bash
-tox -e lint
-````
-
-## Use Cases
 
-### Develop a new feature
+#### Step 3: Develop and Commit Changes
+On the new branch, code, and add commits as necessary.
 
-1. Make sure your `dev` branch is up-to-date
-
-2. Create a feature branch based off of `dev`
-
-   ```bash
-   $ git checkout dev
-   $ git checkout -b feature/new-documentation
-   $ git push --set-upstream feature/new-documentation
-   ```
+#### Step 4: Push the New Branch
+Push the new branch to trigger unit tests, static code analysis, Sonar cube checks, security checks, etc.,
+before merging to `main`.
+```bash
+git push origin feature/<feature_id>
+```
 
-3. Develop the code for the new feature and commit. Push your changes often. This
-   allows others to see your changes and suggest improvements/changes as well as
-   provides a safety net should your hard drive crash.
+#### Step 5: Create a Pull Request (PR)
+Open a PR against `main` to kick off a discussion.
+Open a PR as soon as possible, even if it's not ready (mark it as WIP).
+This ensures ample time for review and discussion, improving code quality.
 
-    ```bash
-    $ ... make changes
-    $ git add -A .
-    $ git commit -m "Add new documentation files"
-    $ ... make more changes
-    $ git add -A .
-    $ git commit -m "Fix some spelling errors"
-    $ git push
-    ```
+#### Step 6: PR Review, Rebase, and Merge
+After your PR is reviewed, make any necessary changes by repeating steps 3 and 4.
+A team member will merge your PR after approval,
+ensuring it's reviewed by someone who has not contributed to the branch.
+Also ensure that your branch is rebased against the latest changes to `main`.
+```bash
+git pull --rebase origin main
+git push origin feature/<feature_id>
+```
+Now you are ready to merge your changes!
 
-4. Navigate to the project on [GitHub](https://github.com) and open a pull request with
-   the following branch settings:
-    * Base: `dev`
-    * Compare: `feature/new-documentation`
+### 2. Contributing via Fork and Pull Request (External Developer)
 
-5. When the pull request was reviewed, merge and close it and delete the
-   `feature/new-documentation` branch.
+We welcome contributions from the community!
+If you're looking to contribute to our project, please follow these steps to fork the repository,
+make your changes, and submit a pull request (PR) for review.
 
-### Develop multiple features in parallel
+#### Step 1: Fork the Repository
 
-There's nothing special about that. Each developer follows the above [Develop a new feature](#develop-a-new-feature) process.
+1. Navigate to the GitHub page of our repository.
+2. In the top-right corner of the page, click the **Fork** button. This creates a copy of the repository in your GitHub account.
 
-### Create and deploy a release
-1. Merge `main` into `dev` to ensure the new release will contain the
-   latest production code. This reduces the chance of a merge conflict during
-   the release.
+#### Step 2: Clone Your Fork
 
+1. On your GitHub page for the forked repository, click the **Clone** or **Download** button and copy the URL.
+2. Open your terminal or command prompt.
+3. Clone your fork using the command:
    ```bash
-   $ git checkout dev
-   $ git merge main
+   git clone [URL of the forked repository]
    ```
-
-2. Create a new `release/vX.Y.Z` release branch off of `dev`.
-
+4. Navigate into the cloned repository:
    ```bash
-   $ git checkout -b release/vX.Y.Z
-   $ git push --set-upstream release/vX.Y.Z
+   cd [repository-name]
    ```
 
-3. Stabilize the release by using bugfix branches off of the `release/vX.Y.Z` branch
-   (the same way you would do a feature branch off of `dev`).
+#### Step 3: Create a New Branch
 
+Create a new branch for your changes based off the latest `main`:
    ```bash
-   $ git checkout release/vX.Y.Z
-   $ git checkout -b hotfix/fix-label-alignment
-   $ git push --set-upstream hotfix/fix-label-alignment
-   ... do work
-   $ git commit -m "Adjust label to align with button"
-   $ git push
+   git checkout -b [branch-name]
    ```
+Follow the branch naming conventions mentioned earlier.
 
-4. When the code is ready to release, navigate to the project on
-   [GitHub](https://github.com) and open a pull request with the following branch
-   settings:
-    * Base: `main`
-    * Compare: `release/vX.Y.Z`
-      Paste the Release Checklist into the PR body. Each project should define a release
-      checklist. TBD - will link release checklist here.
-
-5. At some point in the checklist you will merge the release branch into `main`.
-   You can do this by using the "Merge pull request" button on the release PR.
-
-6. Now you are ready to create the actual release. Navigate to the project page
-   on GitHub and draft a new release with the following settings:
-    * Tag version: `vX.Y.Z`
-    * Target: `main`
-    * Release title: `Release vX.Y.Z`
-    * Description: Includes a high-level list of things changed in this release.
-      Click `Publish release`.
+If you wish to work with a stable release version of our project,
+you might want to clone a specific release tag rather than the latest state of the main branch.
+Here's how you can do that:
 
-7. Merge the `release/vX.Y.Z` into `dev`.
-
-    ```bash
-    $ git checkout dev
-    $ git merge release/vX.Y.Z
-    $ git push
-    ```
+1. **List Available Tags**: First, to see the available tags (release versions), navigate to the repository on GitHub, then go to the **Tags** section in the **Releases** tab. Alternatively, you can list tags from the command line using:
+   ```bash
+   git ls-remote --tags [URL of the original repository]
+   ```
+2. **Clone the Repository**: If you haven't already, clone the repository using:
+   ```bash
+   git clone [URL of the original repository]
+   ```
+3. **Checkout the Tag**: Navigate into the cloned repository directory:
+   ```bash
+   cd [repository-name]
+   ```
+   Then, checkout the specific tag you're interested in working with:
+   ```bash
+   git checkout tags/[tag-name]
+   ```
+   Replace `[tag-name]` with the desired tag.
+   This will put you in a 'detached HEAD' state, which is fine for browsing a tag.
 
-8. Finish off the tasks in the release checklist. Once everything is done, close
-   the release PR.
+If you plan to make changes starting from this tag and contribute back,
+it's a good idea to create a new branch from this tag:
 
-9. Delete the release branch on GitHub.
+```bash
+git checkout -b [new-branch-name] tags/[tag-name]
+```
 
-### Production hot fix
+This way, you can start your changes from a specific version of the project.
 
-A production hotfix is very similar to a full-scale release except that you do
-your work in a branch taken directly off of `main`. Hotfixes are useful in cases
-where you want to patch a bug in a released version, but `dev` has unreleased
-code in it already.
+#### Step 4: Make Your Changes
 
-**TBD: Insert diagram**
+- Make the necessary changes in your branch. Feel free to add, edit, or remove content as needed.
 
-1. Create a hot fix branch based off of `main`.
+#### Step 5: Commit Your Changes
 
+1. Stage your changes for commit:
    ```bash
-   $ git checkout main
-   $ git checkout -b hotfix/documentation-broken-links
-   $ git push --set-upstream origin hotfix/documentation-broken-links
+   git add .
+   ```
+2. Commit your changes with a meaningful commit message:
+   ```bash
+   git commit -m "A descriptive message explaining your changes"
    ```
 
-2. Add a test case to validate the bug, fix the bug, and commit.
+#### Step 6: Push Your Changes
 
+1. Push your changes to your fork:
    ```bash
-   ... add test, fix bug, verify
-   $ git add -A .
-   $ git commit -m "Fix broken links"
-   $ git push
+   git push origin [branch-name]
    ```
 
-3. Navigate to the project on [GitHub](https://github.com) and open a pull request
-   with the following branch settings:
-   * Base: `main`
-   * Compare: `hotfix/documentation-broken-links`
-     Paste your release checklist into the PR and work through the PR to get the
-     hotfix into production.
+#### Step 7: Create a Pull Request
 
-4. At some point in the checklist you will merge the hotfix branch into `main`.
-   You can do this by using the "Merge pull request" button on the release PR.
+1. Navigate to your forked repository on GitHub.
+2. Click the **Pull Request** button.
+3. Ensure the base repository is the original repository you forked from and the base branch is the one you want your changes pulled into.
+4. Select your branch as the compare branch.
+5. Fill in a title and description for your pull request explaining the changes you've made.
+6. Click **Create Pull Request**.
 
-5. Now that the hotfix code is in `main` you are ready to create the actual
-   release. Navigate to the project page on GitHub and draft a new release with
-   the following settings:
-   * Tag version: `vX.Y.Z`
-   * Target: `main`
-   * Release title: `Release vX.Y.Z (hotfix)`
-   * Description: Includes a high-level list of things changed in this release.
+#### Final Step: Await Review
 
-Click `Publish release`.
+- Once your pull request is submitted, our team will review your changes. We may request further modifications or provide feedback before merging your changes.
+- Keep an eye on your GitHub notifications for comments or requests for changes from the project maintainers.
 
-*Note: Hotfix releases _are_ actual releases. You should bump at least the
-patch part of the version when releasing a hotfix, and so even hotfixes go
-through the process of creating a release like this.*
+## Linting
 
-1. Merge the `hotfix/documentation-broken-links` into `dev`.
+This package uses a number of tools to enforce formatting, linting, and general best practices:
+* [eslint](https://github.com/pre-commit/mirrors-eslint) to check pep8 compliance and logical errors in code
+* [prettier](https://github.com/pre-commit/mirrors-prettier) to check pep8 compliance and logical errors in code
+* [pre-commit](https://github.com/pre-commit/pre-commit-hooks) to install and control linters in githooks
 
-   ```bash
-   $ git checkout dev
-   $ git merge hotfix/documentation-broken-links
-   $ git push
-   ```
+After pulling the repository, you should enable auto linting git commit hooks by running:
 
-2. Finish off the tasks in the release checklist. Once everything is done, close
-   the hotfix PR.
+```bash
+python3 -m pip install pre-commit
+pre-commit install
+```
+
+In addition to running the linters against files included in a commit, you can perform linting on all the files
+in the package by running:
+```bash
+pre-commit run --all-files --show-diff-on-failure
+```
+or if using tox
+```bash
+tox -e lint
+````
 
 ## Code Style
 
@@ -253,7 +208,7 @@ documentation for examples and additional context.
 
 ## Issue Tracking
 
-We utilize the issue tracking functionality of GitHub to manage our project's roadmap and track bugs or feature requests.
+We use the issue tracking functionality of GitHub to manage our project's roadmap and track bugs or feature requests.
 If you encounter any problems or have a new idea, please search the issues to ensure it hasn't already been reported.
 If necessary, open a new issue providing a clear description, steps to reproduce, and any relevant information.
 
diff --git a/Dockerfile b/Dockerfile
index b3e15319..740a3c16 100755
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,5 @@
+# Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
 # Stage 1: Build environment
 FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal as build
 
diff --git a/GUIDE_FOR_MODEL_DEVELOPERS.md b/GUIDE_FOR_MODEL_DEVELOPERS.md
new file mode 100644
index 00000000..aada5ee4
--- /dev/null
+++ b/GUIDE_FOR_MODEL_DEVELOPERS.md
@@ -0,0 +1,306 @@
+# Guide for Model Developers
+
+## Introduction:
+
+These sections describe how OversightML interacts with containerized computer vision (CV) models that need to process
+geospatial imagery. Our approach is to make maximum use of well established open standards, working within the
+structures they have defined, to exchange imagery and geospatial features between system components. In particular
+this project has adopted [GeoJSON](https://geojson.org/) ([RFC 7946](https://datatracker.ietf.org/doc/html/rfc7946))
+as a standard for geospatial features and [GeoTIFF](https://www.ogc.org/standard/geotiff/) or the National Imagery
+Transmission Format ([ISO/IEC BIIF Profile JBP-2021.2](https://nsgreg.nga.mil/doc/view?i=5262&month=9&day=23&year=2023))
+as the standards for imagery with geospatial metadata. Other common imagery formats, for example PNG
+([REC-PNG-20031110](https://www.w3.org/TR/2003/REC-PNG-20031110/)) and JPEG
+([ISO/IEC 10918](https://jpeg.org/jpeg/workplan.html)), are also supported for containers that do not make use of
+geospatial metadata.
+
+## How OversightML Invokes Your Computer Vision Model:
+
+OversightML is designed to be compatible with SageMaker Hosted Model Endpoints. Instructions on how to build a
+compatible container are covered in the [Use Your Own Inference Code With Hosting Services](https://docs.aws.amazon.com/sagemaker/latest/dg/your-algorithms-inference-code.html)
+section of the SageMaker documentation. Those containers must implement a REST API that meets the following criteria:
+
+* The model container must have a web server listening on port 8080 and must accept POST requests to the `/invocations` endpoint and GET  requests to the `/ping` endpoint.
+* The model container must accept socket connection requests within 250 ms.
+* The model container must begin to respond to health checks within 4 minutes after startup. The minimum healthy response is an HTTP 200 status code and an empty body. The request timeout on `/ping` attempts is 2 seconds.
+* The model container must respond to `/invocations` requests within 60 seconds. The body of the post request will be an image chip encoded using the format provided along with the request. If successful, the HTTP status code 200 should be used and the response must be a valid GeoJSON document containing detected features. On failure an appropriate 4xx or 5xx status code should be returned.
+
+An example application using FastAPI is shown below:
+
+```python
+from fastapi import FastAPI, status, Request, Response
+
+app = FastAPI()
+
+@app.post('/invocations')
+async def invocations(request: Request):
+    # model() is your function that gets the inference output
+    # create_features() is your function that creates a feature collection
+    model_resp = await model(Request)
+    geojson_resp = await create_features(model_resp)
+    response = Response(
+        content=geojson_resp,
+        status_code=status.HTTP_200_OK,
+        media_type="application/geo+json",
+    )
+    return response
+
+@app.get('/ping')
+async def health_check(request: Request):
+    return Response(status_code=status.HTTP_200_OK)
+```
+
+## The Images OversightML Sends to Your Computer Vision Model:
+
+An OversightML image processing request contains the following parameters that determine what kind of image gets sent to the model container:
+
+* **imageProcessorTileSize**: The maximum dimensions of the image in pixels that should be sent to the model. Note that tiles at the edges of the image may be less than this maximum size.
+* **imageProcessorTileOverlap**: The overlap between tiles in pixels
+* **imageProcessorTileFormat**: The image format to send to the model. The following options are supported: “NITF”, “JPEG”, “PNG”, “GTIFF”.
+* **imageProcessorTileCompression**: The compression method to use which is dependent on the format chosen. For NITF images the valid options are: “J2K”, “JPEG”, and “NONE”. GeoTIFF images support “LZW”, “JPEG”, and “NONE”.
+
+![Image Tiling](images/image-tiling.png)
+
+**Figure 1:** Use of tile size and overlap parameters to break a full image into chunks small enough for a CV model.
+
+For images that contain substantial metadata (NITF, SICD) the Model Runner will update any fields necessary to identify
+this image as a chip of a larger image and pass along all the other metadata as is. For NITFs that involves adding an
+ICHIPB TRE along with updating the IGEOLO field in the image subheader. For SICD the FirstRow/FirstCol and
+NumRows/NumCols elements in the ImageData are updated to identify the chip. Passing this robust image metadata along
+with each set of pixels allows advanced models to make use of the geospatial context to refine their results.
+
+Note that care must be taken to choose tile size, format, and compression options that will keep the tiles under the
+6MB limit imposed by the SageMaker Hosting Real-Time Endpoints. There are many combinations of acceptable values but
+common cases are shown below:
+
+| Image Type           | # Bands | Pixel Size | Max Tile Size | Format | Compression | Sample Image Payload Size |
+|:---------------------|:-------:|:----------:|:-------------:|:------:|:-----------:|:-------------------------:|
+| Panchromatic Imagery |    1    | 11-16 bit  |     2048      |  NITF  |     J2K     |           2.1MB           |
+| Pan-sharpened RGB    |    3    |   8-bit    |     4096      |  NITF  |     J2K     |           4.2MB           |
+| Pan-sharpened RGB    |    3    |   8-bit    |     2048      | GTIFF  |    NONE     |           4.2MB           |
+| RGB PNG              |    3    |   8-bit    |     2048      |  PNG   |   DEFLATE   |           1.8MB           |
+| WBID SAR             |    1    |   8-bit    |     4096      |  NITF  |     J2K     |           4.2MB           |
+| SICD Int-16          |    2    |   16-bit   |     2048      |  NITF  |     J2K     |           4.3MB           |
+| SICD Float-32        |    2    |   32-bit   |      512      |  NITF  |    NONE*    |           2.1MB           |
+
+*NOTE: At the current time the OpenJPEG library used to implement J2K compression does not support floating point
+pixels resulting in a much smaller size limit for complex SAR data using a floating point representation.
+
+## What OversightML Expects Your Computer Vision Model to Return:
+
+OversightML has adopted GeoJSON (RFC 7946) as a standard means of exchanging geospatial information so we expect the
+computer vision models to return a valid GeoJSON document containing a FeatureCollection where each Feature is a
+result on the image.
+
+OversightML expects computer vision models to return results that have not yet been assigned geographic coordinates.
+In this case the features returned from the model are “unlocated” and have a null value for the “geometry” property of
+the feature (see section 3.2 of RFC 7946). Instead we expect the models to specify the locations of each feature in
+pixel coordinates referencing the top left corner of the image chip as 0,0. The GeoJSON specification does not provide
+a standard way to capture non-world coordinates so the following convention has been adopted.
+
+Models SHOULD return a imageGeometry key in each feature’s properties. The value of that key will follow the same
+structure and coordinate ordering as a GeoJSON Geometry Object (see section 3.1 of RFC 7946) except the values of the
+coordinates will not be in WGS-84 they will be in pixels relative to the processed image chip ordered as (x, y). Each
+feature MAY choose to return a property value associated with the imageBBox key. In this case the value will be an
+array of values consistent with the ordering of the geospatial bbox feature member (see section 5 of RFC 7946) but
+instead of longitude, latitude, and optional elevation the values will be in x, y pixels [min x, min y, max x, max y].
+Providing support for both of these fields gives model developers the latitude to return everything from a single point
+object detection through complex polygons provided by segmentation models. Examples include but are not limited to:
+
+**Example 1:** Single Point location for a detected object with the optional axis aligned bounding box.
+
+```json
+"properties": {
+    "imageGeometry": {
+        "type": "Point",
+        "coordinates": [105.0, 5.0]
+    },
+    "imageBBox": [100, 0, 110, 10],
+}
+```
+**Example 2:** A LineString geometry used to define a simple road segment.
+
+```json
+"properties": {
+    "imageGeometry": {
+        "type": "LineString",
+        "coordinates": [[170.0, 45.0], [180.0, 47.0], [182.0, 49.0], ...]
+    }
+}
+```
+**Example 3:** A Polygon for a 4-corner building boundary. Note that the first coordinate is repeated to close the exterior boundary of the polygon.
+
+```json
+"properties": {
+    "imageGeometry": {
+        "type": "Polygon",
+        "coordinates": [[[0,0], [10,2],[10,12], [0,12], [0,0]]]
+    }
+}
+```
+Models SHOULD also return a collection of one or more classes for their features using the featureClasses property.
+Each entry in this list should have an Internationalized Resource Identifier (IRI) for the feature class iri and
+optionally a score between 0.0 and 1.0 where a 1.0 is the models most confident in the result.
+
+```json
+"featureClasses": [{
+    "iri": "http://foo.bar/sample/1.0.0/maritime_vessel",
+    "score": 0.90
+    },{
+    "iri": "http://foo.bar/sample/1.0.0/fishing_boat",
+    "score": 0.75
+}],
+```
+
+These are the only non-standard properties required by OversightML. Computer Vision models are free to return other
+property values as needed (ex. the modelMetadata property in the example below). They will be passed along unaltered in
+the aggregated result but be aware that the size of each feature should be less than 300KB (and ideally much smaller).
+
+These properties were not defined as a “foreign member“, see section 6.1 of RFC 7946, because some GeoJSON
+implementations have problems supporting those extensions.  We have found support for arbitrary properties is much
+more common and chose to work within the existing constructs.
+
+**Example 4:** A full result containing a single feature with imageGeometry, imageBBox, and featureClasses properties.
+
+```json
+{
+    "type": "FeatureCollection",
+    "features": [{
+        "type": "Feature",
+        "geometry": null,
+        "properties": {
+            "imageGeometry": {
+                "type": "Point",
+                "coordinates": [102.0, 0.5]
+            },
+            "imageBBox": [100, 0, 104, 1],
+            "featureClasses": [{
+                "iri": "http://foo.bar/sample/1.0.0/maritime_vessel",
+                "score": 0.90
+                },{
+                "iri": "http://foo.bar/sample/1.0.0/fishing_boat",
+                "score": 0.75
+              }],
+            "modelMetadata": {
+                "modelName": "FasterRCNN_ResNet50_FPN_V2",
+                "ontologyName": "SampleOntology",
+                "ontologyVersion": "1.0.0",
+                "classification": "U//XX/XX//XXXX"
+            }
+        }
+    }]
+}
+```
+
+**DEPRECATION WARNING:** *The original version of this software allowed models to return image coordinates using a
+bounds_imcoords and feature classes using feature_types . Those properties are now deprecated and will likely be
+removed in the next major release.*
+
+## The Post Processing OversightML Will Perform on Results
+
+### Compute Geospatial Locations for Features
+
+OversightML will attempt to assign geographic locations to every feature returned by a computer vision model. To do
+this it will first update the imageGeometry and imageBBox properties to have values relative to the full image by
+adding in the offset of the chip’s upper left corner; (x, y) → (x + ULx, y + ULy). It will then run all of those full
+image coordinates through the image_to_world operation on the sensor model defined in the image metadata to create the
+WGS-84 coordinates for the geometry and optional bbox members of the feature.
+
+The “type” of the resulting “geometry” member will match the type specified in the imageGeometry property. The decision
+about whether the coordinates will have 2D (longitude, latitude) or 3D (longitude, latitude, elevation) values is
+dependent on the type of sensor model available with the image metadata. In most cases Model Runner will return 3D
+coordinates.
+
+### Add Properties to Capture Information Source
+
+In addition to creating the geometry members OversightML will add property values to help maintain the information
+pedigree of each feature. This information is currently grouped under the inferenceMetadata and sourceMetadata
+properties but community standards for tracking this information are evolving. WARNING: The names and structure of
+these properties has not been finalized and will change in the future.
+
+The `sourceMetadata` property contains a minimal set of values extracted from the metadata associated with each source.
+Note that the property itself is a list which will allow us to capture metadata from multiple sources (e.g. an image
+and digital terrain data or changes detected between two images). These include:
+
+* **location**: The location where the inference platform used the file for as input to the model.
+* **format**: The format of the source information.
+* **category**: This field differentiates between EO, FMV, WAMI, SAR, and other potential future source data types.
+* **sourceId**: This is an unique identifier can be used to look up original imagery/video from view of authoritative data repository. It is pulled from the image metadata or a duplicate of the location field if the metadata is not available.
+* **sourceDT**: The time for when the source was collected. The value will be a ISO 8601 string as defined by RFC 3339 section 5.6.
+* **classification**: The level at which the image is classified. These values will be taken directly from the overall classification of the source information and encoded as a valid paragraph marking.
+
+The `inferenceMetadata` property contains information about the ML job that produced the feature. The specific values include:
+
+* **jobId**: The Job ID is an associated UUID to verify which inference job the detection came from to track inference outputs.
+* **inferenceDT**: The time at which the model ran on the image and generated the features. The value will be a ISO 8601 string as defined by RFC 3339 section 5.6.
+
+**Example 5:** Full result shown above including the additions made by post processing:
+
+```json
+{
+    "type": "FeatureCollection",
+    "features": [{
+        "type": "Feature",
+        "geometry": {
+        "type": "Point",
+          "coordinates": [32.123456, 18.123456, 42.0]
+        },
+        "bbox": [32.123450, 18.123450, 42.0, 32.123460, 18.123460, 42.0],
+        "properties": {
+            "imageGeometry": {
+                "type": "Point",
+                "coordinates": [102.0, 0.5]
+            },
+            "imageBBox": [100, 0, 104, 1],
+            "featureClasses": [{
+                "iri": "http://foo.bar/sample/1.0.0/maritime_vessel",
+                "score": 0.90
+                },{
+                "iri": "http://foo.bar/sample/1.0.0/fishing_boat",
+                "score": 0.75
+              }],
+            "modelMetadata": {
+                "modelName": "FasterRCNN_ResNet50_FPN_V2",
+                "ontologyName": "SampleOntology",
+                "ontologyVersion": "1.0.0",
+                "classification": "U//XX/XX//XXXX"
+            },
+            "sourceMetadata": [{
+                "location": "s3://bucket-name/sample-image.NTF",
+                "format": "NITF",
+                "category": "EO",
+                "sourceId": "20230101-sample-ID",
+                "sourceDT": "2023-01-01T05:48:13Z",
+                "classification": "U//XX/XX//XXXX"
+            }],
+            "inferenceMetadata": {
+                "jobId": "dbc68a2c-2ddf-45a4-9910-ef3a34ed00c1",
+                "inferenceDT": "2023-01-01T05:49:00Z"
+            }
+        }
+    }]
+}
+```
+
+### Aggregate Features on Tile Boundaries
+
+The image processing request allows the requestor to select how OversightML handles features that were clipped at tile
+boundaries. At this time the options are:
+
+* **NONE**: No additional processing is performed.
+* **NMS**: A Non-Maximal Suppression algorithm is run that will drop features that overlap results from other tiles.
+* **SOFT-NMS**: An algorithm that decays the detection scores of duplicate objects instead of dropping them from the result set.
+
+For the soft-NMS the detection scores of the features are not overwritten but instead the class scores are updated
+and a new property rawScore is added to track the original value.
+
+```json
+"featureClasses": [{
+    "iri": "http://foo.bar/sample/1.0.0/maritime_vessel",
+    "score": 0.40,
+    "rawScore": 0.90
+    },{
+    "iri": "http://foo.bar/sample/1.0.0/fishing_boat",
+    "score": 0.35,
+    "rawScore": 0.75
+}]
+```
diff --git a/METRICS_AND_DASHBOARDS.md b/METRICS_AND_DASHBOARDS.md
new file mode 100644
index 00000000..757eb9d9
--- /dev/null
+++ b/METRICS_AND_DASHBOARDS.md
@@ -0,0 +1,178 @@
+# Metrics and Dashboards
+
+## Metrics
+
+The OversightML ModelRunner application outputs custom [CloudWatch](https://aws.amazon.com/cloudwatch/) metrics using
+the `OSML/ModelRunner` [Namespace](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_concepts.html#Namespace).
+The application outputs common metrics to capture the processing time, invocation counts, and errors.
+
+| Namespace        |   Metric    | Notes                                                 |
+|:-----------------|:-----------:|:------------------------------------------------------|
+| OSML/ModelRunner |  Duration   | End to end time for the overall operation to complete |
+|                  | Invocations | Number of times the operation is executed             |
+|                  |   Errors    | Number of times the operation failed                  |
+|                  |  Throttles  | Number of times the operation had to wait             |
+|                  |   Retries   | Number of times the operation had to be retried       |
+
+These metrics are emitted for different portions of each image processing job and we expect those
+timelines to be heavily influenced by details of the imagery and computer vision models provided with each request.
+We are using [CloudWatch Dimensions](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_concepts.html#Dimension)
+to provide more detailed context for each metric value.
+
+| Dimension   |                      Sample Values                      | Notes                                                                                                                                                                                                                            |
+|:------------|:-------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| Operation   | ImageProcessing, RegionProcessing, TileGeneration, ...  | This will let us track latency, errors, etc. for various subsections of the dataflow. For now this will primairly decompose to the image, region, and tile distributed computing but it can be expanded in the future as needed. |
+| ModelName   | maritime-vessel-detector, urban-building-extractor, ... | The overall time to process an image is highly dependent on the model complexity and endpoint configuration. This dimension allows us to break out metrics on a per model basis.                                                 |
+| InputFormat |                       NITF, TIFF                        | The format of the input image is another factor that can greatly impact processing time since it drives our IO and tile cutting timelines.                                                                                       |
+
+CloudWatch will treat each permutation of Namespace, Metric, and Dimensions as a unique identifier for a series of
+related data points. The Operation dimension is always present and it defines the portion of the image processing job
+described by the metric. The following diagram shows the relationship between the various metered ModelRunner Operations
+running on a distributed system.
+
+![Diagram of Operations](./images/metric-operations-diagram.png)
+
+## Dashboards
+
+These metrics can be combined with metrics from other AWS services to build dashboards to monitor imagery processed
+by the system. The choice of what to monitor is up to you but the following example demonstrates several common examples.
+Note that all these widgets are sensitive to the CloudWatch Dashboard time range which will allow you to easily analyze
+ongoing and historical system performance.
+
+![Example Dashboard](./images/dashboard-example.png)
+
+### Completed Counts
+This widget displays the number of images and tiles processed in the selected time range. It is a single value metric
+that displays the number of times the ImageProcessing and TileProcessing operations were invoked.
+```json
+{
+    "metrics": [
+        [ { "label": "Images", "expression": "SUM(SEARCH('{OSML/ModelRunner, InputFormat, ModelName, Operation} ImageProcessing Invocations', 'Sum'))", "region": "us-west-2", "period": 3600 } ],
+        [ { "label": "Tiles", "expression": "SUM(SEARCH('{OSML/ModelRunner, ModelName, Operation} TileProcessing Invocations', 'Sum'))", "region": "us-west-2", "period": 3600 } ]
+    ],
+    "view": "singleValue",
+    "title": "Completed",
+    "region": "us-west-2",
+    "sparkline": false,
+    "period": 3600,
+    "stat": "Average",
+    "setPeriodToTimeRange": true,
+    "trend": false
+}
+```
+
+### Average Image Processing Time
+This widget displays the average time required to process images. Each model / format pair is displayed independently
+since both factors can have a large impact on processing time.
+
+```json
+{
+    "metrics": [
+        [ { "expression": "SEARCH('{OSML/ModelRunner, InputFormat, ModelName, Operation} ImageProcessing Duration', 'Average')", "region": "us-west-2", "period": 3600 } ]
+    ],
+    "view": "bar",
+    "title": "Average Image Processing Time",
+    "region": "us-west-2",
+    "annotations": {
+        "horizontal": [
+            {
+                "color": "#2ca02c",
+                "label": "5m",
+                "value": 300,
+                "yAxis": "left"
+            },
+            {
+                "color": "#d62728",
+                "label": "15m",
+                "value": 900,
+                "yAxis": "left"
+            }
+        ]
+    },
+    "stat": "Average",
+    "period": 3600
+}
+```
+
+### Request Volume
+This widget displays the number of image processing requests received and how many are waiting in the queue. Note that
+this information comes from metrics already available through SQS.
+```json
+{
+    "view": "timeSeries",
+    "title": "Request Volume",
+    "region": "us-west-2",
+    "stacked": false,
+    "metrics": [
+        [ "AWS/SQS", "NumberOfMessagesReceived", "QueueName", "ImageRequestQueue", { "label": "Image Requests Received", "stat": "Sum", "region": "us-west-2" } ],
+        [ ".", "ApproximateNumberOfMessagesVisible", ".", ".", { "label": "Image Requests Waiting", "stat": "Sum", "region": "us-west-2" } ]
+    ],
+    "stat": "Sum",
+    "period": 300
+}
+```
+
+### Input Formats
+This widget displays the number of tiles processed for each image format.
+
+```json
+{
+    "view": "pie",
+    "title": "Input Formats",
+    "region": "us-west-2",
+    "metrics": [
+        [ { "expression": "SEARCH('{OSML/ModelRunner, InputFormat, Operation}  TileGeneration Invocations', 'Sum')", "region": "us-west-2" } ]
+    ],
+    "stat": "Average",
+    "period": 300,
+    "setPeriodToTimeRange": true,
+    "sparkline": false,
+    "trend": false
+}
+```
+
+### Model Utilization
+This widget displays the average time it takes for a model endpoint to process an image tile along with counts of the
+total number of invocations, errors, and retries. Different trendlines are provided for each model allowing you to assess
+their performance independently.
+```json
+{
+    "view": "timeSeries",
+    "title": "Model Utilization",
+    "region": "us-west-2",
+    "stacked": false,
+    "metrics": [
+        [ { "expression": "SEARCH('{OSML/ModelRunner, ModelName, Operation} ModelInvocation Duration', 'Average', 60)", "region": "us-west-2" } ],
+        [ { "expression": "SEARCH('{OSML/ModelRunner, ModelName, Operation} ModelInvocation Invocations', 'Sum', 60)", "yAxis": "right", "region": "us-west-2" } ],
+        [ { "expression": "SEARCH('{OSML/ModelRunner, ModelName, Operation} ModelInvocation Errors', 'Sum', 60)", "yAxis": "right", "region": "us-west-2" } ],
+        [ { "expression": "SEARCH('{OSML/ModelRunner, ModelName, Operation} ModelInvocation Retries', 'Sum', 60)", "yAxis": "right", "region": "us-west-2" } ]
+    ],
+    "yAxis": {
+        "right": {
+            "showUnits": false,
+            "label": "Count"
+        },
+        "left": {
+            "showUnits": false,
+            "label": "Seconds"
+        }
+    },
+    "stat": "Average",
+    "period": 300
+}
+```
+
+### Image Status
+This widget takes advantage of CloudWatch's ability to dynamically parse and display information from the application
+logs. In this case the success or failure status logs are parsed from the structured logging output to the
+/aws/OSML/MRService log group and key fields are displayed in a tabular format to provide operators with individual
+results of an image processing job.
+
+```text
+fields job_id as Job, request.image_url as Image, request.model_name as Model, status as Status,
+|   concat(request.region_success, "/", request.region_count) as Regions, request.region_error as Failures,
+|   fromMillis(request.start_time) as Start, fromMillis(request.end_time) as End
+| filter message = "StatusMonitorUpdate" and status in ["SUCCESS", "FAILED"]
+| sort @timestamp desc
+| limit 50
+```
diff --git a/README.md b/README.md
index 5680891f..10a013ec 100755
--- a/README.md
+++ b/README.md
@@ -10,8 +10,8 @@ across instances to process images as quickly as possible.
 * [Getting Started](#getting-started)
   * [Key Design Concepts](#key-design-concepts)
     * [Image Tiling](#image-tiling)
-    * [Lazy IO & Encoding Formats with Internal Tiles](#lazy-io--encoding-formats-with-internal-tiles)
-    * [Tile Overlap and Merging Results](#tile-overlap-and-merging-results)
+    * [Geolocation](#geolocation)
+    * [Merging Results from Overlap Regions](#merging-results-from-overlap-regions)
   * [Package Layout](#package-layout)
   * [Prerequisites](prerequisites)
   * [Development Environment](#development-environment)
@@ -26,15 +26,12 @@ across instances to process images as quickly as possible.
 
 ## Getting Started
 
-This application currently offers one build options:
-
-1.) Build on an Amazon Linux 2 container (see provided Dockerfile):
-* OS=amazonlinux:latest
-* Python=3.10
-* Miniconda=Miniconda3-latest-Linux-x86_64
-
 ### Key Design Concepts
 
+The [Guidance for Model Developers](./GUIDE_FOR_MODEL_DEVELOPERS.md) document contains details of how the
+OversightML ModelRunner applications interacts with containerized computer vision (CV) models and examples of the
+GeoJSON formatted inputs it expects and generates. At a high level this application provides the following functions:
+
 #### Image Tiling
 
 The images to be processed by this application are expected to range anywhere from 500MB to 500GB in size. The upper
@@ -44,16 +41,21 @@ application breaks the full image up into pieces that are small enough for a sin
 the first are placed on a second queue so other model runners can start processing those regions in parallel. The second
 tiling phase is to break each region up into individual chunks that will be sent to the ML models. Many ML model
 containers are configured to process images that are between 512 and 2048 pixels in size so the full processing of a
-large 200,000 x 200,000 satellite image can result in >10,000 requests.
-
-#### Lazy IO & Encoding Formats with Internal Tiles
+large 200,000 x 200,000 satellite image can result in >10,000 requests to those model endpoints.
 
 The images themselves are assumed to reside in S3 and are assumed to be compressed and encoded in such a way as to
 facilitate piecewise access to tiles without downloading the entire image. The GDAL library, a frequently used open
 source implementation of GIS data tools, has the ability to read images directly from S3 making use of partial range
 reads to only download the part of the overall image necessary to process the region.
 
-#### Tile Overlap and Merging Results
+#### Geolocation
+
+Most ML models do not contain the photogrammetry libraries needed to geolocate objects detected in an
+image. ModelRunner will convert these detections into geospatial features by using sensor models described
+in an image metadata. The details of the photogrammetry operations are in the
+[osml-imagery-toolkit](https://github.com/aws-solutions-library-samples/osml-imagery-toolkit) library.
+
+#### Merging Results from Overlap Regions
 
 Many of the ML algorithms we expect to run will involve object detection or feature extraction. It is possible that
 features of interest would fall on the tile boundaries and therefore be missed by the ML models because they are only
@@ -63,6 +65,12 @@ overlapping the previous by the specified amount. Then the results from each til
 Non-Maximal Suppression algorithm used to eliminate duplicates in cases where an object in an overlap region was picked
 up by multiple model runs.
 
+### Metrics and Logs
+
+As the application runs key performance metrics and detailed logging information are output to [CloudWatch](https://aws.amazon.com/cloudwatch/).
+A detailed description of what information is tracked along with example dashboards can be found in
+[METRICS_AND_DASHBOARDS.md](./METRICS_AND_DASHBOARDS.md).
+
 ### Package Layout
 
 * **/src**: This is the Python implementation of this application.
@@ -114,7 +122,7 @@ Sample ImageRequest:
 #### S3
 When configuring S3 buckets for images and results, be sure to follow [S3 Security Best Practices](https://docs.aws.amazon.com/AmazonS3/latest/userguide/security-best-practices.html).
 
-### Documentation
+### Code Documentation
 
 You can find documentation for this library in the `./doc` directory. Sphinx is used to construct a searchable HTML
 version of the API documents.
diff --git a/bin/oversightml-mr-entry-point.py b/bin/oversightml-mr-entry-point.py
index 659658f1..3f924c7f 100644
--- a/bin/oversightml-mr-entry-point.py
+++ b/bin/oversightml-mr-entry-point.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import argparse
 import logging
diff --git a/environment-py311.yml b/environment-py311.yml
index 4cbe0965..1f222408 100644
--- a/environment-py311.yml
+++ b/environment-py311.yml
@@ -1,3 +1,5 @@
+# Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
 name: osml_model_runner
 channels:
   - conda-forge
diff --git a/environment.yml b/environment.yml
index ecab725e..c505be12 100644
--- a/environment.yml
+++ b/environment.yml
@@ -1,3 +1,5 @@
+# Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
 name: osml_model_runner
 channels:
   - conda-forge
diff --git a/images/dashboard-example.png b/images/dashboard-example.png
new file mode 100644
index 00000000..c072625c
Binary files /dev/null and b/images/dashboard-example.png differ
diff --git a/images/image-tiling.png b/images/image-tiling.png
new file mode 100644
index 00000000..6096deee
Binary files /dev/null and b/images/image-tiling.png differ
diff --git a/images/metric-operations-diagram.png b/images/metric-operations-diagram.png
new file mode 100644
index 00000000..2b31dc53
Binary files /dev/null and b/images/metric-operations-diagram.png differ
diff --git a/images/release-deployment.png b/images/release-deployment.png
deleted file mode 100644
index 65dcc2b3..00000000
Binary files a/images/release-deployment.png and /dev/null differ
diff --git a/images/release_deployment.sketch b/images/release_deployment.sketch
deleted file mode 100644
index 1d43e596..00000000
Binary files a/images/release_deployment.sketch and /dev/null differ
diff --git a/scripts/describe_image.py b/scripts/describe_image.py
index 8b73661e..6484b1dd 100755
--- a/scripts/describe_image.py
+++ b/scripts/describe_image.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import re
 import sys
diff --git a/scripts/image_converter.py b/scripts/image_converter.py
index 4dde8537..7a1419a1 100755
--- a/scripts/image_converter.py
+++ b/scripts/image_converter.py
@@ -1,3 +1,5 @@
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
 import logging
 import os
 from argparse import ArgumentParser
diff --git a/scripts/requirements-scripts.txt b/scripts/requirements-scripts.txt
index 9cdb88bc..5c8a94d3 100644
--- a/scripts/requirements-scripts.txt
+++ b/scripts/requirements-scripts.txt
@@ -1,3 +1,5 @@
+# Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
 # Added to support the KML visualization script
 lxml==4.9.1
 defusedxml==0.7.1
diff --git a/scripts/run_container.sh b/scripts/run_container.sh
index 98f8f6d7..4c302e00 100644
--- a/scripts/run_container.sh
+++ b/scripts/run_container.sh
@@ -1,6 +1,6 @@
 #!/bin/bash
 #
-# Copyright 2023 Amazon.com, Inc. or its affiliates.
+# Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 #
 
 # This is a utility to run a local model runner container with an ENV imported from ECS.
diff --git a/scripts/summarize_image.py b/scripts/summarize_image.py
index f71165b3..0b74688d 100755
--- a/scripts/summarize_image.py
+++ b/scripts/summarize_image.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import pathlib
 import re
diff --git a/scripts/tile_read_timer.py b/scripts/tile_read_timer.py
index fd69900e..5dd7bfaf 100755
--- a/scripts/tile_read_timer.py
+++ b/scripts/tile_read_timer.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import argparse
 import time
diff --git a/setup.cfg b/setup.cfg
index f4b2b0c8..3519d125 100755
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,7 @@
 [metadata]
 name = osml-model-runner
-version = 1.4.0
+version = 2.0.0
+
 description = Application to run large scale imagery against AI/ML models
 long_description = file: README.md
 long_description_content_type = text/markdown
@@ -39,7 +40,7 @@ python_requires = >=3.8
 include_package_data = True
 
 install_requires =
-    osml-imagery-toolkit>=1.2.0
+    osml-imagery-toolkit>=1.3.0
     numpy>=1.24.4
     shapely>=2.0.2
     aws-embedded-metrics==3.2.0
diff --git a/src/aws/osml/model_runner/__init__.py b/src/aws/osml/model_runner/__init__.py
index 281fa430..588610ec 100755
--- a/src/aws/osml/model_runner/__init__.py
+++ b/src/aws/osml/model_runner/__init__.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 # Telling flake8 to not flag errors in this file. It is normal that these classes are imported but not used in an
 # __init__.py file.
diff --git a/src/aws/osml/model_runner/api/__init__.py b/src/aws/osml/model_runner/api/__init__.py
index 8963ce10..2f871f87 100755
--- a/src/aws/osml/model_runner/api/__init__.py
+++ b/src/aws/osml/model_runner/api/__init__.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 # Telling flake8 to not flag errors in this file. It is normal that these classes are imported but not used in an
 # __init__.py file.
diff --git a/src/aws/osml/model_runner/api/exceptions.py b/src/aws/osml/model_runner/api/exceptions.py
index a219e62a..30c06a16 100755
--- a/src/aws/osml/model_runner/api/exceptions.py
+++ b/src/aws/osml/model_runner/api/exceptions.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 
 # API Exceptions
diff --git a/src/aws/osml/model_runner/api/image_request.py b/src/aws/osml/model_runner/api/image_request.py
index 14e31e31..4f1243e1 100755
--- a/src/aws/osml/model_runner/api/image_request.py
+++ b/src/aws/osml/model_runner/api/image_request.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import logging
 from json import dumps, loads
@@ -159,7 +159,7 @@ def is_valid(self) -> bool:
 
         num_feature_detection_options = len(self.get_feature_distillation_option())
         if num_feature_detection_options > 1:
-            logger.error("{} feature distillation options in ImageRequest".format(num_feature_detection_options))
+            logger.error(f"{num_feature_detection_options} feature distillation options in ImageRequest")
             return False
 
         return True
diff --git a/src/aws/osml/model_runner/api/inference.py b/src/aws/osml/model_runner/api/inference.py
index d5caf780..3c2bce5b 100755
--- a/src/aws/osml/model_runner/api/inference.py
+++ b/src/aws/osml/model_runner/api/inference.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 from enum import auto
 
diff --git a/src/aws/osml/model_runner/api/region_request.py b/src/aws/osml/model_runner/api/region_request.py
index 32bff9fd..149db915 100755
--- a/src/aws/osml/model_runner/api/region_request.py
+++ b/src/aws/osml/model_runner/api/region_request.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 from typing import Any, Dict
 
diff --git a/src/aws/osml/model_runner/api/request_utils.py b/src/aws/osml/model_runner/api/request_utils.py
index f46835bb..fa13b5b3 100755
--- a/src/aws/osml/model_runner/api/request_utils.py
+++ b/src/aws/osml/model_runner/api/request_utils.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 from aws.osml.model_runner.common import VALID_IMAGE_COMPRESSION, VALID_IMAGE_FORMATS
 
diff --git a/src/aws/osml/model_runner/api/sink.py b/src/aws/osml/model_runner/api/sink.py
index 40f9c970..3a72545c 100755
--- a/src/aws/osml/model_runner/api/sink.py
+++ b/src/aws/osml/model_runner/api/sink.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 from enum import auto
 
diff --git a/src/aws/osml/model_runner/app.py b/src/aws/osml/model_runner/app.py
index 9ed46f8e..4651768b 100755
--- a/src/aws/osml/model_runner/app.py
+++ b/src/aws/osml/model_runner/app.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import ast
 import functools
@@ -6,7 +6,6 @@
 import logging
 import math
 from dataclasses import asdict
-from datetime import datetime
 from decimal import Decimal
 from json import dumps
 from math import degrees
@@ -44,6 +43,7 @@
     Timer,
     build_embedded_metrics_config,
     get_credentials_for_assumed_role,
+    get_feature_image_bounds,
     mr_post_processing_options_factory,
 )
 from .database import EndpointStatisticsTable, FeatureTable, JobItem, JobTable, RegionRequestItem, RegionRequestTable
@@ -178,8 +178,9 @@ def monitor_work_queues(self) -> None:
                             )
                             self.region_request_table.start_region_request(region_request_item)
                             logging.info(
-                                "Adding region request: imageid: {0} - regionid: {1}".format(
-                                    region_request_item.image_id, region_request_item.region_id
+                                (
+                                    f"Adding region request: image id: {region_request_item.image_id} - "
+                                    f"region id: {region_request_item.region_id}"
                                 )
                             )
 
@@ -203,7 +204,7 @@ def monitor_work_queues(self) -> None:
                             visibility_timeout=int(ServiceConfig.throttling_retry_timeout),
                         )
                     except Exception as err:
-                        logger.error("There was a problem processing the region request: {}".format(err))
+                        logger.error(f"There was a problem processing the region request: {err}")
                         self.region_request_queue.finish_request(receipt_handle)
                 else:
                     logger.debug("Checking work queue for images to process ...")
@@ -219,7 +220,7 @@ def monitor_work_queues(self) -> None:
 
                             # Check that our image request looks good
                             if not image_request.is_valid():
-                                error = "Invalid image request: {}".format(image_request_message)
+                                error = f"Invalid image request: {image_request_message}"
                                 logger.exception(error)
                                 raise InvalidImageRequestException(error)
 
@@ -231,7 +232,7 @@ def monitor_work_queues(self) -> None:
                         except RetryableJobException:
                             self.image_request_queue.reset_request(receipt_handle, visibility_timeout=0)
                         except Exception as err:
-                            logger.error("There was a problem processing the image request: {}".format(err))
+                            logger.error(f"There was a problem processing the image request: {err}")
                             min_image_id = image_request.image_id if image_request and image_request.image_id else ""
                             min_job_id = image_request.job_id if image_request and image_request.job_id else ""
                             min_job_arn = image_request.job_arn if image_request and image_request.job_arn else ""
@@ -269,7 +270,7 @@ def process_image_request(self, image_request: ImageRequest) -> None:
                 self.endpoint_statistics_table.upsert_endpoint(image_request.model_name, max_regions)
 
             # Update the image status to started and include relevant image meta-data
-            logger.info("Starting processing of {}".format(image_request.image_url))
+            logger.info(f"Starting processing of {image_request.image_url}")
             image_request_item = JobItem(
                 image_id=image_request.image_id,
                 job_id=image_request.job_id,
@@ -303,9 +304,7 @@ def process_image_request(self, image_request: ImageRequest) -> None:
 
             if sensor_model is None:
                 logging.warning(
-                    "Dataset {} did not have a geo transform. Results are not geo-referenced.".format(
-                        image_request_item.image_id
-                    )
+                    f"Dataset {image_request_item.image_id} did not have a geo transform. Results are not geo-referenced."
                 )
 
             # If we got valid outputs
@@ -323,7 +322,7 @@ def process_image_request(self, image_request: ImageRequest) -> None:
 
                 # If we can get a valid source metadata from the source image - attach it to features
                 # else, just pass in whatever custom features if they were provided
-                source_metadata = get_source_property(image_extension, raster_dataset)
+                source_metadata = get_source_property(image_request_item.image_url, image_extension, raster_dataset)
                 if isinstance(source_metadata, dict):
                     feature_properties.append(source_metadata)
 
@@ -378,7 +377,7 @@ def queue_region_request(
         # Set aside the first region
         first_region = all_regions.pop(0)
         for region in all_regions:
-            logger.info("Queueing region: {}".format(region))
+            logger.info(f"Queueing region: {region}")
 
             region_pixel_bounds = f"{region[0]}{region[1]}"
             region_id = f"{region_pixel_bounds}-{token_hex(16)}"
@@ -395,8 +394,9 @@ def queue_region_request(
             )
             self.region_request_table.start_region_request(region_request_item)
             logging.info(
-                "Adding region request: imageid: {0} - regionid: {1}".format(
-                    region_request_item.image_id, region_request_item.region_id
+                (
+                    f"Adding region request: image id: {region_request_item.image_id} - "
+                    f"region id: {region_request_item.region_id}"
                 )
             )
 
@@ -404,7 +404,7 @@ def queue_region_request(
             self.region_request_queue.send_request(region_request.__dict__)
 
         # Go ahead and process the first region
-        logger.info("Processing first region {}: {}".format(0, first_region))
+        logger.info(f"Processing first region {0}: {first_region}")
 
         region_pixel_bounds = f"{first_region[0]}{first_region[1]}"
         region_id = f"{region_pixel_bounds}-{token_hex(16)}"
@@ -424,9 +424,7 @@ def queue_region_request(
         )
         self.region_request_table.start_region_request(region_request_item)
         logging.info(
-            "Adding region request: imageid: {0} - regionid: {1}".format(
-                region_request_item.image_id, region_request_item.region_id
-            )
+            f"Adding region request: imageid: {region_request_item.image_id} - regionid: {region_request_item.region_id}"
         )
 
         # Processes our region request and return the updated item
@@ -465,7 +463,7 @@ def process_region_request(
             metrics.set_dimensions()
 
         if not region_request.is_valid():
-            logger.error("Invalid Region Request! {}".format(region_request.__dict__))
+            logger.error(f"Invalid Region Request! {region_request.__dict__}")
             raise ValueError("Invalid Region Request")
 
         if isinstance(metrics, MetricsLogger):
@@ -489,7 +487,8 @@ def process_region_request(
             if in_progress >= max_regions:
                 if isinstance(metrics, MetricsLogger):
                     metrics.put_metric(MetricLabels.THROTTLES, 1, str(Unit.COUNT.value))
-                logger.info("Throttling region request. (Max: {} In-progress: {}".format(max_regions, in_progress))
+
+                logger.info(f"Throttling region request. (Max: {max_regions} In-progress: {in_progress}")
                 raise SelfThrottledRegionException
 
             # Increment the endpoint region counter
@@ -497,7 +496,7 @@ def process_region_request(
 
         try:
             with Timer(
-                task_str="Processing region {} {}".format(region_request.image_url, region_request.region_bounds),
+                task_str=f"Processing region {region_request.image_url} {region_request.region_bounds}",
                 metric_name=MetricLabels.DURATION,
                 logger=logger,
                 metrics_logger=metrics,
@@ -531,9 +530,10 @@ def process_region_request(
             return image_request_item
 
         except Exception as err:
-            logger.error("Failed to process image region: {}".format(err))
+            failed_msg = f"Failed to process image region: {err}"
+            logger.error(failed_msg)
             # update the table to take in that exception
-            region_request_item.message = "Failed to process image region: {0}".format(err)
+            region_request_item.message = failed_msg
             return self.fail_region_request(region_request_item, metrics)
 
         finally:
@@ -624,7 +624,9 @@ def fail_image_request_send_messages(self, image_request_item: JobItem, err: Exc
 
         :return: None
         """
-        logger.exception("Failed to start image processing!: {}".format(err))
+
+        logger.exception(f"Failed to start image processing!: {err}")
+
         self.status_monitor.process_event(image_request_item, ImageRequestStatus.FAILED, str(err))
 
     def complete_image_request(self, region_request: RegionRequest, image_format: str) -> None:
@@ -792,7 +794,7 @@ def identify_overlap(
 
         :return: a tuple: minx, maxx, miny, maxy that identifies any overlap.
         """
-        bbox = feature["properties"][GeojsonDetectionField.BOUNDS]
+        bbox = get_feature_image_bounds(feature)
 
         # If an offset origin was supplied adjust the bbox so the key is relative to the origin.
         bbox = (bbox[0] - origin[0], bbox[1] - origin[1], bbox[2] - origin[0], bbox[3] - origin[1])
@@ -951,7 +953,7 @@ def sync_features(image_request_item: JobItem, features: List[Feature], metrics:
 
             # Ensure we have outputs defined for where to dump our features
             if image_request_item.outputs:
-                logging.info("Writing aggregate feature for job '{}'".format(image_request_item.job_id))
+                logging.info(f"Writing aggregate feature for job '{image_request_item.job_id}'")
                 for sink in SinkFactory.outputs_to_sinks(json.loads(image_request_item.outputs)):
                     if sink.mode == SinkMode.AGGREGATE and image_request_item.job_id:
                         is_write_output_succeeded = sink.write(image_request_item.job_id, features)
@@ -1000,6 +1002,8 @@ def add_properties_to_features(self, image_request_item: JobItem, features: List
                     del feature["properties"]["inferenceTime"]
                 if feature.get("properties", {}).get(GeojsonDetectionField.BOUNDS):
                     del feature["properties"][GeojsonDetectionField.BOUNDS]
+                if feature.get("properties", {}).get(GeojsonDetectionField.GEOM):
+                    del feature["properties"][GeojsonDetectionField.GEOM]
                 if feature.get("properties", {}).get("detection_score"):
                     del feature["properties"]["detection_score"]
                 if feature.get("properties", {}).get("feature_types"):
@@ -1024,15 +1028,10 @@ def get_inference_metadata_property(image_request_item: JobItem, inference_time:
 
         :return: Dict[str, Any] = an inference metadata dictionary property to attach to features
         """
-        seconds = float(image_request_item.start_time) / 1000.0
-        receive_time = datetime.fromtimestamp(seconds).isoformat()
         inference_metadata_property = {
             "inferenceMetadata": {
                 "jobId": image_request_item.job_id,
-                "filePath": image_request_item.image_url,
-                "receiveTime": receive_time,
-                "inferenceTime": inference_time,
-                "tileOverlapFeatureSelection": image_request_item.feature_distillation_option,
+                "inferenceDT": inference_time,
             }
         }
         return inference_metadata_property
diff --git a/src/aws/osml/model_runner/app_config.py b/src/aws/osml/model_runner/app_config.py
index 10a9323b..f1b50806 100755
--- a/src/aws/osml/model_runner/app_config.py
+++ b/src/aws/osml/model_runner/app_config.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import os
 from dataclasses import dataclass
diff --git a/src/aws/osml/model_runner/common/__init__.py b/src/aws/osml/model_runner/common/__init__.py
index 0544886a..d9a549c4 100755
--- a/src/aws/osml/model_runner/common/__init__.py
+++ b/src/aws/osml/model_runner/common/__init__.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 # Telling flake8 to not flag errors in this file. It is normal that these classes are imported but not used in an
 # __init__.py file.
@@ -8,6 +8,7 @@
 from .credentials_utils import get_credentials_for_assumed_role
 from .endpoint_utils import EndpointUtils
 from .exceptions import InvalidAssumedRoleException, InvalidClassificationException
+from .feature_utils import get_feature_image_bounds
 from .log_context import ThreadingLocalContextFilter
 from .metrics_utils import build_embedded_metrics_config
 from .mr_post_processing import (
diff --git a/src/aws/osml/model_runner/common/auto_string_enum.py b/src/aws/osml/model_runner/common/auto_string_enum.py
index 60124746..a1c65468 100755
--- a/src/aws/osml/model_runner/common/auto_string_enum.py
+++ b/src/aws/osml/model_runner/common/auto_string_enum.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 from enum import Enum
 
diff --git a/src/aws/osml/model_runner/common/credentials_utils.py b/src/aws/osml/model_runner/common/credentials_utils.py
index aa14dfc8..f7e09c07 100755
--- a/src/aws/osml/model_runner/common/credentials_utils.py
+++ b/src/aws/osml/model_runner/common/credentials_utils.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 from typing import Dict
 
diff --git a/src/aws/osml/model_runner/common/endpoint_utils.py b/src/aws/osml/model_runner/common/endpoint_utils.py
index 0a2b12bf..7bfc2cac 100755
--- a/src/aws/osml/model_runner/common/endpoint_utils.py
+++ b/src/aws/osml/model_runner/common/endpoint_utils.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import logging
 import math
diff --git a/src/aws/osml/model_runner/common/exceptions.py b/src/aws/osml/model_runner/common/exceptions.py
index ca45630b..50618ed3 100755
--- a/src/aws/osml/model_runner/common/exceptions.py
+++ b/src/aws/osml/model_runner/common/exceptions.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 
 # Common Exceptions
diff --git a/src/aws/osml/model_runner/common/feature_utils.py b/src/aws/osml/model_runner/common/feature_utils.py
new file mode 100644
index 00000000..c62f6f4e
--- /dev/null
+++ b/src/aws/osml/model_runner/common/feature_utils.py
@@ -0,0 +1,23 @@
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
+from typing import Optional, Tuple
+
+import geojson
+
+from aws.osml.features import ImagedFeaturePropertyAccessor
+
+property_accessor = ImagedFeaturePropertyAccessor()
+
+
+def get_feature_image_bounds(feature: geojson.Feature) -> Optional[Tuple[float, float, float, float]]:
+    """
+    This is a utility function that creates a [minx, miny, maxx, maxy] tuple for the boundary of the
+    image geometry of a feature. If no image geometry property can be found None is returned.
+
+    :param feature: the feature to calculate bounds for
+    :return: the bounds or None
+    """
+    image_geometry = property_accessor.find_image_geometry(feature)
+    if not image_geometry:
+        return None
+    return image_geometry.bounds
diff --git a/src/aws/osml/model_runner/common/log_context.py b/src/aws/osml/model_runner/common/log_context.py
index f40ec691..d5e5e640 100644
--- a/src/aws/osml/model_runner/common/log_context.py
+++ b/src/aws/osml/model_runner/common/log_context.py
@@ -1,3 +1,5 @@
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
 import logging
 import threading
 from typing import List, Optional
diff --git a/src/aws/osml/model_runner/common/metrics_utils.py b/src/aws/osml/model_runner/common/metrics_utils.py
index a4c4342b..58545052 100755
--- a/src/aws/osml/model_runner/common/metrics_utils.py
+++ b/src/aws/osml/model_runner/common/metrics_utils.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 from aws_embedded_metrics.config import get_config
 
diff --git a/src/aws/osml/model_runner/common/mr_post_processing.py b/src/aws/osml/model_runner/common/mr_post_processing.py
index 87cc2366..08dc70e7 100755
--- a/src/aws/osml/model_runner/common/mr_post_processing.py
+++ b/src/aws/osml/model_runner/common/mr_post_processing.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 from abc import ABC, ABCMeta, abstractmethod
 from dataclasses import dataclass, field
diff --git a/src/aws/osml/model_runner/common/security_classification.py b/src/aws/osml/model_runner/common/security_classification.py
index 9c476df6..098638f7 100755
--- a/src/aws/osml/model_runner/common/security_classification.py
+++ b/src/aws/osml/model_runner/common/security_classification.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 from dataclasses import dataclass, field
 from enum import auto
diff --git a/src/aws/osml/model_runner/common/timer.py b/src/aws/osml/model_runner/common/timer.py
index 6783c207..cd18eda7 100755
--- a/src/aws/osml/model_runner/common/timer.py
+++ b/src/aws/osml/model_runner/common/timer.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import logging
 import time
diff --git a/src/aws/osml/model_runner/common/typing.py b/src/aws/osml/model_runner/common/typing.py
index 906a06e7..0d68a904 100755
--- a/src/aws/osml/model_runner/common/typing.py
+++ b/src/aws/osml/model_runner/common/typing.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 from enum import Enum, auto
 from typing import Tuple
diff --git a/src/aws/osml/model_runner/database/__init__.py b/src/aws/osml/model_runner/database/__init__.py
index 04bb58ac..eeb494e2 100755
--- a/src/aws/osml/model_runner/database/__init__.py
+++ b/src/aws/osml/model_runner/database/__init__.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 # Telling flake8 to not flag errors in this file. It is normal that these classes are imported but not used in an
 # __init__.py file.
diff --git a/src/aws/osml/model_runner/database/ddb_helper.py b/src/aws/osml/model_runner/database/ddb_helper.py
index eafee45d..d9b6b0cf 100755
--- a/src/aws/osml/model_runner/database/ddb_helper.py
+++ b/src/aws/osml/model_runner/database/ddb_helper.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import logging
 import random
diff --git a/src/aws/osml/model_runner/database/endpoint_statistics_table.py b/src/aws/osml/model_runner/database/endpoint_statistics_table.py
index acae9f3e..5f67518c 100755
--- a/src/aws/osml/model_runner/database/endpoint_statistics_table.py
+++ b/src/aws/osml/model_runner/database/endpoint_statistics_table.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import logging
 from dataclasses import dataclass
diff --git a/src/aws/osml/model_runner/database/exceptions.py b/src/aws/osml/model_runner/database/exceptions.py
index 550c9a41..7bac3799 100755
--- a/src/aws/osml/model_runner/database/exceptions.py
+++ b/src/aws/osml/model_runner/database/exceptions.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 
 # Database Exceptions
diff --git a/src/aws/osml/model_runner/database/feature_table.py b/src/aws/osml/model_runner/database/feature_table.py
index 2c141d01..1172f1ed 100755
--- a/src/aws/osml/model_runner/database/feature_table.py
+++ b/src/aws/osml/model_runner/database/feature_table.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import logging
 import random
@@ -18,7 +18,7 @@
 from geojson import Feature
 
 from aws.osml.model_runner.app_config import MetricLabels, ServiceConfig
-from aws.osml.model_runner.common import GeojsonDetectionField, ImageDimensions, Timer
+from aws.osml.model_runner.common import ImageDimensions, Timer, get_feature_image_bounds
 
 from .ddb_helper import DDBHelper, DDBItem, DDBKey
 from .exceptions import AddFeaturesException
@@ -246,7 +246,7 @@ def generate_tile_key(self, feature: Feature) -> str:
         :param feature: Properties of a feature
         :return: The tile key associated with this list of features.
         """
-        bbox = feature["properties"][GeojsonDetectionField.BOUNDS]
+        bbox = get_feature_image_bounds(feature)
 
         # This is the size of the unique pixels in each tile
         stride_x = self.tile_size[0] - self.overlap[0]
@@ -265,6 +265,4 @@ def generate_tile_key(self, feature: Feature) -> str:
         if min_y_offset < self.overlap[1] and min_y_index > 0:
             min_y_index -= 1
 
-        return "{}-region-{}:{}:{}:{}".format(
-            feature["properties"]["image_id"], min_x_index, max_x_index, min_y_index, max_y_index
-        )
+        return f"{feature['properties']['image_id']}-region-{min_x_index}:{max_x_index}:{min_y_index}:{max_y_index}"
diff --git a/src/aws/osml/model_runner/database/job_table.py b/src/aws/osml/model_runner/database/job_table.py
index 0e1e2921..dc2091cc 100755
--- a/src/aws/osml/model_runner/database/job_table.py
+++ b/src/aws/osml/model_runner/database/job_table.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import time
 from dataclasses import dataclass
diff --git a/src/aws/osml/model_runner/database/region_request_table.py b/src/aws/osml/model_runner/database/region_request_table.py
index 3491a176..cb373947 100755
--- a/src/aws/osml/model_runner/database/region_request_table.py
+++ b/src/aws/osml/model_runner/database/region_request_table.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import logging
 import time
@@ -147,6 +147,6 @@ def get_region_request(self, region_id: str, image_id: str) -> Optional[RegionRe
                 RegionRequestItem,
                 self.get_ddb_item(RegionRequestItem(region_id=region_id, image_id=image_id)),
             )
-        except Exception as e:
-            logger.warning(GetRegionRequestItemException("Failed to get RegionRequestItem! {0}".format(e)))
+        except Exception as err:
+            logger.warning(GetRegionRequestItemException(f"Failed to get RegionRequestItem! {err}"))
             return None
diff --git a/src/aws/osml/model_runner/exceptions.py b/src/aws/osml/model_runner/exceptions.py
index 78d9790e..1c10cd20 100755
--- a/src/aws/osml/model_runner/exceptions.py
+++ b/src/aws/osml/model_runner/exceptions.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 
 # ModelRunner Exceptions
diff --git a/src/aws/osml/model_runner/inference/__init__.py b/src/aws/osml/model_runner/inference/__init__.py
index 40403d1a..b683b024 100755
--- a/src/aws/osml/model_runner/inference/__init__.py
+++ b/src/aws/osml/model_runner/inference/__init__.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 # Telling flake8 to not flag errors in this file. It is normal that these classes are imported but not used in an
 # __init__.py file.
diff --git a/src/aws/osml/model_runner/inference/detector.py b/src/aws/osml/model_runner/inference/detector.py
index 0d391bb6..fa614015 100755
--- a/src/aws/osml/model_runner/inference/detector.py
+++ b/src/aws/osml/model_runner/inference/detector.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import abc
 from io import BufferedReader
diff --git a/src/aws/osml/model_runner/inference/endpoint_builder.py b/src/aws/osml/model_runner/inference/endpoint_builder.py
index f5146ec1..b4afa2d4 100644
--- a/src/aws/osml/model_runner/inference/endpoint_builder.py
+++ b/src/aws/osml/model_runner/inference/endpoint_builder.py
@@ -1,3 +1,5 @@
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
 from abc import ABC, abstractmethod
 from typing import Optional
 
diff --git a/src/aws/osml/model_runner/inference/endpoint_factory.py b/src/aws/osml/model_runner/inference/endpoint_factory.py
index 1bad4df4..f0d1c05f 100644
--- a/src/aws/osml/model_runner/inference/endpoint_factory.py
+++ b/src/aws/osml/model_runner/inference/endpoint_factory.py
@@ -1,3 +1,5 @@
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
 from typing import Dict, Optional
 
 from aws.osml.model_runner.api import ModelInvokeMode
diff --git a/src/aws/osml/model_runner/inference/exceptions.py b/src/aws/osml/model_runner/inference/exceptions.py
index e561b694..94bb6510 100755
--- a/src/aws/osml/model_runner/inference/exceptions.py
+++ b/src/aws/osml/model_runner/inference/exceptions.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 # Telling flake8 to not flag errors in this file. It is normal that these classes are imported but not used in an
 # __init__.py file.
diff --git a/src/aws/osml/model_runner/inference/feature_selection.py b/src/aws/osml/model_runner/inference/feature_selection.py
index e6aaa958..3c9c5b3f 100755
--- a/src/aws/osml/model_runner/inference/feature_selection.py
+++ b/src/aws/osml/model_runner/inference/feature_selection.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 from collections import OrderedDict
 from typing import List, Tuple
@@ -9,7 +9,7 @@
 from aws.osml.model_runner.common import (
     FeatureDistillationAlgorithm,
     FeatureDistillationAlgorithmType,
-    GeojsonDetectionField,
+    get_feature_image_bounds,
 )
 from aws.osml.model_runner.inference.exceptions import FeatureDistillationException
 
@@ -24,12 +24,17 @@ class FeatureSelector:
 
     def __init__(self, options: FeatureDistillationAlgorithm = None) -> None:
         """
+        Constructor for FeatureSelector class that selects an algorithm (e.g. NMS, Soft NMS, etc.) and sets parameters
+        specific to each algorithm (e.g. IoU threshold).
+
         :param options: FeatureSelectionOptions = options to use to set the algorithm, thresholds, and parameters.
         """
         self.options = options
 
     def select_features(self, feature_list: List[Feature]) -> List[Feature]:
         """
+        Selects a subset of features from a larger set of features using an algorithm such as NMS or Soft NMS.
+
         :param feature_list: a list of geojson features with a property of bounds_imcoords
         :return: the filtered list of features
         """
@@ -58,7 +63,12 @@ def select_features(self, feature_list: List[Feature]) -> List[Feature]:
 
     def _get_lists_from_features(self, feature_list: List[Feature]) -> Tuple[List, List, List]:
         """
-        :param feature_list:
+        This function converts the GeoJSON features into lists of normalized bounding boxes, scores, and label IDs
+        needed by the selection algorithm implementations. As a side effect of this function various class attributes
+        are set to support the inverse mapping of algorithm primitives to the full features. See
+        _get_features_from_lists for the inverse function.
+
+        :param feature_list: the input set of GeoJSON features to preprocess
         :return: tuple of lists - bounding boxes, confidence scores, category labels
         """
 
@@ -68,9 +78,10 @@ def _get_lists_from_features(self, feature_list: List[Feature]) -> Tuple[List, L
         self.extents = [None, None, None, None]  # [min_x, min_y, max_x, max_y]
         self.feature_id_map = OrderedDict()
         self.labels_map = dict()
+
         for feature in feature_list:
-            # imcoords: [x1, y1, x2, y2]
-            bounds_imcoords = feature.get("properties", {}).get(GeojsonDetectionField.BOUNDS)
+            # [min_x, min_y, max_x, max_y]
+            bounds_imcoords = get_feature_image_bounds(feature)
             category, score = self._get_category_and_score_from_feature(feature)
             boxes.append(bounds_imcoords)
             categories.append(category)
@@ -96,6 +107,15 @@ def _get_lists_from_features(self, feature_list: List[Feature]) -> Tuple[List, L
         return normalized_boxes, scores, labels_indexes
 
     def _normalize_boxes(self, boxes: List[List[int]]) -> List[List[float]]:
+        """
+        This function normalizes the bounding boxes by subtracting the minimum x and y coordinates from each
+        coordinate and dividing by the range of x and y coordinates. That means that all bounding boxes coordinates
+        will be in the range of [0.0, 1.0] where 0.0 is the minimum of the extent and 1.0 is the maximum. See
+        _denormalize_boxes() to convert back to bboxes in pixel coordinates.
+
+        :param boxes: the list of bounding boxes to normalize
+        :return: the normalized list of bounding boxes
+        """
         # boxes: [x1, y1, x2, y2]
         min_x = self.extents[0]
         min_y = self.extents[1]
@@ -112,18 +132,30 @@ def _normalize_boxes(self, boxes: List[List[int]]) -> List[List[float]]:
 
     @staticmethod
     def _get_category_and_score_from_feature(feature: Feature) -> Tuple[str, float]:
-        feature_dict = feature.get("properties", {}).get("feature_types")
-        if feature_dict:
-            return max(feature_dict.items(), key=lambda x: x[1])
-        else:
-            return "", 1.0
+        """
+        Get the feature class with the highest score from the featureClasses property.
+
+        :return: tuple of feature class and highest score
+        """
+        max_score = -1.0
+        max_class = ""
+        for feature_class in feature.get("properties", {}).get("featureClasses", []):
+            if feature_class.get("score") > max_score:
+                max_score = feature_class.get("score")
+                max_class = feature_class.get("iri")
+        return max_class, max_score
 
     def _get_features_from_lists(self, boxes: List, scores: List, labels: List) -> List[Feature]:
         """
-        :param boxes:
-        :param scores:
-        :param labels:
-        :return:
+        This function consolidates the lists of bounding boxes, scores, and labels into the GeoJSON features.
+        This happens by finding the feature with a matching bounding box and category in the original feature list
+        and updating the score if necessary. Any features that were in the original feature list that were not
+        in the input lists end up filtered out of the result.
+
+        :param boxes: the normalized bounding boxes for the features
+        :param scores: the updated scores for each bounding box
+        :param labels: the labels for each bounding box
+        :return: the refined list of GeoJSON features
         """
         features = []
         im_boxes = self._denormalize_boxes(boxes)
@@ -132,22 +164,23 @@ def _get_features_from_lists(self, boxes: List, scores: List, labels: List) -> L
             feature_hash_id = hash(str(box) + category)
             feature = self.feature_id_map.get(feature_hash_id)
             if feature:
-                if self.options.algorithm_type == FeatureDistillationAlgorithmType.SOFT_NMS and score != feature.get(
-                    "properties", {}
-                ).get("detection_score"):
-                    feature["properties"]["adjusted_feature_types"] = {category: score}
-                    feature_ontology_list = feature.get("properties", {}).get("detection", {}).get("ontology")
-                    if feature_ontology_list:
-                        adjusted_feature_ontology_list = []
-                        for iri in feature_ontology_list:
-                            if iri.get("iri") == category:
-                                iri["adjustedDetectionScore"] = score
-                            adjusted_feature_ontology_list.append(iri)
-                        feature["properties"]["detection"]["ontology"] = adjusted_feature_ontology_list
+                if self.options.algorithm_type == FeatureDistillationAlgorithmType.SOFT_NMS:
+                    for feature_class in feature.get("properties", {}).get("featureClasses", []):
+                        if feature_class.get("iri") == category:
+                            feature_class["rawScore"] = feature_class.get("score")
+                            feature_class["score"] = score
                 features.append(feature)
         return features
 
     def _denormalize_boxes(self, boxes: List[List[float]]) -> List[List[int]]:
+        """
+        This function denormalizes the bounding boxes by multiplying each coordinate by the width or height
+        of the extent and then adding in the extent minimums. That puts all bounding boxes back into the
+        image coordinate space. This is the inverse of _normalize_boxes().
+
+        :param boxes: the list of bounding boxes to denormalize
+        :return: the denormalized list of bounding boxes
+        """
         # boxes: [x1, y1, x2, y2]
         min_x = self.extents[0]
         min_y = self.extents[1]
diff --git a/src/aws/osml/model_runner/inference/feature_utils.py b/src/aws/osml/model_runner/inference/feature_utils.py
index a58ea4cc..fe772f8c 100755
--- a/src/aws/osml/model_runner/inference/feature_utils.py
+++ b/src/aws/osml/model_runner/inference/feature_utils.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import logging
 from datetime import datetime
@@ -209,14 +209,15 @@ def calculate_processing_bounds(
     return processing_bounds
 
 
-def get_source_property(image_extension: str, dataset: gdal.Dataset) -> Optional[Dict]:
+def get_source_property(image_location: str, image_extension: str, dataset: gdal.Dataset) -> Optional[Dict]:
     """
     Get the source property from NITF image
 
-    :param image_extension: str = the file extension type of the source image
-    :param dataset: gdal.Dataset = the GDAL dataset to probe for source data
+    :param image_location: the location of the source image
+    :param image_extension: the file extension type of the source image
+    :param dataset: the GDAL dataset to probe for source data
 
-    :return: Optional[Dict] = the source dictionary property to attach to features
+    :return: the source dictionary property to attach to features
     """
     # Currently we only support deriving source metadata from NITF images
     if image_extension == "NITF":
@@ -227,7 +228,7 @@ def get_source_property(image_extension: str, dataset: gdal.Dataset) -> Optional
             source_id = metadata.get("NITF_FTITLE", None)
             # Format of datetime string follows 14 digit spec in MIL-STD-2500C for NITFs
             source_dt = (
-                datetime.strptime(metadata.get("NITF_IDATIM"), "%Y%m%d%H%M%S").isoformat()
+                datetime.strptime(metadata.get("NITF_IDATIM"), "%Y%m%d%H%M%S").isoformat(timespec="seconds") + "Z"
                 if metadata.get("NITF_IDATIM")
                 else None
             )
@@ -239,25 +240,22 @@ def get_source_property(image_extension: str, dataset: gdal.Dataset) -> Optional
 
             # Build a source property for features
             source_property = {
-                "source": [
+                "sourceMetadata": [
                     {
-                        "fileType": "NITF",
-                        "info": {
-                            "imageCategory": data_type,
-                            "metadata": {
-                                "sourceId": source_id,
-                                "sourceDt": source_dt,
-                                "classification": source_classification_str,
-                            },
-                        },
+                        "location": image_location,
+                        "format": "NITF",
+                        "category": data_type,
+                        "sourceId": source_id,
+                        "sourceDT": source_dt,
+                        "classification": source_classification_str,
                     }
                 ]
             }
 
             return source_property
         except Exception as err:
-            logging.warning("Source metadata not available for {} image extension! {}".format(image_extension, err))
+            logging.warning(f"Source metadata not available for {image_extension} image extension! {err}")
             return None
     else:
-        logging.warning("Source metadata not available for {} image extension!".format(image_extension))
+        logging.warning(f"Source metadata not available for {image_extension} image extension!")
         return None
diff --git a/src/aws/osml/model_runner/inference/http_detector.py b/src/aws/osml/model_runner/inference/http_detector.py
index 3fd648a5..d637670d 100644
--- a/src/aws/osml/model_runner/inference/http_detector.py
+++ b/src/aws/osml/model_runner/inference/http_detector.py
@@ -1,3 +1,5 @@
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
 import logging
 from io import BufferedReader
 from json import JSONDecodeError
@@ -108,7 +110,7 @@ def find_features(self, payload: BufferedReader, metrics: MetricsLogger) -> Feat
 
         :return: GeoJSON FeatureCollection containing the center point of a tile
         """
-        logger.info("Invoking Model: {}".format(self.name))
+        logger.info(f"Invoking Model: {self.name}")
         if isinstance(metrics, MetricsLogger):
             metrics.set_dimensions()
             metrics.put_dimensions(
@@ -149,21 +151,22 @@ def find_features(self, payload: BufferedReader, metrics: MetricsLogger) -> Feat
             self.error_count += 1
             if isinstance(metrics, MetricsLogger):
                 metrics.put_metric(MetricLabels.ERRORS, 1, str(Unit.COUNT.value))
-            logger.error("Retry failed - failed due to {}".format(err))
+            logger.error(f"Retry failed - failed due to {err}")
             logger.exception(err)
         except MaxRetryError as err:
             self.error_count += 1
             if isinstance(metrics, MetricsLogger):
                 metrics.put_metric(MetricLabels.ERRORS, 1, str(Unit.COUNT.value))
-            logger.error("Max retries reached - failed due to {}".format(err.reason))
+            logger.error(f"Max retries reached - failed due to {err.reason}")
             logger.exception(err)
         except JSONDecodeError as err:
             self.error_count += 1
             if isinstance(metrics, MetricsLogger):
                 metrics.put_metric(MetricLabels.ERRORS, 1, str(Unit.COUNT.value))
             logger.error(
-                "Unable to decode response from model. URL: {}, Status: {}, Headers: {}, Response: {}".format(
-                    self.endpoint, response.status, response.info(), response.data
+                (
+                    f"Unable to decode response from model. URL: {self.endpoint}, Status: {response.status}, "
+                    f"Headers: {response.info()}, Response: {response.data}"
                 )
             )
             logger.exception(err)
diff --git a/src/aws/osml/model_runner/inference/sm_detector.py b/src/aws/osml/model_runner/inference/sm_detector.py
index e0480a3b..007f02ce 100644
--- a/src/aws/osml/model_runner/inference/sm_detector.py
+++ b/src/aws/osml/model_runner/inference/sm_detector.py
@@ -1,6 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
-
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import logging
 from io import BufferedReader
@@ -70,7 +68,7 @@ def find_features(self, payload: BufferedReader, metrics: MetricsLogger) -> Feat
 
         :return: FeatureCollection = a feature collection containing the center point of a tile
         """
-        logger.info("Invoking Model: {}".format(self.endpoint))
+        logger.info(f"Invoking Model: {self.endpoint}")
         if isinstance(metrics, MetricsLogger):
             metrics.set_dimensions()
             metrics.put_dimensions(
@@ -115,9 +113,7 @@ def find_features(self, payload: BufferedReader, metrics: MetricsLogger) -> Feat
             if isinstance(metrics, MetricsLogger):
                 metrics.put_metric(MetricLabels.ERRORS, 1, str(Unit.COUNT.value))
             logger.error(
-                "Unable to get detections from model - HTTP Status Code: {}, Error Code: {}".format(
-                    http_status_code, error_code
-                )
+                f"Unable to get detections from model - HTTP Status Code: {http_status_code}, Error Code: {error_code}"
             )
             logger.exception(ce)
         # If there was an error parsing the models output
diff --git a/src/aws/osml/model_runner/queue/__init__.py b/src/aws/osml/model_runner/queue/__init__.py
index 818d7d35..6924afae 100755
--- a/src/aws/osml/model_runner/queue/__init__.py
+++ b/src/aws/osml/model_runner/queue/__init__.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 # Telling flake8 to not flag errors in this file. It is normal that these classes are imported but not used in an
 # __init__.py file.
diff --git a/src/aws/osml/model_runner/queue/request_queue.py b/src/aws/osml/model_runner/queue/request_queue.py
index b0ee14fe..5e539db5 100755
--- a/src/aws/osml/model_runner/queue/request_queue.py
+++ b/src/aws/osml/model_runner/queue/request_queue.py
@@ -1,11 +1,11 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import json
 import logging
 from typing import Dict
 
 import boto3
-import botocore
+from botocore.exceptions import ClientError
 
 from aws.osml.model_runner.app_config import BotoConfig
 
@@ -33,12 +33,12 @@ def __iter__(self):
                     WaitTimeSeconds=self.wait_seconds,
                 )
 
-                logging.debug("Dequeued processing request {}".format(str(queue_response)))
+                logging.debug(f"Dequeued processing request {queue_response}")
 
                 if "Messages" in queue_response:
                     for message in queue_response["Messages"]:
                         message_body = message["Body"]
-                        logging.debug("Message Body {}".format(message_body))
+                        logging.debug(f"Message Body {message_body}")
 
                         try:
                             work_request = json.loads(message_body)
@@ -46,13 +46,13 @@ def __iter__(self):
                             yield message["ReceiptHandle"], work_request
 
                         except json.JSONDecodeError:
-                            logging.warning("Skipping message that is not valid JSON: {}".format(message_body))
+                            logging.warning(f"Skipping message that is not valid JSON: {message_body}")
                             yield None, None
                 else:
                     yield None, None
 
-            except botocore.exceptions.ClientError as error:
-                logging.error("Unable to retrieve message from queue: {}".format(error))
+            except ClientError as err:
+                logging.error(f"Unable to retrieve message from queue: {err}")
                 yield None, None
 
     def finish_request(self, receipt_handle: str) -> None:
@@ -66,8 +66,8 @@ def finish_request(self, receipt_handle: str) -> None:
         try:
             # Remove the message from the queue since it has been successfully processed
             self.sqs_client.delete_message(QueueUrl=self.queue_url, ReceiptHandle=receipt_handle)
-        except botocore.exceptions.ClientError as error:
-            logging.error("Unable to remove message from queue: {}".format(error))
+        except ClientError as err:
+            logging.error(f"Unable to remove message from queue: {err}")
 
     def reset_request(self, receipt_handle: str, visibility_timeout: int = 0) -> None:
         """
@@ -85,8 +85,8 @@ def reset_request(self, receipt_handle: str, visibility_timeout: int = 0) -> Non
                 ReceiptHandle=receipt_handle,
                 VisibilityTimeout=visibility_timeout,
             )
-        except botocore.exceptions.ClientError as error:
-            logging.error("Unable to reset message visibility: {}".format(error))
+        except ClientError as err:
+            logging.error(f"Unable to reset message visibility: {err}")
 
     def send_request(self, request: Dict) -> None:
         """
@@ -98,5 +98,5 @@ def send_request(self, request: Dict) -> None:
         """
         try:
             self.sqs_client.send_message(QueueUrl=self.queue_url, MessageBody=json.dumps(request))
-        except botocore.exceptions.ClientError as error:
-            logging.error("Unable to send message visibility: {}".format(error))
+        except ClientError as err:
+            logging.error(f"Unable to send message visibility: {err}")
diff --git a/src/aws/osml/model_runner/sink/__init__.py b/src/aws/osml/model_runner/sink/__init__.py
index f3312eb0..8a7e2f4e 100755
--- a/src/aws/osml/model_runner/sink/__init__.py
+++ b/src/aws/osml/model_runner/sink/__init__.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 # Telling flake8 to not flag errors in this file. It is normal that these classes are imported but not used in an
 # __init__.py file.
diff --git a/src/aws/osml/model_runner/sink/exceptions.py b/src/aws/osml/model_runner/sink/exceptions.py
index 51e16b21..dbb9150a 100755
--- a/src/aws/osml/model_runner/sink/exceptions.py
+++ b/src/aws/osml/model_runner/sink/exceptions.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 
 # SINK Exceptions
diff --git a/src/aws/osml/model_runner/sink/kinesis_sink.py b/src/aws/osml/model_runner/sink/kinesis_sink.py
index cd1c01a8..6a704344 100755
--- a/src/aws/osml/model_runner/sink/kinesis_sink.py
+++ b/src/aws/osml/model_runner/sink/kinesis_sink.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import logging
 import sys
@@ -77,12 +77,10 @@ def write(self, job_id: str, features: List[Feature]) -> bool:
             # Flush any remaining features
             if pending_features:
                 self._flush_stream(job_id, pending_features)
-            logger.info("Wrote {} features for job '{}' to Kinesis Stream '{}'".format(len(features), job_id, self.stream))
+            logger.info(f"Wrote {len(features)} features for job '{job_id}' to Kinesis Stream '{self.stream}'")
             return True
         else:
-            logger.error(
-                "Cannot write {} features for job '{}' to Kinesis Stream '{}'".format(len(features), job_id, self.stream)
-            )
+            logger.error(f"Cannot write {len(features)} features for job '{job_id}' to Kinesis Stream '{self.stream}'")
             return False
 
     def validate_kinesis_stream(self) -> bool:
@@ -100,12 +98,10 @@ def validate_kinesis_stream(self) -> bool:
                 # reason to include UPDATING is that Kinesis Stream functions during these operations
                 return True
             else:
-                logging.error(
-                    "{} current status is: {}. It is not in ACTIVE or UPDATING state.".format(self.stream, stream_status)
-                )
+                logging.error(f"{self.stream} current status is: {stream_status}. It is not in ACTIVE or UPDATING state.")
                 return False
-        except Exception as e:
-            logger.error("Failed to fetch Kinesis stream - {}. {}".format(self.stream, e))
+        except Exception as err:
+            logger.error(f"Failed to fetch Kinesis stream - {self.stream}. {err}")
             return False
 
     @staticmethod
diff --git a/src/aws/osml/model_runner/sink/s3_sink.py b/src/aws/osml/model_runner/sink/s3_sink.py
index 01e02f38..e9400387 100755
--- a/src/aws/osml/model_runner/sink/s3_sink.py
+++ b/src/aws/osml/model_runner/sink/s3_sink.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import logging
 import os
@@ -63,9 +63,7 @@ def write(self, image_id: str, features: List[Feature]) -> bool:
                 Key=object_key,
                 ACL="bucket-owner-full-control",
             )
-            logger.info(
-                "Wrote aggregate feature collection for Image '{}' to s3://{}/{}".format(image_id, self.bucket, object_key)
-            )
+            logger.info(f"Wrote aggregate feature collection for Image '{image_id}' to s3://{self.bucket}/{object_key}")
             return True
         else:
             return False
@@ -81,10 +79,10 @@ def validate_s3_bucket(self) -> bool:
             return True
         except ClientError as err:
             if err.response["Error"]["Code"] == "404":  # Does not exist
-                logging.error("This S3 Bucket({}) does not exist".format(self.bucket))
+                logging.error(f"This S3 Bucket({self.bucket}) does not exist")
             elif err.response["Error"]["Code"] == "403":  # Forbidden access
-                logging.error("Do not have permission to read/write this S3 Bucket({})".format(self.bucket))
-            logging.error("Cannot read/write S3 Bucket ({})".format(self.bucket))
+                logging.error(f"Do not have permission to read/write this S3 Bucket({self.bucket})")
+            logging.error(f"Cannot read/write S3 Bucket ({self.bucket})")
             return False
 
     @staticmethod
diff --git a/src/aws/osml/model_runner/sink/sink.py b/src/aws/osml/model_runner/sink/sink.py
index df54df8a..cfbfe7d5 100755
--- a/src/aws/osml/model_runner/sink/sink.py
+++ b/src/aws/osml/model_runner/sink/sink.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import abc
 from typing import List
diff --git a/src/aws/osml/model_runner/sink/sink_factory.py b/src/aws/osml/model_runner/sink/sink_factory.py
index d2c508e8..9d8df31c 100755
--- a/src/aws/osml/model_runner/sink/sink_factory.py
+++ b/src/aws/osml/model_runner/sink/sink_factory.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import logging
 from typing import Any, Dict, List
diff --git a/src/aws/osml/model_runner/status/__init__.py b/src/aws/osml/model_runner/status/__init__.py
index 9edb686f..3ed4b49a 100755
--- a/src/aws/osml/model_runner/status/__init__.py
+++ b/src/aws/osml/model_runner/status/__init__.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 # Telling flake8 to not flag errors in this file. It is normal that these classes are imported but not used in an
 # __init__.py file.
diff --git a/src/aws/osml/model_runner/status/exceptions.py b/src/aws/osml/model_runner/status/exceptions.py
index a8cbdaed..76f9d70b 100755
--- a/src/aws/osml/model_runner/status/exceptions.py
+++ b/src/aws/osml/model_runner/status/exceptions.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 
 # Status Exceptions
diff --git a/src/aws/osml/model_runner/status/image_request_status.py b/src/aws/osml/model_runner/status/image_request_status.py
index 3a32963a..36c22ccc 100755
--- a/src/aws/osml/model_runner/status/image_request_status.py
+++ b/src/aws/osml/model_runner/status/image_request_status.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 from dataclasses import dataclass, field, fields
 from decimal import Decimal
diff --git a/src/aws/osml/model_runner/status/sns_helper.py b/src/aws/osml/model_runner/status/sns_helper.py
index 16d066e9..8bbcc3dc 100755
--- a/src/aws/osml/model_runner/status/sns_helper.py
+++ b/src/aws/osml/model_runner/status/sns_helper.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import logging
 from typing import Any, Dict, Optional, Union
diff --git a/src/aws/osml/model_runner/status/status_monitor.py b/src/aws/osml/model_runner/status/status_monitor.py
index 16a63f88..01471b36 100755
--- a/src/aws/osml/model_runner/status/status_monitor.py
+++ b/src/aws/osml/model_runner/status/status_monitor.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import logging
 
@@ -56,11 +56,9 @@ def process_event(self, image_request_item: JobItem, status: ImageRequestStatus,
                     sns_message_attributes.asdict_str_values(),
                 )
             except Exception as err:
-                raise StatusMonitorException(
-                    "StatusMonitor failed: {} {}: {}".format(status, image_request_item.job_id, err)
-                )
+                raise StatusMonitorException(f"StatusMonitor failed: {status} {image_request_item.job_id}: {err}")
         else:
-            raise StatusMonitorException("StatusMonitor failed: {} {}".format(status, image_request_item.job_id))
+            raise StatusMonitorException(f"StatusMonitor failed: {status} {image_request_item.job_id}")
 
     @staticmethod
     def get_image_request_status(image_request_item: JobItem) -> ImageRequestStatus:
diff --git a/src/aws/osml/model_runner/tile_worker/__init__.py b/src/aws/osml/model_runner/tile_worker/__init__.py
index c8d94373..035bccdd 100755
--- a/src/aws/osml/model_runner/tile_worker/__init__.py
+++ b/src/aws/osml/model_runner/tile_worker/__init__.py
@@ -1,9 +1,8 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 # Telling flake8 to not flag errors in this file. It is normal that these classes are imported but not used in an
 # __init__.py file.
 # flake8: noqa
 
-from .feature_refinery import FeatureRefinery
 from .tile_worker import TileWorker
 from .tile_worker_utils import generate_crops, process_tiles, setup_tile_workers
diff --git a/src/aws/osml/model_runner/tile_worker/exceptions.py b/src/aws/osml/model_runner/tile_worker/exceptions.py
index 2eefd880..5bc15590 100755
--- a/src/aws/osml/model_runner/tile_worker/exceptions.py
+++ b/src/aws/osml/model_runner/tile_worker/exceptions.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 
 # TileWorker Exceptions
diff --git a/src/aws/osml/model_runner/tile_worker/feature_refinery.py b/src/aws/osml/model_runner/tile_worker/feature_refinery.py
deleted file mode 100755
index 2fc78d94..00000000
--- a/src/aws/osml/model_runner/tile_worker/feature_refinery.py
+++ /dev/null
@@ -1,409 +0,0 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
-
-import functools
-import math
-from typing import List, Optional, Tuple
-
-import geojson
-import numpy as np
-from scipy.interpolate import RectBivariateSpline
-from shapely.geometry import Polygon
-
-from aws.osml.model_runner.common import GeojsonDetectionField
-from aws.osml.photogrammetry import (
-    CompositeSensorModel,
-    ElevationModel,
-    GeodeticWorldCoordinate,
-    ImageCoordinate,
-    SensorModel,
-)
-
-
-class LocationGridInterpolator:
-    """
-    This class can be used to approximate geodetic world coordinates from a grid of correspondences that is
-    computed over a given area.
-    """
-
-    def __init__(
-        self,
-        sensor_model: SensorModel,
-        elevation_model: Optional[ElevationModel],
-        grid_area_ulx: float,
-        grid_area_uly: float,
-        grid_area_width: float,
-        grid_area_height: float,
-        grid_resolution: int,
-    ) -> None:
-        """
-        Construct the grid of correspondences of the requested size/resolution from using the sensor model provided.
-
-        :param sensor_model: SensorModel = the sensor model for the image
-        :param elevation_model: Optional[Elevationmodel] = an optional external elevation model
-        :param grid_area_ulx: float = the x component of the upper left corner of the grid in pixel space
-        :param grid_area_uly: float = the y component of the upper left corner of the grid in pixel space
-        :param grid_area_width: float = the width of the grid in pixels
-        :param grid_area_height: float = the height of the grid in pixels
-        :param grid_resolution: int = the number of points to calculate across the grid. Total points will be resolution^^2
-
-        :return: None
-        """
-        xs = np.linspace(grid_area_ulx, grid_area_ulx + grid_area_width, grid_resolution)
-        ys = np.linspace(grid_area_uly, grid_area_uly + grid_area_height, grid_resolution)
-        longitude_values = np.empty(len(xs) * len(ys))
-        latitude_values = np.empty(len(longitude_values))
-        i = 0
-        for y in ys:
-            for x in xs:
-                world_coordinate = sensor_model.image_to_world(ImageCoordinate([x, y]), elevation_model=elevation_model)
-                longitude_values[i] = world_coordinate.longitude
-                latitude_values[i] = world_coordinate.latitude
-                i += 1
-        xgrid, ygrid = np.meshgrid(xs, ys)
-        longitude_values.shape = xgrid.shape
-        latitude_values.shape = xgrid.shape
-
-        self.longitude_interpolator = RectBivariateSpline(xs, ys, longitude_values, kx=1, ky=1)
-        self.latitude_interpolator = RectBivariateSpline(xs, ys, latitude_values, kx=1, ky=1)
-        self.elevation_model = elevation_model
-
-    def __call__(self, *args, **kwargs):
-        """
-        Call this interpolation function given an image coordinate array.
-
-        :param args: a single argument for the coordinate array
-        :param kwargs: not used
-        :return: a GeodeticWorldCoordinate for that image location
-        """
-        image_coord = args[0]
-        world_coord = [
-            self.longitude_interpolator(image_coord[0], image_coord[1])[0][0],
-            self.latitude_interpolator(image_coord[0], image_coord[1])[0][0],
-            0.0,
-        ]
-        world_coordinate = GeodeticWorldCoordinate(world_coord)
-        if self.elevation_model is not None:
-            self.elevation_model.set_elevation(world_coordinate)
-        return world_coordinate
-
-
-class FeatureRefinery:
-    """
-    A FeatureRefinery is a class that improves and updates the features returned by object detectors. Its primary
-    purpose is to assign geographic coordinates for the features, but it can be expanded over time to make other
-    adjustments (e.g. assign classification metadata).
-    """
-
-    def __init__(
-        self,
-        sensor_model: SensorModel,
-        elevation_model: Optional[ElevationModel] = None,
-        approximation_grid_size: int = 11,
-    ) -> None:
-        """
-        Construct a refinery given the context objects necessary for geolocating features.
-
-        :param sensor_model: SensorModel = sensor model for the image
-        :param elevation_model: Optional[ElevationModel] = external elevation model
-        :param approximation_grid_size: int = resolution of the approximation grid to use
-
-        :return: None
-        """
-        self.sensor_model = sensor_model
-        self.elevation_model = elevation_model
-        self.approximation_grid_size = approximation_grid_size
-
-    def refine_features_for_tile(self, features: List[geojson.Feature]) -> None:
-        """
-        Update the features to contain additional information from the context provided.
-
-        :param features: List[geojson.Feature] = the input features to refine
-        :return: None, the features are updated in place
-        """
-        if len(features) < self.approximation_grid_size * self.approximation_grid_size:
-            # This is a sparsely populated tile, the cost of computing the grid is likely larger than
-            # calculating locations for the detections directly. In this case we geolocate the individual
-            # features one by one
-            self._geolocate_individual_features(features)
-        else:
-            # Create an approximation grid and then compute geolocation
-            self._geolocate_features_using_approximation_grid(features)
-
-    def _geolocate_individual_features(self, features: List[geojson.Feature]) -> None:
-        """
-        This method computes geolocations directly for each feature. It is useful for sparse feature sets where the
-        cost of computing the precise locations for an approximation grid is higher than geolocating the individual
-        features.
-
-        :param features: List[geojson.Feature] = the input features
-        :return: None, but the individual features have their geometry property updated
-        """
-        for feature in features:
-            # Calculate the center of the detection and convert it to a geodetic coordinate
-            initial_sensor_model = self.sensor_model
-            if isinstance(self.sensor_model, CompositeSensorModel):
-                initial_sensor_model = self.sensor_model.approximate_sensor_model
-
-            if GeojsonDetectionField.GEOM in feature["properties"]:
-                mask = Polygon(feature["properties"][GeojsonDetectionField.GEOM])
-                center_xy = (mask.centroid.x, mask.centroid.y)
-                image_coords = mask.exterior.coords
-            elif GeojsonDetectionField.BOUNDS in feature["properties"]:
-                bbox = feature["properties"][GeojsonDetectionField.BOUNDS]
-                center_xy = [
-                    (bbox[0] + bbox[2]) / 2.0,
-                    (bbox[1] + bbox[3]) / 2.0,
-                ]
-                # Calculate image coordinates and update feature
-                image_coords = self.imcoords_bbox_to_polygon(bbox)
-
-            approximate_center_location = initial_sensor_model.image_to_world(
-                ImageCoordinate(center_xy), elevation_model=self.elevation_model
-            )
-
-            # Calculate the geodetic coordinates of the bounding box
-            polygon_image_coords = [
-                initial_sensor_model.image_to_world(ImageCoordinate(image_coord), elevation_model=self.elevation_model)
-                for image_coord in image_coords
-            ]
-
-            if not isinstance(self.sensor_model, CompositeSensorModel):
-                final_center_location = approximate_center_location
-            else:
-                # Convert the detection center to a geodetic coordinate using the precision sensor model and then
-                # calculate a delta between the approximate result and the precision result.
-                final_center_location = self.sensor_model.image_to_world(
-                    ImageCoordinate(center_xy),
-                    elevation_model=self.elevation_model,
-                    options={
-                        "initial_guess": [
-                            approximate_center_location.longitude,
-                            approximate_center_location.latitude,
-                        ],
-                        "initial_search_distance": math.radians(0.005),
-                    },
-                )
-                delta_longitude = final_center_location.longitude - approximate_center_location.longitude
-                delta_latitude = final_center_location.latitude - approximate_center_location.latitude
-
-                # Move all the boundary coordinates by the delta. This assumes that the boundary coordinates are
-                # relatively close to the center and that they will exhibit the same shift that we saw between the
-                # approximate center and the precision center
-                for image_coord in polygon_image_coords:
-                    image_coord.longitude += delta_longitude
-                    image_coord.latitude += delta_latitude
-
-            # Convert the geodetic coordinates to the [degrees, degrees, meters] array that is expected by GeoJSON.
-            # The first coordinate is added to the end of the list to close the bounding box as required by some
-            # visualization tools.
-            polygon_coords = [
-                FeatureRefinery.radians_coordinate_to_degrees(image_coord) for image_coord in polygon_image_coords
-            ]
-            polygon_coords.append(polygon_coords[0])
-
-            # Note that for geojson polygons the "coordinates" member must be an array of
-            # LinearRing coordinate arrays. For Polygons with multiple rings, the first must be
-            # the exterior ring and any others must be interior rings or holes. We only have an
-            # exterior ring hence creating an array of the coordinates is appropriate here.
-            feature["geometry"] = geojson.Polygon([tuple(polygon_coords)])
-
-            self.compute_center_lat_long(feature, polygon_coords, final_center_location)
-
-    def _geolocate_features_using_approximation_grid(self, features: List[geojson.Feature]) -> None:
-        """
-        This method computes geolocations for features using an approximation grid. It is useful for dense feature
-        sets where the cost of computing precise locations for a set of close features is expensive and unnecessary.
-        Here we first compute a grid of locations covering the features in question that is then used to efficiently
-        assign geolocations for all the features.
-
-        :param features: List[geojson.Feature] = the input features
-        :return: None, but the individual features have their geometry property updated
-        """
-
-        # Compute the boundary of these features. Normally this will be similar to the tile boundary but the
-        # interpolation needs to be setup to cover the entire extent, so we calculate it explicitly here. If the
-        # features happen to be very tightly packed and only occupy a small portion of the tile we will gain some
-        # benefit by creating the same resolution of approximation grid over the smaller area.
-        feature_bounds = functools.reduce(
-            lambda prev, f: [
-                min(f["properties"][GeojsonDetectionField.BOUNDS][0], prev[0]),
-                min(f["properties"][GeojsonDetectionField.BOUNDS][1], prev[1]),
-                max(f["properties"][GeojsonDetectionField.BOUNDS][2], prev[2]),
-                max(f["properties"][GeojsonDetectionField.BOUNDS][3], prev[3]),
-            ],
-            features,
-            [math.inf, math.inf, -math.inf, -math.inf],
-        )
-
-        # Use the feature boundary to set up an approximation grid for the region"bounds_imcoords"
-        grid_area_ulx = feature_bounds[0]
-        grid_area_uly = feature_bounds[1]
-        grid_area_width = feature_bounds[2] - feature_bounds[0]
-        grid_area_height = feature_bounds[3] - feature_bounds[1]
-        tile_interpolation_grid = LocationGridInterpolator(
-            self.sensor_model,
-            self.elevation_model,
-            grid_area_ulx,
-            grid_area_uly,
-            grid_area_width,
-            grid_area_height,
-            self.approximation_grid_size,
-        )
-
-        for feature in features:
-            # Calculate the center of the detection and convert it to a geodetic coordinate using the
-            # approximation grid.
-            if GeojsonDetectionField.GEOM in feature["properties"]:
-                mask = Polygon(feature["properties"][GeojsonDetectionField.GEOM])
-                center_xy = (mask.centroid.x, mask.centroid.y)
-                image_coords = mask.exterior.coords
-            elif GeojsonDetectionField.BOUNDS in feature["properties"]:
-                bbox = feature["properties"][GeojsonDetectionField.BOUNDS]
-                center_xy = [
-                    (bbox[0] + bbox[2]) / 2.0,
-                    (bbox[1] + bbox[3]) / 2.0,
-                ]
-                # Calculate image coordinates and update feature
-                image_coords = self.imcoords_bbox_to_polygon(bbox)
-
-            center_location = tile_interpolation_grid(center_xy)
-
-            # Calculate the geodetic coordinates of the bounding box using the approximation grid. This also
-            # converts the resulting GeodeticWorldCoordinate to he [degrees, degrees, meters] array that is
-            # expected by GeoJSON. The first coordinate is added to the end of the list to close the bounding
-            # box as required by some visualization tools.
-            polygon_coords = [
-                FeatureRefinery.radians_coordinate_to_degrees(tile_interpolation_grid(image_coord))
-                for image_coord in image_coords
-            ]
-            polygon_coords.append(polygon_coords[0])
-
-            # Note that for geojson polygons the "coordinates" member must be an array of
-            # LinearRing coordinate arrays. For Polygons with multiple rings, the first must be
-            # the exterior ring and any others must be interior rings or holes. We only have an
-            # exterior ring hence creating an array of the coordinates is appropriate here.
-            feature["geometry"] = geojson.Polygon([tuple(polygon_coords)])
-
-            self.compute_center_lat_long(feature, polygon_coords, center_location)
-
-    @staticmethod
-    def imcoords_bbox_to_polygon(bbox: List[float]) -> List[List[float]]:
-        """
-        Converts a bbox of image coordinates into a four point polygon of coordinates.
-
-        :param bbox: List[float] = list of pixel coordinates bounding box: [a, b, c ,d]
-        :return: List[List[float]] = list of pixel coordinates as a 4 point polygon: [[a,b],[a,d],[c,d],[c,b]]
-        """
-        return [
-            [bbox[0], bbox[1]],
-            [bbox[0], bbox[3]],
-            [bbox[2], bbox[3]],
-            [bbox[2], bbox[1]],
-        ]
-
-    @staticmethod
-    def feature_property_transformation(feature: geojson.Feature) -> None:
-        """
-        Mutate a feature list property blob inplace into the bellow structure:
-
-        "properties": {
-            "detection": {
-                "type": "Polygon",
-                "pixelCoordinates": [
-                    [0, 0],
-                    [0, 10],
-                    [10, 10],
-                    [10, 0]
-                ],
-                "ontology": [
-                    {
-                    "iri": "https://myOntology.com/object/001",
-                    "detectionScore": 0.42
-                    }
-                ]
-            }
-        }
-
-        :param feature: geojson.Feature = the feature that needs it's "properties" blob conformed.
-
-        :return: None
-        """
-
-        # Create an ontology based on the models returned feature_types
-        ontology = []
-
-        for feature_type in feature["properties"]["feature_types"]:
-            ontology.append(
-                {
-                    "iri": feature_type,
-                    "detectionScore": feature["properties"]["feature_types"][feature_type],
-                }
-            )
-
-        if GeojsonDetectionField.GEOM in feature["properties"]:
-            pixel_coordinates = feature["properties"][GeojsonDetectionField.GEOM]
-
-        elif GeojsonDetectionField.BOUNDS in feature["properties"]:
-            # Grab the input coordinate boundaries from the model output
-            bbox = feature["properties"][GeojsonDetectionField.BOUNDS]
-            pixel_coordinates = FeatureRefinery.imcoords_bbox_to_polygon(bbox)
-
-        # Create detection property for feature output
-        feature["properties"]["detection"] = {
-            "type": "Polygon",
-            "pixelCoordinates": pixel_coordinates,
-            "ontology": ontology,
-        }
-
-    @staticmethod
-    def radians_coordinate_to_degrees(
-        coordinate: GeodeticWorldCoordinate,
-    ) -> Tuple[float, float, float]:
-        """
-        GeoJSON coordinate order is a decimal longitude, latitude with an optional height as a 3rd value
-        (i.e. [lon, lat, ht]). The WorldCoordinate uses the same ordering but the longitude and latitude are expressed
-        in radians rather than degrees.
-
-        :param coordinate: GeodeticWorldCoordinate = the geodetic world coordinate (longitude, latitude, elevation)
-
-        :return: Tuple[float, float, float] = degrees(longitude), degrees(latitude), elevation
-        """
-        return (
-            math.degrees(coordinate.longitude),
-            math.degrees(coordinate.latitude),
-            coordinate.elevation,
-        )
-
-    @staticmethod
-    def compute_center_lat_long(
-        feature: geojson.Feature,
-        polygon_coords: List[tuple],
-        final_center_location: GeodeticWorldCoordinate,
-    ) -> None:
-        """
-        Geojson features can optionally have a bounding box that contains [min lon, min lat,
-        max lon, max lat]. This code computes that bounding box from the polygon boundary
-        coordinates
-
-        :param feature: geojson.Feature = the feature that needs it's "properties" blob conformed.
-        :param polygon_coords: List[tuple] = list of polygon coordinates
-        :param final_center_location: GeodeticWorldCoordinate = Geodetic coordinate using the precision sensor model
-                                    and then calculated using a delta between approximate result and the precision result
-
-        :return None
-        """
-        feature["bbox"] = functools.reduce(
-            lambda prev, coord: [
-                min(coord[0], prev[0]),
-                min(coord[1], prev[1]),
-                max(coord[0], prev[2]),
-                max(coord[1], prev[3]),
-            ],
-            polygon_coords,
-            [math.inf, math.inf, -math.inf, -math.inf],
-        )
-
-        # Adding these because some visualization tools (e.g. kepler.gl) can perform more
-        # advanced rendering (e.g. cluster layers) if the data points have single coordinates.
-        feature["properties"]["center_longitude"] = math.degrees(final_center_location.longitude)
-        feature["properties"]["center_latitude"] = math.degrees(final_center_location.latitude)
diff --git a/src/aws/osml/model_runner/tile_worker/tile_worker.py b/src/aws/osml/model_runner/tile_worker/tile_worker.py
index ea8e1f4f..1083f072 100755
--- a/src/aws/osml/model_runner/tile_worker/tile_worker.py
+++ b/src/aws/osml/model_runner/tile_worker/tile_worker.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import asyncio
 import logging
@@ -12,13 +12,12 @@
 from aws_embedded_metrics.metric_scope import metric_scope
 from aws_embedded_metrics.unit import Unit
 from shapely.affinity import translate
-from shapely.geometry import Polygon
 
+from aws.osml.features import Geolocator, ImagedFeaturePropertyAccessor
 from aws.osml.model_runner.app_config import MetricLabels
-from aws.osml.model_runner.common import GeojsonDetectionField, ThreadingLocalContextFilter, Timer
+from aws.osml.model_runner.common import ThreadingLocalContextFilter, Timer
 from aws.osml.model_runner.database import FeatureTable
 from aws.osml.model_runner.inference import Detector
-from aws.osml.model_runner.tile_worker import FeatureRefinery
 
 logger = logging.getLogger(__name__)
 
@@ -28,14 +27,15 @@ def __init__(
         self,
         in_queue: Queue,
         feature_detector: Detector,
-        feature_refinery: Optional[FeatureRefinery],
+        geolocator: Optional[Geolocator],
         feature_table: FeatureTable,
     ) -> None:
         super().__init__()
         self.in_queue = in_queue
         self.feature_detector = feature_detector
-        self.feature_refinery = feature_refinery
+        self.geolocator = geolocator
         self.feature_table = feature_table
+        self.property_accessor = ImagedFeaturePropertyAccessor()
 
     def run(self) -> None:
         thread_event_loop = asyncio.new_event_loop()
@@ -47,8 +47,9 @@ def run(self) -> None:
             if image_info is None:
                 logging.info("All images processed. Stopping tile worker.")
                 logging.info(
-                    "Feature Detector Stats: {} requests with {} errors".format(
-                        self.feature_detector.request_count, self.feature_detector.error_count
+                    (
+                        f"Feature Detector Stats: {self.feature_detector.request_count} requests "
+                        f"with {self.feature_detector.error_count} errors"
                     )
                 )
                 break
@@ -139,35 +140,75 @@ def _refine_features(self, feature_collection, image_info: Dict, metrics: Metric
             ulx = image_info["region"][0][1]
             uly = image_info["region"][0][0]
             if isinstance(feature_collection, dict) and "features" in feature_collection:
-                logging.info("SM Model returned {} features".format(len(feature_collection["features"])))
+                logging.info(f"SM Model returned {len(feature_collection['features'])} features")
+
                 for feature in feature_collection["features"]:
-                    # model returned a mask
-                    scaled_polygon = None
-                    if GeojsonDetectionField.GEOM in feature["properties"]:
-                        polygon = Polygon(feature["properties"][GeojsonDetectionField.GEOM])
-                        scaled_polygon = translate(polygon, xoff=ulx, yoff=uly)
-                        feature["properties"][GeojsonDetectionField.GEOM] = list(scaled_polygon.exterior.coords)
-                    # model returned a bounding box
-                    if GeojsonDetectionField.BOUNDS in feature["properties"]:
-                        tile_bbox = feature["properties"][GeojsonDetectionField.BOUNDS]
-                        scaled_bbox = translate(
-                            Polygon(FeatureRefinery.imcoords_bbox_to_polygon(tile_bbox)), xoff=ulx, yoff=uly
-                        )
-                        feature["properties"][GeojsonDetectionField.BOUNDS] = scaled_bbox.bounds
-                    elif scaled_polygon is not None:
-                        feature["properties"][GeojsonDetectionField.BOUNDS] = scaled_polygon.bounds
-                    else:
-                        logging.warning(f"There isn't a valid detection shape for feature: {feature}")
+                    # Check to see if there is a bbox defined in image coordinates. If so, update it to
+                    # use full image coordinates and store the updated value in the feature properties.
+                    tiled_image_bbox = self.property_accessor.get_image_bbox(feature)
+                    if tiled_image_bbox is not None:
+                        full_image_bbox = translate(tiled_image_bbox, xoff=ulx, yoff=uly)
+                        self.property_accessor.set_image_bbox(feature, full_image_bbox)
+
+                    # Check to see if there is a geometry defined in image coordinates. If not and
+                    # there also isn't a bbox then search to see if some of the older deprecated
+                    # image properties are in use and if so use the geometry from those properties.
+                    # Note that this property search will be deprecated and removed in a future release.
+                    tiled_image_geometry = self.property_accessor.get_image_geometry(feature)
+                    if tiled_image_bbox is None and tiled_image_geometry is None:
+                        logging.info(f"Feature may be using deprecated attributes: {feature}")
+                        tiled_image_geometry = self.property_accessor.find_image_geometry(feature)
+                        if tiled_image_geometry is None:
+                            logging.warning(f"There isn't a valid detection shape for feature: {feature}")
+
+                    # If we found an image geometry update it to use full image coordinates and store the
+                    # value in the feature properties.
+                    if tiled_image_geometry is not None:
+                        full_image_geometry = translate(tiled_image_geometry, xoff=ulx, yoff=uly)
+                        self.property_accessor.set_image_geometry(feature, full_image_geometry)
+
                     feature["properties"]["image_id"] = image_info["image_id"]
-                    feature["properties"]["inferenceTime"] = datetime.now(tz=timezone.utc).isoformat()
-                    FeatureRefinery.feature_property_transformation(feature)
+                    feature["properties"]["inferenceTime"] = (
+                        datetime.now(tz=timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
+                    )
+
+                    # This conversion only happens here so the rest of the system can depend on a standard
+                    # set of properties. Eventually this call should be removed once the old properties are
+                    # no longer used by CV models.
+                    TileWorker.convert_deprecated_feature_properties(feature)
+
                     features.append(feature)
-            logging.info("# Features Created: {}".format(len(features)))
+            logging.info(f"# Features Created: {len(features)}")
+
             if len(features) > 0:
-                if self.feature_refinery is not None:
+                if self.geolocator is not None:
                     # Create a geometry for each feature in the result. The geographic coordinates of these
                     # features are computed using the sensor model provided in the image metadata
-                    self.feature_refinery.refine_features_for_tile(features)
-                    logging.info("Created Geographic Coordinates for {} features".format(len(features)))
+                    self.geolocator.geolocate_features(features)
+                    logging.info(f"Created Geographic Coordinates for {len(features)} features")
 
         return features
+
+    @staticmethod
+    def convert_deprecated_feature_properties(feature: geojson.Feature) -> None:
+        """
+        This function converts the legacy properties produced by CV models into the feature properties used
+        by OversightML.
+
+        :param feature: the feature that needs its "properties" updated.
+
+        :return: None
+        """
+
+        # Create an ontology based on the models returned feature_types
+        if "feature_types" in feature["properties"] and "featureClasses" not in feature["properties"]:
+            feature_classes = []
+            for feature_type in feature["properties"]["feature_types"]:
+                feature_classes.append(
+                    {
+                        "iri": feature_type,
+                        "score": feature["properties"]["feature_types"][feature_type],
+                    }
+                )
+            feature["properties"]["featureClasses"] = feature_classes
+            feature["properties"].pop("feature_types", None)
diff --git a/src/aws/osml/model_runner/tile_worker/tile_worker_utils.py b/src/aws/osml/model_runner/tile_worker/tile_worker_utils.py
index 02f1cea5..744d5d27 100755
--- a/src/aws/osml/model_runner/tile_worker/tile_worker_utils.py
+++ b/src/aws/osml/model_runner/tile_worker/tile_worker_utils.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import logging
 import tempfile
@@ -12,13 +12,14 @@
 from aws_embedded_metrics.unit import Unit
 from osgeo import gdal
 
+from aws.osml.features import Geolocator, ImagedFeaturePropertyAccessor
 from aws.osml.gdal import GDALConfigEnv
 from aws.osml.image_processing.gdal_tile_factory import GDALTileFactory
 from aws.osml.model_runner.api import RegionRequest
 from aws.osml.model_runner.app_config import MetricLabels, ServiceConfig
 from aws.osml.model_runner.common import ImageDimensions, ImageRegion, Timer, get_credentials_for_assumed_role
 from aws.osml.model_runner.database import FeatureTable
-from aws.osml.model_runner.tile_worker import FeatureRefinery, TileWorker
+from aws.osml.model_runner.tile_worker import TileWorker
 from aws.osml.photogrammetry import ElevationModel, SensorModel
 
 from ..inference.endpoint_factory import FeatureDetectorFactory
@@ -66,19 +67,19 @@ def setup_tile_workers(
                 assumed_credentials=model_invocation_credentials,
             ).build()
 
-            feature_refinery = None
+            geolocator = None
             if sensor_model is not None:
-                feature_refinery = FeatureRefinery(sensor_model, elevation_model=elevation_model)
+                geolocator = Geolocator(ImagedFeaturePropertyAccessor(), sensor_model, elevation_model=elevation_model)
 
-            worker = TileWorker(tile_queue, feature_detector, feature_refinery, feature_table)
+            worker = TileWorker(tile_queue, feature_detector, geolocator, feature_table)
             worker.start()
             tile_workers.append(worker)
 
-        logger.info("Setup pool of {} tile workers".format(len(tile_workers)))
+        logger.info(f"Setup pool of {len(tile_workers)} tile workers")
 
         return tile_queue, tile_workers
     except Exception as err:
-        logger.exception("Failed to setup tile workers!: {}".format(err))
+        logger.exception(f"Failed to setup tile workers!: {err}")
         raise SetupTileWorkersException("Failed to setup tile workers!") from err
 
 
@@ -133,13 +134,9 @@ def process_tiles(
                     region_request.tile_overlap,
                 ):
                     # Create a temp file name for the encoded region
-                    region_image_filename = "{}-region-{}-{}-{}-{}.{}".format(
-                        token_hex(16),
-                        tile_bounds[0][0],
-                        tile_bounds[0][1],
-                        tile_bounds[1][0],
-                        tile_bounds[1][1],
-                        region_request.tile_format,
+                    region_image_filename = (
+                        f"{token_hex(16)}-region-{tile_bounds[0][0]}-{tile_bounds[0][1]}-"
+                        f"{tile_bounds[1][0]}-{tile_bounds[1][1]}.{region_request.tile_format}"
                     )
 
                     # Set a path for the tmp image
@@ -178,12 +175,13 @@ def process_tiles(
                     tile_error_count += worker.feature_detector.error_count
 
         logger.info(
-            "Model Runner Stats Processed {} image tiles for region {}. {} tile errors.".format(
-                total_tile_count, region_request.region_bounds, tile_error_count
+            (
+                f"Model Runner Stats Processed {total_tile_count} image tiles for "
+                f"region {region_request.region_bounds}. {tile_error_count} tile errors."
             )
         )
     except Exception as err:
-        logger.exception("File processing tiles: {}", err)
+        logger.exception(f"File processing tiles: {err}")
         raise ProcessTilesException("Failed to process tiles!") from err
 
     return total_tile_count, tile_error_count
@@ -212,7 +210,7 @@ def _create_tile(gdal_tile_factory, tile_bounds, tmp_image_path, metrics: Metric
     # Use GDAL to create an encoded tile of the image region
     absolute_tile_path = tmp_image_path.absolute()
     with Timer(
-        task_str="Creating image tile: {}".format(absolute_tile_path),
+        task_str=f"Creating image tile: {absolute_tile_path}",
         metric_name=MetricLabels.DURATION,
         logger=logger,
         metrics_logger=metrics,


Test patch:
diff --git a/test/__init__.py b/test/__init__.py
index 281fa430..588610ec 100755
--- a/test/__init__.py
+++ b/test/__init__.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 # Telling flake8 to not flag errors in this file. It is normal that these classes are imported but not used in an
 # __init__.py file.
diff --git a/test/aws/osml/model_runner/api/test_image_request.py b/test/aws/osml/model_runner/api/test_image_request.py
index 226ac996..355467b4 100755
--- a/test/aws/osml/model_runner/api/test_image_request.py
+++ b/test/aws/osml/model_runner/api/test_image_request.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import unittest
 
diff --git a/test/aws/osml/model_runner/common/test_credentials_utils.py b/test/aws/osml/model_runner/common/test_credentials_utils.py
index b71ce0dc..171dd2f4 100755
--- a/test/aws/osml/model_runner/common/test_credentials_utils.py
+++ b/test/aws/osml/model_runner/common/test_credentials_utils.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import unittest
 
diff --git a/test/aws/osml/model_runner/common/test_log_context.py b/test/aws/osml/model_runner/common/test_log_context.py
index 546a08db..53c08cb6 100644
--- a/test/aws/osml/model_runner/common/test_log_context.py
+++ b/test/aws/osml/model_runner/common/test_log_context.py
@@ -1,3 +1,5 @@
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
 import logging
 from threading import Thread
 from unittest import TestCase, main
diff --git a/test/aws/osml/model_runner/common/test_mr_post_processing.py b/test/aws/osml/model_runner/common/test_mr_post_processing.py
index a678c72b..04bb681b 100755
--- a/test/aws/osml/model_runner/common/test_mr_post_processing.py
+++ b/test/aws/osml/model_runner/common/test_mr_post_processing.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 from dataclasses import asdict
 from json import dumps, loads
diff --git a/test/aws/osml/model_runner/common/test_security_classification.py b/test/aws/osml/model_runner/common/test_security_classification.py
index 57e6f9e0..a8318ef2 100755
--- a/test/aws/osml/model_runner/common/test_security_classification.py
+++ b/test/aws/osml/model_runner/common/test_security_classification.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import json
 from dataclasses import asdict
diff --git a/test/aws/osml/model_runner/common/test_timer.py b/test/aws/osml/model_runner/common/test_timer.py
index 4ca13927..f69617b8 100755
--- a/test/aws/osml/model_runner/common/test_timer.py
+++ b/test/aws/osml/model_runner/common/test_timer.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import time
 from unittest import TestCase, main
diff --git a/test/aws/osml/model_runner/database/test_ddb_helper.py b/test/aws/osml/model_runner/database/test_ddb_helper.py
index ddeb8401..a0516dde 100755
--- a/test/aws/osml/model_runner/database/test_ddb_helper.py
+++ b/test/aws/osml/model_runner/database/test_ddb_helper.py
@@ -1,4 +1,5 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
 import os
 from unittest import TestCase
 
diff --git a/test/aws/osml/model_runner/database/test_endpoint_statistics_table.py b/test/aws/osml/model_runner/database/test_endpoint_statistics_table.py
index 2f1fd6eb..22e8498a 100755
--- a/test/aws/osml/model_runner/database/test_endpoint_statistics_table.py
+++ b/test/aws/osml/model_runner/database/test_endpoint_statistics_table.py
@@ -1,4 +1,5 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
 import os
 import unittest
 from unittest.mock import Mock
diff --git a/test/aws/osml/model_runner/database/test_feature_table.py b/test/aws/osml/model_runner/database/test_feature_table.py
index 8e5509c8..e0aca72b 100755
--- a/test/aws/osml/model_runner/database/test_feature_table.py
+++ b/test/aws/osml/model_runner/database/test_feature_table.py
@@ -1,4 +1,5 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
 import os
 import unittest
 from typing import List
diff --git a/test/aws/osml/model_runner/database/test_job_table.py b/test/aws/osml/model_runner/database/test_job_table.py
index 3c103fc2..9e2665b6 100755
--- a/test/aws/osml/model_runner/database/test_job_table.py
+++ b/test/aws/osml/model_runner/database/test_job_table.py
@@ -1,4 +1,5 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
 import os
 import unittest
 from decimal import Decimal
diff --git a/test/aws/osml/model_runner/database/test_region_request_table.py b/test/aws/osml/model_runner/database/test_region_request_table.py
index a967b713..d5f0fb5e 100755
--- a/test/aws/osml/model_runner/database/test_region_request_table.py
+++ b/test/aws/osml/model_runner/database/test_region_request_table.py
@@ -1,4 +1,5 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
 import os
 import unittest
 from unittest.mock import Mock
diff --git a/test/aws/osml/model_runner/inference/test_endpoint_factory.py b/test/aws/osml/model_runner/inference/test_endpoint_factory.py
index 56e40f3d..6657f0d2 100644
--- a/test/aws/osml/model_runner/inference/test_endpoint_factory.py
+++ b/test/aws/osml/model_runner/inference/test_endpoint_factory.py
@@ -1,3 +1,5 @@
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
 import unittest
 
 
diff --git a/test/aws/osml/model_runner/inference/test_feature_selection.py b/test/aws/osml/model_runner/inference/test_feature_selection.py
index a18a84f2..8cd3c155 100755
--- a/test/aws/osml/model_runner/inference/test_feature_selection.py
+++ b/test/aws/osml/model_runner/inference/test_feature_selection.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 from unittest import TestCase
 
@@ -169,7 +169,7 @@ def test_feature_selection_nms_overlaps_multiple_categories(self):
                 geometry=Point((0, 0)),
                 properties={
                     "bounds_imcoords": [50, 50, 100, 100],
-                    "feature_types": {"ground_motor_passenger_vehicle": 0.45},
+                    "featureClasses": [{"iri": "ground_motor_passenger_vehicle", "score": 0.45}],
                 },
             ),
             Feature(
@@ -177,7 +177,7 @@ def test_feature_selection_nms_overlaps_multiple_categories(self):
                 geometry=Point((0, 0)),
                 properties={
                     "bounds_imcoords": [45, 45, 101, 101],
-                    "feature_types": {"boat": 0.57},
+                    "featureClasses": [{"iri": "boat", "score": 0.57}],
                 },
             ),
             Feature(
@@ -185,7 +185,7 @@ def test_feature_selection_nms_overlaps_multiple_categories(self):
                 geometry=Point((0, 0)),
                 properties={
                     "bounds_imcoords": [250, 250, 300, 275],
-                    "feature_types": {"ground_motor_passenger_vehicle": 0.80},
+                    "featureClasses": [{"iri": "ground_motor_passenger_vehicle", "score": 0.80}],
                 },
             ),
         ]
@@ -206,10 +206,7 @@ def test_feature_selection_soft_nms_overlaps(self):
                 geometry=Point((0, 0)),
                 properties={
                     "bounds_imcoords": [50, 50, 100, 100],
-                    "feature_types": {"boat": 0.85},
-                    "detection": {
-                        "ontology": [{"detectionScore": 0.85, "iri": "boat"}],
-                    },
+                    "featureClasses": [{"iri": "boat", "score": 0.85}],
                 },
             ),
             Feature(
@@ -217,10 +214,7 @@ def test_feature_selection_soft_nms_overlaps(self):
                 geometry=Point((0, 0)),
                 properties={
                     "bounds_imcoords": [45, 45, 101, 101],
-                    "feature_types": {"boat": 0.93},
-                    "detection": {
-                        "ontology": [{"detectionScore": 0.93, "iri": "boat"}],
-                    },
+                    "featureClasses": [{"iri": "boat", "score": 0.93}],
                 },
             ),
             Feature(
@@ -228,10 +222,7 @@ def test_feature_selection_soft_nms_overlaps(self):
                 geometry=Point((0, 0)),
                 properties={
                     "bounds_imcoords": [250, 255, 300, 275],
-                    "feature_types": {"boat": 0.80},
-                    "detection": {
-                        "ontology": [{"detectionScore": 0.80, "iri": "boat"}],
-                    },
+                    "featureClasses": [{"iri": "boat", "score": 0.80}],
                 },
             ),
         ]
@@ -241,27 +232,21 @@ def test_feature_selection_soft_nms_overlaps(self):
         expected_properties = [
             {
                 "bounds_imcoords": [45, 45, 101, 101],
-                "feature_types": {"boat": 0.93},
-                "adjusted_feature_types": {"boat": 0.93},
-                "detection": {
-                    "ontology": [{"detectionScore": 0.93, "iri": "boat", "adjustedDetectionScore": 0.93}],
-                },
+                "featureClasses": [{"iri": "boat", "score": 0.93, "rawScore": 0.93}],
             },
             {
                 "bounds_imcoords": [250, 255, 300, 275],
-                "feature_types": {"boat": 0.80},
-                "adjusted_feature_types": {"boat": 0.80},
-                "detection": {
-                    "ontology": [{"detectionScore": 0.80, "iri": "boat", "adjustedDetectionScore": 0.80}],
-                },
+                "featureClasses": [{"iri": "boat", "score": 0.80, "rawScore": 0.80}],
             },
             {
                 "bounds_imcoords": [50, 50, 100, 100],
-                "feature_types": {"boat": 0.85},
-                "adjusted_feature_types": {"boat": 0.85},
-                "detection": {
-                    "ontology": [{"detectionScore": 0.85, "iri": "boat", "adjustedDetectionScore": 0.85}],
-                },
+                "featureClasses": [
+                    {
+                        "iri": "boat",
+                        "score": 0.85,
+                        "rawScore": 0.85,
+                    }
+                ],
             },
         ]
         assert processed_properties == expected_properties
diff --git a/test/aws/osml/model_runner/inference/test_feature_utils.py b/test/aws/osml/model_runner/inference/test_feature_utils.py
index ce3ac102..0c4ee670 100755
--- a/test/aws/osml/model_runner/inference/test_feature_utils.py
+++ b/test/aws/osml/model_runner/inference/test_feature_utils.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import unittest
 from math import degrees
@@ -162,13 +162,13 @@ def test_get_source_property_not_available(self):
         from aws.osml.model_runner.inference.feature_utils import get_source_property
 
         ds, sensor_model = self.get_dataset_and_camera()
-        source_property = get_source_property("UNSUPPORTED", ds)
+        source_property = get_source_property("./test/data/GeogToWGS84GeoKey5.tif", "UNSUPPORTED", ds)
         assert source_property is None
 
     def test_get_source_property_exception(self):
         from aws.osml.model_runner.inference.feature_utils import get_source_property
 
-        source_property = get_source_property("NITF", dataset=None)
+        source_property = get_source_property("./test/data/GeogToWGS84GeoKey5.tif", "NITF", dataset=None)
         assert source_property is None
 
     @staticmethod
diff --git a/test/aws/osml/model_runner/inference/test_http_detector.py b/test/aws/osml/model_runner/inference/test_http_detector.py
index a5595f08..0384fe00 100644
--- a/test/aws/osml/model_runner/inference/test_http_detector.py
+++ b/test/aws/osml/model_runner/inference/test_http_detector.py
@@ -1,3 +1,5 @@
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
 import json
 from unittest import TestCase
 from unittest.mock import patch
diff --git a/test/aws/osml/model_runner/inference/test_sm_detector.py b/test/aws/osml/model_runner/inference/test_sm_detector.py
index 4f3c673c..9ed4320f 100755
--- a/test/aws/osml/model_runner/inference/test_sm_detector.py
+++ b/test/aws/osml/model_runner/inference/test_sm_detector.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import datetime
 import io
diff --git a/test/aws/osml/model_runner/queue/test_request_queue.py b/test/aws/osml/model_runner/queue/test_request_queue.py
index d533d124..9237b84d 100755
--- a/test/aws/osml/model_runner/queue/test_request_queue.py
+++ b/test/aws/osml/model_runner/queue/test_request_queue.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import unittest
 from unittest.mock import Mock
diff --git a/test/aws/osml/model_runner/sink/test_kinesis_sink.py b/test/aws/osml/model_runner/sink/test_kinesis_sink.py
index 0577d8f4..1e828366 100755
--- a/test/aws/osml/model_runner/sink/test_kinesis_sink.py
+++ b/test/aws/osml/model_runner/sink/test_kinesis_sink.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import datetime
 import unittest
diff --git a/test/aws/osml/model_runner/sink/test_s3_sink.py b/test/aws/osml/model_runner/sink/test_s3_sink.py
index 42bc972a..d509ae6a 100755
--- a/test/aws/osml/model_runner/sink/test_s3_sink.py
+++ b/test/aws/osml/model_runner/sink/test_s3_sink.py
@@ -1,4 +1,5 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
+
 import unittest
 from typing import List
 from unittest import mock
diff --git a/test/aws/osml/model_runner/status/test_image_request_status.py b/test/aws/osml/model_runner/status/test_image_request_status.py
index cb846e42..db52686b 100755
--- a/test/aws/osml/model_runner/status/test_image_request_status.py
+++ b/test/aws/osml/model_runner/status/test_image_request_status.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 from decimal import Decimal
 from unittest import TestCase
diff --git a/test/aws/osml/model_runner/status/test_sns_helper.py b/test/aws/osml/model_runner/status/test_sns_helper.py
index b2699f1b..6fd4b759 100755
--- a/test/aws/osml/model_runner/status/test_sns_helper.py
+++ b/test/aws/osml/model_runner/status/test_sns_helper.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import json
 import os
diff --git a/test/aws/osml/model_runner/status/test_status_monitor.py b/test/aws/osml/model_runner/status/test_status_monitor.py
index 901c483e..c91d337d 100755
--- a/test/aws/osml/model_runner/status/test_status_monitor.py
+++ b/test/aws/osml/model_runner/status/test_status_monitor.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 import json
 import os
diff --git a/test/aws/osml/model_runner/tile_worker/test_feature_refinery.py b/test/aws/osml/model_runner/tile_worker/test_feature_refinery.py
deleted file mode 100755
index 635bda6c..00000000
--- a/test/aws/osml/model_runner/tile_worker/test_feature_refinery.py
+++ /dev/null
@@ -1,80 +0,0 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
-
-import unittest
-from secrets import token_hex
-
-import geojson
-from defusedxml import ElementTree
-
-
-class TestFeatureRefinery(unittest.TestCase):
-    def setUp(self) -> None:
-        from aws.osml.model_runner.tile_worker.feature_refinery import FeatureRefinery
-
-        self.sensor_model = self.build_sensor_model()
-        self.sample_geojson_detections = self.build_geojson_detections()
-        self.synthetic_detections = self.build_synthetic_detections()
-        self.feature_refinery = FeatureRefinery(self.sensor_model)
-
-    def test_build_features_for_sparse_tile(self):
-        self.feature_refinery.refine_features_for_tile(self.sample_geojson_detections)
-        for feature in self.sample_geojson_detections:
-            assert feature["geometry"]["type"] == "Polygon"
-            assert "bbox" in feature
-            assert "center_latitude" in feature["properties"]
-            assert "center_longitude" in feature["properties"]
-
-    def test_build_features_for_dense_tile(self):
-        self.feature_refinery.refine_features_for_tile(self.synthetic_detections)
-        for feature in self.synthetic_detections:
-            assert feature["geometry"]["type"] == "Polygon"
-            assert "bbox" in feature
-            assert "center_latitude" in feature["properties"]
-            assert "center_longitude" in feature["properties"]
-
-    @staticmethod
-    def build_geojson_detections():
-        with open("./test/data/detections.geojson", "r") as geojson_file:
-            return geojson.load(geojson_file)["features"]
-
-    @staticmethod
-    def build_synthetic_detections():
-        features_per_row = 40
-        feature_stride = 15
-        feature_size = 5
-        selected_features = []
-        for y in range(0, features_per_row):
-            for x in range(0, features_per_row):
-                selected_features.append(
-                    geojson.Feature(
-                        id=token_hex(16),
-                        geometry=None,
-                        properties={
-                            "bounds_imcoords": [
-                                x * feature_stride,
-                                y * feature_stride,
-                                x * feature_stride + feature_size,
-                                y * feature_stride + feature_size,
-                            ]
-                        },
-                    )
-                )
-        return selected_features
-
-    @staticmethod
-    def build_sensor_model():
-        from aws.osml.gdal.sensor_model_factory import SensorModelFactory, SensorModelTypes
-
-        with open("test/data/sample-metadata-ms-rpc00b.xml", "rb") as xml_file:
-            xml_tres = ElementTree.parse(xml_file)
-            sensor_model_builder = SensorModelFactory(
-                2048,
-                2048,
-                xml_tres=xml_tres,
-                selected_sensor_model_types=[SensorModelTypes.PROJECTIVE, SensorModelTypes.RPC],
-            )
-            return sensor_model_builder.build()
-
-
-if __name__ == "__main__":
-    unittest.main()
diff --git a/test/aws/osml/model_runner/tile_worker/test_tile_worker_utils.py b/test/aws/osml/model_runner/tile_worker/test_tile_worker_utils.py
index 53f62e0d..198c8837 100755
--- a/test/aws/osml/model_runner/tile_worker/test_tile_worker_utils.py
+++ b/test/aws/osml/model_runner/tile_worker/test_tile_worker_utils.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 from unittest import TestCase, main
 from unittest.mock import Mock, patch
diff --git a/test/configuration.py b/test/configuration.py
index a4323d74..dc49ae50 100755
--- a/test/configuration.py
+++ b/test/configuration.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 # Environment Variable Configuration
 TEST_ENV_CONFIG = {
diff --git a/test/data/detections.geojson b/test/data/detections.geojson
index 11b610ce..0f112621 100755
--- a/test/data/detections.geojson
+++ b/test/data/detections.geojson
@@ -20,25 +20,30 @@
         ],
         "geom_imcoords": [
           [
-            646,
-            1649
+            429,
+            553
           ],
           [
-            646,
-            1658
+            429,
+            561
           ],
           [
-            654,
-            1658
+            440,
+            561
           ],
           [
-            654,
-            646
+            440,
+            553
+          ],
+          [
+            429,
+            553
           ]
         ],
-        "feature_types": {
-          "ground_motor_passenger_vehicle": 0.2961518168449402
-        },
+        "featureClasses": [{
+          "iri": "ground_motor_passenger_vehicle",
+          "score": 0.2961518168449402
+        }],
         "detection_score": 0.2961518168449402,
         "image_id": "7db12549-3bcb-49c8-acba-25d46ef5cbf3:s3://spacenet-dataset/AOIs/AOI_1_Rio/srcData/mosaic_3band/013022223131.tif"
       }
@@ -62,25 +67,30 @@
         ],
         "geom_imcoords": [
           [
-            646,
-            1649
+            414,
+            505
           ],
           [
-            646,
-            1658
+            414,
+            515
           ],
           [
-            654,
-            1658
+            423,
+            515
           ],
           [
-            654,
-            646
+            423,
+            505
+          ],
+          [
+            414,
+            505
           ]
         ],
-        "feature_types": {
-          "ground_motor_passenger_vehicle": 0.2887502908706665
-        },
+        "featureClasses": [{
+          "iri": "ground_motor_passenger_vehicle",
+          "score": 0.2887502908706665
+        }],
         "detection_score": 0.2887502908706665,
         "image_id": "7db12549-3bcb-49c8-acba-25d46ef5cbf3:s3://spacenet-dataset/AOIs/AOI_1_Rio/srcData/mosaic_3band/013022223131.tif"
       }
@@ -104,25 +114,30 @@
         ],
         "geom_imcoords": [
           [
-            646,
-            1649
+            664,
+            597
           ],
           [
-            646,
-            1658
+            664,
+            607
           ],
           [
-            654,
-            1658
+            674,
+            607
           ],
           [
-            654,
-            646
+            674,
+            597
+          ],
+          [
+            664,
+            597
           ]
         ],
-        "feature_types": {
-          "ground_motor_passenger_vehicle": 0.2716290056705475
-        },
+        "featureClasses": [{
+          "iri": "ground_motor_passenger_vehicle",
+          "score": 0.2716290056705475
+        }],
         "detection_score": 0.2716290056705475,
         "image_id": "7db12549-3bcb-49c8-acba-25d46ef5cbf3:s3://spacenet-dataset/AOIs/AOI_1_Rio/srcData/mosaic_3band/013022223131.tif"
       }
@@ -159,12 +174,17 @@
           ],
           [
             654,
-            646
+            1649
+          ],
+          [
+            646,
+            1649
           ]
         ],
-        "feature_types": {
-          "ground_motor_passenger_vehicle": 0.25180017948150635
-        },
+        "featureClasses": [{
+          "iri": "ground_motor_passenger_vehicle",
+          "score": 0.25180017948150635
+        }],
         "detection_score": 0.25180017948150635,
         "image_id": "7db12549-3bcb-49c8-acba-25d46ef5cbf3:s3://spacenet-dataset/AOIs/AOI_1_Rio/srcData/mosaic_3band/013022223131.tif"
       }
diff --git a/test/test_api.py b/test/test_api.py
index 417fb7b7..168bd0eb 100755
--- a/test/test_api.py
+++ b/test/test_api.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Amazon.com, Inc. or its affiliates.
+#  Copyright 2023-2024 Amazon.com, Inc. or its affiliates.
 
 from typing import Any, Dict
 from unittest import TestCase, main
diff --git a/test/test_app.py b/test/test_app.py
index 673cae2d..ce80049d 100755
--- a/test/test_app.py
+++ b/test/test_app.py
@@ -113,15 +113,12 @@ def setUp(self):
         # This is the expected results for the source property derived from the small test image
         self.test_feature_source_property = [
             {
-                "fileType": "NITF",
-                "info": {
-                    "imageCategory": "VIS",
-                    "metadata": {
-                        "sourceId": "Checks an uncompressed 1024x1024 8 bit mono image with GEOcentric data. Airfield",
-                        "sourceDt": "1996-12-17T10:26:30",
-                        "classification": "UNCLASSIFIED",
-                    },
-                },
+                "location": TEST_IMAGE_FILE,
+                "format": "NITF",
+                "category": "VIS",
+                "sourceId": "Checks an uncompressed 1024x1024 8 bit mono image with GEOcentric data. Airfield",
+                "sourceDT": "1996-12-17T10:26:30Z",
+                "classification": "UNCLASSIFIED",
             }
         ]
 
@@ -337,7 +334,7 @@ def test_process_bounds_image_request(self):
         assert results_features[0]["properties"]["modelMetadata"] == self.test_custom_feature_properties.get("modelMetadata")
 
         # Check we got the correct source data for the small.ntf file
-        assert results_features[0]["properties"]["source"] == self.test_feature_source_property
+        assert results_features[0]["properties"]["sourceMetadata"] == self.test_feature_source_property
 
         # Check that we calculated the max in progress regions
         # Test instance type is set to m5.12xl with 48 vcpus. Default
@@ -397,7 +394,7 @@ def test_process_geom_image_request(self):
         assert results_features[0]["properties"]["modelMetadata"] == self.test_custom_feature_properties.get("modelMetadata")
 
         # Check we got the correct source data for the small.ntf file
-        assert results_features[0]["properties"]["source"] == self.test_feature_source_property
+        assert results_features[0]["properties"]["sourceMetadata"] == self.test_feature_source_property
 
         # Check that we calculated the max in progress regions
         # Test instance type is set to m5.12xl with 48 vcpus. Default