From 6e39c8dd30f1940e4a677fdeb9abb4d162ab8c61 Mon Sep 17 00:00:00 2001
From: Jakob Maljaars <jakob.maljaars@stcorp.nl>
Date: Fri, 8 Apr 2022 14:58:27 +0000
Subject: [PATCH] AST-779 set up deconvolution repository

---
 .clang-format                                 | 156 +++
 .gitlab-ci.astron.yml                         |   7 +
 .gitlab-ci.common.yml                         | 126 +++
 .gitmodules                                   |   9 +
 CMakeLists.txt                                | 170 ++++
 LICENSE                                       | 676 ++++++++++++
 README.md                                     |   4 +-
 cmake/FindCFITSIO.cmake                       |  56 +
 cmake/FindCasacore.cmake                      | 277 +++++
 cmake/config/radler-config-version.cmake.in   |  14 +
 cmake/config/radler-config.cmake.in           |  45 +
 cmake/unittest.cmake                          |  45 +
 cpp/CMakeLists.txt                            | 116 +++
 cpp/algorithms/deconvolution_algorithm.cc     |  51 +
 cpp/algorithms/deconvolution_algorithm.h      | 141 +++
 cpp/algorithms/generic_clean.cc               | 208 ++++
 cpp/algorithms/generic_clean.h                |  46 +
 cpp/algorithms/iuwt/image_analysis.cc         | 328 ++++++
 cpp/algorithms/iuwt/image_analysis.h          |  84 ++
 cpp/algorithms/iuwt/iuwt_decomposition.cc     | 240 +++++
 cpp/algorithms/iuwt/iuwt_decomposition.h      | 346 +++++++
 cpp/algorithms/iuwt/iuwt_mask.cc              |  36 +
 cpp/algorithms/iuwt/iuwt_mask.h               |  86 ++
 cpp/algorithms/iuwt_deconvolution.h           |  47 +
 .../iuwt_deconvolution_algorithm.cc           | 962 ++++++++++++++++++
 cpp/algorithms/iuwt_deconvolution_algorithm.h | 194 ++++
 cpp/algorithms/ls_deconvolution.cc            | 315 ++++++
 cpp/algorithms/ls_deconvolution.h             |  63 ++
 cpp/algorithms/more_sane.cc                   |  87 ++
 cpp/algorithms/more_sane.h                    |  42 +
 .../multiscale/multiscale_transforms.cc       |  39 +
 .../multiscale/multiscale_transforms.h        | 196 ++++
 cpp/algorithms/multiscale_algorithm.cc        | 711 +++++++++++++
 cpp/algorithms/multiscale_algorithm.h         | 138 +++
 cpp/algorithms/parallel_deconvolution.cc      | 436 ++++++++
 cpp/algorithms/parallel_deconvolution.h       | 105 ++
 cpp/algorithms/python_deconvolution.cc        | 277 +++++
 cpp/algorithms/python_deconvolution.h         |  52 +
 cpp/algorithms/simple_clean.cc                | 179 ++++
 cpp/algorithms/simple_clean.h                 |  40 +
 cpp/algorithms/subminor_loop.cc               | 232 +++++
 cpp/algorithms/subminor_loop.h                | 209 ++++
 cpp/algorithms/test/CMakeLists.txt            |   8 +
 cpp/algorithms/test/runtests.cc               |   6 +
 cpp/algorithms/test/test_simple_clean.cc      |  54 +
 .../threaded_deconvolution_tools.cc           | 197 ++++
 cpp/algorithms/threaded_deconvolution_tools.h |  99 ++
 cpp/component_list.cc                         | 158 +++
 cpp/component_list.h                          | 234 +++++
 cpp/deconvolution_settings.h                  | 155 +++
 cpp/deconvolution_table.cc                    |  39 +
 cpp/deconvolution_table.h                     | 159 +++
 cpp/deconvolution_table_entry.h               |  67 ++
 cpp/demo/CMakeLists.txt                       |   7 +
 cpp/demo/multiscale_example.cc                | 105 ++
 cpp/image_set.cc                              | 464 +++++++++
 cpp/image_set.h                               | 337 ++++++
 cpp/logging/controllable_log.h                |  67 ++
 cpp/logging/subimage_logset.h                 | 104 ++
 cpp/math/dijkstrasplitter.h                   | 320 ++++++
 cpp/math/peak_finder.cc                       | 262 +++++
 cpp/math/peak_finder.h                        | 129 +++
 cpp/math/rms_image.cc                         |  96 ++
 cpp/math/rms_image.h                          |  35 +
 cpp/math/test/CMakeLists.txt                  |   9 +
 cpp/math/test/runtests.cc                     |   6 +
 cpp/math/test/test_dijkstra_splitter.cc       | 539 ++++++++++
 cpp/math/test/test_peak_finder.cc             | 220 ++++
 cpp/radler.cc                                 | 391 +++++++
 cpp/radler.h                                  |  80 ++
 cpp/test/CMakeLists.txt                       |   9 +
 cpp/test/runtests.cc                          |   6 +
 cpp/test/smartptr.h                           |  54 +
 cpp/test/test_component_list.cc               | 110 ++
 cpp/test/test_deconvolution_table.cc          |  96 ++
 cpp/test/test_image_set.cc                    | 495 +++++++++
 cpp/utils/application.h                       |  49 +
 cpp/utils/casa_mask_reader.cc                 |  40 +
 cpp/utils/casa_mask_reader.h                  |  26 +
 cpp/utils/write_model.h                       |  61 ++
 docker/ubuntu_20_04_base                      |  17 +
 docker/ubuntu_22_04_base                      |  17 +
 external/aocommon                             |   1 +
 external/pybind11                             |   1 +
 external/schaapcommon                         |   1 +
 scripts/run-format.sh                         |  21 +
 86 files changed, 12940 insertions(+), 2 deletions(-)
 create mode 100644 .clang-format
 create mode 100644 .gitlab-ci.astron.yml
 create mode 100644 .gitlab-ci.common.yml
 create mode 100644 .gitmodules
 create mode 100644 CMakeLists.txt
 create mode 100644 LICENSE
 create mode 100644 cmake/FindCFITSIO.cmake
 create mode 100644 cmake/FindCasacore.cmake
 create mode 100644 cmake/config/radler-config-version.cmake.in
 create mode 100644 cmake/config/radler-config.cmake.in
 create mode 100644 cmake/unittest.cmake
 create mode 100644 cpp/CMakeLists.txt
 create mode 100644 cpp/algorithms/deconvolution_algorithm.cc
 create mode 100644 cpp/algorithms/deconvolution_algorithm.h
 create mode 100644 cpp/algorithms/generic_clean.cc
 create mode 100644 cpp/algorithms/generic_clean.h
 create mode 100644 cpp/algorithms/iuwt/image_analysis.cc
 create mode 100644 cpp/algorithms/iuwt/image_analysis.h
 create mode 100644 cpp/algorithms/iuwt/iuwt_decomposition.cc
 create mode 100644 cpp/algorithms/iuwt/iuwt_decomposition.h
 create mode 100644 cpp/algorithms/iuwt/iuwt_mask.cc
 create mode 100644 cpp/algorithms/iuwt/iuwt_mask.h
 create mode 100644 cpp/algorithms/iuwt_deconvolution.h
 create mode 100644 cpp/algorithms/iuwt_deconvolution_algorithm.cc
 create mode 100644 cpp/algorithms/iuwt_deconvolution_algorithm.h
 create mode 100644 cpp/algorithms/ls_deconvolution.cc
 create mode 100644 cpp/algorithms/ls_deconvolution.h
 create mode 100644 cpp/algorithms/more_sane.cc
 create mode 100644 cpp/algorithms/more_sane.h
 create mode 100644 cpp/algorithms/multiscale/multiscale_transforms.cc
 create mode 100644 cpp/algorithms/multiscale/multiscale_transforms.h
 create mode 100644 cpp/algorithms/multiscale_algorithm.cc
 create mode 100644 cpp/algorithms/multiscale_algorithm.h
 create mode 100644 cpp/algorithms/parallel_deconvolution.cc
 create mode 100644 cpp/algorithms/parallel_deconvolution.h
 create mode 100644 cpp/algorithms/python_deconvolution.cc
 create mode 100644 cpp/algorithms/python_deconvolution.h
 create mode 100644 cpp/algorithms/simple_clean.cc
 create mode 100644 cpp/algorithms/simple_clean.h
 create mode 100644 cpp/algorithms/subminor_loop.cc
 create mode 100644 cpp/algorithms/subminor_loop.h
 create mode 100644 cpp/algorithms/test/CMakeLists.txt
 create mode 100644 cpp/algorithms/test/runtests.cc
 create mode 100644 cpp/algorithms/test/test_simple_clean.cc
 create mode 100644 cpp/algorithms/threaded_deconvolution_tools.cc
 create mode 100644 cpp/algorithms/threaded_deconvolution_tools.h
 create mode 100644 cpp/component_list.cc
 create mode 100644 cpp/component_list.h
 create mode 100644 cpp/deconvolution_settings.h
 create mode 100644 cpp/deconvolution_table.cc
 create mode 100644 cpp/deconvolution_table.h
 create mode 100644 cpp/deconvolution_table_entry.h
 create mode 100644 cpp/demo/CMakeLists.txt
 create mode 100644 cpp/demo/multiscale_example.cc
 create mode 100644 cpp/image_set.cc
 create mode 100644 cpp/image_set.h
 create mode 100644 cpp/logging/controllable_log.h
 create mode 100644 cpp/logging/subimage_logset.h
 create mode 100644 cpp/math/dijkstrasplitter.h
 create mode 100644 cpp/math/peak_finder.cc
 create mode 100644 cpp/math/peak_finder.h
 create mode 100644 cpp/math/rms_image.cc
 create mode 100644 cpp/math/rms_image.h
 create mode 100644 cpp/math/test/CMakeLists.txt
 create mode 100644 cpp/math/test/runtests.cc
 create mode 100644 cpp/math/test/test_dijkstra_splitter.cc
 create mode 100644 cpp/math/test/test_peak_finder.cc
 create mode 100644 cpp/radler.cc
 create mode 100644 cpp/radler.h
 create mode 100644 cpp/test/CMakeLists.txt
 create mode 100644 cpp/test/runtests.cc
 create mode 100644 cpp/test/smartptr.h
 create mode 100644 cpp/test/test_component_list.cc
 create mode 100644 cpp/test/test_deconvolution_table.cc
 create mode 100644 cpp/test/test_image_set.cc
 create mode 100644 cpp/utils/application.h
 create mode 100644 cpp/utils/casa_mask_reader.cc
 create mode 100644 cpp/utils/casa_mask_reader.h
 create mode 100644 cpp/utils/write_model.h
 create mode 100644 docker/ubuntu_20_04_base
 create mode 100644 docker/ubuntu_22_04_base
 create mode 160000 external/aocommon
 create mode 160000 external/pybind11
 create mode 160000 external/schaapcommon
 create mode 100755 scripts/run-format.sh

diff --git a/.clang-format b/.clang-format
new file mode 100644
index 00000000..caf3c006
--- /dev/null
+++ b/.clang-format
@@ -0,0 +1,156 @@
+---
+Language:        Cpp
+# BasedOnStyle:  Google
+AccessModifierOffset: -1
+AlignAfterOpenBracket: Align
+AlignConsecutiveMacros: false
+AlignConsecutiveAssignments: false
+AlignConsecutiveDeclarations: false
+AlignEscapedNewlines: Left
+AlignOperands:   true
+AlignTrailingComments: true
+AllowAllArgumentsOnNextLine: true
+AllowAllConstructorInitializersOnNextLine: true
+AllowAllParametersOfDeclarationOnNextLine: true
+AllowShortBlocksOnASingleLine: false
+AllowShortCaseLabelsOnASingleLine: false
+AllowShortFunctionsOnASingleLine: All
+AllowShortLambdasOnASingleLine: All
+AllowShortIfStatementsOnASingleLine: WithoutElse
+AllowShortLoopsOnASingleLine: true
+AlwaysBreakAfterDefinitionReturnType: None
+AlwaysBreakAfterReturnType: None
+AlwaysBreakBeforeMultilineStrings: true
+AlwaysBreakTemplateDeclarations: Yes
+BinPackArguments: true
+BinPackParameters: true
+BraceWrapping:
+  AfterCaseLabel:  false
+  AfterClass:      false
+  AfterControlStatement: false
+  AfterEnum:       false
+  AfterFunction:   false
+  AfterNamespace:  false
+  AfterObjCDeclaration: false
+  AfterStruct:     false
+  AfterUnion:      false
+  AfterExternBlock: false
+  BeforeCatch:     false
+  BeforeElse:      false
+  IndentBraces:    false
+  SplitEmptyFunction: true
+  SplitEmptyRecord: true
+  SplitEmptyNamespace: true
+BreakBeforeBinaryOperators: None
+BreakBeforeBraces: Attach
+BreakBeforeInheritanceComma: false
+BreakInheritanceList: BeforeColon
+BreakBeforeTernaryOperators: true
+BreakConstructorInitializersBeforeComma: false
+BreakConstructorInitializers: BeforeColon
+BreakAfterJavaFieldAnnotations: false
+BreakStringLiterals: true
+ColumnLimit:     80
+CommentPragmas:  '^ IWYU pragma:'
+CompactNamespaces: false
+ConstructorInitializerAllOnOneLineOrOnePerLine: true
+ConstructorInitializerIndentWidth: 4
+ContinuationIndentWidth: 4
+Cpp11BracedListStyle: true
+DerivePointerAlignment: false
+DisableFormat:   false
+ExperimentalAutoDetectBinPacking: false
+FixNamespaceComments: true
+ForEachMacros:
+  - foreach
+  - Q_FOREACH
+  - BOOST_FOREACH
+IncludeBlocks:   Regroup
+IncludeCategories:
+  - Regex:           '^<ext/.*\.h>'
+    Priority:        2
+  - Regex:           '^<.*\.h>'
+    Priority:        1
+  - Regex:           '^<.*'
+    Priority:        2
+  - Regex:           '.*'
+    Priority:        3
+IncludeIsMainRegex: '([-_](test|unittest))?$'
+IndentCaseLabels: true
+IndentPPDirectives: None
+IndentWidth:     2
+IndentWrappedFunctionNames: false
+JavaScriptQuotes: Leave
+JavaScriptWrapImports: true
+KeepEmptyLinesAtTheStartOfBlocks: false
+MacroBlockBegin: ''
+MacroBlockEnd:   ''
+MaxEmptyLinesToKeep: 1
+NamespaceIndentation: None
+ObjCBinPackProtocolList: Never
+ObjCBlockIndentWidth: 2
+ObjCSpaceAfterProperty: false
+ObjCSpaceBeforeProtocolList: true
+PenaltyBreakAssignment: 2
+PenaltyBreakBeforeFirstCallParameter: 1
+PenaltyBreakComment: 300
+PenaltyBreakFirstLessLess: 120
+PenaltyBreakString: 1000
+PenaltyBreakTemplateDeclaration: 10
+PenaltyExcessCharacter: 1000000
+PenaltyReturnTypeOnItsOwnLine: 200
+PointerAlignment: Left
+RawStringFormats:
+  - Language:        Cpp
+    Delimiters:
+      - cc
+      - CC
+      - cpp
+      - Cpp
+      - CPP
+      - 'c++'
+      - 'C++'
+    CanonicalDelimiter: ''
+    BasedOnStyle:    google
+  - Language:        TextProto
+    Delimiters:
+      - pb
+      - PB
+      - proto
+      - PROTO
+    EnclosingFunctions:
+      - EqualsProto
+      - EquivToProto
+      - PARSE_PARTIAL_TEXT_PROTO
+      - PARSE_TEST_PROTO
+      - PARSE_TEXT_PROTO
+      - ParseTextOrDie
+      - ParseTextProtoOrDie
+    CanonicalDelimiter: ''
+    BasedOnStyle:    google
+ReflowComments:  true
+SortIncludes:    false
+SortUsingDeclarations: true
+SpaceAfterCStyleCast: false
+SpaceAfterLogicalNot: false
+SpaceAfterTemplateKeyword: true
+SpaceBeforeAssignmentOperators: true
+SpaceBeforeCpp11BracedList: false
+SpaceBeforeCtorInitializerColon: true
+SpaceBeforeInheritanceColon: true
+SpaceBeforeParens: ControlStatements
+SpaceBeforeRangeBasedForLoopColon: true
+SpaceInEmptyParentheses: false
+SpacesBeforeTrailingComments: 2
+SpacesInAngles:  false
+SpacesInContainerLiterals: true
+SpacesInCStyleCastParentheses: false
+SpacesInParentheses: false
+SpacesInSquareBrackets: false
+Standard:        c++17
+StatementMacros:
+  - Q_UNUSED
+  - QT_REQUIRE_VERSION
+TabWidth:        8
+UseTab:          Never
+...
diff --git a/.gitlab-ci.astron.yml b/.gitlab-ci.astron.yml
new file mode 100644
index 00000000..cf5e75ea
--- /dev/null
+++ b/.gitlab-ci.astron.yml
@@ -0,0 +1,7 @@
+# Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# This file contains the pipelines that run on the Astron repository of Radler,
+# which is at https://git.astron.nl/RD/radler
+
+include: .gitlab-ci.common.yml
\ No newline at end of file
diff --git a/.gitlab-ci.common.yml b/.gitlab-ci.common.yml
new file mode 100644
index 00000000..c2cbafac
--- /dev/null
+++ b/.gitlab-ci.common.yml
@@ -0,0 +1,126 @@
+# Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+workflow:
+  rules:
+    # don't create a pipeline if its a commit pipeline, on a branch and that branch has open merge requests (we will get a MR build instead)
+    - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
+      when: never
+    - when: always
+
+stages:
+  - versioning
+  - prepare
+  - linting
+  - build
+  - test
+
+# The 'IMAGE' variables allow reusing docker images between different pipelines.
+# See https://confluence.skatelescope.org/display/SE/Caching+Docker+images+using+GitLab+CI+registry
+versioning:
+  stage: versioning
+  image: bitnami/git
+  script:
+    # Unshallowing ensures that 'git log' works
+    # - git fetch --unshallow
+    - git fetch --depth=1000
+    - echo BASE_IMAGE_2004=${CI_REGISTRY_IMAGE}/base_2004:$(git log -n 1 --pretty=format:%H -- docker/ubuntu_20_04_base) > versions.env
+    - echo BASE_IMAGE_2204=${CI_REGISTRY_IMAGE}/base_2204:$(git log -n 1 --pretty=format:%H -- docker/ubuntu_22_04_base) >> versions.env
+    - cat versions.env
+  artifacts:
+    reports:
+      dotenv: versions.env
+
+.prepare:
+  stage: prepare
+  needs: ["versioning"]
+  image: docker:20.10
+  services:
+    - docker:20.10-dind
+  before_script:
+    - echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY
+  script:
+    - |
+      if ! docker manifest inspect $DOCKER_IMAGE > /dev/null; then
+        docker build $DOCKER_BUILD_ARG --tag $DOCKER_IMAGE -f $DOCKER_FILE .
+        docker push $DOCKER_IMAGE
+      fi
+  # Skip the job if there are no changes to the Docker file. This shortcut only
+  # works for push and merge request jobs.
+  # A manual pipeline run will thus create missing docker images.
+  rules:
+    - changes:
+      - $DOCKER_FILE
+
+prepare-base-2004:
+  extends: .prepare
+  variables:
+    DOCKER_IMAGE: $BASE_IMAGE_2004
+    DOCKER_FILE: docker/ubuntu_20_04_base
+
+prepare-base-2204:
+  extends: .prepare
+  variables:
+    DOCKER_IMAGE: $BASE_IMAGE_2204
+    DOCKER_FILE: docker/ubuntu_22_04_base
+
+.needs-2004:
+  needs:
+    - job: versioning
+    - job: prepare-base-2004
+      optional: true
+  image: $BASE_IMAGE_2004
+
+.needs-2204:
+  needs:
+    - job: versioning
+    - job: prepare-base-2204
+      optional: true
+  image: $BASE_IMAGE_2204
+
+clang-format:
+  extends: .needs-2004
+  stage: linting
+  script:
+    # Explicitly checking out submodules might become redundant
+    # once git fetch --unshallow works.
+    - git submodule update --init --recursive --checkout --depth 1
+    - ./scripts/run-format.sh
+
+.build:
+  stage: build
+  script:
+    - cmake --version
+    - mkdir build && cd build
+    - cmake -DBUILD_TESTING=ON -DCMAKE_INSTALL_PREFIX=.. -DCMAKE_CXX_FLAGS="-coverage" -DCMAKE_EXE_LINKER_FLAGS="-coverage" ..
+    - make -j`nproc`
+    - make install
+  artifacts:
+    paths:
+      - build
+
+build-2004:
+  extends: [".needs-2004",".build"]
+
+build-2204:
+  extends: [".needs-2204",".build"]
+
+.test:
+  stage: test
+  script:
+     - cd build/
+     - ctest -j`nproc` --output-on-failure -T test
+
+test-2004:
+  extends: .test
+  needs: ["versioning","build-2004"]
+  image: $BASE_IMAGE_2004
+  after_script:
+    - gcovr -j`nproc` -r .. -e '.*/external/.*' -e '.*/CompilerIdCXX/.*' -e '.*/test/.*'
+
+test-2204:
+  extends: .test
+  needs: ["versioning","build-2204"]
+  image: $BASE_IMAGE_2204
+  after_script:
+    - gcovr -j`nproc` -r .. -e '.*/external/.*' -e '.*/CompilerIdCXX/.*' -e '.*/test/.*'
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000..819fcba2
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,9 @@
+[submodule "external/aocommon"]
+	path = external/aocommon
+	url = https://gitlab.com/aroffringa/aocommon.git
+[submodule "external/schaapcommon"]
+	path = external/schaapcommon
+	url = https://git.astron.nl/RD/schaapcommon.git
+[submodule "external/pybind11"]
+	path = external/pybind11
+	url = https://github.com/pybind/pybind11.git
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 00000000..38374b06
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,170 @@
+# Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+cmake_minimum_required(VERSION 3.8)
+
+# When Radler is compiled as an ExternalProject inside another project, set this
+# option to On. See, e.g., the wsclean CMake file for an example.
+option(COMPILE_AS_EXTERNAL_PROJECT OFF)
+
+set(RADLER_VERSION 0.0.0)
+project(radler VERSION ${RADLER_VERSION})
+if(RADLER_VERSION MATCHES "^([0-9]+)\\.([0-9]+)\\.([0-9]+)")
+  set(RADLER_VERSION_MAJOR "${CMAKE_MATCH_1}")
+  set(RADLER_VERSION_MINOR "${CMAKE_MATCH_2}")
+  set(RADLER_VERSION_PATCH "${CMAKE_MATCH_3}")
+else()
+  message(FATAL_ERROR "Failed to parse RADLER_VERSION='${RADLER_VERSION}'")
+endif()
+
+if(POLICY CMP0076)
+  cmake_policy(SET CMP0076 NEW)
+endif()
+
+set(CMAKE_CXX_STANDARD 17)
+set(CMAKE_CXX_STANDARD_REQUIRED YES)
+set(CMAKE_CXX_EXTENSIONS OFF)
+
+include(CheckCXXCompilerFlag)
+if(PORTABLE)
+  if(DEFINED TARGET_CPU)
+    message(WARNING "You have selected to build PORTABLE binaries. "
+                    "TARGET_CPU settings will be ignored.")
+    unset(TARGET_CPU CACHE)
+  endif()
+endif()
+
+if(NOT COMPILE_AS_EXTERNAL_PROJECT)
+  # Include submodules
+  set(ExternalSubmoduleDirectories aocommon pybind11 schaapcommon)
+  foreach(ExternalSubmodule ${ExternalSubmoduleDirectories})
+    if(NOT EXISTS ${CMAKE_SOURCE_DIR}/external/${ExternalSubmodule})
+      message(
+        FATAL_ERROR
+          "The external submodule '${ExternalSubmodule}' is missing in the external/ subdirectory. "
+          "This is likely the result of downloading a git tarball without submodules. "
+          "This is not supported: git tarballs do not provide the required versioning "
+          "information for the submodules. Please perform a git clone of this repository."
+      )
+    endif()
+  endforeach()
+
+  # Find and include git submodules
+  find_package(Git QUIET)
+  if(GIT_FOUND AND EXISTS "${PROJECT_SOURCE_DIR}/.git")
+    # Update submodules as needed
+    option(GIT_SUBMODULE "Check submodules during build" ON)
+    if(GIT_SUBMODULE)
+      message(STATUS "Submodule update")
+      execute_process(
+        COMMAND ${GIT_EXECUTABLE} submodule update --init --recursive --checkout
+                --depth 1
+        WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
+        RESULT_VARIABLE GIT_SUBMOD_RESULT)
+      if(NOT GIT_SUBMOD_RESULT EQUAL "0")
+        message(
+          FATAL_ERROR
+            "git submodule update --init failed with ${GIT_SUBMOD_RESULT}, please checkout submodules"
+        )
+      endif()
+    endif()
+  endif()
+endif()
+
+if(COMPILE_AS_EXTERNAL_PROJECT)
+  message(
+    STATUS "Radler is compiled as an external project within another project.")
+  if(NOT DEFINED AOCOMMON_INCLUDE_DIR)
+    message(
+      FATAL_ERROR
+        "AOCOMMON_INCLUDE_DIR not specified. Please add -DAOCOMMON_INCLUDE_DIR to the CMAKE_ARGS."
+    )
+  endif()
+  if(NOT DEFINED SCHAAPCOMMON_SOURCE_DIR)
+    message(
+      FATAL_ERROR
+        "SCHAAPCOMMON_SOURCE_DIR not specified. Please add -DSCHAAPCOMMON_SOURCE_DIR to the CMAKE_ARGS."
+    )
+  endif()
+  if(NOT DEFINED PYBIND11_SOURCE_DIR)
+    message(
+      FATAL_ERROR
+        "PYBIND11_SOURCE_DIR not specified. Please add -DPYBIND11_SOURCE_DIR to the CMAKE_ARGS."
+    )
+  endif()
+else()
+  set(AOCOMMON_INCLUDE_DIR ${CMAKE_SOURCE_DIR}/external/aocommon/include)
+  set(SCHAAPCOMMON_SOURCE_DIR ${CMAKE_SOURCE_DIR}/external/schaapcommon)
+  set(PYBIND11_SOURCE_DIR ${CMAKE_SOURCE_DIR}/external/pybind11)
+endif()
+
+set(CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR}/cmake)
+
+# Boost date_time is needed in aocommon
+find_package(
+  Boost
+  COMPONENTS date_time
+  REQUIRED)
+
+# Threads
+find_package(Threads REQUIRED)
+
+# Find and include HDF5
+find_package(
+  HDF5
+  COMPONENTS C CXX
+  REQUIRED)
+add_definitions(${HDF5_DEFINITIONS} -DH5_USE_110_API)
+
+# Casacore
+set(CASACORE_MAKE_REQUIRED_EXTERNALS_OPTIONAL TRUE)
+find_package(Casacore REQUIRED COMPONENTS fits casa ms tables measures)
+
+# CFitsio
+find_package(CFITSIO REQUIRED)
+
+# Python3
+find_package(PythonLibs 3 REQUIRED)
+find_package(PythonInterp REQUIRED)
+message(STATUS "Using python version ${PYTHON_VERSION_STRING}")
+
+if(COMPILE_AS_EXTERNAL_PROJECT)
+  add_subdirectory(${SCHAAPCOMMON_SOURCE_DIR}
+                   ${PROJECT_BINARY_DIR}/schaapcommon)
+  add_subdirectory(${PYBIND11_SOURCE_DIR} ${PROJECT_BINARY_DIR}/pybind11)
+else()
+  add_subdirectory(${SCHAAPCOMMON_SOURCE_DIR})
+  add_subdirectory(${PYBIND11_SOURCE_DIR})
+endif()
+target_include_directories(schaapcommon SYSTEM PUBLIC ${AOCOMMON_INCLUDE_DIR})
+
+set(RADLER_TARGET_INCLUDE_DIRS
+    ${AOCOMMON_INCLUDE_DIR}
+    ${Boost_INCLUDE_DIRS}
+    ${CASACORE_INCLUDE_DIRS}
+    ${CFITSIO_INCLUDE_DIR}
+    ${HDF5_INCLUDE_DIRS}
+    ${pybind11_INCLUDE_DIRS}
+    ${SCHAAPCOMMON_SOURCE_DIR}/include)
+
+set(RADLER_TARGET_LIBS
+    ${Boost_DATE_TIME_LIBRARY} ${CMAKE_THREAD_LIBS_INIT} ${CASACORE_LIBRARIES}
+    ${CFITSIO_LIBRARY} pybind11::embed schaapcommon)
+
+# Source directories
+add_subdirectory(cpp)
+
+# Compile tests
+if(NOT ${COMPILE_AS_EXTERNAL_PROJECT} AND ${BUILD_TESTING})
+  include(CTest)
+  find_package(
+    Boost
+    COMPONENTS unit_test_framework
+    REQUIRED)
+  add_subdirectory(cpp/test)
+  add_subdirectory(cpp/algorithms/test)
+  add_subdirectory(cpp/math/test)
+
+  # demo targets are excluded from a default build
+  add_subdirectory(cpp/demo)
+endif()
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..be8c8257
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,676 @@
+                       Copyright 2022 ASTRON
+
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.  We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors.  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights.  Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received.  You must make sure that they, too, receive
+or can get the source code.  And you must show them these terms so they
+know their rights.
+
+  Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+  For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software.  For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+  Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so.  This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software.  The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable.  Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products.  If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+  Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary.  To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Use with the GNU Affero General Public License.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+    <program>  Copyright (C) <year>  <name of author>
+    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<http://www.gnu.org/licenses/>.
+
+  The GNU General Public License does not permit incorporating your program
+into proprietary programs.  If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.  But first, please read
+<http://www.gnu.org/philosophy/why-not-lgpl.html>.
diff --git a/README.md b/README.md
index 62ebb4c3..7af9854f 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,3 @@
-# Radio Astronomical DeconvolvER
+# Radio Astronomical Deconvolution Library
 
-RADLER is a library providing functionality for deconvolving astronomical images. RADLER evolved as a stand-alone library from the w-stacking clean (WSClean) imager https://gitlab.com/aroffringa/wsclean.
+Radler is a library providing functionality for deconvolving astronomical images. Radler evolved as a stand-alone library from the w-stacking clean (WSClean) imager https://gitlab.com/aroffringa/wsclean.
diff --git a/cmake/FindCFITSIO.cmake b/cmake/FindCFITSIO.cmake
new file mode 100644
index 00000000..bcdd2143
--- /dev/null
+++ b/cmake/FindCFITSIO.cmake
@@ -0,0 +1,56 @@
+# * Try to find CFITSIO. Variables used by this module: CFITSIO_ROOT_DIR     -
+#   CFITSIO root directory Variables defined by this module: CFITSIO_FOUND -
+#   system has CFITSIO CFITSIO_INCLUDE_DIR  - the CFITSIO include directory
+#   (cached) CFITSIO_INCLUDE_DIRS - the CFITSIO include directories (identical
+#   to CFITSIO_INCLUDE_DIR) CFITSIO_LIBRARY      - the CFITSIO library (cached)
+#   CFITSIO_LIBRARIES    - the CFITSIO libraries (identical to CFITSIO_LIBRARY)
+#   CFITSIO_VERSION_STRING the found version of CFITSIO, padded to 3 digits
+
+# Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+if(NOT CFITSIO_FOUND)
+
+  find_path(
+    CFITSIO_INCLUDE_DIR fitsio.h
+    HINTS ${CFITSIO_ROOT_DIR}
+    PATH_SUFFIXES include include/cfitsio include/libcfitsio0)
+
+  if(CFITSIO_INCLUDE_DIR)
+    file(READ "${CFITSIO_INCLUDE_DIR}/fitsio.h" CFITSIO_H)
+    set(CFITSIO_VERSION_REGEX
+        ".*#define CFITSIO_VERSION[^0-9]*([0-9]+)\\.([0-9]+).*")
+    if("${CFITSIO_H}" MATCHES ${CFITSIO_VERSION_REGEX})
+      # Pad CFITSIO minor version to three digit because 3.181 is older than
+      # 3.35
+      string(REGEX REPLACE ${CFITSIO_VERSION_REGEX} "\\1.\\200"
+                           CFITSIO_VERSION_STRING "${CFITSIO_H}")
+      string(SUBSTRING ${CFITSIO_VERSION_STRING} 0 5 CFITSIO_VERSION_STRING)
+      string(REGEX REPLACE "^([0-9]+)[.]([0-9]+)" "\\1" CFITSIO_VERSION_MAJOR
+                           ${CFITSIO_VERSION_STRING})
+      # CFITSIO_VERSION_MINOR will contain 80 for 3.08, 181 for 3.181 and 200
+      # for 3.2
+      string(REGEX REPLACE "^([0-9]+)[.]0*([0-9]+)" "\\2" CFITSIO_VERSION_MINOR
+                           ${CFITSIO_VERSION_STRING})
+    else()
+      set(CFITSIO_VERSION_STRING "Unknown")
+    endif()
+  endif(CFITSIO_INCLUDE_DIR)
+
+  find_library(
+    CFITSIO_LIBRARY cfitsio
+    HINTS ${CFITSIO_ROOT_DIR}
+    PATH_SUFFIXES lib)
+  find_library(M_LIBRARY m)
+  mark_as_advanced(CFITSIO_INCLUDE_DIR CFITSIO_LIBRARY M_LIBRARY)
+
+  include(FindPackageHandleStandardArgs)
+  find_package_handle_standard_args(
+    CFITSIO
+    REQUIRED_VARS CFITSIO_LIBRARY M_LIBRARY CFITSIO_INCLUDE_DIR
+    VERSION_VAR CFITSIO_VERSION_STRING)
+
+  set(CFITSIO_INCLUDE_DIRS ${CFITSIO_INCLUDE_DIR})
+  set(CFITSIO_LIBRARIES ${CFITSIO_LIBRARY} ${M_LIBRARY})
+
+endif(NOT CFITSIO_FOUND)
diff --git a/cmake/FindCasacore.cmake b/cmake/FindCasacore.cmake
new file mode 100644
index 00000000..1a7be36f
--- /dev/null
+++ b/cmake/FindCasacore.cmake
@@ -0,0 +1,277 @@
+# * Try to find Casacore include dirs and libraries Usage: find_package(Casacore
+#   [REQUIRED] [COMPONENTS components...]) Valid components are: casa,
+#   coordinates, derivedmscal, fits, images, lattices, meas, measures, mirlib,
+#   ms, msfits, python, scimath, scimath_f, tables
+#
+# Note that most components are dependent on other (more basic) components. In
+# that case, it suffices to specify the "top-level" components; dependent
+# components will be searched for automatically.
+#
+# The dependency tree can be generated using the script get_casacore_deps.sh.
+# For this, you need to have a complete casacore installation, built with shared
+# libraries, at your disposal.
+#
+# The dependencies in this macro were generated against casacore release 1.7.0.
+#
+# Variables used by this module: CASACORE_ROOT_DIR         - Casacore root
+# directory.
+#
+# Variables defined by this module: CASACORE_FOUND            - System has
+# Casacore, which means that the include dir was found, as well as all libraries
+# specified (not cached) CASACORE_INCLUDE_DIR      - Casacore include directory
+# (cached) CASACORE_INCLUDE_DIRS     - Casacore include directories (not cached)
+# identical to CASACORE_INCLUDE_DIR CASACORE_LIBRARIES        - The Casacore
+# libraries (not cached) CASA_${COMPONENT}_LIBRARY - The absolute path of
+# Casacore library "component" (cached) HAVE_AIPSPP               - True if
+# system has Casacore (cached) for backward compatibility with AIPS++
+# HAVE_CASACORE             - True if system has Casacore (cached) identical to
+# CASACORE_FOUND TAQL_EXECUTABLE           - The absolute path of the TaQL
+# executable (cached)
+#
+# ATTENTION: The component names need to be in lower case, just as the casacore
+# library names. However, the CMake variables use all upper case.
+
+# Copyright (C) 2009 ASTRON (Netherlands Institute for Radio Astronomy) P.O.Box
+# 2, 7990 AA Dwingeloo, The Netherlands
+#
+# This file is part of the LOFAR software suite. The LOFAR software suite is
+# free software: you can redistribute it and/or modify it under the terms of the
+# GNU General Public License as published by the Free Software Foundation,
+# either version 3 of the License, or (at your option) any later version.
+#
+# The LOFAR software suite is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# the LOFAR software suite. If not, see <http://www.gnu.org/licenses/>.
+#
+# $Id: FindCasacore.cmake 31487 2015-04-16 11:28:17Z dijkema $
+
+# * casacore_resolve_dependencies(_result)
+#
+# Resolve the Casacore library dependencies for the given components. The list
+# of dependent libraries will be returned in the variable result. It is sorted
+# from least dependent to most dependent library, so it can be directly fed to
+# the linker.
+#
+# Usage: casacore_resolve_dependencies(result components...)
+#
+macro(casacore_resolve_dependencies _result)
+  set(${_result} ${ARGN})
+  set(_index 0)
+  # Do a breadth-first search through the dependency graph; append to the result
+  # list the dependent components for each item in that list. Duplicates will be
+  # removed later.
+  while(1)
+    list(LENGTH ${_result} _length)
+    if(NOT _index LESS _length)
+      break()
+    endif(NOT _index LESS _length)
+    list(GET ${_result} ${_index} item)
+    list(APPEND ${_result} ${Casacore_${item}_DEPENDENCIES})
+    math(EXPR _index "${_index}+1")
+  endwhile(1)
+  # Remove all duplicates in the current result list, while retaining only the
+  # last of each duplicate.
+  list(REVERSE ${_result})
+  list(REMOVE_DUPLICATES ${_result})
+  list(REVERSE ${_result})
+endmacro(casacore_resolve_dependencies _result)
+
+# * casacore_find_library(_name)
+#
+# Search for the library ${_name}. If library is found, add it to
+# CASACORE_LIBRARIES; if not, add ${_name} to CASACORE_MISSING_COMPONENTS and
+# set CASACORE_FOUND to false.
+#
+# Usage: casacore_find_library(name)
+#
+macro(casacore_find_library _name)
+  string(TOUPPER ${_name} _NAME)
+  find_library(
+    ${_NAME}_LIBRARY ${_name}
+    HINTS ${CASACORE_ROOT_DIR}
+    PATH_SUFFIXES lib)
+  mark_as_advanced(${_NAME}_LIBRARY)
+  if(${_NAME}_LIBRARY)
+    list(APPEND CASACORE_LIBRARIES ${${_NAME}_LIBRARY})
+  else(${_NAME}_LIBRARY)
+    set(CASACORE_FOUND FALSE)
+    list(APPEND CASACORE_MISSING_COMPONENTS ${_name})
+  endif(${_NAME}_LIBRARY)
+endmacro(casacore_find_library _name)
+
+# * casacore_find_package(_name)
+#
+# Search for the package ${_name}. If the package is found, add the contents of
+# ${_name}_INCLUDE_DIRS to CASACORE_INCLUDE_DIRS and ${_name}_LIBRARIES to
+# CASACORE_LIBRARIES.
+#
+# If Casacore itself is required, then, strictly speaking, the packages it
+# requires must be present. However, when linking against static libraries they
+# may not be needed. One can override the REQUIRED setting by switching
+# CASACORE_MAKE_REQUIRED_EXTERNALS_OPTIONAL to ON. Beware that this might cause
+# compile and/or link errors.
+#
+# Usage: casacore_find_package(name [REQUIRED])
+#
+macro(casacore_find_package _name)
+  if("${ARGN}" MATCHES "^REQUIRED$"
+     AND Casacore_FIND_REQUIRED
+     AND NOT CASACORE_MAKE_REQUIRED_EXTERNALS_OPTIONAL)
+    find_package(${_name} REQUIRED)
+  else()
+    find_package(${_name})
+  endif()
+  if(${_name}_FOUND)
+    list(APPEND CASACORE_INCLUDE_DIRS ${${_name}_INCLUDE_DIRS})
+    list(APPEND CASACORE_LIBRARIES ${${_name}_LIBRARIES})
+  endif(${_name}_FOUND)
+endmacro(casacore_find_package _name)
+
+# Define the Casacore components.
+set(Casacore_components
+    casa
+    coordinates
+    derivedmscal
+    fits
+    images
+    lattices
+    meas
+    measures
+    mirlib
+    ms
+    msfits
+    python
+    scimath
+    scimath_f
+    tables)
+
+# Define the Casacore components' inter-dependencies.
+set(Casacore_casa_DEPENDENCIES)
+set(Casacore_coordinates_DEPENDENCIES fits measures casa)
+set(Casacore_derivedmscal_DEPENDENCIES ms measures tables casa)
+set(Casacore_fits_DEPENDENCIES measures tables casa)
+set(Casacore_images_DEPENDENCIES
+    mirlib
+    lattices
+    coordinates
+    fits
+    measures
+    scimath
+    tables
+    casa)
+set(Casacore_lattices_DEPENDENCIES tables scimath casa)
+set(Casacore_meas_DEPENDENCIES measures tables casa)
+set(Casacore_measures_DEPENDENCIES tables casa)
+set(Casacore_mirlib_DEPENDENCIES)
+set(Casacore_ms_DEPENDENCIES measures scimath tables casa)
+set(Casacore_msfits_DEPENDENCIES ms fits measures tables casa)
+set(Casacore_python_DEPENDENCIES casa)
+set(Casacore_scimath_DEPENDENCIES scimath_f casa)
+set(Casacore_scimath_f_DEPENDENCIES)
+set(Casacore_tables_DEPENDENCIES casa)
+
+# Initialize variables.
+set(CASACORE_FOUND FALSE)
+set(CASACORE_DEFINITIONS)
+set(CASACORE_LIBRARIES)
+set(CASACORE_MISSING_COMPONENTS)
+
+# Search for the header file first.
+if(NOT CASACORE_INCLUDE_DIR)
+  find_path(
+    CASACORE_INCLUDE_DIR casacore/casa/aips.h
+    HINTS ${CASACORE_ROOT_DIR}
+    PATH_SUFFIXES include)
+  mark_as_advanced(CASACORE_INCLUDE_DIR)
+endif(NOT CASACORE_INCLUDE_DIR)
+
+# Fallback for systems that have old casacore installed in directory not called
+# 'casacore' This fallback can be removed once we move to casacore 2.0 which
+# always puts headers in 'casacore'
+if(NOT CASACORE_INCLUDE_DIR)
+  find_path(
+    CASACORE_INCLUDE_DIR casa/aips.h
+    HINTS ${CASACORE_ROOT_DIR}
+    PATH_SUFFIXES include)
+  mark_as_advanced(CASACORE_INCLUDE_DIR)
+endif(NOT CASACORE_INCLUDE_DIR)
+
+if(NOT CASACORE_INCLUDE_DIR)
+  set(CASACORE_ERROR_MESSAGE
+      "Casacore: unable to find the header file casa/aips.h.\nPlease set CASACORE_ROOT_DIR to the root directory containing Casacore."
+  )
+else(NOT CASACORE_INCLUDE_DIR)
+  # We've found the header file; let's continue.
+  set(CASACORE_FOUND TRUE)
+  # Note that new Casacore uses #include<casacore/casa/...>, while LOFAR still
+  # uses #include<casa/...>. Hence use both in -I path.
+  set(CASACORE_INCLUDE_DIRS ${CASACORE_INCLUDE_DIR}
+                            ${CASACORE_INCLUDE_DIR}/casacore)
+
+  # Search for some often used binaries.
+  find_program(TAQL_EXECUTABLE taql HINTS ${CASACORE_ROOT_DIR}/bin)
+  mark_as_advanced(TAQL_EXECUTABLE)
+
+  # If the user specified components explicity, use that list; otherwise we'll
+  # assume that the user wants to use all components.
+  if(NOT Casacore_FIND_COMPONENTS)
+    set(Casacore_FIND_COMPONENTS ${Casacore_components})
+  endif(NOT Casacore_FIND_COMPONENTS)
+
+  # Get a list of all dependent Casacore libraries that need to be found.
+  casacore_resolve_dependencies(_find_components ${Casacore_FIND_COMPONENTS})
+
+  # Find the library for each component, and handle external dependencies
+  foreach(_comp ${_find_components})
+    casacore_find_library(casa_${_comp})
+    if(${_comp} STREQUAL casa)
+      casacore_find_package(HDF5)
+      casacore_find_library(m)
+      list(APPEND CASACORE_LIBRARIES ${CMAKE_DL_LIBS})
+    elseif(${_comp} STREQUAL coordinates)
+      casacore_find_package(WCSLIB REQUIRED)
+    elseif(${_comp} STREQUAL fits)
+      casacore_find_package(CFITSIO REQUIRED)
+    elseif(${_comp} STREQUAL scimath_f)
+      casacore_find_package(LAPACK REQUIRED)
+    endif(${_comp} STREQUAL casa)
+  endforeach(_comp ${_find_components})
+endif(NOT CASACORE_INCLUDE_DIR)
+
+# Set HAVE_CASACORE; and HAVE_AIPSPP (for backward compatibility with AIPS++).
+if(CASACORE_FOUND)
+  set(HAVE_CASACORE
+      TRUE
+      CACHE INTERNAL "Define if Casacore is installed")
+  set(HAVE_AIPSPP
+      TRUE
+      CACHE INTERNAL "Define if AIPS++/Casacore is installed")
+endif(CASACORE_FOUND)
+
+# Compose diagnostic message if not all necessary components were found.
+if(CASACORE_MISSING_COMPONENTS)
+  set(CASACORE_ERROR_MESSAGE
+      "Casacore: the following components could not be found:\n     ${CASACORE_MISSING_COMPONENTS}"
+  )
+endif(CASACORE_MISSING_COMPONENTS)
+
+# Print diagnostics.
+if(CASACORE_FOUND)
+  if(NOT Casacore_FIND_QUIETLY)
+    message(STATUS "Found the following Casacore components: ")
+    foreach(_comp ${_find_components})
+      string(TOUPPER casa_${_comp} _COMP)
+      message(STATUS "  ${_comp}: ${${_COMP}_LIBRARY}")
+    endforeach(_comp ${_find_components})
+  endif(NOT Casacore_FIND_QUIETLY)
+else(CASACORE_FOUND)
+  if(Casacore_FIND_REQUIRED)
+    message(FATAL_ERROR "${CASACORE_ERROR_MESSAGE}")
+  else(Casacore_FIND_REQUIRED)
+    message(STATUS "${CASACORE_ERROR_MESSAGE}")
+  endif(Casacore_FIND_REQUIRED)
+endif(CASACORE_FOUND)
diff --git a/cmake/config/radler-config-version.cmake.in b/cmake/config/radler-config-version.cmake.in
new file mode 100644
index 00000000..59cdd0c2
--- /dev/null
+++ b/cmake/config/radler-config-version.cmake.in
@@ -0,0 +1,14 @@
+# Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+set(PACKAGE_VERSION "@RADLER_VERSION@")
+
+# Check whether the requested PACKAGE_FIND_VERSION is compatible
+if("${PACKAGE_VERSION}" VERSION_LESS "${PACKAGE_FIND_VERSION}")
+  set(PACKAGE_VERSION_COMPATIBLE FALSE)
+else()
+  set(PACKAGE_VERSION_COMPATIBLE TRUE)
+  if ("${PACKAGE_VERSION}" VERSION_EQUAL "${PACKAGE_FIND_VERSION}")
+    set(PACKAGE_VERSION_EXACT TRUE)
+  endif()
+endif()
\ No newline at end of file
diff --git a/cmake/config/radler-config.cmake.in b/cmake/config/radler-config.cmake.in
new file mode 100644
index 00000000..cce73266
--- /dev/null
+++ b/cmake/config/radler-config.cmake.in
@@ -0,0 +1,45 @@
+# Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+#  Config file for the radler library, it sets the following variables
+#
+# - RADLER_FOUND
+# - RADLER_ROOT_DIR
+# - RADLER_INCLUDE_DIR
+# - RADLER_INCLUDE_DIRS (equals RADLER_INCLUDE_DIR)
+# - RADLER_LIB_PATH
+# - RADLER_LIB
+# - RADLER_VERSION[_MAJOR/_MINOR/_PATCH]
+
+# Compute paths
+get_filename_component(_RADLER_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" PATH)
+get_filename_component(_RADLER_CMAKE_DIR_ABS "${_RADLER_CMAKE_DIR}" ABSOLUTE)
+get_filename_component(_RADLER_ROOT_DIR "${_RADLER_CMAKE_DIR_ABS}/../.." ABSOLUTE)
+
+# Use FORCE to override cached variables
+set(RADLER_ROOT_DIR "${_RADLER_ROOT_DIR}"
+    CACHE PATH "Radler root (prefix) directory" FORCE)
+
+set(RADLER_INCLUDE_DIR "${RADLER_ROOT_DIR}/include"
+    CACHE PATH "Radler include directory" FORCE)
+
+set(RADLER_INCLUDE_DIRS ${RADLER_INCLUDE_DIR})
+
+set(RADLER_LIB_PATH "${RADLER_ROOT_DIR}/lib"
+    CACHE PATH "Radler library directory" FORCE)
+
+find_library(RADLER_LIB radler PATH ${RADLER_LIB_PATH} NO_DEFAULT_PATH
+             DOC "Radler library directory")
+message(STATUS "Found Radler @RADLER_VERSION@.")
+message(STATUS "  Radler include dir: ${RADLER_INCLUDE_DIR}")
+message(STATUS "  Radler lib: ${RADLER_LIB}")
+
+set(RADLER_VERSION "@RADLER_VERSION@")
+set(RADLER_VERSION_MAJOR @RADLER_VERSION_MAJOR@)
+set(RADLER_VERSION_MINOR @RADLER_VERSION_MINOR@)
+set(RADLER_VERSION_PATCH @RADLER_VERSION_PATCH@)
+set(RADLER_FOUND 1)
+
+unset(_RADLER_ROOT_DIR)
+unset(_RADLER_CMAKE_DIR)
+unset(_RADLER_CMAKE_DIR_ABS)
diff --git a/cmake/unittest.cmake b/cmake/unittest.cmake
new file mode 100644
index 00000000..ba527eb9
--- /dev/null
+++ b/cmake/unittest.cmake
@@ -0,0 +1,45 @@
+# Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Generic function for adding a radler unittest. It creates a 'test' that builds
+# the unittest and a test that runs it. Arguments:
+#
+# * First argument: Module name, e.g., 'math'.
+# * Next arguments: Source file names.
+#
+# Return value:
+#
+# * Sets TEST_NAME to the unit test name in the parent scope.
+
+function(add_unittest MODULE_NAME)
+  set(TEST_NAME "unittests_${MODULE_NAME}")
+  set(TEST_NAME
+      ${TEST_NAME}
+      PARENT_SCOPE)
+  set(FILENAMES ${ARGN})
+
+  # Add boost dynamic link flag for all test files.
+  # https://www.boost.org/doc/libs/1_66_0/libs/test/doc/html/boost_test/usage_variants.html
+  # Without this flag, linking is incorrect and boost performs duplicate
+  # delete() calls after running all tests, in the cleanup phase.
+  set_source_files_properties(${FILENAMES} PROPERTIES COMPILE_DEFINITIONS
+                                                      "BOOST_TEST_DYN_LINK")
+
+  add_executable(${TEST_NAME} ${FILENAMES})
+  target_link_libraries(${TEST_NAME} radler
+                        ${Boost_UNIT_TEST_FRAMEWORK_LIBRARY})
+  target_include_directories(
+    ${TEST_NAME} PRIVATE $<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/cpp>)
+
+  # Add test for automatically (re)building the test if needed.
+  add_test(build_${TEST_NAME} ${CMAKE_COMMAND} --build ${CMAKE_BINARY_DIR}
+           --target ${TEST_NAME})
+  set_tests_properties(build_${TEST_NAME} PROPERTIES FIXTURES_SETUP
+                                                     ${TEST_NAME})
+
+  add_test(NAME ${TEST_NAME} COMMAND ${TEST_NAME} -f JUNIT -k ${TEST_NAME}.xml
+                                     --catch_system_error=yes)
+  set_tests_properties(${TEST_NAME} PROPERTIES LABELS unit FIXTURES_REQUIRED
+                                               ${TEST_NAME})
+
+endfunction()
diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt
new file mode 100644
index 00000000..f9299f58
--- /dev/null
+++ b/cpp/CMakeLists.txt
@@ -0,0 +1,116 @@
+# Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# CMake function to keep directory structure when installing headers.
+function(install_headers_with_directory HEADER_LIST)
+  foreach(HEADER ${HEADER_LIST})
+    string(REGEX MATCH ".*\/" DIR ${HEADER})
+    install(FILES ${HEADER}
+            DESTINATION ${CMAKE_INSTALL_PREFIX}/include/radler/${DIR})
+  endforeach(HEADER)
+endfunction(install_headers_with_directory)
+
+add_library(${PROJECT_NAME} SHARED "")
+
+set(RADLER_FILES
+    radler.cc
+    deconvolution_table.cc
+    component_list.cc
+    image_set.cc
+    algorithms/deconvolution_algorithm.cc
+    algorithms/generic_clean.cc
+    algorithms/iuwt_deconvolution_algorithm.cc
+    # algorithms/ls_deconvolution.cc // TODO: Complete or remove this class.
+    algorithms/more_sane.cc
+    algorithms/multiscale_algorithm.cc
+    algorithms/parallel_deconvolution.cc
+    algorithms/python_deconvolution.cc
+    algorithms/simple_clean.cc
+    algorithms/subminor_loop.cc
+    algorithms/threaded_deconvolution_tools.cc
+    algorithms/iuwt/image_analysis.cc
+    algorithms/iuwt/iuwt_decomposition.cc
+    algorithms/iuwt/iuwt_mask.cc
+    algorithms/multiscale/multiscale_transforms.cc
+    math/peak_finder.cc
+    math/rms_image.cc
+    utils/casa_mask_reader.cc)
+
+# A number of files perform the 'core' high-performance floating point
+# operations. In these files, NaNs are avoided and thus -ffast-math is allowed.
+# Note that visibilities can be NaN hence this can not be turned on for all
+# files.
+set_source_files_properties(
+  image_set.cpp
+  algorithms/generic_clean.cpp
+  algorithms/multiscale_algorithm.cpp
+  algorithms/threaded_deconvolution_tools.cpp
+  algorithms/simple_clean.cpp
+  algorithms/subminor_loop.cpp
+  algorithms/multiscale/multiscale_transforms.cpp
+  PROPERTIES COMPILE_FLAGS -ffast-math)
+
+# Using pybind11 requires using -fvisibility=hidden. See
+# https://pybind11.readthedocs.io/en/stable/faq.html
+set_source_files_properties(algorithms/python_deconvolution.cpp
+                            PROPERTIES COMPILE_FLAGS -fvisibility=hidden)
+
+target_sources(${PROJECT_NAME} PRIVATE ${RADLER_FILES})
+target_link_libraries(${PROJECT_NAME} ${RADLER_TARGET_LIBS})
+target_include_directories(${PROJECT_NAME} SYSTEM
+                           PUBLIC ${RADLER_TARGET_INCLUDE_DIRS})
+
+# Allows including the paths relative to base algorithm, in line with Google
+# style
+# https://google.github.io/styleguide/cppguide.html#Names_and_Order_of_Includes
+target_include_directories(
+  ${PROJECT_NAME} PRIVATE $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}>)
+
+target_compile_options(radler PRIVATE -O3 -Wall -Wzero-as-null-pointer-constant)
+
+if(NOT PORTABLE)
+  if(DEFINED TARGET_CPU)
+    target_compile_options(radler BEFORE PRIVATE -march=${TARGET_CPU})
+  else()
+    check_cxx_compiler_flag("-march=native" COMPILER_HAS_MARCH_NATIVE)
+    if(COMPILER_HAS_MARCH_NATIVE)
+      target_compile_options(radler BEFORE PRIVATE -march=native)
+    else()
+      message(
+        WARNING "The compiler doesn't support -march=native for your CPU.")
+    endif()
+  endif()
+endif()
+
+if(NOT COMPILE_AS_EXTERNAL_PROJECT)
+  include(GNUInstallDirs)
+  install(TARGETS ${PROJECT_NAME}
+          LIBRARY DESTINATION ${CMAKE_INSTALL_FULL_LIBDIR})
+else()
+  install(TARGETS ${PROJECT_NAME}
+          LIBRARY DESTINATION ${CMAKE_INSTALL_PREFIX}/lib)
+endif()
+
+set(PUBLIC_HEADERS
+    component_list.h
+    radler.h
+    deconvolution_settings.h
+    deconvolution_table.h
+    deconvolution_table_entry.h
+    image_set.h
+    algorithms/multiscale/multiscale_transforms.h)
+
+install_headers_with_directory("${PUBLIC_HEADERS}")
+
+if(NOT COMPILE_AS_EXTERNAL_PROJECT)
+  configure_file(${PROJECT_SOURCE_DIR}/cmake/config/radler-config.cmake.in
+                 ${PROJECT_BINARY_DIR}/CMakeFiles/radler-config.cmake @ONLY)
+  configure_file(
+    ${PROJECT_SOURCE_DIR}/cmake/config/radler-config-version.cmake.in
+    ${PROJECT_BINARY_DIR}/CMakeFiles/radler-config-version.cmake @ONLY)
+
+  # Install configuration files
+  install(FILES ${PROJECT_BINARY_DIR}/CMakeFiles/radler-config.cmake
+                ${PROJECT_BINARY_DIR}/CMakeFiles/radler-config-version.cmake
+          DESTINATION ${CMAKE_INSTALL_PREFIX}/share/radler)
+endif()
diff --git a/cpp/algorithms/deconvolution_algorithm.cc b/cpp/algorithms/deconvolution_algorithm.cc
new file mode 100644
index 00000000..6b64b09c
--- /dev/null
+++ b/cpp/algorithms/deconvolution_algorithm.cc
@@ -0,0 +1,51 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "algorithms/deconvolution_algorithm.h"
+
+#include <aocommon/system.h>
+
+namespace radler::algorithms {
+
+DeconvolutionAlgorithm::DeconvolutionAlgorithm()
+    : _threshold(0.0),
+      _majorIterThreshold(0.0),
+      _gain(0.1),
+      _mGain(1.0),
+      _cleanBorderRatio(0.05),
+      _maxIter(500),
+      _iterationNumber(0),
+      _threadCount(aocommon::system::ProcessorCount()),
+      _allowNegativeComponents(true),
+      _stopOnNegativeComponent(false),
+      _cleanMask(nullptr),
+      _logReceiver(nullptr),
+      _spectralFitter(schaapcommon::fitters::SpectralFittingMode::NoFitting,
+                      0) {}
+
+void DeconvolutionAlgorithm::ResizeImage(float* dest, size_t newWidth,
+                                         size_t newHeight, const float* source,
+                                         size_t width, size_t height) {
+  size_t srcStartX = (width - newWidth) / 2,
+         srcStartY = (height - newHeight) / 2;
+  for (size_t y = 0; y != newHeight; ++y) {
+    float* destPtr = dest + y * newWidth;
+    const float* srcPtr = source + (y + srcStartY) * width + srcStartX;
+    std::copy_n(srcPtr, newWidth, destPtr);
+  }
+}
+
+void DeconvolutionAlgorithm::RemoveNaNsInPSF(float* psf, size_t width,
+                                             size_t height) {
+  float* endPtr = psf + width * height;
+  while (psf != endPtr) {
+    if (!std::isfinite(*psf)) *psf = 0.0;
+    ++psf;
+  }
+}
+
+void DeconvolutionAlgorithm::PerformSpectralFit(float* values, size_t x,
+                                                size_t y) const {
+  _spectralFitter.FitAndEvaluate(values, x, y, _fittingScratch);
+}
+}  // namespace radler::algorithms
\ No newline at end of file
diff --git a/cpp/algorithms/deconvolution_algorithm.h b/cpp/algorithms/deconvolution_algorithm.h
new file mode 100644
index 00000000..a6d7c83a
--- /dev/null
+++ b/cpp/algorithms/deconvolution_algorithm.h
@@ -0,0 +1,141 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_ALGORITHMS_DECONVOLUTION_ALGORITHM_H_
+#define RADLER_ALGORITHMS_DECONVOLUTION_ALGORITHM_H_
+
+#include <string>
+#include <cmath>
+
+#include <aocommon/image.h>
+#include <aocommon/logger.h>
+#include <aocommon/polarization.h>
+#include <aocommon/uvector.h>
+
+#include <schaapcommon/fitters/spectralfitter.h>
+
+#include "image_set.h"
+
+namespace radler::algorithms {
+
+class DeconvolutionAlgorithm {
+ public:
+  virtual ~DeconvolutionAlgorithm() {}
+
+  virtual float ExecuteMajorIteration(
+      ImageSet& dataImage, ImageSet& modelImage,
+      const std::vector<aocommon::Image>& psfImages,
+      bool& reachedMajorThreshold) = 0;
+
+  virtual std::unique_ptr<DeconvolutionAlgorithm> Clone() const = 0;
+
+  void SetMaxNIter(size_t nIter) { _maxIter = nIter; }
+
+  void SetThreshold(float threshold) { _threshold = threshold; }
+
+  void SetMajorIterThreshold(float mThreshold) {
+    _majorIterThreshold = mThreshold;
+  }
+
+  void SetGain(float gain) { _gain = gain; }
+
+  void SetMGain(float mGain) { _mGain = mGain; }
+
+  void SetAllowNegativeComponents(bool allowNegativeComponents) {
+    _allowNegativeComponents = allowNegativeComponents;
+  }
+
+  void SetStopOnNegativeComponents(bool stopOnNegative) {
+    _stopOnNegativeComponent = stopOnNegative;
+  }
+
+  void SetCleanBorderRatio(float borderRatio) {
+    _cleanBorderRatio = borderRatio;
+  }
+
+  void SetThreadCount(size_t threadCount) { _threadCount = threadCount; }
+
+  void SetLogReceiver(aocommon::LogReceiver& receiver) {
+    _logReceiver = &receiver;
+  }
+
+  size_t MaxNIter() const { return _maxIter; }
+  float Threshold() const { return _threshold; }
+  float MajorIterThreshold() const { return _majorIterThreshold; }
+  float Gain() const { return _gain; }
+  float MGain() const { return _mGain; }
+  float CleanBorderRatio() const { return _cleanBorderRatio; }
+  bool AllowNegativeComponents() const { return _allowNegativeComponents; }
+  bool StopOnNegativeComponents() const { return _stopOnNegativeComponent; }
+
+  void SetCleanMask(const bool* cleanMask) { _cleanMask = cleanMask; }
+
+  size_t IterationNumber() const { return _iterationNumber; }
+
+  void SetIterationNumber(size_t iterationNumber) {
+    _iterationNumber = iterationNumber;
+  }
+
+  static void ResizeImage(float* dest, size_t newWidth, size_t newHeight,
+                          const float* source, size_t width, size_t height);
+
+  static void RemoveNaNsInPSF(float* psf, size_t width, size_t height);
+
+  void CopyConfigFrom(const DeconvolutionAlgorithm& source) {
+    _threshold = source._threshold;
+    _gain = source._gain;
+    _mGain = source._mGain;
+    _cleanBorderRatio = source._cleanBorderRatio;
+    _maxIter = source._maxIter;
+    // skip _iterationNumber
+    _allowNegativeComponents = source._allowNegativeComponents;
+    _stopOnNegativeComponent = source._stopOnNegativeComponent;
+    _cleanMask = source._cleanMask;
+    _spectralFitter = source._spectralFitter;
+  }
+
+  void SetSpectralFittingMode(schaapcommon::fitters::SpectralFittingMode mode,
+                              size_t nTerms) {
+    _spectralFitter.SetMode(mode, nTerms);
+  }
+
+  void SetSpectrallyForcedImages(std::vector<aocommon::Image>&& images) {
+    _spectralFitter.SetForcedImages(std::move(images));
+  }
+
+  void InitializeFrequencies(const aocommon::UVector<double>& frequencies,
+                             const aocommon::UVector<float>& weights) {
+    _spectralFitter.SetFrequencies(frequencies.data(), weights.data(),
+                                   frequencies.size());
+  }
+
+  const schaapcommon::fitters::SpectralFitter& Fitter() const {
+    return _spectralFitter;
+  }
+
+  void SetRMSFactorImage(aocommon::Image&& image) {
+    _rmsFactorImage = std::move(image);
+  }
+  const aocommon::Image& RMSFactorImage() const { return _rmsFactorImage; }
+
+ protected:
+  DeconvolutionAlgorithm();
+
+  DeconvolutionAlgorithm(const DeconvolutionAlgorithm&) = default;
+  DeconvolutionAlgorithm& operator=(const DeconvolutionAlgorithm&) = default;
+
+  void PerformSpectralFit(float* values, size_t x, size_t y) const;
+
+  float _threshold, _majorIterThreshold, _gain, _mGain, _cleanBorderRatio;
+  size_t _maxIter, _iterationNumber, _threadCount;
+  bool _allowNegativeComponents, _stopOnNegativeComponent;
+  const bool* _cleanMask;
+  aocommon::Image _rmsFactorImage;
+  mutable std::vector<float> _fittingScratch;
+
+  aocommon::LogReceiver* _logReceiver;
+
+  schaapcommon::fitters::SpectralFitter _spectralFitter;
+};
+}  // namespace radler::algorithms
+#endif  // RADLER_ALGORITHMS_DECONVOLUTION_ALGORITHM_H_
diff --git a/cpp/algorithms/generic_clean.cc b/cpp/algorithms/generic_clean.cc
new file mode 100644
index 00000000..33bbbf56
--- /dev/null
+++ b/cpp/algorithms/generic_clean.cc
@@ -0,0 +1,208 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "algorithms/generic_clean.h"
+
+#include <aocommon/image.h>
+#include <aocommon/lane.h>
+#include <aocommon/units/fluxdensity.h>
+
+#include "algorithms/subminor_loop.h"
+#include "algorithms/threaded_deconvolution_tools.h"
+#include "math/peak_finder.h"
+
+using aocommon::units::FluxDensity;
+
+namespace radler::algorithms {
+namespace {
+std::string peakDescription(const aocommon::Image& image, size_t x, size_t y) {
+  std::ostringstream str;
+  const size_t index = x + y * image.Width();
+  const float peak = image[index];
+  str << FluxDensity::ToNiceString(peak) << " at " << x << "," << y;
+  return str.str();
+}
+}  // namespace
+
+GenericClean::GenericClean(bool useSubMinorOptimization)
+    : _convolutionPadding(1.1),
+      _useSubMinorOptimization(useSubMinorOptimization) {}
+
+float GenericClean::ExecuteMajorIteration(
+    ImageSet& dirtySet, ImageSet& modelSet,
+    const std::vector<aocommon::Image>& psfs, bool& reachedMajorThreshold) {
+  const size_t width = dirtySet.Width();
+  const size_t height = dirtySet.Height();
+  const size_t iterationCounterAtStart = _iterationNumber;
+  if (_stopOnNegativeComponent) _allowNegativeComponents = true;
+  _convolutionWidth = ceil(_convolutionPadding * width);
+  _convolutionHeight = ceil(_convolutionPadding * height);
+  if (_convolutionWidth % 2 != 0) ++_convolutionWidth;
+  if (_convolutionHeight % 2 != 0) ++_convolutionHeight;
+
+  aocommon::Image integrated(width, height);
+  aocommon::Image scratchA(_convolutionWidth, _convolutionHeight);
+  aocommon::Image scratchB(_convolutionWidth, _convolutionHeight);
+  dirtySet.GetLinearIntegrated(integrated);
+  size_t componentX = 0;
+  size_t componentY = 0;
+  std::optional<float> maxValue =
+      findPeak(integrated, scratchA.Data(), componentX, componentY);
+  if (!maxValue) {
+    _logReceiver->Info << "No peak found.\n";
+    reachedMajorThreshold = false;
+    return 0.0;
+  }
+  _logReceiver->Info << "Initial peak: "
+                     << peakDescription(integrated, componentX, componentY)
+                     << '\n';
+  float firstThreshold = this->_threshold;
+  float majorIterThreshold = std::max<float>(
+      MajorIterThreshold(), std::fabs(*maxValue) * (1.0 - this->_mGain));
+  if (majorIterThreshold > firstThreshold) {
+    firstThreshold = majorIterThreshold;
+    _logReceiver->Info << "Next major iteration at: "
+                       << FluxDensity::ToNiceString(majorIterThreshold) << '\n';
+  } else if (this->_mGain != 1.0) {
+    _logReceiver->Info
+        << "Major iteration threshold reached global threshold of "
+        << FluxDensity::ToNiceString(this->_threshold)
+        << ": final major iteration.\n";
+  }
+
+  if (_useSubMinorOptimization) {
+    size_t startIteration = _iterationNumber;
+    SubMinorLoop subMinorLoop(width, height, _convolutionWidth,
+                              _convolutionHeight, *_logReceiver);
+    subMinorLoop.SetIterationInfo(_iterationNumber, MaxNIter());
+    subMinorLoop.SetThreshold(firstThreshold, firstThreshold * 0.99);
+    subMinorLoop.SetGain(Gain());
+    subMinorLoop.SetAllowNegativeComponents(AllowNegativeComponents());
+    subMinorLoop.SetStopOnNegativeComponent(StopOnNegativeComponents());
+    subMinorLoop.SetSpectralFitter(&Fitter());
+    if (!_rmsFactorImage.Empty())
+      subMinorLoop.SetRMSFactorImage(_rmsFactorImage);
+    if (_cleanMask) subMinorLoop.SetMask(_cleanMask);
+    const size_t horBorderSize = std::round(width * CleanBorderRatio());
+    const size_t vertBorderSize = std::round(height * CleanBorderRatio());
+    subMinorLoop.SetCleanBorders(horBorderSize, vertBorderSize);
+    subMinorLoop.SetThreadCount(_threadCount);
+
+    maxValue = subMinorLoop.Run(dirtySet, psfs);
+
+    _iterationNumber = subMinorLoop.CurrentIteration();
+
+    _logReceiver->Info
+        << "Performed " << _iterationNumber << " iterations in total, "
+        << (_iterationNumber - startIteration)
+        << " in this major iteration with sub-minor optimization.\n";
+
+    for (size_t imageIndex = 0; imageIndex != dirtySet.size(); ++imageIndex) {
+      // TODO this can be multi-threaded if each thread has its own temporaries
+      const aocommon::Image& psf = psfs[dirtySet.PSFIndex(imageIndex)];
+      subMinorLoop.CorrectResidualDirty(scratchA.Data(), scratchB.Data(),
+                                        integrated.Data(), imageIndex,
+                                        dirtySet.Data(imageIndex), psf.Data());
+
+      subMinorLoop.GetFullIndividualModel(imageIndex, scratchA.Data());
+      float* model = modelSet.Data(imageIndex);
+      for (size_t i = 0; i != width * height; ++i)
+        model[i] += scratchA.Data()[i];
+    }
+  } else {
+    ThreadedDeconvolutionTools tools(_threadCount);
+    size_t peakIndex = componentX + componentY * width;
+
+    aocommon::UVector<float> peakValues(dirtySet.size());
+
+    while (maxValue && fabs(*maxValue) > firstThreshold &&
+           this->_iterationNumber < this->_maxIter &&
+           !(maxValue < 0.0f && this->_stopOnNegativeComponent)) {
+      if (this->_iterationNumber <= 10 ||
+          (this->_iterationNumber <= 100 && this->_iterationNumber % 10 == 0) ||
+          (this->_iterationNumber <= 1000 &&
+           this->_iterationNumber % 100 == 0) ||
+          this->_iterationNumber % 1000 == 0)
+        _logReceiver->Info << "Iteration " << this->_iterationNumber << ": "
+                           << peakDescription(integrated, componentX,
+                                              componentY)
+                           << '\n';
+
+      for (size_t i = 0; i != dirtySet.size(); ++i)
+        peakValues[i] = dirtySet[i][peakIndex];
+
+      PerformSpectralFit(peakValues.data(), componentX, componentY);
+
+      for (size_t i = 0; i != dirtySet.size(); ++i) {
+        peakValues[i] *= this->_gain;
+        modelSet.Data(i)[peakIndex] += peakValues[i];
+
+        size_t psfIndex = dirtySet.PSFIndex(i);
+
+        tools.SubtractImage(dirtySet.Data(i), psfs[psfIndex], componentX,
+                            componentY, peakValues[i]);
+      }
+
+      dirtySet.GetSquareIntegrated(integrated, scratchA);
+      maxValue = findPeak(integrated, scratchA.Data(), componentX, componentY);
+
+      peakIndex = componentX + componentY * width;
+
+      ++this->_iterationNumber;
+    }
+  }
+  if (maxValue) {
+    _logReceiver->Info << "Stopped on peak "
+                       << FluxDensity::ToNiceString(*maxValue) << ", because ";
+    bool maxIterReached = _iterationNumber >= MaxNIter(),
+         finalThresholdReached =
+             std::fabs(*maxValue) <= _threshold || maxValue == 0.0f,
+         negativeReached = maxValue < 0.0f && this->_stopOnNegativeComponent,
+         mgainReached = std::fabs(*maxValue) <= majorIterThreshold,
+         didWork = (_iterationNumber - iterationCounterAtStart) != 0;
+
+    if (maxIterReached)
+      _logReceiver->Info << "maximum number of iterations was reached.\n";
+    else if (finalThresholdReached)
+      _logReceiver->Info << "the threshold was reached.\n";
+    else if (negativeReached)
+      _logReceiver->Info << "a negative component was found.\n";
+    else if (!didWork)
+      _logReceiver->Info << "no iterations could be performed.\n";
+    else
+      _logReceiver->Info << "the minor-loop threshold was reached. Continuing "
+                            "cleaning after inversion/prediction round.\n";
+    reachedMajorThreshold =
+        mgainReached && didWork && !negativeReached && !finalThresholdReached;
+    return *maxValue;
+  } else {
+    _logReceiver->Info << "Deconvolution aborted.\n";
+    reachedMajorThreshold = false;
+    return 0.0;
+  }
+}
+
+std::optional<float> GenericClean::findPeak(const aocommon::Image& image,
+                                            float* scratch_buffer, size_t& x,
+                                            size_t& y) {
+  const float* actual_image = image.Data();
+  if (!_rmsFactorImage.Empty()) {
+    std::copy_n(image.Data(), image.Size(), scratch_buffer);
+    for (size_t i = 0; i != image.Size(); ++i) {
+      scratch_buffer[i] *= _rmsFactorImage[i];
+    }
+    actual_image = scratch_buffer;
+  }
+
+  if (_cleanMask == nullptr) {
+    return math::PeakFinder::Find(actual_image, image.Width(), image.Height(),
+                                  x, y, _allowNegativeComponents, 0,
+                                  image.Height(), _cleanBorderRatio);
+  } else {
+    return math::PeakFinder::FindWithMask(
+        actual_image, image.Width(), image.Height(), x, y,
+        _allowNegativeComponents, 0, image.Height(), _cleanMask,
+        _cleanBorderRatio);
+  }
+}
+}  // namespace radler::algorithms
\ No newline at end of file
diff --git a/cpp/algorithms/generic_clean.h b/cpp/algorithms/generic_clean.h
new file mode 100644
index 00000000..f2a1aad8
--- /dev/null
+++ b/cpp/algorithms/generic_clean.h
@@ -0,0 +1,46 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_GENERIC_CLEAN_H_
+#define RADLER_GENERIC_CLEAN_H_
+
+#include <optional>
+
+#include <aocommon/uvector.h>
+
+#include "image_set.h"
+#include "algorithms/deconvolution_algorithm.h"
+#include "algorithms/simple_clean.h"
+
+namespace radler::algorithms {
+/**
+ * This class implements a generalized version of Högbom clean. It performs a
+ * single-channel or joined cleaning, depending on the number of images
+ * provided. It can use a Clark-like optimization to speed up the cleaning. When
+ * multiple frequencies are provided, it can perform spectral fitting.
+ */
+class GenericClean : public DeconvolutionAlgorithm {
+ public:
+  explicit GenericClean(bool useSubMinorOptimization);
+
+  float ExecuteMajorIteration(ImageSet& dirtySet, ImageSet& modelSet,
+                              const std::vector<aocommon::Image>& psfs,
+                              bool& reachedMajorThreshold) final override;
+
+  virtual std::unique_ptr<DeconvolutionAlgorithm> Clone() const final override {
+    return std::unique_ptr<DeconvolutionAlgorithm>(new GenericClean(*this));
+  }
+
+ private:
+  size_t _convolutionWidth;
+  size_t _convolutionHeight;
+  const float _convolutionPadding;
+  bool _useSubMinorOptimization;
+
+  // Scratch buffer should at least accomodate space for image.Size() floats
+  // and is only used to avoid unnecessary memory allocations.
+  std::optional<float> findPeak(const aocommon::Image& image,
+                                float* scratch_buffer, size_t& x, size_t& y);
+};
+}  // namespace radler::algorithms
+#endif
diff --git a/cpp/algorithms/iuwt/image_analysis.cc b/cpp/algorithms/iuwt/image_analysis.cc
new file mode 100644
index 00000000..c07c1a80
--- /dev/null
+++ b/cpp/algorithms/iuwt/image_analysis.cc
@@ -0,0 +1,328 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "algorithms/iuwt/image_analysis.h"
+
+#include <stack>
+
+namespace radler::algorithms::iuwt {
+
+bool ImageAnalysis::IsHighestOnScale0(const IUWTDecomposition& iuwt,
+                                      IUWTMask& markedMask, size_t& x,
+                                      size_t& y, size_t endScale,
+                                      float& highestScale0) {
+  const size_t width = iuwt.Width(), height = iuwt.Height();
+  Component component(x, y, 0);
+  std::stack<Component> todo;
+  todo.push(component);
+  markedMask[0][x + y * width] = false;
+  highestScale0 = iuwt[0][x + y * width];
+  float highestOtherScales = 0.0;
+  while (!todo.empty()) {
+    Component c = todo.top();
+    todo.pop();
+    size_t index = c.x + c.y * width;
+    if (c.scale == 0) {
+      if (exceedsThreshold(iuwt[0][index], highestScale0)) {
+        highestScale0 = iuwt[0][index];
+        x = c.x;
+        y = c.y;
+      }
+    } else {
+      if (exceedsThreshold(iuwt[c.scale][index], highestOtherScales))
+        highestOtherScales = iuwt[c.scale][index];
+    }
+    if (c.x > 0) {
+      if (markedMask[c.scale][index - 1]) {
+        markedMask[c.scale][index - 1] = false;
+        todo.push(Component(c.x - 1, c.y, c.scale));
+      }
+    }
+    if (c.x < width - 1) {
+      if (markedMask[c.scale][index + 1]) {
+        markedMask[c.scale][index + 1] = false;
+        todo.push(Component(c.x + 1, c.y, c.scale));
+      }
+    }
+    if (c.y > 0) {
+      if (markedMask[c.scale][index - width]) {
+        markedMask[c.scale][index - width] = false;
+        todo.push(Component(c.x, c.y - 1, c.scale));
+      }
+    }
+    if (c.y < height - 1) {
+      if (markedMask[c.scale][index + width]) {
+        markedMask[c.scale][index + width] = false;
+        todo.push(Component(c.x, c.y + 1, c.scale));
+      }
+    }
+    if (c.scale > int(0)) {
+      if (markedMask[c.scale - 1][index]) {
+        markedMask[c.scale - 1][index] = false;
+        todo.push(Component(c.x, c.y, c.scale - 1));
+      }
+    }
+    if (c.scale < int(endScale) - 1) {
+      if (markedMask[c.scale + 1][index]) {
+        markedMask[c.scale + 1][index] = false;
+        todo.push(Component(c.x, c.y, c.scale + 1));
+      }
+    }
+  }
+  return std::fabs(highestScale0) > std::fabs(highestOtherScales);
+}
+
+void ImageAnalysis::Floodfill(const IUWTDecomposition& iuwt, IUWTMask& mask,
+                              const aocommon::UVector<float>& thresholds,
+                              size_t minScale, size_t endScale,
+                              const Component& component, float cleanBorder,
+                              size_t& areaSize) {
+  const size_t width = iuwt.Width(), height = iuwt.Height();
+  size_t xBorder = cleanBorder * width;
+  size_t yBorder = cleanBorder * height;
+  size_t minX = xBorder, maxX = width - xBorder;
+  size_t minY = yBorder, maxY = height - yBorder;
+
+  areaSize = 0;
+  endScale = std::min<size_t>(endScale, iuwt.NScales());
+  std::stack<Component> todo;
+  todo.push(component);
+  mask[component.scale][component.x + component.y * width] = true;
+  while (!todo.empty()) {
+    Component c = todo.top();
+    ++areaSize;
+    todo.pop();
+    size_t index = c.x + c.y * width;
+    if (c.x > minX) {
+      if (exceedsThreshold(iuwt[c.scale][index - 1], thresholds[c.scale]) &&
+          !mask[c.scale][index - 1]) {
+        mask[c.scale][index - 1] = true;
+        todo.push(Component(c.x - 1, c.y, c.scale));
+      }
+    }
+    if (c.x < maxX - 1) {
+      if (exceedsThreshold(iuwt[c.scale][index + 1], thresholds[c.scale]) &&
+          !mask[c.scale][index + 1]) {
+        mask[c.scale][index + 1] = true;
+        todo.push(Component(c.x + 1, c.y, c.scale));
+      }
+    }
+    if (c.y > minY) {
+      if (exceedsThreshold(iuwt[c.scale][index - width], thresholds[c.scale]) &&
+          !mask[c.scale][index - width]) {
+        mask[c.scale][index - width] = true;
+        todo.push(Component(c.x, c.y - 1, c.scale));
+      }
+    }
+    if (c.y < maxY - 1) {
+      if (exceedsThreshold(iuwt[c.scale][index + width], thresholds[c.scale]) &&
+          !mask[c.scale][index + width]) {
+        mask[c.scale][index + width] = true;
+        todo.push(Component(c.x, c.y + 1, c.scale));
+      }
+    }
+    if (c.scale > int(minScale)) {
+      if (exceedsThreshold(iuwt[c.scale - 1][index], thresholds[c.scale - 1]) &&
+          !mask[c.scale - 1][index]) {
+        mask[c.scale - 1][index] = true;
+        todo.push(Component(c.x, c.y, c.scale - 1));
+      }
+    }
+    if (c.scale < int(endScale) - 1) {
+      if (exceedsThreshold(iuwt[c.scale + 1][index], thresholds[c.scale + 1]) &&
+          !mask[c.scale + 1][index]) {
+        mask[c.scale + 1][index] = true;
+        todo.push(Component(c.x, c.y, c.scale + 1));
+      }
+    }
+  }
+}
+
+void ImageAnalysis::MaskedFloodfill(const IUWTDecomposition& iuwt,
+                                    IUWTMask& mask,
+                                    const aocommon::UVector<float>& thresholds,
+                                    size_t minScale, size_t endScale,
+                                    const Component& component,
+                                    float cleanBorder, const bool* priorMask,
+                                    size_t& areaSize) {
+  const size_t width = iuwt.Width(), height = iuwt.Height();
+  size_t xBorder = cleanBorder * width;
+  size_t yBorder = cleanBorder * height;
+  size_t minX = xBorder, maxX = width - xBorder;
+  size_t minY = yBorder, maxY = height - yBorder;
+
+  areaSize = 0;
+  endScale = std::min<size_t>(endScale, iuwt.NScales());
+  std::stack<Component> todo;
+  todo.push(component);
+  mask[component.scale][component.x + component.y * width] = true;
+  while (!todo.empty()) {
+    Component c = todo.top();
+    ++areaSize;
+    todo.pop();
+    size_t index = c.x + c.y * width;
+    if (c.x > minX) {
+      if (exceedsThreshold(iuwt[c.scale][index - 1], thresholds[c.scale]) &&
+          !mask[c.scale][index - 1] && priorMask[index - 1]) {
+        mask[c.scale][index - 1] = true;
+        todo.push(Component(c.x - 1, c.y, c.scale));
+      }
+    }
+    if (c.x < maxX - 1) {
+      if (exceedsThreshold(iuwt[c.scale][index + 1], thresholds[c.scale]) &&
+          !mask[c.scale][index + 1] && priorMask[index + 1]) {
+        mask[c.scale][index + 1] = true;
+        todo.push(Component(c.x + 1, c.y, c.scale));
+      }
+    }
+    if (c.y > minY) {
+      if (exceedsThreshold(iuwt[c.scale][index - width], thresholds[c.scale]) &&
+          !mask[c.scale][index - width] && priorMask[index - width]) {
+        mask[c.scale][index - width] = true;
+        todo.push(Component(c.x, c.y - 1, c.scale));
+      }
+    }
+    if (c.y < maxY - 1) {
+      if (exceedsThreshold(iuwt[c.scale][index + width], thresholds[c.scale]) &&
+          !mask[c.scale][index + width] && priorMask[index + width]) {
+        mask[c.scale][index + width] = true;
+        todo.push(Component(c.x, c.y + 1, c.scale));
+      }
+    }
+    if (c.scale > int(minScale)) {
+      if (exceedsThreshold(iuwt[c.scale - 1][index], thresholds[c.scale - 1]) &&
+          !mask[c.scale - 1][index] && priorMask[index]) {
+        mask[c.scale - 1][index] = true;
+        todo.push(Component(c.x, c.y, c.scale - 1));
+      }
+    }
+    if (c.scale < int(endScale) - 1) {
+      if (exceedsThreshold(iuwt[c.scale + 1][index], thresholds[c.scale + 1]) &&
+          !mask[c.scale + 1][index] && priorMask[index]) {
+        mask[c.scale + 1][index] = true;
+        todo.push(Component(c.x, c.y, c.scale + 1));
+      }
+    }
+  }
+}
+
+void ImageAnalysis::SelectStructures(const IUWTDecomposition& iuwt,
+                                     IUWTMask& mask,
+                                     const aocommon::UVector<float>& thresholds,
+                                     size_t minScale, size_t endScale,
+                                     float cleanBorder, const bool* priorMask,
+                                     size_t& areaSize) {
+  const size_t width = iuwt.Width(), height = iuwt.Height();
+  const size_t xBorder = cleanBorder * width, yBorder = cleanBorder * height,
+               minX = xBorder, maxX = width - xBorder, minY = yBorder,
+               maxY = height - yBorder;
+
+  areaSize = 0;
+
+  for (size_t scale = minScale; scale != endScale; ++scale) {
+    for (size_t y = minY; y != maxY; ++y) {
+      for (size_t x = minX; x != maxX; ++x) {
+        size_t index = x + y * width;
+        bool isInPriorMask = (priorMask == nullptr) || priorMask[index];
+        if (exceedsThreshold(iuwt[scale][index], thresholds[scale]) &&
+            !mask[scale][index] && isInPriorMask) {
+          Component component(x, y, scale);
+          size_t subAreaSize = 0;
+          if (priorMask == nullptr)
+            Floodfill(iuwt, mask, thresholds, minScale, endScale, component,
+                      cleanBorder, subAreaSize);
+          else
+            MaskedFloodfill(iuwt, mask, thresholds, minScale, endScale,
+                            component, cleanBorder, priorMask, subAreaSize);
+          areaSize += subAreaSize;
+        }
+      }
+    }
+  }
+}
+
+void ImageAnalysis::FloodFill2D(const float* image, bool* mask, float threshold,
+                                const ImageAnalysis::Component2D& component,
+                                size_t width, size_t height, size_t& areaSize) {
+  areaSize = 0;
+  std::stack<Component2D> todo;
+  todo.push(component);
+  mask[component.x + component.y * width] = true;
+  while (!todo.empty()) {
+    Component2D c = todo.top();
+    ++areaSize;
+    todo.pop();
+    size_t index = c.x + c.y * width;
+    if (c.x > 0) {
+      if (exceedsThreshold(image[index - 1], threshold) && !mask[index - 1]) {
+        mask[index - 1] = true;
+        todo.push(Component2D(c.x - 1, c.y));
+      }
+    }
+    if (c.x < width - 1) {
+      if (exceedsThreshold(image[index + 1], threshold) && !mask[index + 1]) {
+        mask[index + 1] = true;
+        todo.push(Component2D(c.x + 1, c.y));
+      }
+    }
+    if (c.y > 0) {
+      if (exceedsThreshold(image[index - width], threshold) &&
+          !mask[index - width]) {
+        mask[index - width] = true;
+        todo.push(Component2D(c.x, c.y - 1));
+      }
+    }
+    if (c.y < height - 1) {
+      if (exceedsThreshold(image[index + width], threshold) &&
+          !mask[index + width]) {
+        mask[index + width] = true;
+        todo.push(Component2D(c.x, c.y + 1));
+      }
+    }
+  }
+}
+
+void ImageAnalysis::FloodFill2D(const float* image, bool* mask, float threshold,
+                                const ImageAnalysis::Component2D& component,
+                                size_t width, size_t height,
+                                std::vector<Component2D>& area) {
+  area.clear();
+  std::stack<Component2D> todo;
+  todo.push(component);
+  mask[component.x + component.y * width] = true;
+  while (!todo.empty()) {
+    Component2D c = todo.top();
+    area.push_back(c);
+    todo.pop();
+    size_t index = c.x + c.y * width;
+    if (c.x > 0) {
+      if (exceedsThresholdAbs(image[index - 1], threshold) &&
+          !mask[index - 1]) {
+        mask[index - 1] = true;
+        todo.push(Component2D(c.x - 1, c.y));
+      }
+    }
+    if (c.x < width - 1) {
+      if (exceedsThresholdAbs(image[index + 1], threshold) &&
+          !mask[index + 1]) {
+        mask[index + 1] = true;
+        todo.push(Component2D(c.x + 1, c.y));
+      }
+    }
+    if (c.y > 0) {
+      if (exceedsThresholdAbs(image[index - width], threshold) &&
+          !mask[index - width]) {
+        mask[index - width] = true;
+        todo.push(Component2D(c.x, c.y - 1));
+      }
+    }
+    if (c.y < height - 1) {
+      if (exceedsThresholdAbs(image[index + width], threshold) &&
+          !mask[index + width]) {
+        mask[index + width] = true;
+        todo.push(Component2D(c.x, c.y + 1));
+      }
+    }
+  }
+}
+}  // namespace radler::algorithms::iuwt
\ No newline at end of file
diff --git a/cpp/algorithms/iuwt/image_analysis.h b/cpp/algorithms/iuwt/image_analysis.h
new file mode 100644
index 00000000..283306aa
--- /dev/null
+++ b/cpp/algorithms/iuwt/image_analysis.h
@@ -0,0 +1,84 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_ALGORITHMS_IUWT_IMAGE_ANALYSIS_H_
+#define RADLER_ALGORITHMS_IUWT_IMAGE_ANALYSIS_H_
+
+#include "algorithms/iuwt/iuwt_decomposition.h"
+
+namespace radler::algorithms::iuwt {
+
+class ImageAnalysis {
+ public:
+  struct Component {
+    Component(size_t _x, size_t _y, int _scale) : x(_x), y(_y), scale(_scale) {}
+
+    std::string ToString() const {
+      std::ostringstream str;
+      str << x << ',' << y << ", scale " << scale;
+      return str.str();
+    }
+
+    size_t x, y;
+    int scale;
+  };
+
+  struct Component2D {
+    Component2D(size_t _x, size_t _y) : x(_x), y(_y) {}
+
+    std::string ToString() const {
+      std::ostringstream str;
+      str << x << ',' << y;
+      return str.str();
+    }
+
+    size_t x, y;
+  };
+
+  static void SelectStructures(const IUWTDecomposition& iuwt, IUWTMask& mask,
+                               const aocommon::UVector<float>& thresholds,
+                               size_t minScale, size_t endScale,
+                               float cleanBorder, const bool* priorMask,
+                               size_t& areaSize);
+
+  static bool IsHighestOnScale0(const IUWTDecomposition& iuwt,
+                                IUWTMask& markedMask, size_t& x, size_t& y,
+                                size_t endScale, float& highestScale0);
+
+  static void Floodfill(const IUWTDecomposition& iuwt, IUWTMask& mask,
+                        const aocommon::UVector<float>& thresholds,
+                        size_t minScale, size_t endScale,
+                        const Component& component, float cleanBorder,
+                        size_t& areaSize);
+
+  static void MaskedFloodfill(const IUWTDecomposition& iuwt, IUWTMask& mask,
+                              const aocommon::UVector<float>& thresholds,
+                              size_t minScale, size_t endScale,
+                              const Component& component, float cleanBorder,
+                              const bool* priorMask, size_t& areaSize);
+
+  static void FloodFill2D(const float* image, bool* mask, float threshold,
+                          const Component2D& component, size_t width,
+                          size_t height, size_t& areaSize);
+
+  /**
+   * Exactly like above, but now collecting the components in the
+   * area vector, instead of returning just the area size.
+   */
+  static void FloodFill2D(const float* image, bool* mask, float threshold,
+                          const Component2D& component, size_t width,
+                          size_t height, std::vector<Component2D>& area);
+
+ private:
+  static bool exceedsThreshold(float val, float threshold) {
+    if (threshold >= 0.0)
+      return val > threshold;
+    else
+      return val < threshold || val > -threshold;
+  }
+  static bool exceedsThresholdAbs(float val, float threshold) {
+    return std::fabs(val) > threshold;
+  }
+};
+}  // namespace radler::algorithms::iuwt
+#endif  // RADLER_ALGORITHMS_IUWT_IMAGE_ANALYSIS_H_
diff --git a/cpp/algorithms/iuwt/iuwt_decomposition.cc b/cpp/algorithms/iuwt/iuwt_decomposition.cc
new file mode 100644
index 00000000..a250f94e
--- /dev/null
+++ b/cpp/algorithms/iuwt/iuwt_decomposition.cc
@@ -0,0 +1,240 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "algorithms/iuwt/iuwt_decomposition.h"
+
+using aocommon::Image;
+
+namespace radler::algorithms::iuwt {
+
+void IUWTDecomposition::DecomposeMT(aocommon::StaticFor<size_t>& loop,
+                                    const float* input, float* scratch,
+                                    bool includeLargest) {
+  Image& i1(_scales.back().Coefficients());
+  i1 = Image(_width, _height);
+
+  // The first iteration of the loop, unrolled, so that we don't have to
+  // copy the input into i0.
+  Image& coefficients0 = _scales[0].Coefficients();
+  coefficients0 = Image(_width, _height);
+  convolveMT(loop, i1.Data(), input, scratch, _width, _height, 1);
+  convolveMT(loop, coefficients0.Data(), i1.Data(), scratch, _width, _height,
+             1);
+
+  // coefficients = i0 - i2
+  differenceMT(loop, coefficients0.Data(), input, coefficients0.Data(), _width,
+               _height);
+
+  // i0 = i1;
+  Image i0(i1);
+
+  for (int scale = 1; scale != int(_scaleCount); ++scale) {
+    Image& coefficients = _scales[scale].Coefficients();
+    coefficients = Image(_width, _height);
+    convolveMT(loop, i1.Data(), i0.Data(), scratch, _width, _height, scale + 1);
+    convolveMT(loop, coefficients.Data(), i1.Data(), scratch, _width, _height,
+               scale + 1);
+
+    // coefficients = i0 - i2
+    differenceMT(loop, coefficients.Data(), i0.Data(), coefficients.Data(),
+                 _width, _height);
+
+    // i0 = i1;
+    if (scale + 1 != int(_scaleCount)) {
+      std::copy_n(i1.Data(), _width * _height, i0.Data());
+    }
+  }
+
+  // The largest (residual) scales are in i1, but since the
+  // largest scale is aliased to i1, it's already stored there.
+  // Hence we can skip this:
+  // if(includeLargest)
+  //	_scales.back().Coefficients() = i1;
+
+  // Do free the memory of the largest scale if it is not necessary:
+  if (!includeLargest) _scales.back().Coefficients().Reset();
+}
+
+void IUWTDecomposition::convolveMT(aocommon::StaticFor<size_t>& loop,
+                                   float* output, const float* image,
+                                   float* scratch, size_t width, size_t height,
+                                   int scale) {
+  loop.Run(0, height, [&](size_t y_start, size_t y_end) {
+    convolveHorizontalPartial(scratch, image, width, y_start, y_end, scale);
+  });
+
+  loop.Run(0, width, [&](size_t x_start, size_t x_end) {
+    convolveVerticalPartialFast(output, scratch, width, height, x_start, x_end,
+                                scale);
+  });
+}
+
+void IUWTDecomposition::differenceMT(aocommon::StaticFor<size_t>& loop,
+                                     float* dest, const float* lhs,
+                                     const float* rhs, size_t width,
+                                     size_t height) {
+  loop.Run(0, height, [&](size_t y_start, size_t y_end) {
+    differencePartial(dest, lhs, rhs, width, y_start, y_end);
+  });
+}
+
+void IUWTDecomposition::convolveHorizontalFast(float* output,
+                                               const float* image, size_t width,
+                                               size_t height, int scale) {
+  const size_t H_SIZE = 5;
+  const float h[H_SIZE] = {1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0,
+                           1.0 / 16.0};
+  int scaleDist = (1 << scale);
+  int dist[H_SIZE];
+  size_t minX[H_SIZE], maxX[H_SIZE];
+  for (int hIndex = 0; hIndex != H_SIZE; ++hIndex) {
+    int hShift = hIndex - H_SIZE / 2;
+    dist[hIndex] = (scaleDist - 1) * hShift;
+    minX[hIndex] = std::max<int>(0, -dist[hIndex]);
+    maxX[hIndex] = std::min<int>(width, width - dist[hIndex]);
+  }
+  for (size_t y = 0; y != height; ++y) {
+    float* outputPtr = &output[y * width];
+    const float* inputPtr = &image[y * width];
+
+    for (size_t x = 0; x != minX[1]; ++x) {
+      outputPtr[x] = inputPtr[x + dist[2]] * h[2] +
+                     inputPtr[x + dist[3]] * h[3] +
+                     inputPtr[x + dist[4]] * h[4];
+    }
+
+    for (size_t x = minX[1]; x != minX[0]; ++x) {
+      outputPtr[x] =
+          inputPtr[x + dist[2]] * h[2] + inputPtr[x + dist[1]] * h[1] +
+          inputPtr[x + dist[3]] * h[3] + inputPtr[x + dist[4]] * h[4];
+    }
+
+    for (size_t x = minX[0]; x != maxX[4]; ++x) {
+      outputPtr[x] =
+          inputPtr[x + dist[2]] * h[2] + inputPtr[x + dist[1]] * h[1] +
+          inputPtr[x + dist[0]] * h[0] + inputPtr[x + dist[3]] * h[3] +
+          inputPtr[x + dist[4]] * h[4];
+    }
+
+    for (size_t x = maxX[4]; x != maxX[3]; ++x) {
+      outputPtr[x] =
+          inputPtr[x + dist[2]] * h[2] + inputPtr[x + dist[1]] * h[1] +
+          inputPtr[x + dist[0]] * h[0] + inputPtr[x + dist[3]] * h[3];
+    }
+
+    for (size_t x = maxX[3]; x != width; ++x) {
+      outputPtr[x] = inputPtr[x + dist[2]] * h[2] +
+                     inputPtr[x + dist[1]] * h[1] +
+                     inputPtr[x + dist[0]] * h[0];
+    }
+  }
+}
+
+// This version is not as fast as the one below.
+void IUWTDecomposition::convolveVerticalPartialFastFailed(
+    float* output, const float* image, size_t width, size_t height,
+    size_t startX, size_t endX, int scale) {
+  const size_t H_SIZE = 5;
+  const float h[H_SIZE] = {1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0,
+                           1.0 / 16.0};
+  int scaleDist = (1 << scale);
+  for (size_t y = 0; y < height; ++y) {
+    float* outputPtr = &output[y * width];
+    const float* inputPtr = &image[y * width];
+    for (size_t x = startX; x != endX; ++x) outputPtr[x] = inputPtr[x] * h[2];
+  }
+  for (int hIndex = 0; hIndex != H_SIZE; ++hIndex) {
+    if (hIndex != 2) {
+      int hShift = hIndex - H_SIZE / 2;
+      int dist = (scaleDist - 1) * hShift;
+      size_t minY = std::max<int>(0, -dist),
+             maxY = std::min<int>(height, height - dist);
+      for (size_t y = minY; y < maxY; ++y) {
+        float* outputPtr = &output[y * width];
+        const float* inputPtr = &image[(y + dist) * width];
+        for (size_t x = startX; x != endX; ++x)
+          outputPtr[x] += inputPtr[x] * h[hIndex];
+      }
+    }
+  }
+}
+
+void IUWTDecomposition::convolveVerticalPartialFast(float* output,
+                                                    const float* image,
+                                                    size_t width, size_t height,
+                                                    size_t startX, size_t endX,
+                                                    int scale) {
+  const size_t H_SIZE = 5;
+  const float h[H_SIZE] = {1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0,
+                           1.0 / 16.0};
+  int scaleDist = (1 << scale);
+  int dist[H_SIZE];
+  size_t minY[H_SIZE], maxY[H_SIZE];
+  for (int hIndex = 0; hIndex != H_SIZE; ++hIndex) {
+    int hShift = hIndex - H_SIZE / 2;
+    dist[hIndex] = (scaleDist - 1) * hShift;
+    minY[hIndex] = std::max<int>(0, -dist[hIndex]);
+    maxY[hIndex] = std::min<int>(height, height - dist[hIndex]);
+  }
+
+  for (size_t y = 0; y != minY[1]; ++y) {
+    float* outputPtr = &output[y * width];
+    const float* inputPtr2 = &image[(y + dist[2]) * width];
+    const float* inputPtr3 = &image[(y + dist[3]) * width];
+    const float* inputPtr4 = &image[(y + dist[4]) * width];
+    for (size_t x = startX; x != endX; ++x) {
+      outputPtr[x] =
+          inputPtr2[x] * h[2] + inputPtr3[x] * h[3] + inputPtr4[x] * h[4];
+    }
+  }
+
+  for (size_t y = minY[1]; y != minY[0]; ++y) {
+    float* outputPtr = &output[y * width];
+    const float* inputPtr1 = &image[(y + dist[1]) * width];
+    const float* inputPtr2 = &image[(y + dist[2]) * width];
+    const float* inputPtr3 = &image[(y + dist[3]) * width];
+    const float* inputPtr4 = &image[(y + dist[4]) * width];
+    for (size_t x = startX; x != endX; ++x) {
+      outputPtr[x] = inputPtr1[x] * h[1] + inputPtr2[x] * h[2] +
+                     inputPtr3[x] * h[3] + inputPtr4[x] * h[4];
+    }
+  }
+
+  for (size_t y = minY[0]; y != maxY[4]; ++y) {
+    float* outputPtr = &output[y * width];
+    const float* inputPtr0 = &image[(y + dist[0]) * width];
+    const float* inputPtr1 = &image[(y + dist[1]) * width];
+    const float* inputPtr2 = &image[(y + dist[2]) * width];
+    const float* inputPtr3 = &image[(y + dist[3]) * width];
+    const float* inputPtr4 = &image[(y + dist[4]) * width];
+    for (size_t x = startX; x != endX; ++x) {
+      outputPtr[x] = inputPtr0[x] * h[0] + inputPtr1[x] * h[1] +
+                     inputPtr2[x] * h[2] + inputPtr3[x] * h[3] +
+                     inputPtr4[x] * h[4];
+    }
+  }
+
+  for (size_t y = maxY[4]; y != maxY[3]; ++y) {
+    float* outputPtr = &output[y * width];
+    const float* inputPtr0 = &image[(y + dist[0]) * width];
+    const float* inputPtr1 = &image[(y + dist[1]) * width];
+    const float* inputPtr2 = &image[(y + dist[2]) * width];
+    const float* inputPtr3 = &image[(y + dist[3]) * width];
+    for (size_t x = startX; x != endX; ++x) {
+      outputPtr[x] = inputPtr0[x] * h[0] + inputPtr1[x] * h[1] +
+                     inputPtr2[x] * h[2] + inputPtr3[x] * h[3];
+    }
+  }
+
+  for (size_t y = maxY[3]; y != height; ++y) {
+    float* outputPtr = &output[y * width];
+    const float* inputPtr0 = &image[(y + dist[0]) * width];
+    const float* inputPtr1 = &image[(y + dist[1]) * width];
+    const float* inputPtr2 = &image[(y + dist[2]) * width];
+    for (size_t x = startX; x != endX; ++x) {
+      outputPtr[x] =
+          inputPtr0[x] * h[0] + inputPtr1[x] * h[1] + inputPtr2[x] * h[2];
+    }
+  }
+}
+}  // namespace radler::algorithms::iuwt
\ No newline at end of file
diff --git a/cpp/algorithms/iuwt/iuwt_decomposition.h b/cpp/algorithms/iuwt/iuwt_decomposition.h
new file mode 100644
index 00000000..c26de728
--- /dev/null
+++ b/cpp/algorithms/iuwt/iuwt_decomposition.h
@@ -0,0 +1,346 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_ALGORITHMS_IUWT_DECOMPOSITION_H_
+#define RADLER_ALGORITHMS_IUWT_DECOMPOSITION_H_
+
+#include <iostream>
+#include <string>
+#include <sstream>
+
+#include <aocommon/fits/fitswriter.h>
+#include <aocommon/image.h>
+#include <aocommon/staticfor.h>
+#include <aocommon/uvector.h>
+
+#include "algorithms/iuwt/iuwt_mask.h"
+
+namespace radler::algorithms::iuwt {
+
+class IUWTDecompositionScale {
+ public:
+  aocommon::Image& Coefficients() { return _coefficients; }
+  const aocommon::Image& Coefficients() const { return _coefficients; }
+  float& operator[](size_t index) { return _coefficients[index]; }
+  const float& operator[](size_t index) const { return _coefficients[index]; }
+
+ private:
+  aocommon::Image _coefficients;
+};
+
+class IUWTDecomposition {
+ public:
+  IUWTDecomposition(int scaleCount, size_t width, size_t height)
+      : _scales(scaleCount + 1),
+        _scaleCount(scaleCount),
+        _width(width),
+        _height(height) {}
+
+  IUWTDecomposition* CreateTrimmed(int newScaleCount, size_t x1, size_t y1,
+                                   size_t x2, size_t y2) const {
+    std::unique_ptr<IUWTDecomposition> p(
+        new IUWTDecomposition(newScaleCount, x2 - x1, y2 - y1));
+    for (int i = 0; i != newScaleCount; ++i) {
+      copySmallerPart(_scales[i].Coefficients(), p->_scales[i].Coefficients(),
+                      x1, y1, x2, y2);
+    }
+    p->_scales.back().Coefficients() = aocommon::Image(_width, _height, 0.0);
+    return p.release();
+  }
+
+  void Convolve(aocommon::Image& image, int toScale) {
+    aocommon::Image scratch(image.Width(), image.Height());
+    for (int scale = 0; scale != toScale; ++scale) {
+      convolve(image.Data(), image.Data(), scratch.Data(), _width, _height,
+               scale + 1);
+    }
+  }
+
+  void DecomposeSimple(aocommon::Image& input) {
+    aocommon::Image scratch(input.Width(), input.Height());
+    for (int scale = 0; scale != int(_scaleCount); ++scale) {
+      aocommon::Image& coefficients = _scales[scale].Coefficients();
+      coefficients = aocommon::Image(_width, _height);
+
+      aocommon::Image tmp(_width, _height);
+      convolve(tmp.Data(), input.Data(), scratch.Data(), _width, _height,
+               scale);
+      difference(coefficients.Data(), input.Data(), tmp.Data(), _width,
+                 _height);
+      std::copy_n(tmp.Data(), _width * _height, input.Data());
+    }
+    _scales.back().Coefficients() = input;
+  }
+
+  void RecomposeSimple(aocommon::Image& output) {
+    output = _scales[0].Coefficients();
+    for (size_t scale = 1; scale != _scaleCount - 1; ++scale) {
+      for (size_t i = 0; i != _width * _height; ++i)
+        output[i] += _scales[scale][i];
+    }
+  }
+
+  void Decompose(aocommon::StaticFor<size_t>& loop, const float* input,
+                 float* scratch, bool includeLargest) {
+    DecomposeMT(loop, input, scratch, includeLargest);
+  }
+
+  void DecomposeMT(aocommon::StaticFor<size_t>& loop, const float* input,
+                   float* scratch, bool includeLargest);
+
+  void DecomposeST(const float* input, float* scratch) {
+    aocommon::UVector<float> i0(input, input + _width * _height);
+    aocommon::Image i1(_width, _height);
+    aocommon::Image i2(_width, _height);
+    for (int scale = 0; scale != int(_scaleCount); ++scale) {
+      aocommon::Image& coefficients = _scales[scale].Coefficients();
+      coefficients = aocommon::Image(_width, _height);
+      convolve(i1.Data(), i0.data(), scratch, _width, _height, scale + 1);
+      convolve(i2.Data(), i1.Data(), scratch, _width, _height, scale + 1);
+
+      // coefficients = i0 - i2
+      difference(coefficients.Data(), i0.data(), i2.Data(), _width, _height);
+
+      // i0 = i1;
+      if (scale + 1 != int(_scaleCount)) {
+        std::copy_n(i1.Data(), _width * _height, i0.data());
+      }
+    }
+    _scales.back().Coefficients() = i1;
+  }
+
+  void Recompose(aocommon::Image& output, bool includeLargest) {
+    aocommon::Image scratch1(_width, _height);
+    aocommon::Image scratch2(_width, _height);
+    bool isZero;
+    if (includeLargest) {
+      output = _scales.back().Coefficients();
+      isZero = false;
+    } else {
+      output = aocommon::Image(_width, _height, 0.0);
+      isZero = true;
+    }
+    for (int scale = int(_scaleCount) - 1; scale != -1; --scale) {
+      const aocommon::Image& coefficients = _scales[scale].Coefficients();
+      if (isZero) {
+        output = coefficients;
+        isZero = false;
+      } else {
+        // output = output (x) IUWT
+        convolve(scratch2.Data(), output.Data(), scratch1.Data(), _width,
+                 _height, scale + 1);
+        output = scratch2;
+
+        // output += coefficients
+        for (size_t i = 0; i != output.Size(); ++i)
+          output[i] += coefficients[i];
+      }
+    }
+  }
+
+  size_t NScales() const { return _scaleCount; }
+
+  size_t Width() const { return _width; }
+
+  size_t Height() const { return _height; }
+
+  IUWTDecompositionScale& operator[](int scale) { return _scales[scale]; }
+
+  const IUWTDecompositionScale& operator[](int scale) const {
+    return _scales[scale];
+  }
+
+  void ApplyMask(const IUWTMask& mask) {
+    for (size_t scale = 0; scale != _scaleCount; ++scale) {
+      for (size_t i = 0; i != _scales[scale].Coefficients().Size(); ++i) {
+        if (!mask[scale][i]) _scales[scale][i] = 0.0;
+      }
+    }
+    _scales[_scaleCount].Coefficients() = aocommon::Image(_width, _height, 0.0);
+  }
+
+  void Save(const std::string& prefix) {
+    std::cout << "Saving scales...\n";
+    aocommon::FitsWriter writer;
+    writer.SetImageDimensions(_width, _height);
+    for (size_t scale = 0; scale != _scales.size(); ++scale) {
+      std::ostringstream str;
+      str << prefix << "-iuwt-" << scale << ".fits";
+      writer.Write(str.str(), _scales[scale].Coefficients().Data());
+    }
+  }
+
+  static int EndScale(size_t maxImageDimension) {
+    return std::max(int(log2(maxImageDimension)) - 3, 2);
+  }
+
+  static size_t MinImageDimension(int endScale) {
+    return (1 << (endScale + 3));
+  }
+
+  std::string Summary() const {
+    std::ostringstream str;
+    str << "IUWTDecomposition, NScales()=" << NScales()
+        << ", MinImageDimension()=" << MinImageDimension(NScales() + 1)
+        << ", width=" << _width << ", height=" << _height;
+    return str.str();
+  }
+
+ private:
+  static void convolveComponentHorizontal(const float* input, float* output,
+                                          size_t width, size_t height,
+                                          float val, int dist) {
+    size_t minX = std::max<int>(0, -dist),
+           maxX = std::min<int>(width, width - dist);
+    for (size_t y = 0; y != height; ++y) {
+      float* outputPtr = &output[y * width];
+      const float* inputPtr = &input[y * width];
+      for (size_t x = minX; x < maxX; ++x) {
+        outputPtr[x] += inputPtr[x + dist] * val;
+      }
+    }
+  }
+
+  static void convolveComponentVertical(const float* input, float* output,
+                                        size_t width, size_t height, float val,
+                                        int dist) {
+    size_t minY = std::max<int>(0, -dist),
+           maxY = std::min<int>(height, height - dist);
+    for (size_t y = minY; y < maxY; ++y) {
+      float* outputPtr = &output[y * width];
+      const float* inputPtr = &input[(y + dist) * width];
+      for (size_t x = 0; x != width; ++x) {
+        outputPtr[x] += inputPtr[x] * val;
+      }
+    }
+  }
+
+  static void convolveComponentVerticalPartial(const float* input,
+                                               float* output, size_t width,
+                                               size_t height, size_t startX,
+                                               size_t endX, float val,
+                                               int dist) {
+    size_t minY = std::max<int>(0, -dist),
+           maxY = std::min<int>(height, height - dist);
+    for (size_t y = minY; y < maxY; ++y) {
+      float* outputPtr = &output[y * width];
+      const float* inputPtr = &input[(y + dist) * width];
+      for (size_t x = startX; x != endX; ++x) {
+        outputPtr[x] += inputPtr[x] * val;
+      }
+    }
+  }
+
+  static void convolve(float* output, const float* image, float* scratch,
+                       size_t width, size_t height, int scale) {
+    for (size_t i = 0; i != width * height; ++i) scratch[i] = 0.0;
+    const size_t H_SIZE = 5;
+    const float h[H_SIZE] = {1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0,
+                             1.0 / 16.0};
+    int scaleDist = (1 << scale);
+    for (int hIndex = 0; hIndex != H_SIZE; ++hIndex) {
+      int hShift = hIndex - H_SIZE / 2;
+      convolveComponentHorizontal(image, scratch, width, height, h[hIndex],
+                                  (scaleDist - 1) * hShift);
+    }
+    for (size_t i = 0; i != width * height; ++i) output[i] = 0.0;
+    for (int hIndex = 0; hIndex != H_SIZE; ++hIndex) {
+      int hShift = hIndex - H_SIZE / 2;
+      convolveComponentVertical(scratch, output, width, height, h[hIndex],
+                                (scaleDist - 1) * hShift);
+    }
+  }
+
+  static void convolveMT(aocommon::StaticFor<size_t>& loop, float* output,
+                         const float* image, float* scratch, size_t width,
+                         size_t height, int scale);
+
+  static void convolveHorizontalPartial(float* output, const float* image,
+                                        size_t width, size_t startY,
+                                        size_t endY, int scale) {
+    size_t startIndex = startY * width;
+    convolveHorizontalFast(&output[startIndex], &image[startIndex], width,
+                           endY - startY, scale);
+  }
+
+  static void convolveHorizontal(float* output, const float* image,
+                                 size_t width, size_t height, int scale) {
+    for (size_t i = 0; i != width * height; ++i) output[i] = 0.0;
+    const size_t H_SIZE = 5;
+    const float h[H_SIZE] = {1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0,
+                             1.0 / 16.0};
+    int scaleDist = (1 << scale);
+    for (int hIndex = 0; hIndex != H_SIZE; ++hIndex) {
+      int hShift = hIndex - H_SIZE / 2;
+      convolveComponentHorizontal(image, output, width, height, h[hIndex],
+                                  (scaleDist - 1) * hShift);
+    }
+  }
+
+  static void convolveHorizontalFast(float* output, const float* image,
+                                     size_t width, size_t height, int scale);
+
+  static void convolveVerticalPartial(float* output, const float* image,
+                                      size_t width, size_t height,
+                                      size_t startX, size_t endX, int scale) {
+    for (size_t i = 0; i != width * height; ++i) output[i] = 0.0;
+    const size_t H_SIZE = 5;
+    const float h[H_SIZE] = {1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0,
+                             1.0 / 16.0};
+    int scaleDist = (1 << scale);
+    for (int hIndex = 0; hIndex != H_SIZE; ++hIndex) {
+      int hShift = hIndex - H_SIZE / 2;
+      convolveComponentVerticalPartial(image, output, width, height, startX,
+                                       endX, h[hIndex],
+                                       (scaleDist - 1) * hShift);
+    }
+  }
+
+  static void convolveVerticalPartialFast(float* output, const float* image,
+                                          size_t width, size_t height,
+                                          size_t startX, size_t endX,
+                                          int scale);
+
+  static void convolveVerticalPartialFastFailed(float* output,
+                                                const float* image,
+                                                size_t width, size_t height,
+                                                size_t startX, size_t endX,
+                                                int scale);
+
+  static void differencePartial(float* dest, const float* lhs, const float* rhs,
+                                size_t width, size_t startY, size_t endY) {
+    size_t startIndex = startY * width;
+    difference(&dest[startIndex], &lhs[startIndex], &rhs[startIndex], width,
+               endY - startY);
+  }
+
+  static void differenceMT(aocommon::StaticFor<size_t>& loop, float* dest,
+                           const float* lhs, const float* rhs, size_t width,
+                           size_t height);
+
+  static void difference(float* dest, const float* lhs, const float* rhs,
+                         size_t width, size_t height) {
+    for (size_t i = 0; i != width * height; ++i) {
+      dest[i] = lhs[i] - rhs[i];
+    }
+  }
+
+  void copySmallerPart(const aocommon::Image& input, aocommon::Image& output,
+                       size_t x1, size_t y1, size_t x2, size_t y2) const {
+    size_t newWidth = x2 - x1;
+    output = aocommon::Image(newWidth, y2 - y1);
+    for (size_t y = y1; y != y2; ++y) {
+      const float* oldPtr = &input[y * _width];
+      float* newPtr = &output[(y - y1) * newWidth];
+      for (size_t x = x1; x != x2; ++x) {
+        newPtr[x - x1] = oldPtr[x];
+      }
+    }
+  }
+
+ private:
+  std::vector<IUWTDecompositionScale> _scales;
+  size_t _scaleCount, _width, _height;
+};
+}  // namespace radler::algorithms::iuwt
+#endif
diff --git a/cpp/algorithms/iuwt/iuwt_mask.cc b/cpp/algorithms/iuwt/iuwt_mask.cc
new file mode 100644
index 00000000..c8308fcf
--- /dev/null
+++ b/cpp/algorithms/iuwt/iuwt_mask.cc
@@ -0,0 +1,36 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "algorithms/iuwt/iuwt_mask.h"
+
+#include <boost/numeric/conversion/bounds.hpp>
+
+#include "algorithms/iuwt/iuwt_decomposition.h"
+
+namespace radler::algorithms::iuwt {
+
+std::string IUWTMask::Summary(const IUWTDecomposition& iuwt) const {
+  std::ostringstream str;
+  str << "IUWTMask with " << _masks.size()
+      << " scale masks (iuwt: " << iuwt.Summary() << ")\n";
+  for (size_t i = 0; i != _masks.size(); ++i) {
+    double maxVal = boost::numeric::bounds<double>::lowest();
+    double minVal = std::numeric_limits<double>::max();
+    size_t count = 0;
+    for (size_t j = 0; j != _masks[i].size(); ++j) {
+      if (_masks[i][j]) {
+        ++count;
+        if (iuwt[i][j] > maxVal) maxVal = iuwt[i][j];
+        if (iuwt[i][j] < minVal) minVal = iuwt[i][j];
+      }
+    }
+    if (maxVal == boost::numeric::bounds<double>::lowest()) {
+      maxVal = std::numeric_limits<double>::quiet_NaN();
+      minVal = std::numeric_limits<double>::quiet_NaN();
+    }
+    str << "Scale " << i << ": " << count << " (" << minVal << " - " << maxVal
+        << ")\n";
+  }
+  return str.str();
+}
+}  // namespace radler::algorithms::iuwt
diff --git a/cpp/algorithms/iuwt/iuwt_mask.h b/cpp/algorithms/iuwt/iuwt_mask.h
new file mode 100644
index 00000000..b456c5ca
--- /dev/null
+++ b/cpp/algorithms/iuwt/iuwt_mask.h
@@ -0,0 +1,86 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_ALGORITHMS_IUWT_IUWT_MASK_H_
+#define RADLER_ALGORITHMS_IUWT_IUWT_MASK_H_
+
+#include <vector>
+#include <string>
+#include <sstream>
+#include <limits>
+
+#include <aocommon/uvector.h>
+
+namespace radler::algorithms::iuwt {
+class IUWTMask {
+ public:
+  IUWTMask(int scaleCount, size_t width, size_t height)
+      : _masks(scaleCount), _width(width), _height(height) {
+    for (int i = 0; i != scaleCount; ++i)
+      _masks[i].assign(width * height, false);
+  }
+
+  IUWTMask* CreateTrimmed(size_t x1, size_t y1, size_t x2, size_t y2) const {
+    std::unique_ptr<IUWTMask> p(new IUWTMask(ScaleCount(), x2 - x1, y2 - y1));
+
+    for (size_t i = 0; i != _masks.size(); ++i)
+      copySmallerPart(_masks[i], p->_masks[i], x1, y1, x2, y2);
+
+    return p.release();
+  }
+
+  aocommon::UVector<bool>& operator[](int scale) { return _masks[scale]; }
+  const aocommon::UVector<bool>& operator[](int scale) const {
+    return _masks[scale];
+  }
+  std::string Summary() const {
+    std::ostringstream str;
+    str << "IUWTMask with " << _masks.size() << " scale masks.\n";
+    for (size_t i = 0; i != _masks.size(); ++i) {
+      size_t count = 0;
+      for (size_t j = 0; j != _masks[i].size(); ++j) {
+        if (_masks[i][j]) ++count;
+      }
+      str << "Scale " << i << ": " << count << '\n';
+    }
+    return str.str();
+  }
+  std::string Summary(const class IUWTDecomposition& iuwt) const;
+
+  size_t HighestActiveScale() const {
+    for (int m = _masks.size() - 1; m != -1; --m) {
+      for (size_t i = 0; i != _masks[m].size(); ++i) {
+        if (_masks[m][i]) return m;
+      }
+    }
+    return 0;  // avoid compiler warning
+  }
+
+  void TransferScaleUpwards(size_t fromScale) {
+    if (fromScale > 0) {
+      size_t toScale = fromScale - 1;
+      for (size_t i = 0; i != _masks[fromScale].size(); ++i) {
+        if (_masks[fromScale][i]) _masks[toScale][i] = true;
+      }
+    }
+  }
+  size_t ScaleCount() const { return _masks.size(); }
+
+ private:
+  std::vector<aocommon::UVector<bool>> _masks;
+  size_t _width, _height;
+
+  void copySmallerPart(const aocommon::UVector<bool>& input,
+                       aocommon::UVector<bool>& output, size_t x1, size_t y1,
+                       size_t x2, size_t y2) const {
+    size_t newWidth = x2 - x1;
+    output.resize(newWidth * (y2 - y1));
+    for (size_t y = y1; y != y2; ++y) {
+      const bool* oldPtr = &input[y * _width];
+      bool* newPtr = &output[(y - y1) * newWidth];
+      for (size_t x = x1; x != x2; ++x) newPtr[x - x1] = oldPtr[x];
+    }
+  }
+};
+}  // namespace radler::algorithms::iuwt
+#endif
diff --git a/cpp/algorithms/iuwt_deconvolution.h b/cpp/algorithms/iuwt_deconvolution.h
new file mode 100644
index 00000000..4b13897d
--- /dev/null
+++ b/cpp/algorithms/iuwt_deconvolution.h
@@ -0,0 +1,47 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_ALGORITHMS_IUWT_DECONVOLUTION_H_
+#define RADLER_ALGORITHMS_IUWT_DECONVOLUTION_H_
+
+#include <memory>
+#include <string>
+
+#include <aocommon/uvector.h>
+
+#include "image_set.h"
+#include "algorithms/deconvolution_algorithm.h"
+#include "algorithms/iuwt_deconvolution_algorithm.h"
+
+// TODO: consider merging IUWTDeconvolutionAlgorithms into this class.
+
+namespace radler::algorithms {
+
+class IUWTDeconvolution : public DeconvolutionAlgorithm {
+ public:
+  IUWTDeconvolution() : _useSNRTest(false) {}
+
+  float ExecuteMajorIteration(ImageSet& dataImage, ImageSet& modelImage,
+                              const std::vector<aocommon::Image>& psfImages,
+                              bool& reachedMajorThreshold) final override {
+    IUWTDeconvolutionAlgorithm algorithm(
+        dataImage.Width(), dataImage.Height(), _gain, _mGain, _cleanBorderRatio,
+        _allowNegativeComponents, _cleanMask, _threshold, _useSNRTest);
+    float val = algorithm.PerformMajorIteration(
+        _iterationNumber, MaxNIter(), modelImage, dataImage, psfImages,
+        reachedMajorThreshold);
+    if (_iterationNumber >= MaxNIter()) reachedMajorThreshold = false;
+    return val;
+  }
+
+  std::unique_ptr<DeconvolutionAlgorithm> Clone() const final override {
+    return std::make_unique<IUWTDeconvolution>(*this);
+  }
+
+  void SetUseSNRTest(bool useSNRTest) { _useSNRTest = useSNRTest; }
+
+ private:
+  bool _useSNRTest;
+};
+}  // namespace radler::algorithms
+#endif  // RADLER_ALGORITHMS_IUWT_DECONVOLUTION_H_
diff --git a/cpp/algorithms/iuwt_deconvolution_algorithm.cc b/cpp/algorithms/iuwt_deconvolution_algorithm.cc
new file mode 100644
index 00000000..5a8e05f5
--- /dev/null
+++ b/cpp/algorithms/iuwt_deconvolution_algorithm.cc
@@ -0,0 +1,962 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "algorithms/iuwt_deconvolution_algorithm.h"
+
+#include <algorithm>
+#include <iostream>
+
+#include <boost/numeric/conversion/bounds.hpp>
+
+#include <aocommon/image.h>
+#include <aocommon/system.h>
+
+#include <schaapcommon/fft/convolution.h>
+#include <schaapcommon/fitters/gaussianfitter.h>
+
+#include "image_set.h"
+#include "algorithms/iuwt/image_analysis.h"
+
+using aocommon::Image;
+
+namespace radler::algorithms {
+
+using iuwt::ImageAnalysis;
+using iuwt::IUWTDecomposition;
+using iuwt::IUWTMask;
+
+IUWTDeconvolutionAlgorithm::IUWTDeconvolutionAlgorithm(
+    size_t width, size_t height, float gain, float mGain, float cleanBorder,
+    bool allowNegativeComponents, const bool* mask, float absoluteThreshold,
+    float thresholdSigmaLevel, float tolerance, bool useSNRTest)
+    : _width(width),
+      _height(height),
+      _gain(gain),
+      _mGain(mGain),
+      _cleanBorder(cleanBorder),
+      _mask(mask),
+      _absoluteThreshold(absoluteThreshold),
+      _thresholdSigmaLevel(thresholdSigmaLevel),
+      _tolerance(tolerance),
+      _allowNegativeComponents(allowNegativeComponents),
+      _useSNRTest(useSNRTest) {}
+
+void IUWTDeconvolutionAlgorithm::measureRMSPerScale(
+    const float* image, const float* convolvedImage, float* scratch,
+    size_t endScale, std::vector<ScaleResponse>& psfResponse) {
+  IUWTDecomposition imageIUWT(endScale, _width, _height);
+  imageIUWT.Decompose(*_staticFor, image, scratch, false);
+
+  _psfMaj = 2.0;
+  _psfMin = 2.0;
+  _psfPA = 0.0;
+  double fl = 0.0;
+  schaapcommon::fitters::GaussianFitter fitter;
+  fitter.Fit2DGaussianCentred(image, _width, _height, 2.0, _psfMaj, _psfMin,
+                              _psfPA);
+  _psfVolume = (M_PI / 4.0) * _psfMaj * _psfMin / M_LOG2E;
+
+  double v = 1.0, x = _width / 2, y = _height / 2;
+  double bMaj = _psfMaj, bMin = _psfMin, bPA = _psfPA;
+  fitter.Fit2DGaussianFull(image, _width, _height, v, x, y, bMaj, bMin, bPA,
+                           &fl);
+
+  psfResponse.resize(endScale);
+  for (size_t scale = 0; scale != endScale; ++scale) {
+    psfResponse[scale].rms = rms(imageIUWT[scale].Coefficients());
+    psfResponse[scale].peakResponse =
+        centralPeak(imageIUWT[scale].Coefficients());
+    bMaj = 2.0;
+    bMin = 2.0;
+    bPA = 0.0;
+    v = 1.0;
+    x = _width / 2;
+    y = _height / 2;
+    fitter.Fit2DGaussianFull(imageIUWT[scale].Coefficients().Data(), _width,
+                             _height, v, x, y, bMaj, bMin, bPA, &fl);
+    psfResponse[scale].bMaj = bMaj;
+    psfResponse[scale].bMin = bMin;
+    psfResponse[scale].bPA = bPA;
+  }
+
+  imageIUWT.Decompose(*_staticFor, imageIUWT[1].Coefficients().Data(), scratch,
+                      false);
+  for (size_t scale = 0; scale != endScale; ++scale) {
+    psfResponse[scale].peakResponseToNextScale =
+        centralPeak(imageIUWT[scale].Coefficients());
+  }
+
+  imageIUWT.Decompose(*_staticFor, convolvedImage, scratch, false);
+
+  for (size_t scale = 0; scale != endScale; ++scale) {
+    psfResponse[scale].convolvedPeakResponse =
+        centralPeak(imageIUWT[scale].Coefficients());
+  }
+
+  aocommon::UVector<float> thresholds(imageIUWT.NScales());
+  for (size_t i = 0; i != imageIUWT.NScales(); ++i) {
+    thresholds[i] = psfResponse[0].convolvedPeakResponse * _tolerance;
+  }
+  IUWTMask mask(imageIUWT.NScales(), _width, _height);
+  ImageAnalysis::Component component(_width / 2, _height / 2, 0);
+  size_t areaSize;
+  ImageAnalysis::Floodfill(imageIUWT, mask, thresholds, 0,
+                           std::min<size_t>(endScale, 2), component, 0.0,
+                           areaSize);
+  aocommon::UVector<bool> markedMask0(mask[0].size(), false);
+  ImageAnalysis::Component2D c2D(_width / 2, _height / 2);
+  float threshold = psfResponse[0].convolvedPeakResponse * _tolerance;
+  ImageAnalysis::FloodFill2D(imageIUWT[0].Coefficients().Data(),
+                             markedMask0.data(), threshold, c2D, _width,
+                             _height, psfResponse[0].convolvedArea);
+}
+
+float IUWTDeconvolutionAlgorithm::mad(const float* dest) {
+  Image v(_width, _height);
+  for (size_t i = 0; i != _width * _height; ++i) v[i] = std::fabs(dest[i]);
+  size_t mid = (_width * _height) / 2;
+  std::nth_element(v.begin(), v.begin() + mid, v.end());
+  return v[mid] / 0.674559;
+}
+
+float IUWTDeconvolutionAlgorithm::getMaxAbsWithoutMask(const Image& data,
+                                                       size_t& x, size_t& y,
+                                                       size_t width) {
+  size_t height = data.Size() / width;
+  size_t xBorder = _cleanBorder * width;
+  size_t yBorder = _cleanBorder * height;
+  size_t minX = xBorder, maxX = width - xBorder;
+  size_t minY = yBorder, maxY = height - yBorder;
+  x = width;
+  y = height;
+
+  float maxVal = boost::numeric::bounds<float>::lowest();
+  for (size_t yi = minY; yi != maxY; ++yi) {
+    const float* dataPtr = data.Data() + yi * width;
+    for (size_t xi = minX; xi != maxX; ++xi) {
+      float val =
+          _allowNegativeComponents ? std::fabs(dataPtr[xi]) : dataPtr[xi];
+      if (val > maxVal) {
+        maxVal = val;
+        x = xi;
+        y = yi;
+      }
+    }
+  }
+  return maxVal;
+}
+
+float IUWTDeconvolutionAlgorithm::getMaxAbsWithMask(const Image& data,
+                                                    size_t& x, size_t& y,
+                                                    size_t width) {
+  size_t height = data.Size() / width;
+  size_t xBorder = _cleanBorder * width;
+  size_t yBorder = _cleanBorder * height;
+  size_t minX = xBorder, maxX = width - xBorder;
+  size_t minY = yBorder, maxY = height - yBorder;
+  x = width;
+  y = height;
+
+  float maxVal = boost::numeric::bounds<float>::lowest();
+  for (size_t yi = minY; yi != maxY; ++yi) {
+    const float* dataPtr = data.Data() + yi * width;
+    const bool* maskPtr = _mask + yi * width;
+    for (size_t xi = minX; xi != maxX; ++xi) {
+      if (maskPtr[xi]) {
+        float val =
+            _allowNegativeComponents ? std::fabs(dataPtr[xi]) : dataPtr[xi];
+        if (val > maxVal) {
+          maxVal = val;
+          x = xi;
+          y = yi;
+        }
+      }
+    }
+  }
+  return maxVal;
+}
+
+float IUWTDeconvolutionAlgorithm::dotProduct(const Image& lhs,
+                                             const Image& rhs) {
+  float sum = 0.0;
+  for (size_t i = 0; i != lhs.Size(); ++i) sum += lhs[i] * rhs[i];
+  return sum;
+}
+
+void IUWTDeconvolutionAlgorithm::factorAdd(float* dest, const float* rhs,
+                                           float factor, size_t width,
+                                           size_t height) {
+  for (size_t i = 0; i != width * height; ++i) dest[i] += rhs[i] * factor;
+}
+
+void IUWTDeconvolutionAlgorithm::factorAdd(Image& dest, const Image& rhs,
+                                           float factor) {
+  for (size_t i = 0; i != dest.Size(); ++i) dest[i] += rhs[i] * factor;
+}
+
+void IUWTDeconvolutionAlgorithm::Subtract(float* dest, const Image& rhs) {
+  for (size_t i = 0; i != rhs.Size(); ++i) dest[i] -= rhs[i];
+}
+
+void IUWTDeconvolutionAlgorithm::boundingBox(size_t& x1, size_t& y1, size_t& x2,
+                                             size_t& y2, const Image& image,
+                                             size_t width, size_t height) {
+  float mP = *std::max_element(image.begin(), image.end());
+  float mN = *std::min_element(image.begin(), image.end());
+  float m = std::max(mP, -mN);
+  x1 = width;
+  x2 = 0;
+  y1 = height;
+  y2 = 0;
+  for (size_t y = 0; y != height; ++y) {
+    const float* ptr = image.Data() + y * width;
+    for (size_t x = 0; x != x1; ++x) {
+      if (std::fabs(ptr[x]) > m * 0.01) {
+        x1 = x;
+        break;
+      }
+    }
+    for (size_t x = width - 1; x != x2; --x) {
+      if (std::fabs(ptr[x]) > m * 0.01) {
+        x2 = x;
+        break;
+      }
+    }
+  }
+  x2++;
+  for (size_t y = 0; y != height; ++y) {
+    const float* ptr = image.Data() + y * width;
+    for (size_t x = 0; x != width; ++x) {
+      if (std::fabs(ptr[x]) > m * 0.01) {
+        if (y1 > y) y1 = y;
+        if (y2 < y) y2 = y + 1;
+      }
+    }
+  }
+}
+
+void IUWTDeconvolutionAlgorithm::adjustBox(size_t& x1, size_t& y1, size_t& x2,
+                                           size_t& y2, size_t width,
+                                           size_t height, int endScale) {
+  const int minBoxSize = std::max<int>(
+      128, IUWTDecomposition::MinImageDimension(endScale) * 3 / 2);
+
+  int boxWidth = x2 - x1;
+  int boxHeight = y2 - y1;
+  int newX1 = x1 - 0.5 * boxWidth, newX2 = x2 + 0.5 * boxWidth,
+      newY1 = y1 - 0.5 * boxHeight, newY2 = y2 + 0.5 * boxHeight;
+
+  if (newX2 - newX1 < minBoxSize) {
+    int mid = 0.5 * (int(x1) + int(x2));
+    newX1 = mid - minBoxSize / 2;
+    newX2 = mid + minBoxSize / 2;
+  }
+  if (newY2 - newY1 < minBoxSize) {
+    int mid = 0.5 * (int(y1) + int(y2));
+    newY1 = mid - minBoxSize / 2;
+    newY2 = mid + minBoxSize / 2;
+  }
+  if (newX1 >= 0)
+    x1 = newX1;
+  else
+    x1 = 0;
+  if (newX2 < int(width))
+    x2 = newX2;
+  else
+    x2 = width;
+  if (newY1 >= 0)
+    y1 = newY1;
+  else
+    y1 = 0;
+  if (newY2 < int(height))
+    y2 = newY2;
+  else
+    y2 = height;
+  while ((x2 - x1) % 8 != 0) x2--;
+  while ((y2 - y1) % 8 != 0) y2--;
+}
+
+void IUWTDeconvolutionAlgorithm::trim(Image& dest, const float* source,
+                                      size_t oldWidth, size_t x1, size_t y1,
+                                      size_t x2, size_t y2) {
+  // We do this so that dest and source can be the same image.
+  Image scratch((x2 - x1), (y2 - y1));
+  for (size_t y = y1; y != y2; ++y) {
+    const float* oldPtr = &source[y * oldWidth];
+    float* newPtr = &scratch[(y - y1) * (x2 - x1)];
+    for (size_t x = x1; x != x2; ++x) {
+      newPtr[x - x1] = oldPtr[x];
+    }
+  }
+  dest = std::move(scratch);
+}
+
+void IUWTDeconvolutionAlgorithm::untrim(Image& image, size_t width,
+                                        size_t height, size_t x1, size_t y1,
+                                        size_t x2, size_t y2) {
+  Image scratch(width, height, 0.0);
+  std::copy_n(image.Data(), image.Width() * image.Height(), scratch.Data());
+  image = scratch;
+  size_t y = y2;
+  while (y != y1) {
+    --y;
+    float* newPtr = &image[y * width];
+    float* oldPtr = &image[(y - y1) * (x2 - x1)];
+    size_t x = x2;
+    while (x != x1) {
+      --x;
+      newPtr[x] = oldPtr[x - x1];
+    }
+  }
+  for (size_t y = 0; y != y1; ++y) {
+    float* ptr = &image[y * width];
+    for (size_t x = 0; x != width; ++x) ptr[x] = 0;
+  }
+  for (size_t y = y1; y != y2; ++y) {
+    float* ptr = &image[y * width];
+    for (size_t x = 0; x != x1; ++x) ptr[x] = 0.0;
+    for (size_t x = x2; x != width; ++x) ptr[x] = 0.0;
+  }
+  for (size_t y = y2; y != height; ++y) {
+    float* ptr = &image[y * width];
+    for (size_t x = 0; x != width; ++x) ptr[x] = 0;
+  }
+}
+
+float IUWTDeconvolutionAlgorithm::sum(const Image& img) const {
+  float s = 0.0;
+  for (size_t i = 0; i != img.Size(); ++i) s += img[i];
+  return s;
+}
+
+float IUWTDeconvolutionAlgorithm::snr(const IUWTDecomposition& noisyImg,
+                                      const IUWTDecomposition& model) const {
+  float mSum = 0.0, nSum = 0.0;
+  for (size_t scale = 0; scale != noisyImg.NScales(); ++scale) {
+    const Image& n = noisyImg[scale].Coefficients();
+    const Image& m = model[scale].Coefficients();
+    for (size_t i = 0; i != n.Size(); ++i) {
+      mSum += m[i] * m[i];
+      float d = m[i] - n[i];
+      nSum += d * d;
+    }
+  }
+  return mSum / nSum;
+}
+
+float IUWTDeconvolutionAlgorithm::rmsDiff(const Image& a, const Image& b) {
+  float sum = 0.0;
+  for (size_t i = 0; i != a.Size(); ++i) {
+    float d = a[i] - b[i];
+    sum += d * d;
+  }
+  return std::sqrt(sum / a.Size());
+}
+
+float IUWTDeconvolutionAlgorithm::rms(const Image& image) {
+  float sum = 0.0;
+  for (size_t i = 0; i != image.Size(); ++i) {
+    float v = image[i];
+    sum += v * v;
+  }
+  return std::sqrt(sum / image.Size());
+}
+
+bool IUWTDeconvolutionAlgorithm::runConjugateGradient(
+    IUWTDecomposition& iuwt, const IUWTMask& mask, Image& maskedDirty,
+    Image& structureModel, Image& scratch, const Image& psfKernel, size_t width,
+    size_t height) {
+  Image gradient = maskedDirty;
+  float modelSNR = 0.0;
+
+  IUWTDecomposition initialDirtyIUWT(iuwt);
+
+  for (size_t minorIter = 0; minorIter != 20; ++minorIter) {
+    // scratch = gradient (x) psf
+    scratch = gradient;
+    schaapcommon::fft::Convolve(scratch.Data(), psfKernel.Data(), width, height,
+                                _staticFor->NThreads());
+
+    // calc: IUWT gradient (x) psf
+    iuwt.Decompose(*_staticFor, scratch.Data(), scratch.Data(), false);
+
+    // calc: mask IUWT gradient (x) psf
+    iuwt.ApplyMask(mask);
+
+    // scratch = IUWT^-1 mask IUWT gradient (x) psf
+    iuwt.Recompose(scratch, false);
+
+    // stepsize = <residual, residual> / <gradient, scratch>
+    float gradientDotScratch = dotProduct(gradient, scratch);
+    if (gradientDotScratch == 0.0) return false;
+    float stepSize = dotProduct(maskedDirty, maskedDirty) / gradientDotScratch;
+
+    // model_i+1 = model_i + stepsize * gradient
+    factorAdd(structureModel.Data(), gradient.Data(), stepSize, width, height);
+
+    // For Dabbech's approach (see below) :
+    //  Image scratch2 = maskedDirty;
+
+    float gradStepDen = dotProduct(maskedDirty, maskedDirty);
+    if (gradStepDen == 0.0) return false;
+    // residual_i+1 = residual_i - stepsize * scratch
+    factorAdd(maskedDirty.Data(), scratch.Data(), -stepSize, width, height);
+
+    // PyMORESANE uses this:
+    // gradstep = <residual_i+1, residual_i+1> / <residual_i, residual_i>
+    // float gradStep = dotProduct(maskedDirty, maskedDirty) / gradStepDen;
+    // But in MORESANE's paper A. Dabbech says this:
+    // gradstep = <residual_i+1 - residual_i, residual_i+1> / <residual_i,
+    // residual_i> scratch = maskedDirty; subtract(scratch, scratch2); float
+    // gradStep = dotProduct(scratch, maskedDirty) / gradStepDen;
+    float gradStep = dotProduct(maskedDirty, maskedDirty) / gradStepDen;
+
+    // gradient_i+1 = residual_i+1 + gradstep * gradient_i
+    scratch = gradient;
+    gradient = maskedDirty;
+    factorAdd(gradient.Data(), scratch.Data(), gradStep, width, height);
+
+    // scratch = mask IUWT PSF (x) model
+    scratch = structureModel;
+    schaapcommon::fft::Convolve(scratch.Data(), psfKernel.Data(), width, height,
+                                _staticFor->NThreads());
+    iuwt.Decompose(*_staticFor, scratch.Data(), scratch.Data(), false);
+    iuwt.ApplyMask(mask);
+
+    float previousSNR = modelSNR;
+    modelSNR = snr(iuwt, initialDirtyIUWT);
+    if (modelSNR > 100 && minorIter > 2) {
+      std::cout << "Converged after " << minorIter << " iterations.\n";
+      return true;
+    } else if (modelSNR < previousSNR && minorIter > 5) {
+      if (modelSNR > 3) {
+        std::cout << "SNR decreased after " << minorIter
+                  << " iterations (SNR=" << modelSNR << ").\n";
+        return true;
+      }
+    }
+  }
+  if (modelSNR <= 3.0) {
+    std::cout << "Failed to converge (SNR=" << modelSNR << ").\n";
+    structureModel = Image(width, height, 0.0);
+    return false;
+  }
+  return true;
+}
+
+struct PointSource {
+  float x, y, flux;
+  bool operator<(const PointSource& rhs) const { return flux < rhs.flux; }
+};
+
+bool IUWTDeconvolutionAlgorithm::findAndDeconvolveStructure(
+    IUWTDecomposition& iuwt, Image& dirty, const Image& psf,
+    const Image& psfKernel, const std::vector<Image>& psfs, Image& scratch,
+    ImageSet& structureModel, size_t curEndScale, size_t curMinScale,
+    std::vector<IUWTDeconvolutionAlgorithm::ValComponent>& maxComponents) {
+  iuwt.Decompose(*_staticFor, dirty.Data(), scratch.Data(), false);
+  aocommon::UVector<float> thresholds(curEndScale);
+  _rmses.resize(curEndScale);
+  for (size_t scale = 0; scale != curEndScale; ++scale) {
+    float r = mad(iuwt[scale].Coefficients().Data());
+    _rmses[scale] = r;
+    thresholds[scale] = r * (_thresholdSigmaLevel * 4.0 / 5.0);
+  }
+
+  scratch = dirty;
+  maxComponents.resize(curEndScale);
+  for (size_t scale = 0; scale != curEndScale; ++scale) {
+    size_t x, y;
+    float maxAbsCoef = getMaxAbs(iuwt[scale].Coefficients(), x, y, _width);
+    maxComponents[scale].x = x;
+    maxComponents[scale].y = y;
+    maxComponents[scale].scale = scale;
+    maxComponents[scale].val = maxAbsCoef;
+  }
+
+  float maxVal = -1.0;
+  size_t maxX = 0, maxY = 0;
+  int maxValScale = -1;
+  for (size_t scale = 0; scale != curEndScale; ++scale) {
+    // Considerations for this section:
+    // - Scale 0 should be chosen if the input corresponds to the PSF.
+    //   Therefore, a peak on scale 1 should be at least:
+    //   (PSF peak on scale 1) * (peak on scale 0) / (PSF (x) scale 1 peak
+    //   response) Such that anything smaller than scale 1 will be considered
+    //   scale 0.
+
+    const ValComponent& val = maxComponents[scale];
+    float absCoef = val.val / _psfResponse[scale].rms;
+    // std::cout << scale << ">=" << curMinScale << " && " << absCoef << " > "
+    // << maxVal << " && " << val.val << " > " << _rmses[scale]*_thresholdLevel
+    // << "\n";
+    if (scale >= curMinScale && absCoef > maxVal &&
+        val.val > _rmses[scale] * _thresholdSigmaLevel &&
+        val.val > _rmses[scale] / _rmses[0] * _absoluteThreshold) {
+      maxX = val.x;
+      maxY = val.y;
+      maxValScale = scale;
+      if (scale == 0) {
+        float lowestRMS = std::min(_psfResponse[0].rms, _psfResponse[1].rms);
+        maxVal = val.val / lowestRMS * _psfResponse[1].peakResponse /
+                 _psfResponse[0].peakResponseToNextScale;
+      } else
+        maxVal = absCoef;
+    }
+  }
+  if (maxValScale == -1) {
+    std::cout << "No significant pixel found.\n";
+    return false;
+  }
+
+  maxVal = iuwt[maxValScale][maxX + maxY * _width];
+  std::cout << "Most significant pixel: " << maxX << ',' << maxY << "="
+            << maxVal << " (" << maxVal / _rmses[maxValScale] << "σ) on scale "
+            << maxValScale << '\n';
+
+  if (std::fabs(maxVal) < thresholds[maxValScale]) {
+    std::cout << "Most significant pixel is in the noise, stopping.\n";
+    return false;
+  }
+
+  float scaleMaxAbsVal = std::fabs(maxVal);
+  for (size_t scale = 0; scale != curEndScale; ++scale) {
+    if (thresholds[scale] < _tolerance * scaleMaxAbsVal) {
+      thresholds[scale] = _tolerance * scaleMaxAbsVal;
+    }
+    if (maxVal < 0.0) thresholds[scale] = -thresholds[scale];
+  }
+
+  ImageAnalysis::Component maxComp(maxX, maxY, maxValScale);
+  return fillAndDeconvolveStructure(
+      iuwt, dirty, structureModel, scratch, psf, psfKernel, psfs, curEndScale,
+      curMinScale, _width, _height, thresholds, maxComp, true, _mask);
+}
+
+bool IUWTDeconvolutionAlgorithm::fillAndDeconvolveStructure(
+    IUWTDecomposition& iuwt, Image& dirty, ImageSet& structureModelFull,
+    Image& scratch, const Image& psf, const Image& psfKernel,
+    const std::vector<Image>& psfs, size_t curEndScale, size_t curMinScale,
+    size_t width, size_t height, const aocommon::UVector<float>& thresholds,
+    const ImageAnalysis::Component& maxComp, bool allowTrimming,
+    const bool* priorMask) {
+  IUWTMask mask(curEndScale, width, height);
+  size_t areaSize;
+  ImageAnalysis::SelectStructures(iuwt, mask, thresholds, curMinScale,
+                                  curEndScale, _cleanBorder, priorMask,
+                                  areaSize);
+  std::cout << "Flood-filled area contains " << areaSize
+            << " significant components.\n";
+
+  iuwt.ApplyMask(mask);
+  iuwt.Recompose(scratch, false);
+
+  // Find bounding box
+  size_t x1, y1, x2, y2;
+  boundingBox(x1, y1, x2, y2, scratch, width, height);
+  adjustBox(x1, y1, x2, y2, width, height, maxComp.scale + 1);
+  if (allowTrimming && ((x2 - x1) < width || (y2 - y1) < height)) {
+    _curBoxXStart = x1;
+    _curBoxXEnd = x2;
+    _curBoxYStart = y1;
+    _curBoxYEnd = y2;
+    std::cout << "Bounding box: (" << x1 << ',' << y1 << ")-(" << x2 << ','
+              << y2 << ")\n";
+    size_t newWidth = x2 - x1, newHeight = y2 - y1;
+    trim(dirty, dirty, x1, y1, x2, y2);
+    Image smallPSF;
+
+    trimPsf(smallPSF, psf, newWidth, newHeight);
+
+    Image smallPSFKernel(smallPSF.Width(), smallPSF.Height());
+    schaapcommon::fft::PrepareConvolutionKernel(
+        smallPSFKernel.Data(), smallPSF.Data(), newWidth, newHeight,
+        _staticFor->NThreads());
+
+    scratch = Image(dirty.Width(), dirty.Height());
+
+    int maxScale =
+        std::max(IUWTDecomposition::EndScale(std::min(x2 - x1, y2 - y1)),
+                 maxComp.scale + 1);
+    if (maxScale < int(curEndScale)) {
+      std::cout << "Bounding box too small for largest scale of " << curEndScale
+                << " -- ignoring scales>=" << maxScale
+                << " in this iteration.\n";
+      curEndScale = maxScale;
+    }
+    std::unique_ptr<IUWTDecomposition> trimmedIUWT(
+        iuwt.CreateTrimmed(curEndScale, x1, y1, x2, y2));
+
+    std::unique_ptr<ImageSet> trimmedStructureModel(
+        structureModelFull.Trim(x1, y1, x2, y2, width));
+
+    aocommon::UVector<bool> trimmedPriorMask;
+    bool* trimmedPriorMaskPtr;
+    if (priorMask == nullptr)
+      trimmedPriorMaskPtr = nullptr;
+    else {
+      trimmedPriorMask.resize(newWidth * newHeight);
+      trimmedPriorMaskPtr = trimmedPriorMask.data();
+      Image::TrimBox(trimmedPriorMaskPtr, x1, y1, newWidth, newHeight,
+                     priorMask, width, height);
+    }
+
+    ImageAnalysis::Component newMaxComp(maxComp.x - x1, maxComp.y - y1,
+                                        maxComp.scale);
+    bool result = fillAndDeconvolveStructure(
+        *trimmedIUWT, dirty, *trimmedStructureModel, scratch, smallPSF,
+        smallPSFKernel, psfs, curEndScale, curMinScale, x2 - x1, y2 - y1,
+        thresholds, newMaxComp, false, trimmedPriorMaskPtr);
+    for (size_t i = 0; i != structureModelFull.size(); ++i) {
+      std::copy_n((*trimmedStructureModel)[i].Data(), (y2 - y1) * (x2 - x1),
+                  scratch.Data());
+      untrim(scratch, width, height, x1, y1, x2, y2);
+      std::copy_n(scratch.Data(), width * height, structureModelFull.Data(i));
+    }
+
+    dirty = Image(scratch.Width(), scratch.Height());
+    _curBoxXStart = 0;
+    _curBoxXEnd = width;
+    _curBoxYStart = 0;
+    _curBoxYEnd = height;
+    return result;
+  } else {
+    if (curEndScale <= 3) {
+      // TODO: remove?
+      // bool pointSourcesWereFound = extractPointSources(iuwt, mask,
+      // dirty.Data(), structureModel.Data()); if(pointSourcesWereFound)
+      // return
+      // true;
+    }
+
+    // get undeconvolved dirty
+    iuwt.Decompose(*_staticFor, dirty.Data(), scratch.Data(), false);
+
+    iuwt.ApplyMask(mask);
+    iuwt.Recompose(scratch, false);
+
+    Image maskedDirty = scratch;
+
+    Image structureModel(width, height, 0.0);
+    bool success = runConjugateGradient(iuwt, mask, maskedDirty, structureModel,
+                                        scratch, psfKernel, width, height);
+    if (!success) return false;
+
+    float rmsBefore = rms(dirty);
+    scratch = structureModel;
+    schaapcommon::fft::Convolve(scratch.Data(), psfKernel.Data(), width, height,
+                                _staticFor->NThreads());
+    maskedDirty = dirty;  // we use maskedDirty as temporary
+    factorAdd(maskedDirty.Data(), scratch.Data(), -_gain, width, height);
+    float rmsAfter = rms(maskedDirty);
+    if (rmsAfter > rmsBefore) {
+      std::cout << "RMS got worse: " << rmsBefore << " -> " << rmsAfter << '\n';
+      return false;
+    }
+
+    // TODO when only one image is available, this is not necessary
+    performSubImageFitAll(iuwt, mask, structureModel, scratch, maskedDirty,
+                          maxComp, structureModelFull, psf, psfs, dirty);
+
+    return true;
+  }
+}
+
+void IUWTDeconvolutionAlgorithm::performSubImageFitAll(
+    IUWTDecomposition& iuwt, const IUWTMask& mask, const Image& structureModel,
+    Image& scratchA, Image& scratchB, const ImageAnalysis::Component& maxComp,
+    ImageSet& fittedModel, const Image& psf, const std::vector<Image>& psfs,
+    const Image& dirty) {
+  size_t width = iuwt.Width(), height = iuwt.Height();
+
+  if (_dirtySet->size() == 1) {
+    // With only one image, we don't have to refit
+    Image img(width, height);
+    std::copy_n(structureModel.Data(), width * height, img.begin());
+    fittedModel.SetImage(0, std::move(img));
+  } else {
+    std::cout << "Fitting structure in images: " << std::flush;
+    aocommon::UVector<float> correctionFactors;
+    scratchA = dirty;
+    performSubImageFitSingle(iuwt, mask, structureModel, scratchB, maxComp, psf,
+                             scratchA, nullptr, correctionFactors);
+
+    fittedModel = 0.0;
+
+    for (size_t imgIndex = 0; imgIndex != _dirtySet->size(); ++imgIndex) {
+      std::cout << '.' << std::flush;
+      const aocommon::Image& subPsf = psfs[_dirtySet->PSFIndex(imgIndex)];
+
+      trim(scratchA, (*_dirtySet)[imgIndex], _curBoxXStart, _curBoxYStart,
+           _curBoxXEnd, _curBoxYEnd);
+
+      Image smallSubPsf;
+      const Image* subPsfImage;
+      if (_width != width || _height != height) {
+        trimPsf(smallSubPsf, subPsf, width, height);
+        subPsfImage = &smallSubPsf;
+      } else {
+        subPsfImage = &subPsf;
+      }
+
+      performSubImageFitSingle(iuwt, mask, structureModel, scratchB, maxComp,
+                               *subPsfImage, scratchA,
+                               fittedModel.Data(imgIndex), correctionFactors);
+    }
+    std::cout << '\n';
+  }
+}
+
+void IUWTDeconvolutionAlgorithm::performSubImageFitSingle(
+    IUWTDecomposition& iuwt, const IUWTMask& mask, const Image& structureModel,
+    Image& scratchB, const ImageAnalysis::Component& maxComp, const Image& psf,
+    Image& subDirty, float* fittedSubModel,
+    aocommon::UVector<float>& correctionFactors) {
+  size_t width = iuwt.Width(), height = iuwt.Height();
+
+  Image psfKernel(width, height);
+  schaapcommon::fft::PrepareConvolutionKernel(
+      psfKernel.Data(), psf.Data(), width, height, _staticFor->NThreads());
+
+  Image& maskedDirty = scratchB;
+
+  iuwt.Decompose(*_staticFor, subDirty.Data(), subDirty.Data(), false);
+  iuwt.ApplyMask(mask);
+  iuwt.Recompose(maskedDirty, false);
+  aocommon::UVector<bool> mask2d(structureModel.Size(), false);
+  float peakLevel = std::fabs(structureModel[maxComp.y * width + maxComp.x]);
+  size_t componentIndex = 0;
+  for (size_t y = 0; y != height; ++y) {
+    bool* maskRow = &mask2d[y * width];
+    const float* modelRow = &structureModel[y * width];
+    for (size_t x = 0; x != width; ++x) {
+      if (!maskRow[x] && std::fabs(modelRow[x]) > peakLevel * 1e-4) {
+        std::vector<ImageAnalysis::Component2D> area;
+        ImageAnalysis::Component2D comp(x, y);
+        ImageAnalysis::FloodFill2D(structureModel.Data(), mask2d.data(),
+                                   peakLevel * 1e-4, comp, width, height, area);
+        // Find bounding box and copy active pixels to subDirty
+        subDirty = Image(width, height, 0.0);
+        size_t boxX1 = width, boxX2 = 0, boxY1 = height, boxY2 = 0;
+        for (std::vector<ImageAnalysis::Component2D>::const_iterator a =
+                 area.begin();
+             a != area.end(); ++a) {
+          size_t index = a->x + a->y * width;
+          boxX1 = std::min(a->x, boxX1);
+          boxX2 = std::max(a->x, boxX2);
+          boxY1 = std::min(a->y, boxY1);
+          boxY2 = std::max(a->y, boxY2);
+          subDirty[index] = structureModel[index];
+        }
+        adjustBox(boxX1, boxY1, boxX2, boxY2, width, height, iuwt.NScales());
+
+        float factor = performSubImageComponentFitBoxed(
+            iuwt, mask, area, subDirty, maskedDirty, psf, psfKernel, boxX1,
+            boxY1, boxX2, boxY2);
+
+        // if no fittedSubModel was given, we just need to store the factors.
+        // Otherwise, scale the deconvolved model and add it to the contaminated
+        // model.
+        if (fittedSubModel != nullptr) {
+          const float integratedFactor = correctionFactors[componentIndex];
+          if (std::isfinite(factor) && std::isfinite(integratedFactor) &&
+              integratedFactor != 0.0) {
+            for (std::vector<ImageAnalysis::Component2D>::const_iterator a =
+                     area.begin();
+                 a != area.end(); ++a) {
+              size_t index = a->x + a->y * width;
+              fittedSubModel[index] +=
+                  structureModel[index] * factor / integratedFactor;
+            }
+          }
+          ++componentIndex;
+        } else {
+          correctionFactors.push_back(factor);
+        }
+      }
+    }
+  }
+}
+
+float IUWTDeconvolutionAlgorithm::performSubImageComponentFitBoxed(
+    IUWTDecomposition& iuwt, const IUWTMask& mask,
+    const std::vector<ImageAnalysis::Component2D>& area, Image& model,
+    Image& maskedDirty, const Image& psf, const Image& psfKernel, size_t x1,
+    size_t y1, size_t x2, size_t y2) {
+  const size_t width = iuwt.Width();
+  const size_t height = iuwt.Height();
+  if (x1 > 0 || y1 > 0 || x2 < width || y2 < height) {
+    size_t newWidth = x2 - x1, newHeight = y2 - y1;
+    IUWTDecomposition smallIUWTW(iuwt.NScales(), newWidth, newHeight);
+    std::unique_ptr<IUWTMask> smallMask(mask.CreateTrimmed(x1, y1, x2, y2));
+    Image smallModel;
+    trim(smallModel, model, x1, y1, x2, y2);
+
+    Image smallPsf;
+    trimPsf(smallPsf, psf, newWidth, newHeight);
+    Image smallPsfKernel(smallPsf.Width(), smallPsf.Height());
+    schaapcommon::fft::PrepareConvolutionKernel(
+        smallPsfKernel.Data(), smallPsf.Data(), newWidth, newHeight,
+        _staticFor->NThreads());
+
+    Image smallMaskedDirty;
+    trim(smallMaskedDirty, maskedDirty, x1, y1, x2, y2);
+
+    float factor =
+        performSubImageComponentFit(smallIUWTW, *smallMask, area, smallModel,
+                                    smallMaskedDirty, smallPsfKernel, x1, y1);
+    return factor;
+  } else {
+    return performSubImageComponentFit(iuwt, mask, area, model, maskedDirty,
+                                       psfKernel, 0, 0);
+  }
+}
+
+float IUWTDeconvolutionAlgorithm::performSubImageComponentFit(
+    IUWTDecomposition& iuwt, const IUWTMask& mask,
+    const std::vector<ImageAnalysis::Component2D>& area, Image& model,
+    Image& maskedDirty, const Image& psfKernel, size_t xOffset,
+    size_t yOffset) {
+  const size_t width = iuwt.Width(), height = iuwt.Height();
+  // Calculate IUWT^-1 mask IUWT model (x) PSF
+  schaapcommon::fft::Convolve(model.Data(), psfKernel.Data(), width, height,
+                              _staticFor->NThreads());
+  iuwt.Decompose(*_staticFor, model.Data(), model.Data(), false);
+  iuwt.ApplyMask(mask);
+  iuwt.Recompose(model, false);
+
+  float modelSum = 0.0, dirtySum = 0.0;
+  for (std::vector<ImageAnalysis::Component2D>::const_iterator a = area.begin();
+       a != area.end(); ++a) {
+    size_t index = (a->x - xOffset) + (a->y - yOffset) * width;
+    modelSum += model[index];
+    dirtySum += maskedDirty[index];
+  }
+  // std::cout << "factor=" << dirtySum << " / " << modelSum << " = " <<
+  // dirtySum/modelSum << '\n';
+  if (modelSum == 0.0 || !std::isfinite(dirtySum) || !std::isfinite(modelSum))
+    return 0.0;
+  else
+    return dirtySum / modelSum;
+}
+
+float IUWTDeconvolutionAlgorithm::PerformMajorIteration(
+    size_t& iterCounter, size_t nIter, ImageSet& modelSet, ImageSet& dirtySet,
+    const std::vector<aocommon::Image>& psfs, bool& reachedMajorThreshold) {
+  aocommon::StaticFor<size_t> static_for(aocommon::system::ProcessorCount());
+  _staticFor = &static_for;
+
+  reachedMajorThreshold = false;
+  if (iterCounter == nIter) return 0.0;
+
+  _modelSet = &modelSet;
+  _dirtySet = &dirtySet;
+
+  _curBoxXStart = 0;
+  _curBoxXEnd = _width;
+  _curBoxYStart = 0;
+  _curBoxYEnd = _height;
+
+  Image dirty(_width, _height);
+  dirtySet.GetLinearIntegrated(dirty);
+  Image psf(_width, _height);
+  dirtySet.GetIntegratedPSF(psf, psfs);
+
+  int maxScale = IUWTDecomposition::EndScale(std::min(_width, _height));
+  int curEndScale = 2;
+
+  // Prepare the PSF for convolutions later on
+  Image psfKernel(_width, _height);
+  schaapcommon::fft::PrepareConvolutionKernel(
+      psfKernel.Data(), psf.Data(), _width, _height, static_for.NThreads());
+
+  std::cout << "Measuring PSF...\n";
+  {
+    Image convolvedPSF(psf);
+    Image scratch(_width, _height);
+
+    schaapcommon::fft::Convolve(convolvedPSF.Data(), psfKernel.Data(), _width,
+                                _height, static_for.NThreads());
+    measureRMSPerScale(psf.Data(), convolvedPSF.Data(), scratch.Data(),
+                       maxScale, _psfResponse);
+  }
+
+  ImageSet structureModel(modelSet, _width, _height);
+
+  auto iuwt = std::make_unique<IUWTDecomposition>(curEndScale, _width, _height);
+
+  Image dirtyBeforeIteration;
+
+  float maxValue = 0.0;
+  size_t curMinScale = 0;
+  reachedMajorThreshold = false;
+  bool doContinue = true;
+  std::vector<ValComponent> initialComponents;
+  do {
+    std::cout << "*** Deconvolution iteration " << iterCounter << " ***\n";
+    dirtyBeforeIteration = dirty;
+    schaapcommon::fft::PrepareConvolutionKernel(
+        psfKernel.Data(), psf.Data(), _width, _height, static_for.NThreads());
+    std::vector<ValComponent> maxComponents;
+    Image scratch(_width, _height);
+    bool succeeded = findAndDeconvolveStructure(
+        *iuwt, dirty, psf, psfKernel, psfs, scratch, structureModel,
+        curEndScale, curMinScale, maxComponents);
+
+    if (succeeded) {
+      structureModel *= _gain;
+      modelSet += structureModel;
+
+      // Calculate: dirty = dirty - structureModel (x) psf
+      for (size_t i = 0; i != dirtySet.size(); ++i) {
+        scratch = structureModel[i];
+        size_t psfIndex = dirtySet.PSFIndex(i);
+        schaapcommon::fft::PrepareConvolutionKernel(
+            psfKernel.Data(), psfs[psfIndex].Data(), _width, _height,
+            static_for.NThreads());
+        schaapcommon::fft::Convolve(scratch.Data(), psfKernel.Data(), _width,
+                                    _height, static_for.NThreads());
+        Subtract(dirtySet.Data(i), scratch);
+      }
+      dirtySet.GetLinearIntegrated(dirty);
+
+      while (maxComponents.size() > initialComponents.size()) {
+        initialComponents.push_back(maxComponents[initialComponents.size()]);
+      }
+      maxValue = 0.0;
+      for (size_t c = 0; c != initialComponents.size(); ++c) {
+        std::cout << initialComponents[c].val << " now " << maxComponents[c].val
+                  << '\n';
+        maxValue = std::max(maxValue, maxComponents[c].val);
+        if (std::fabs(maxComponents[c].val) <
+            std::fabs(initialComponents[c].val) * (1.0 - _mGain)) {
+          std::cout << "Scale " << c << " reached mGain (starting level: "
+                    << initialComponents[c].val
+                    << ", now: " << maxComponents[c].val << ").\n";
+          reachedMajorThreshold = true;
+        }
+      }
+      if (reachedMajorThreshold) break;
+    } else {
+      if (int(curMinScale) + 1 < curEndScale) {
+        ++curMinScale;
+        std::cout << "=> Min scale now " << curMinScale << '\n';
+      } else {
+        curMinScale = 0;
+        if (curEndScale != maxScale) {
+          ++curEndScale;
+          std::cout << "=> Scale now " << curEndScale << ".\n";
+          iuwt.reset(new IUWTDecomposition(curEndScale, _width, _height));
+        } else {
+          std::cout << "Max scale reached: finished all scales, quiting.\n";
+          doContinue = false;
+        }
+      }
+      dirty = dirtyBeforeIteration;
+    }
+
+    ++iterCounter;
+  } while (iterCounter != nIter && doContinue);
+  return maxValue;
+}
+}  // namespace radler::algorithms
\ No newline at end of file
diff --git a/cpp/algorithms/iuwt_deconvolution_algorithm.h b/cpp/algorithms/iuwt_deconvolution_algorithm.h
new file mode 100644
index 00000000..15e5dbb7
--- /dev/null
+++ b/cpp/algorithms/iuwt_deconvolution_algorithm.h
@@ -0,0 +1,194 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_ALGORITHMS_IUWT_DECONVOLUTION_ALGORITHM_H_
+#define RADLER_ALGORITHMS_IUWT_DECONVOLUTION_ALGORITHM_H_
+
+#include <vector>
+
+#include <aocommon/fits/fitswriter.h>
+#include <aocommon/image.h>
+#include <aocommon/staticfor.h>
+#include <aocommon/uvector.h>
+
+#include "image_set.h"
+#include "algorithms/iuwt/image_analysis.h"
+#include "algorithms/iuwt/iuwt_decomposition.h"
+
+namespace radler::algorithms {
+
+class IUWTDeconvolutionAlgorithm {
+ public:
+  IUWTDeconvolutionAlgorithm(size_t width, size_t height, float gain,
+                             float mGain, float cleanBorder,
+                             bool allowNegativeComponents, const bool* mask,
+                             float absoluteThreshold,
+                             float thresholdSigmaLevel = 4.0,
+                             float tolerance = 0.75, bool useSNRTest = true);
+
+  float PerformMajorIteration(size_t& iterCounter, size_t nIter,
+                              ImageSet& modelSet, ImageSet& dirtySet,
+                              const std::vector<aocommon::Image>& psfs,
+                              bool& reachedMajorThreshold);
+
+  void Subtract(float* dest, const aocommon::Image& rhs);
+  void Subtract(aocommon::Image& dest, const aocommon::Image& rhs) {
+    Subtract(dest.Data(), rhs);
+  }
+
+ private:
+  struct ValComponent {
+    ValComponent() {}
+    ValComponent(size_t _x, size_t _y, int _scale, float _val = 0.0)
+        : x(_x), y(_y), scale(_scale), val(_val) {}
+
+    std::string ToString() const {
+      std::ostringstream str;
+      str << x << ',' << y << ", scale " << scale;
+      return str.str();
+    }
+
+    size_t x, y;
+    int scale;
+    float val;
+  };
+
+  struct ScaleResponse {
+    float rms, peakResponse, peakResponseToNextScale, convolvedPeakResponse;
+    double bMaj, bMin, bPA;
+    size_t convolvedArea;
+  };
+
+  float getMaxAbsWithoutMask(const aocommon::Image& data, size_t& x, size_t& y,
+                             size_t width);
+  float getMaxAbsWithMask(const aocommon::Image& data, size_t& x, size_t& y,
+                          size_t width);
+  float getMaxAbs(const aocommon::Image& data, size_t& x, size_t& y,
+                  size_t width) {
+    if (_mask == nullptr)
+      return getMaxAbsWithoutMask(data, x, y, width);
+    else
+      return getMaxAbsWithMask(data, x, y, width);
+  }
+
+  void measureRMSPerScale(const float* image, const float* convolvedImage,
+                          float* scratch, size_t endScale,
+                          std::vector<ScaleResponse>& psfResponse);
+
+  float mad(const float* dest);
+
+  float dotProduct(const aocommon::Image& lhs, const aocommon::Image& rhs);
+
+  void factorAdd(float* dest, const float* rhs, float factor, size_t width,
+                 size_t height);
+
+  void factorAdd(aocommon::Image& dest, const aocommon::Image& rhs,
+                 float factor);
+
+  void boundingBox(size_t& x1, size_t& y1, size_t& x2, size_t& y2,
+                   const aocommon::Image& image, size_t width, size_t height);
+
+  void adjustBox(size_t& x1, size_t& y1, size_t& x2, size_t& y2, size_t width,
+                 size_t height, int endScale);
+
+  void trim(aocommon::Image& dest, const float* source, size_t oldWidth,
+            size_t x1, size_t y1, size_t x2, size_t y2);
+
+  void trim(aocommon::Image& dest, const aocommon::Image& source, size_t x1,
+            size_t y1, size_t x2, size_t y2) {
+    trim(dest, source.Data(), source.Width(), x1, y1, x2, y2);
+  }
+
+  void trimPsf(aocommon::Image& dest, const aocommon::Image& source,
+               size_t newWidth, size_t newHeight) {
+    const size_t oldWidth = source.Width();
+    const size_t oldHeight = source.Height();
+    trim(dest, source, (oldWidth - newWidth) / 2, (oldHeight - newHeight) / 2,
+         (oldWidth + newWidth) / 2, (oldHeight + newHeight) / 2);
+  }
+
+  void untrim(aocommon::Image& image, size_t width, size_t height, size_t x1,
+              size_t y1, size_t x2, size_t y2);
+
+  float sum(const aocommon::Image& img) const;
+
+  float snr(const iuwt::IUWTDecomposition& noisyImg,
+            const iuwt::IUWTDecomposition& model) const;
+
+  float rmsDiff(const aocommon::Image& a, const aocommon::Image& b);
+
+  float rms(const aocommon::Image& image);
+
+  bool runConjugateGradient(iuwt::IUWTDecomposition& iuwt,
+                            const iuwt::IUWTMask& mask,
+                            aocommon::Image& maskedDirty,
+                            aocommon::Image& structureModel,
+                            aocommon::Image& scratch,
+                            const aocommon::Image& psfKernel, size_t width,
+                            size_t height);
+
+  bool fillAndDeconvolveStructure(
+      iuwt::IUWTDecomposition& iuwt, aocommon::Image& dirty,
+      ImageSet& structureModelFull, aocommon::Image& scratch,
+      const aocommon::Image& psf, const aocommon::Image& psfKernel,
+      const std::vector<aocommon::Image>& psfs, size_t curEndScale,
+      size_t curMinScale, size_t width, size_t height,
+      const aocommon::UVector<float>& thresholds,
+      const iuwt::ImageAnalysis::Component& maxComp, bool allowTrimming,
+      const bool* priorMask);
+
+  bool findAndDeconvolveStructure(
+      iuwt::IUWTDecomposition& iuwt, aocommon::Image& dirty,
+      const aocommon::Image& psf, const aocommon::Image& psfKernel,
+      const std::vector<aocommon::Image>& psfs, aocommon::Image& scratch,
+      ImageSet& structureModelFull, size_t curEndScale, size_t curMinScale,
+      std::vector<ValComponent>& maxComponents);
+
+  void performSubImageFitAll(
+      iuwt::IUWTDecomposition& iuwt, const iuwt::IUWTMask& mask,
+      const aocommon::Image& structureModel, aocommon::Image& scratchA,
+      aocommon::Image& scratchB, const iuwt::ImageAnalysis::Component& maxComp,
+      ImageSet& fittedModel, const aocommon::Image& psf,
+      const std::vector<aocommon::Image>& psfs, const aocommon::Image& dirty);
+
+  void performSubImageFitSingle(
+      iuwt::IUWTDecomposition& iuwt, const iuwt::IUWTMask& mask,
+      const aocommon::Image& structureModel, aocommon::Image& scratchB,
+      const iuwt::ImageAnalysis::Component& maxComp, const aocommon::Image& psf,
+      aocommon::Image& subDirty, float* fittedSubModel,
+      aocommon::UVector<float>& correctionFactors);
+
+  float performSubImageComponentFitBoxed(
+      iuwt::IUWTDecomposition& iuwt, const iuwt::IUWTMask& mask,
+      const std::vector<iuwt::ImageAnalysis::Component2D>& area,
+      aocommon::Image& scratch, aocommon::Image& maskedDirty,
+      const aocommon::Image& psf, const aocommon::Image& psfKernel, size_t x1,
+      size_t y1, size_t x2, size_t y2);
+
+  float performSubImageComponentFit(
+      iuwt::IUWTDecomposition& iuwt, const iuwt::IUWTMask& mask,
+      const std::vector<iuwt::ImageAnalysis::Component2D>& area,
+      aocommon::Image& scratch, aocommon::Image& maskedDirty,
+      const aocommon::Image& psfKernel, size_t xOffset, size_t yOffset);
+
+  float centralPeak(const aocommon::Image& data) {
+    return data[_width / 2 + (_height / 2) * _width];
+  }
+
+  size_t _width, _height;
+  size_t _curBoxXStart, _curBoxXEnd;
+  size_t _curBoxYStart, _curBoxYEnd;
+  float _gain, _mGain, _cleanBorder;
+  const bool* _mask;
+  float _absoluteThreshold, _thresholdSigmaLevel, _tolerance;
+  double _psfMaj, _psfMin, _psfPA, _psfVolume;
+  aocommon::UVector<float> _rmses;
+  aocommon::FitsWriter _writer;
+  std::vector<ScaleResponse> _psfResponse;
+  bool _allowNegativeComponents, _useSNRTest;
+  ImageSet* _modelSet;
+  ImageSet* _dirtySet;
+  aocommon::StaticFor<size_t>* _staticFor;
+};
+}  // namespace radler::algorithms
+#endif  // RADLER_ALGORITHMS_IUWT_DECONVOLUTION_ALGORITHM_H_
diff --git a/cpp/algorithms/ls_deconvolution.cc b/cpp/algorithms/ls_deconvolution.cc
new file mode 100644
index 00000000..5ef4060d
--- /dev/null
+++ b/cpp/algorithms/ls_deconvolution.cc
@@ -0,0 +1,315 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "algorithms/ls_deconvolution.h"
+
+#include <gsl/gsl_vector.h>
+#include <gsl/gsl_multifit_nlin.h>
+#include <gsl/gsl_multifit.h>
+
+#include <aocommon/logger.h>
+
+#define OUTPUT_LSD_DEBUG_INFO 1
+
+using aocommon::Logger;
+
+namespace radler::algorithms {
+
+struct LSDeconvolutionData {
+  LSDeconvolution* parent;
+  gsl_multifit_fdfsolver* solver;
+  aocommon::UVector<std::pair<size_t, size_t>> maskPositions;
+  size_t width, height;
+  double regularization;
+  const double* dirty;
+  const double* psf;
+
+  static int fitting_func(const gsl_vector* xvec, void* data, gsl_vector* f) {
+    const LSDeconvolutionData& lsData =
+        *reinterpret_cast<LSDeconvolutionData*>(data);
+
+    size_t i = 0, midX = lsData.width + (lsData.width / 2),
+           midY = lsData.height + (lsData.height / 2);
+#ifdef OUTPUT_LSD_DEBUG_INFO
+    double rmsSum = 0.0, cost = 0.0, peakFlux = 0.0;
+#endif
+    // e = (y - sum modelledflux)^2 + mu modelledflux
+    for (size_t y = 0; y != lsData.height; ++y) {
+      for (size_t x = 0; x != lsData.width; ++x) {
+        double modelledFlux = 0.0;
+        for (size_t p = 0; p != lsData.maskPositions.size(); ++p) {
+          int pX = lsData.maskPositions[p].first,
+              pY = lsData.maskPositions[p].second;
+          double pVal = gsl_vector_get(xvec, p);
+          int psfX = (x + midX - pX) % lsData.width;
+          int psfY = (y + midY - pY) % lsData.height;
+          modelledFlux += lsData.psf[psfX + psfY * lsData.width] * pVal;
+        }
+#ifdef OUTPUT_LSD_DEBUG_INFO
+        double pixelCost =
+            (lsData.dirty[i] - modelledFlux) * (lsData.dirty[i] - modelledFlux);
+        rmsSum += pixelCost;
+        cost += pixelCost;
+#endif
+
+        gsl_vector_set(f, i, lsData.dirty[i] - modelledFlux);
+        ++i;
+      }
+    }
+    double totalModelledFlux = 0.0;
+    for (size_t p = 0; p != lsData.maskPositions.size(); ++p) {
+      totalModelledFlux += std::fabs(gsl_vector_get(xvec, p));
+#ifdef OUTPUT_LSD_DEBUG_INFO
+      cost += std::fabs(gsl_vector_get(xvec, p)) * lsData.regularization;
+      peakFlux = std::max(peakFlux, gsl_vector_get(xvec, p));
+#endif
+    }
+    // gsl_vector_set(f, lsData.width*lsData.height,
+    // sqrt(lsData.regularization*totalModelledFlux));
+    gsl_vector_set(f, lsData.width * lsData.height,
+                   lsData.regularization * totalModelledFlux);
+
+#ifdef OUTPUT_LSD_DEBUG_INFO
+    Logger::Debug << "Current RMS="
+                  << sqrt(rmsSum / (lsData.height * lsData.width))
+                  << ", mean flux in model="
+                  << totalModelledFlux / lsData.maskPositions.size()
+                  << ", peak=" << peakFlux << ", total cost=" << cost << '\n';
+#endif
+
+    return GSL_SUCCESS;
+  }
+
+  static int fitting_func_deriv(const gsl_vector* xvec, void* data,
+                                gsl_matrix* J) {
+#ifdef OUTPUT_LSD_DEBUG_INFO
+    // Logger::Info << "Calculating Jacobian... " << std::flush;
+#endif
+    const LSDeconvolutionData& lsData =
+        *reinterpret_cast<LSDeconvolutionData*>(data);
+
+    size_t i = 0, midX = lsData.width + (lsData.width / 2),
+           midY = lsData.height + (lsData.height / 2);
+    for (size_t y = 0; y != lsData.height; ++y) {
+      for (size_t x = 0; x != lsData.width; ++x) {
+        for (size_t p = 0; p != lsData.maskPositions.size(); ++p) {
+          int pX = lsData.maskPositions[p].first,
+              pY = lsData.maskPositions[p].second;
+          // double pVal = gsl_vector_get(xvec, p);
+          int psfX = (x + midX - pX) % lsData.width;
+          int psfY = (y + midY - pY) % lsData.height;
+
+          gsl_matrix_set(J, i, p, -lsData.psf[psfX + psfY * lsData.width]);
+        }
+        ++i;
+      }
+    }
+    for (size_t p = 0; p != lsData.maskPositions.size(); ++p) {
+      // f = sqrt | pval |
+      //   = sqrt sqrt(pval^2)
+      // f'= 2pval * 0.5/sqrt(pval^2) * 0.5/sqrt(sqrt(pval^2))
+      // f'= pval/( 2.0 * |pval| * sqrt(|pval|) )
+      double dpval = gsl_vector_get(xvec, p);
+      /*if(dpval < 0.0)
+              dpval = -0.5/sqrt(-dpval);
+      else if(dpval > 0.0)
+              dpval = 0.5/sqrt(dpval);
+      else
+              dpval = 0.0;*/
+      if (dpval < 0.0) dpval = -dpval;
+      // else if(dpval > 0.0)
+      //	dpval = dpval;
+      // else
+      //	dpval = 0.0;
+      gsl_matrix_set(J, lsData.width * lsData.height, p,
+                     dpval * lsData.regularization);
+    }
+#ifdef OUTPUT_LSD_DEBUG_INFO
+    // Logger::Info << "DONE\n";
+#endif
+    return GSL_SUCCESS;
+  }
+
+  static int fitting_func_both(const gsl_vector* x, void* data, gsl_vector* f,
+                               gsl_matrix* J) {
+    fitting_func(x, data, f);
+    fitting_func_deriv(x, data, J);
+    return GSL_SUCCESS;
+  }
+};
+
+LSDeconvolution::LSDeconvolution() : _data(new LSDeconvolutionData()) {}
+
+LSDeconvolution::LSDeconvolution(const LSDeconvolution& source)
+    : DeconvolutionAlgorithm(), _data(new LSDeconvolutionData(*source._data)) {}
+
+LSDeconvolution::~LSDeconvolution() {}
+
+void LSDeconvolution::getMaskPositions(
+    aocommon::UVector<std::pair<size_t, size_t>>& maskPositions,
+    const bool* mask, size_t width, size_t height) {
+  const bool* maskPtr = mask;
+  for (size_t y = 0; y != height; ++y) {
+    for (size_t x = 0; x != width; ++x) {
+      if (*maskPtr) {
+        maskPositions.push_back(std::make_pair(x, y));
+      }
+      ++maskPtr;
+    }
+  }
+}
+
+void LSDeconvolution::linearFit(double* dataImage, double* modelImage,
+                                const double* psfImage, size_t width,
+                                size_t height,
+                                bool& /*reachedMajorThreshold*/) {
+  aocommon::UVector<std::pair<size_t, size_t>> maskPositions;
+  getMaskPositions(maskPositions, _cleanMask, width, height);
+  Logger::Info << "Running LSDeconvolution with " << maskPositions.size()
+               << " parameters.\n";
+
+  // y = X c
+  //   - y is vector of N,     N=number of data points (pixels in image)
+  //     y_i = pixel value i
+  //   - x is vector of N x M, M=number of parameters (pixels in mask)
+  //     x_ij = (pixel value i) * (psf value j)
+
+  size_t n = width * height;
+  size_t pn = maskPositions.size();
+
+  gsl_matrix* x = gsl_matrix_calloc(n, pn);
+  gsl_vector* y = gsl_vector_alloc(n);
+  gsl_vector* c = gsl_vector_calloc(pn);
+  gsl_matrix* cov = gsl_matrix_alloc(pn, pn);
+  double chisq;
+  gsl_multifit_linear_workspace* work = gsl_multifit_linear_alloc(n, pn);
+
+  for (size_t i = 0; i != n; ++i) gsl_vector_set(y, i, dataImage[i]);
+
+  size_t i = 0, midX = width + (width / 2), midY = height + (height / 2);
+  for (size_t yi = 0; yi != height; ++yi) {
+    for (size_t xi = 0; xi != width; ++xi) {
+      for (size_t p = 0; p != pn; ++p) {
+        int pX = maskPositions[p].first, pY = maskPositions[p].second;
+        int psfX = (xi + midX - pX) % width;
+        int psfY = (yi + midY - pY) % height;
+
+        gsl_matrix_set(x, i, p, psfImage[psfX + psfY * width]);
+      }
+      ++i;
+    }
+  }
+
+  Logger::Info << "psf(0,0) = "
+               << psfImage[midX % width + (midY % height) * width] << "\n";
+  Logger::Info << "Fitting... ";
+  Logger::Info.Flush();
+  int result = gsl_multifit_linear(x, y, c, cov, &chisq, work);
+  Logger::Info << "result=" << gsl_strerror(result) << "\n";
+  gsl_multifit_linear_free(work);
+
+  for (size_t i = 0; i != n; ++n) modelImage[i] = 0.0;
+
+  for (size_t p = 0; p != pn; ++p) {
+    size_t pX = maskPositions[p].first, pY = maskPositions[p].second;
+    modelImage[pY * width + pX] = gsl_vector_get(c, p);
+  }
+
+  for (size_t y = 0; y != height; ++y) {
+    size_t index = y * width;
+    for (size_t x = 0; x != width; ++x) {
+      double val = dataImage[index];
+      for (size_t p = 0; p != pn; ++p) {
+        int pX = maskPositions[p].first, pY = maskPositions[p].second;
+        double pVal = gsl_vector_get(c, p);
+        int psfX = (x + midX - pX) % width;
+        int psfY = (y + midY - pY) % height;
+        val -= psfImage[psfX + psfY * width] * pVal;
+      }
+      dataImage[index] = val;
+      ++index;
+    }
+  }
+
+  gsl_matrix_free(x);
+  gsl_vector_free(y);
+  gsl_vector_free(c);
+  gsl_matrix_free(cov);
+}
+
+void LSDeconvolution::nonLinearFit(double* dataImage, double* modelImage,
+                                   const double* psfImage, size_t width,
+                                   size_t height,
+                                   bool& /*reachedMajorThreshold*/) {
+  if (this->_cleanMask == 0) throw std::runtime_error("No mask available");
+
+  getMaskPositions(_data->maskPositions, _cleanMask, width, height);
+  size_t parameterCount = _data->maskPositions.size(),
+         dataCount = width * height + 1;
+  Logger::Info << "Running LSDeconvolution with " << parameterCount
+               << " parameters.\n";
+
+  const gsl_multifit_fdfsolver_type* T = gsl_multifit_fdfsolver_lmsder;
+  _data->solver = gsl_multifit_fdfsolver_alloc(T, dataCount, parameterCount);
+  _data->dirty = dataImage;
+  _data->psf = psfImage;
+  _data->width = width;
+  _data->height = height;
+  _data->parent = this;
+  _data->regularization = 0.1;
+
+  gsl_multifit_function_fdf fdf;
+  fdf.f = &LSDeconvolutionData::fitting_func;
+  fdf.df = &LSDeconvolutionData::fitting_func_deriv;
+  fdf.fdf = &LSDeconvolutionData::fitting_func_both;
+  fdf.n = dataCount;
+  fdf.p = parameterCount;
+  fdf.params = &*_data;
+
+  aocommon::UVector<double> parameterArray(parameterCount, 0.0);
+  gsl_vector_view initialVals =
+      gsl_vector_view_array(parameterArray.data(), parameterCount);
+  gsl_multifit_fdfsolver_set(_data->solver, &fdf, &initialVals.vector);
+
+  int status;
+  size_t iter = 0;
+  do {
+    iter++;
+    status = gsl_multifit_fdfsolver_iterate(_data->solver);
+
+    if (status) break;
+
+    status = gsl_multifit_test_delta(_data->solver->dx, _data->solver->x, 1e-4,
+                                     1e-4);
+
+  } while (status == GSL_CONTINUE && iter < 100);
+  Logger::Info << "niter=" << iter << ", status=" << gsl_strerror(status)
+               << "\n";
+
+  for (size_t p = 0; p != parameterCount; ++p) {
+    size_t pX = _data->maskPositions[p].first,
+           pY = _data->maskPositions[p].second;
+    modelImage[pY * width + pX] = gsl_vector_get(_data->solver->x, p);
+  }
+
+  size_t midX = width + (width / 2), midY = height + (height / 2);
+  for (size_t y = 0; y != height; ++y) {
+    size_t index = y * width;
+    for (size_t x = 0; x != width; ++x) {
+      double val = dataImage[index];
+      for (size_t p = 0; p != parameterCount; ++p) {
+        int pX = _data->maskPositions[p].first,
+            pY = _data->maskPositions[p].second;
+        double pVal = gsl_vector_get(_data->solver->x, p);
+        int psfX = (x + midX - pX) % width;
+        int psfY = (y + midY - pY) % height;
+        val -= psfImage[psfX + psfY * width] * pVal;
+      }
+      dataImage[index] = val;
+      ++index;
+    }
+  }
+
+  gsl_multifit_fdfsolver_free(_data->solver);
+}
+}  // namespace radler::algorithms
\ No newline at end of file
diff --git a/cpp/algorithms/ls_deconvolution.h b/cpp/algorithms/ls_deconvolution.h
new file mode 100644
index 00000000..cf3ca13f
--- /dev/null
+++ b/cpp/algorithms/ls_deconvolution.h
@@ -0,0 +1,63 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_ALGORITHMS_LSDECONVOLUTION_H_
+#define RADLER_ALGORITHMS_LSDECONVOLUTION_H_
+
+#include <memory>
+#include <string>
+
+#include <aocommon/uvector.h>
+
+#include "image_set.h"
+#include "algorithms/deconvolution_algorithm.h"
+
+// TODO: LSDeconvolution algorithm is currently in
+// a somewhat experimental stage and is not even compiled.
+
+namespace radler::algorithms {
+struct LSDeconvolutionData;
+
+class LSDeconvolution : public DeconvolutionAlgorithm {
+ public:
+  LSDeconvolution();
+  ~LSDeconvolution();
+
+  LSDeconvolution(const LSDeconvolution& source);
+
+  float ExecuteMajorIteration(ImageSet& dataImage, ImageSet& modelImage,
+                              const std::vector<aocommon::Image>& psfImages,
+                              bool& reachedMajorThreshold) final override {
+    ExecuteMajorIteration(dataImage[0], modelImage[0], psfImages[0],
+                          dataImage.Width(), dataImage.Height(),
+                          reachedMajorThreshold);
+    return 0.0;
+  }
+
+  std::unique_ptr<DeconvolutionAlgorithm> Clone() const final override {
+    return std::make_unique<LSDeconvolution>(*this);
+  }
+
+  void ExecuteMajorIteration(double* dataImage, double* modelImage,
+                             const double* psfImage, size_t width,
+                             size_t height, bool& reachedMajorThreshold) {
+    nonLinearFit(dataImage, modelImage, psfImage, width, height,
+                 reachedMajorThreshold);
+  }
+
+ private:
+  void getMaskPositions(
+      aocommon::UVector<std::pair<size_t, size_t>>& maskPositions,
+      const bool* mask, size_t width, size_t height);
+
+  void linearFit(double* dataImage, double* modelImage, const double* psfImage,
+                 size_t width, size_t height, bool& reachedMajorThreshold);
+
+  void nonLinearFit(double* dataImage, double* modelImage,
+                    const double* psfImage, size_t width, size_t height,
+                    bool& reachedMajorThreshold);
+
+  std::unique_ptr<LSDeconvolutionData> _data;
+};
+}  // namespace radler::algorithms
+#endif
diff --git a/cpp/algorithms/more_sane.cc b/cpp/algorithms/more_sane.cc
new file mode 100644
index 00000000..ca85b176
--- /dev/null
+++ b/cpp/algorithms/more_sane.cc
@@ -0,0 +1,87 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "algorithms/more_sane.h"
+
+#include <aocommon/image.h>
+#include <aocommon/fits/fitsreader.h>
+#include <aocommon/fits/fitswriter.h>
+#include <aocommon/logger.h>
+
+#include <schaapcommon/fft/convolution.h>
+
+#include "utils/application.h"
+
+namespace radler::algorithms {
+void MoreSane::ExecuteMajorIteration(float* residualData, float* modelData,
+                                     const aocommon::Image& psfImage) {
+  const size_t width = psfImage.Width();
+  const size_t height = psfImage.Height();
+  if (_iterationNumber != 0) {
+    aocommon::Logger::Info << "Convolving model with psf...\n";
+    aocommon::Image preparedPsf(width, height);
+    schaapcommon::fft::PrepareConvolutionKernel(
+        preparedPsf.Data(), psfImage.Data(), width, height, _threadCount);
+    schaapcommon::fft::Convolve(modelData, preparedPsf.Data(), width, height,
+                                _threadCount);
+    aocommon::Logger::Info << "Adding model back to residual...\n";
+    for (size_t i = 0; i != width * height; ++i)
+      residualData[i] += modelData[i];
+  }
+  std::ostringstream outputStr;
+  outputStr << _prefixName << "-tmp-moresaneoutput" << _iterationNumber;
+  const std::string dirtyName(_prefixName + "-tmp-moresaneinput-dirty.fits"),
+      psfName(_prefixName + "-tmp-moresaneinput-psf.fits"),
+      maskName(_prefixName + "-tmp-moresaneinput-mask.fits"),
+      outputName(outputStr.str());
+  aocommon::FitsWriter writer;
+  writer.SetImageDimensions(width, height);
+  if (_cleanMask != nullptr) writer.WriteMask(maskName, _cleanMask);
+  writer.Write(dirtyName, residualData);
+  writer.Write(psfName, psfImage.Data());
+
+  std::ostringstream commandLine;
+  commandLine << "time python \"" << _moresaneLocation << "\" ";
+  if (!_allowNegativeComponents) commandLine << "-ep ";
+  if (_cleanMask != nullptr) commandLine << "-m \"" << maskName + "\" ";
+  if (!_moresaneArguments.empty()) commandLine << _moresaneArguments << ' ';
+  commandLine << "\"" << dirtyName << "\" \"" << psfName << "\" \""
+              << outputName << '\"';
+  if (!_moresaneSigmaLevels.empty()) {
+    commandLine << " -sl "
+                << _moresaneSigmaLevels[std::min(
+                       _iterationNumber, _moresaneSigmaLevels.size() - 1)]
+                << " ";
+  }
+
+  utils::Application::Run(commandLine.str());
+
+  aocommon::FitsReader modelReader(outputName + "_model.fits");
+  modelReader.Read(modelData);
+  aocommon::FitsReader residualReader(outputName + "_residual.fits");
+  residualReader.Read(residualData);
+
+  unlink(dirtyName.c_str());
+  unlink(psfName.c_str());
+  unlink(maskName.c_str());
+  unlink((outputName + "_model.fits").c_str());
+  unlink((outputName + "_residual.fits").c_str());
+}
+
+float MoreSane::ExecuteMajorIteration(
+    ImageSet& dataImage, ImageSet& modelImage,
+    const std::vector<aocommon::Image>& psfImages,
+    bool& reachedMajorThreshold) {
+  for (size_t i = 0; i != dataImage.size(); ++i) {
+    float* residualData = dataImage.Data(i);
+    float* modelData = modelImage.Data(i);
+    const aocommon::Image psfImage = psfImages[dataImage.PSFIndex(i)];
+    ExecuteMajorIteration(residualData, modelData, psfImage);
+  }
+
+  ++_iterationNumber;
+
+  reachedMajorThreshold = _iterationNumber < _maxIter;
+  return 0.0;
+}
+}  // namespace radler::algorithms
\ No newline at end of file
diff --git a/cpp/algorithms/more_sane.h b/cpp/algorithms/more_sane.h
new file mode 100644
index 00000000..78085bd8
--- /dev/null
+++ b/cpp/algorithms/more_sane.h
@@ -0,0 +1,42 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_ALGORITHMS_MORESANE_H_
+#define RADLER_ALGORITHMS_MORESANE_H_
+
+#include <string>
+
+#include "image_set.h"
+#include "algorithms/deconvolution_algorithm.h"
+
+namespace radler::algorithms {
+class MoreSane : public DeconvolutionAlgorithm {
+ public:
+  MoreSane(const std::string& moreSaneLocation,
+           const std::string& moresaneArguments,
+           const std::vector<double>& moresaneSigmaLevels,
+           const std::string& prefixName)
+      : _moresaneLocation(moreSaneLocation),
+        _moresaneArguments(moresaneArguments),
+        _moresaneSigmaLevels(moresaneSigmaLevels),
+        _prefixName(prefixName) {}
+
+  float ExecuteMajorIteration(ImageSet& dataImage, ImageSet& modelImage,
+                              const std::vector<aocommon::Image>& psfImages,
+                              bool& reachedMajorThreshold) final override;
+
+  virtual std::unique_ptr<DeconvolutionAlgorithm> Clone() const final override {
+    return std::unique_ptr<DeconvolutionAlgorithm>(new MoreSane(*this));
+  }
+
+  void ExecuteMajorIteration(float* residualData, float* modelData,
+                             const aocommon::Image& psfImage);
+
+ private:
+  const std::string _moresaneLocation, _moresaneArguments;
+
+  const std::vector<double> _moresaneSigmaLevels;
+  const std::string _prefixName;
+};
+}  // namespace radler::algorithms
+#endif  // RADLER_ALGORITHMS_MORESANE_H_
diff --git a/cpp/algorithms/multiscale/multiscale_transforms.cc b/cpp/algorithms/multiscale/multiscale_transforms.cc
new file mode 100644
index 00000000..5f48ae0f
--- /dev/null
+++ b/cpp/algorithms/multiscale/multiscale_transforms.cc
@@ -0,0 +1,39 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "algorithms/multiscale/multiscale_transforms.h"
+
+#include <schaapcommon/fft/convolution.h>
+
+using aocommon::Image;
+
+namespace radler::algorithms::multiscale {
+
+void MultiScaleTransforms::Transform(std::vector<Image>& images, Image& scratch,
+                                     float scale) {
+  size_t kernelSize;
+  Image shape = MakeShapeFunction(scale, kernelSize);
+
+  scratch = 0.0;
+
+  schaapcommon::fft::PrepareSmallConvolutionKernel(
+      scratch.Data(), _width, _height, shape.Data(), kernelSize, _threadCount);
+  for (Image& image : images)
+    schaapcommon::fft::Convolve(image.Data(), scratch.Data(), _width, _height,
+                                _threadCount);
+}
+
+void MultiScaleTransforms::PrepareTransform(float* kernel, float scale) {
+  size_t kernelSize;
+  Image shape = MakeShapeFunction(scale, kernelSize);
+
+  std::fill_n(kernel, _width * _height, 0.0);
+
+  schaapcommon::fft::PrepareSmallConvolutionKernel(
+      kernel, _width, _height, shape.Data(), kernelSize, _threadCount);
+}
+
+void MultiScaleTransforms::FinishTransform(float* image, const float* kernel) {
+  schaapcommon::fft::Convolve(image, kernel, _width, _height, _threadCount);
+}
+}  // namespace radler::algorithms::multiscale
diff --git a/cpp/algorithms/multiscale/multiscale_transforms.h b/cpp/algorithms/multiscale/multiscale_transforms.h
new file mode 100644
index 00000000..9f019dc8
--- /dev/null
+++ b/cpp/algorithms/multiscale/multiscale_transforms.h
@@ -0,0 +1,196 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_ALGORITHMS_MULTISCALE_MULTISCALE_TRANSFORMS_H_
+#define RADLER_ALGORITHMS_MULTISCALE_MULTISCALE_TRANSFORMS_H_
+
+#include <cmath>
+#include <initializer_list>
+
+#include <aocommon/image.h>
+#include <aocommon/uvector.h>
+
+#include <vector>
+
+namespace radler::algorithms::multiscale {
+
+enum class Shape { TaperedQuadraticShape, GaussianShape };
+
+class MultiScaleTransforms {
+ public:
+  MultiScaleTransforms(size_t width, size_t height, Shape shape)
+      : _width(width), _height(height), _shape(shape), _threadCount(1) {}
+
+  void PrepareTransform(float* kernel, float scale);
+  void FinishTransform(float* image, const float* kernel);
+
+  void Transform(aocommon::Image& image, aocommon::Image& scratch,
+                 float scale) {
+    std::vector<aocommon::Image> images(1, std::move(image));
+    Transform(images, scratch, scale);
+    image = std::move(images[0]);
+  }
+
+  void Transform(std::vector<aocommon::Image>& images, aocommon::Image& scratch,
+                 float scale);
+
+  size_t Width() const { return _width; }
+  size_t Height() const { return _height; }
+
+  static float KernelIntegratedValue(float scaleInPixels, size_t maxN,
+                                     Shape shape) {
+    size_t n;
+    aocommon::Image kernel = MakeShapeFunction(scaleInPixels, n, maxN, shape);
+
+    float value = 0.0;
+    for (float& x : kernel) value += x;
+
+    return value;
+  }
+
+  static float KernelPeakValue(double scaleInPixels, size_t maxN, Shape shape) {
+    size_t n;
+    aocommon::Image kernel = MakeShapeFunction(scaleInPixels, n, maxN, shape);
+    return kernel[n / 2 + (n / 2) * n];
+  }
+
+  static void AddShapeComponent(float* image, size_t width, size_t height,
+                                float scaleSizeInPixels, size_t x, size_t y,
+                                float gain, Shape shape) {
+    size_t n;
+    aocommon::Image kernel =
+        MakeShapeFunction(scaleSizeInPixels, n, std::min(width, height), shape);
+    int left;
+    if (x > n / 2)
+      left = x - n / 2;
+    else
+      left = 0;
+    int top;
+    if (y > n / 2)
+      top = y - n / 2;
+    else
+      top = 0;
+    size_t right = std::min(x + (n + 1) / 2, width);
+    size_t bottom = std::min(y + (n + 1) / 2, height);
+    for (size_t yi = top; yi != bottom; ++yi) {
+      float* imagePtr = &image[yi * width];
+      const float* kernelPtr =
+          &kernel.Data()[(yi + n / 2 - y) * n + left + n / 2 - x];
+      for (size_t xi = left; xi != right; ++xi) {
+        imagePtr[xi] += *kernelPtr * gain;
+        ++kernelPtr;
+      }
+    }
+  }
+
+  static aocommon::Image MakeShapeFunction(float scaleSizeInPixels, size_t& n,
+                                           size_t maxN, Shape shape) {
+    switch (shape) {
+      default:
+      case Shape::TaperedQuadraticShape:
+        return makeTaperedQuadraticShapeFunction(scaleSizeInPixels, n);
+      case Shape::GaussianShape:
+        return makeGaussianFunction(scaleSizeInPixels, n, maxN);
+    }
+  }
+
+  aocommon::Image MakeShapeFunction(float scaleSizeInPixels, size_t& n) {
+    return MakeShapeFunction(scaleSizeInPixels, n, std::min(_width, _height),
+                             _shape);
+  }
+
+  static float GaussianSigma(float scaleSizeInPixels) {
+    return scaleSizeInPixels * (3.0 / 16.0);
+  }
+
+  void SetThreadCount(size_t threadCount) { _threadCount = threadCount; }
+
+ private:
+  size_t _width, _height;
+  enum Shape _shape;
+  size_t _threadCount;
+
+  static size_t taperedQuadraticKernelSize(double scaleInPixels) {
+    return size_t(ceil(scaleInPixels * 0.5) * 2.0) + 1;
+  }
+
+  static aocommon::Image makeTaperedQuadraticShapeFunction(
+      double scaleSizeInPixels, size_t& n) {
+    n = taperedQuadraticKernelSize(scaleSizeInPixels);
+    aocommon::Image output(n, n);
+    taperedQuadraticShapeFunction(n, output, scaleSizeInPixels);
+    return output;
+  }
+
+  static aocommon::Image makeGaussianFunction(double scaleSizeInPixels,
+                                              size_t& n, size_t maxN) {
+    float sigma = GaussianSigma(scaleSizeInPixels);
+
+    n = int(ceil(sigma * 12.0 / 2.0)) * 2 + 1;  // bounding box of 12 sigma
+    if (n > maxN) {
+      n = maxN;
+      if ((n % 2) == 0 && n > 0) --n;
+    }
+    if (n < 1) n = 1;
+    if (sigma == 0.0) {
+      sigma = 1.0;
+      n = 1;
+    }
+    aocommon::Image output(n, n);
+    const float mu = int(n / 2);
+    const float twoSigmaSquared = 2.0 * sigma * sigma;
+    float sum = 0.0;
+    float* outputPtr = output.Data();
+    aocommon::UVector<float> gaus(n);
+    for (int i = 0; i != int(n); ++i) {
+      float vI = float(i) - mu;
+      gaus[i] = std::exp(-vI * vI / twoSigmaSquared);
+    }
+    for (int y = 0; y != int(n); ++y) {
+      for (int x = 0; x != int(n); ++x) {
+        *outputPtr = gaus[x] * gaus[y];
+        sum += *outputPtr;
+        ++outputPtr;
+      }
+    }
+    float normFactor = 1.0 / sum;
+    output *= normFactor;
+    return output;
+  }
+
+  static void taperedQuadraticShapeFunction(size_t n, aocommon::Image& output2d,
+                                            double scaleSizeInPixels) {
+    if (scaleSizeInPixels == 0.0)
+      output2d[0] = 1.0;
+    else {
+      float sum = 0.0;
+      float* outputPtr = output2d.Data();
+      for (int y = 0; y != int(n); ++y) {
+        float dy = y - 0.5 * (n - 1);
+        float dydy = dy * dy;
+        for (int x = 0; x != int(n); ++x) {
+          float dx = x - 0.5 * (n - 1);
+          float r = std::sqrt(dx * dx + dydy);
+          *outputPtr =
+              hannWindowFunction(r, n) * shapeFunction(r / scaleSizeInPixels);
+          sum += *outputPtr;
+          ++outputPtr;
+        }
+      }
+      float normFactor = 1.0 / sum;
+      output2d *= normFactor;
+    }
+  }
+
+  static float hannWindowFunction(float x, size_t n) {
+    return (x * 2 <= n + 1)
+               ? (0.5 * (1.0 + std::cos(2.0 * M_PI * x / double(n + 1))))
+               : 0.0;
+  }
+
+  static float shapeFunction(float x) {
+    return (x < 1.0) ? (1.0 - x * x) : 0.0;
+  }
+};
+}  // namespace radler::algorithms::multiscale
+#endif  // RADLER_ALGORITHMS_MULTISCALE_MULTISCALE_TRANSFORMS_H_
diff --git a/cpp/algorithms/multiscale_algorithm.cc b/cpp/algorithms/multiscale_algorithm.cc
new file mode 100644
index 00000000..a9102dcd
--- /dev/null
+++ b/cpp/algorithms/multiscale_algorithm.cc
@@ -0,0 +1,711 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "algorithms/multiscale_algorithm.h"
+
+#include <optional>
+
+#include <aocommon/image.h>
+#include <aocommon/logger.h>
+#include <aocommon/units/fluxdensity.h>
+
+#include "component_list.h"
+#include "algorithms/subminor_loop.h"
+#include "math/peak_finder.h"
+#include "multiscale/multiscale_transforms.h"
+
+using aocommon::Image;
+using aocommon::Logger;
+using aocommon::units::FluxDensity;
+
+namespace radler::algorithms {
+
+MultiScaleAlgorithm::MultiScaleAlgorithm(double beamSize, double pixelScaleX,
+                                         double pixelScaleY)
+    : _convolutionPadding(1.1),
+      _beamSizeInPixels(beamSize / std::max(pixelScaleX, pixelScaleY)),
+      _multiscaleScaleBias(0.6),
+      _multiscaleGain(0.2),
+      _scaleShape(multiscale::Shape::TaperedQuadraticShape),
+      _maxScales(0),
+      _trackPerScaleMasks(false),
+      _usePerScaleMasks(false),
+      _fastSubMinorLoop(true),
+      _trackComponents(false) {
+  if (_beamSizeInPixels <= 0.0) _beamSizeInPixels = 1;
+}
+
+MultiScaleAlgorithm::~MultiScaleAlgorithm() {
+  aocommon::Logger::Info << "Multi-scale cleaning summary:\n";
+  size_t sumComponents = 0;
+  float sumFlux = 0.0;
+  for (size_t scaleIndex = 0; scaleIndex != _scaleInfos.size(); ++scaleIndex) {
+    const ScaleInfo& scaleEntry = _scaleInfos[scaleIndex];
+    aocommon::Logger::Info << "- Scale " << round(scaleEntry.scale)
+                           << " px, nr of components cleaned: "
+                           << scaleEntry.nComponentsCleaned << " ("
+                           << FluxDensity::ToNiceString(
+                                  scaleEntry.totalFluxCleaned)
+                           << ")\n";
+    sumComponents += scaleEntry.nComponentsCleaned;
+    sumFlux += scaleEntry.totalFluxCleaned;
+  }
+  aocommon::Logger::Info << "Total: " << sumComponents << " components ("
+                         << FluxDensity::ToNiceString(sumFlux) << ")\n";
+}
+
+float MultiScaleAlgorithm::ExecuteMajorIteration(
+    ImageSet& dirtySet, ImageSet& modelSet,
+    const std::vector<aocommon::Image>& psfs, bool& reachedMajorThreshold) {
+  // Rough overview of the procedure:
+  // Convolve integrated image (all scales)
+  // Find integrated peak & scale
+  // Minor loop:
+  // - Convolve individual images at fixed scale
+  // - Subminor loop:
+  //   - Measure individual peaks per individually convolved image
+  //   - Subtract convolved PSF from individual images
+  //   - Subtract twice convolved PSF from individually convolved images
+  //   - Find integrated peak at fixed scale
+  // - Convolve integrated image (all scales)
+  // - Find integrated peak & scale
+  //
+  // (This excludes creating the convolved PSFs and twice-convolved PSFs
+  //  at the appropriate moments).
+
+  const size_t width = dirtySet.Width();
+  const size_t height = dirtySet.Height();
+
+  if (_stopOnNegativeComponent) _allowNegativeComponents = true;
+  // The threads always need to be stopped at the end of this function, so we
+  // use a scoped unique ptr.
+  std::unique_ptr<ThreadedDeconvolutionTools> tools(
+      new ThreadedDeconvolutionTools(_threadCount));
+
+  initializeScaleInfo(std::min(width, height));
+
+  if (_trackPerScaleMasks) {
+    // Note that in a second round the nr of scales can be different (due to
+    // different width/height, e.g. caused by a different subdivision in
+    // parallel cleaning).
+    for (const aocommon::UVector<bool>& mask : _scaleMasks) {
+      if (mask.size() != width * height)
+        throw std::runtime_error(
+            "Invalid automask size in multiscale algorithm");
+    }
+    while (_scaleMasks.size() < _scaleInfos.size()) {
+      _scaleMasks.emplace_back(width * height, false);
+    }
+  }
+  if (_trackComponents) {
+    if (_componentList == nullptr)
+      _componentList.reset(new ComponentList(width, height, _scaleInfos.size(),
+                                             dirtySet.size()));
+    else if (_componentList->Width() != width ||
+             _componentList->Height() != height) {
+      throw std::runtime_error("Error in component list dimensions!");
+    }
+  }
+  if (!_rmsFactorImage.Empty() &&
+      (_rmsFactorImage.Width() != width || _rmsFactorImage.Height() != height))
+    throw std::runtime_error("Error in RMS factor image dimensions!");
+
+  bool hasHitThresholdInSubLoop = false;
+  size_t thresholdCountdown = std::max(size_t(8), _scaleInfos.size() * 3 / 2);
+
+  Image scratch, scratchB, integratedScratch;
+  // scratch and scratchB are used by the subminorloop, which convolves the
+  // images and requires therefore more space. This space depends on the scale,
+  // so here the required size for the largest scale is calculated.
+  size_t scratchWidth, scratchHeight;
+  getConvolutionDimensions(_scaleInfos.size() - 1, width, height, scratchWidth,
+                           scratchHeight);
+  scratch = Image(scratchWidth, scratchHeight);
+  scratchB = Image(scratchWidth, scratchHeight);
+  integratedScratch = Image(width, height);
+  std::unique_ptr<std::unique_ptr<Image[]>[]> convolvedPSFs(
+      new std::unique_ptr<Image[]>[dirtySet.PSFCount()]);
+  dirtySet.GetIntegratedPSF(integratedScratch, psfs);
+  convolvePSFs(convolvedPSFs[0], integratedScratch, scratch, true);
+
+  // If there's only one, the integrated equals the first, so we can skip this
+  if (dirtySet.PSFCount() > 1) {
+    for (size_t i = 0; i != dirtySet.PSFCount(); ++i) {
+      convolvePSFs(convolvedPSFs[i], psfs[i], scratch, false);
+    }
+  }
+
+  multiscale::MultiScaleTransforms msTransforms(width, height, _scaleShape);
+  msTransforms.SetThreadCount(_threadCount);
+
+  size_t scaleWithPeak;
+  findActiveScaleConvolvedMaxima(dirtySet, integratedScratch, scratch, true,
+                                 tools.get());
+  if (!selectMaximumScale(scaleWithPeak)) {
+    _logReceiver->Warn << "No peak found during multi-scale cleaning! Aborting "
+                          "deconvolution.\n";
+    reachedMajorThreshold = false;
+    return 0.0;
+  }
+
+  bool isFinalThreshold = false;
+  float mGainThreshold =
+      std::fabs(_scaleInfos[scaleWithPeak].maxUnnormalizedImageValue *
+                _scaleInfos[scaleWithPeak].biasFactor) *
+      (1.0 - _mGain);
+  mGainThreshold = std::max(mGainThreshold, MajorIterThreshold());
+  float firstThreshold = mGainThreshold;
+  if (_threshold > firstThreshold) {
+    firstThreshold = _threshold;
+    isFinalThreshold = true;
+  }
+
+  _logReceiver->Info
+      << "Starting multi-scale cleaning. Start peak="
+      << FluxDensity::ToNiceString(
+             _scaleInfos[scaleWithPeak].maxUnnormalizedImageValue *
+             _scaleInfos[scaleWithPeak].biasFactor)
+      << ", major iteration threshold="
+      << FluxDensity::ToNiceString(firstThreshold);
+  if (isFinalThreshold) _logReceiver->Info << " (final)";
+  _logReceiver->Info << '\n';
+
+  ImageSet individualConvolvedImages(dirtySet, width, height);
+
+  //
+  // The minor iteration loop
+  //
+  while (_iterationNumber < MaxNIter() &&
+         std::fabs(_scaleInfos[scaleWithPeak].maxUnnormalizedImageValue *
+                   _scaleInfos[scaleWithPeak].biasFactor) > firstThreshold &&
+         (!StopOnNegativeComponents() ||
+          _scaleInfos[scaleWithPeak].maxUnnormalizedImageValue >= 0.0) &&
+         thresholdCountdown > 0) {
+    // Create double-convolved PSFs & individually convolved images for this
+    // scale
+    std::vector<Image> transformList;
+    transformList.reserve(dirtySet.PSFCount() + dirtySet.size());
+    for (size_t i = 0; i != dirtySet.PSFCount(); ++i) {
+      transformList.push_back(convolvedPSFs[i][scaleWithPeak]);
+    }
+    for (size_t i = 0; i != dirtySet.size(); ++i) {
+      transformList.emplace_back(width, height);
+      std::copy_n(dirtySet.Data(i), width * height,
+                  transformList.back().Data());
+    }
+    if (_scaleInfos[scaleWithPeak].scale != 0.0) {
+      msTransforms.Transform(transformList, scratch,
+                             _scaleInfos[scaleWithPeak].scale);
+    }
+
+    std::vector<Image> twiceConvolvedPSFs;
+    twiceConvolvedPSFs.reserve(dirtySet.PSFCount());
+    for (size_t i = 0; i != dirtySet.PSFCount(); ++i) {
+      twiceConvolvedPSFs.emplace_back(std::move(transformList[i]));
+    }
+    for (size_t i = 0; i != dirtySet.size(); ++i) {
+      individualConvolvedImages.SetImage(
+          i, std::move(transformList[i + dirtySet.PSFCount()]));
+    }
+
+    //
+    // The sub-minor iteration loop for this scale
+    //
+    float subIterationGainThreshold =
+        std::fabs(_scaleInfos[scaleWithPeak].maxUnnormalizedImageValue *
+                  _scaleInfos[scaleWithPeak].biasFactor) *
+        (1.0 - _multiscaleGain);
+    float firstSubIterationThreshold = subIterationGainThreshold;
+    if (firstThreshold > firstSubIterationThreshold) {
+      firstSubIterationThreshold = firstThreshold;
+      if (!hasHitThresholdInSubLoop) {
+        _logReceiver->Info << "Subminor loop is near minor loop threshold. "
+                              "Initiating countdown.\n";
+        hasHitThresholdInSubLoop = true;
+      }
+      thresholdCountdown--;
+      _logReceiver->Info << '(' << thresholdCountdown << ") ";
+    }
+    // TODO we could chose to run the non-fast loop until we hit e.g. 10
+    // iterations in a scale, because the fast loop takes more constant time and
+    // is only efficient when doing many iterations.
+    if (_fastSubMinorLoop) {
+      size_t subMinorStartIteration = _iterationNumber;
+      size_t convolutionWidth, convolutionHeight;
+      getConvolutionDimensions(scaleWithPeak, width, height, convolutionWidth,
+                               convolutionHeight);
+      SubMinorLoop subLoop(width, height, convolutionWidth, convolutionHeight,
+                           *_logReceiver);
+      subLoop.SetIterationInfo(_iterationNumber, MaxNIter());
+      subLoop.SetThreshold(
+          firstSubIterationThreshold / _scaleInfos[scaleWithPeak].biasFactor,
+          subIterationGainThreshold / _scaleInfos[scaleWithPeak].biasFactor);
+      subLoop.SetGain(_scaleInfos[scaleWithPeak].gain);
+      subLoop.SetAllowNegativeComponents(AllowNegativeComponents());
+      subLoop.SetStopOnNegativeComponent(StopOnNegativeComponents());
+      subLoop.SetThreadCount(_threadCount);
+      const size_t scaleBorder =
+                       size_t(ceil(_scaleInfos[scaleWithPeak].scale * 0.5)),
+                   horBorderSize = std::max<size_t>(
+                       round(width * _cleanBorderRatio), scaleBorder),
+                   vertBorderSize = std::max<size_t>(
+                       round(height * _cleanBorderRatio), scaleBorder);
+      subLoop.SetCleanBorders(horBorderSize, vertBorderSize);
+      if (!_rmsFactorImage.Empty()) subLoop.SetRMSFactorImage(_rmsFactorImage);
+      if (_usePerScaleMasks)
+        subLoop.SetMask(_scaleMasks[scaleWithPeak].data());
+      else if (_cleanMask)
+        subLoop.SetMask(_cleanMask);
+      subLoop.SetSpectralFitter(&Fitter());
+
+      subLoop.Run(individualConvolvedImages, twiceConvolvedPSFs);
+
+      _iterationNumber = subLoop.CurrentIteration();
+      _scaleInfos[scaleWithPeak].nComponentsCleaned +=
+          (_iterationNumber - subMinorStartIteration);
+      _scaleInfos[scaleWithPeak].totalFluxCleaned += subLoop.FluxCleaned();
+
+      for (size_t imageIndex = 0; imageIndex != dirtySet.size(); ++imageIndex) {
+        // TODO this can be multi-threaded if each thread has its own
+        // temporaries
+        const aocommon::Image& psf =
+            convolvedPSFs[dirtySet.PSFIndex(imageIndex)][scaleWithPeak];
+        subLoop.CorrectResidualDirty(scratch.Data(), scratchB.Data(),
+                                     integratedScratch.Data(), imageIndex,
+                                     dirtySet.Data(imageIndex), psf.Data());
+
+        subLoop.GetFullIndividualModel(imageIndex, scratch.Data());
+        if (imageIndex == 0) {
+          if (_trackPerScaleMasks)
+            subLoop.UpdateAutoMask(_scaleMasks[scaleWithPeak].data());
+          if (_trackComponents)
+            subLoop.UpdateComponentList(*_componentList, scaleWithPeak);
+        }
+        if (_scaleInfos[scaleWithPeak].scale != 0.0) {
+          std::vector<Image> transformList{std::move(scratch)};
+          msTransforms.Transform(transformList, integratedScratch,
+                                 _scaleInfos[scaleWithPeak].scale);
+          scratch = std::move(transformList[0]);
+        }
+        float* model = modelSet.Data(imageIndex);
+        for (size_t i = 0; i != width * height; ++i)
+          model[i] += scratch.Data()[i];
+      }
+
+    } else {  // don't use the Clark optimization
+      const ScaleInfo& maxScaleInfo = _scaleInfos[scaleWithPeak];
+      while (_iterationNumber < MaxNIter() &&
+             std::fabs(maxScaleInfo.maxUnnormalizedImageValue *
+                       maxScaleInfo.biasFactor) > firstSubIterationThreshold &&
+             (!StopOnNegativeComponents() ||
+              _scaleInfos[scaleWithPeak].maxUnnormalizedImageValue >= 0.0)) {
+        aocommon::UVector<float> componentValues;
+        measureComponentValues(componentValues, scaleWithPeak,
+                               individualConvolvedImages);
+        const size_t x = maxScaleInfo.maxImageValueX;
+        const size_t y = maxScaleInfo.maxImageValueY;
+        PerformSpectralFit(componentValues.data(), x, y);
+
+        for (size_t imgIndex = 0; imgIndex != dirtySet.size(); ++imgIndex) {
+          // Subtract component from individual, non-deconvolved images
+          componentValues[imgIndex] =
+              componentValues[imgIndex] * maxScaleInfo.gain;
+
+          const aocommon::Image& psf =
+              convolvedPSFs[dirtySet.PSFIndex(imgIndex)][scaleWithPeak];
+          tools->SubtractImage(dirtySet.Data(imgIndex), psf, x, y,
+                               componentValues[imgIndex]);
+
+          // Subtract twice convolved PSFs from convolved images
+          tools->SubtractImage(individualConvolvedImages.Data(imgIndex),
+                               twiceConvolvedPSFs[dirtySet.PSFIndex(imgIndex)],
+                               x, y, componentValues[imgIndex]);
+          // TODO this is incorrect, but why is the residual without
+          // Cotton-Schwab still OK ? Should test
+          // tools->SubtractImage(individualConvolvedImages[imgIndex], psf,
+          // width, height, x, y, componentValues[imgIndex]);
+
+          // Adjust model
+          addComponentToModel(modelSet, imgIndex, scaleWithPeak,
+                              componentValues[imgIndex]);
+        }
+        if (_trackComponents) {
+          _componentList->Add(x, y, scaleWithPeak, componentValues.data());
+        }
+
+        // Find maximum for this scale
+        individualConvolvedImages.GetLinearIntegrated(integratedScratch);
+        findPeakDirect(integratedScratch, scratch, scaleWithPeak);
+        _logReceiver->Debug
+            << "Scale now "
+            << std::fabs(_scaleInfos[scaleWithPeak].maxUnnormalizedImageValue *
+                         _scaleInfos[scaleWithPeak].biasFactor)
+            << '\n';
+
+        ++_iterationNumber;
+      }
+    }
+
+    activateScales(scaleWithPeak);
+
+    findActiveScaleConvolvedMaxima(dirtySet, integratedScratch, scratch, false,
+                                   tools.get());
+
+    if (!selectMaximumScale(scaleWithPeak)) {
+      _logReceiver->Warn << "No peak found in main loop of multi-scale "
+                            "cleaning! Aborting deconvolution.\n";
+      reachedMajorThreshold = false;
+      return 0.0;
+    }
+
+    _logReceiver->Info
+        << "Iteration " << _iterationNumber << ", scale "
+        << round(_scaleInfos[scaleWithPeak].scale) << " px : "
+        << FluxDensity::ToNiceString(
+               _scaleInfos[scaleWithPeak].maxUnnormalizedImageValue *
+               _scaleInfos[scaleWithPeak].biasFactor)
+        << " at " << _scaleInfos[scaleWithPeak].maxImageValueX << ','
+        << _scaleInfos[scaleWithPeak].maxImageValueY << '\n';
+  }
+
+  bool maxIterReached = _iterationNumber >= MaxNIter(),
+       negativeReached =
+           StopOnNegativeComponents() &&
+           _scaleInfos[scaleWithPeak].maxUnnormalizedImageValue < 0.0;
+  // finalThresholdReached =
+  // std::fabs(_scaleInfos[scaleWithPeak].maxUnnormalizedImageValue *
+  // _scaleInfos[scaleWithPeak].biasFactor) <= _threshold;
+
+  if (maxIterReached)
+    _logReceiver->Info << "Cleaning finished because maximum number of "
+                          "iterations was reached.\n";
+  else if (negativeReached)
+    _logReceiver->Info
+        << "Cleaning finished because a negative component was found.\n";
+  else if (isFinalThreshold)
+    _logReceiver->Info
+        << "Cleaning finished because the final threshold was reached.\n";
+  else
+    _logReceiver->Info << "Minor loop finished, continuing cleaning after "
+                          "inversion/prediction round.\n";
+
+  reachedMajorThreshold =
+      !maxIterReached && !isFinalThreshold && !negativeReached;
+  return _scaleInfos[scaleWithPeak].maxUnnormalizedImageValue *
+         _scaleInfos[scaleWithPeak].biasFactor;
+}
+
+void MultiScaleAlgorithm::initializeScaleInfo(size_t minWidthHeight) {
+  if (_manualScaleList.empty()) {
+    if (_scaleInfos.empty()) {
+      size_t scaleIndex = 0;
+      double scale = _beamSizeInPixels * 2.0;
+      do {
+        _scaleInfos.push_back(ScaleInfo());
+        ScaleInfo& newEntry = _scaleInfos.back();
+        if (scaleIndex == 0)
+          newEntry.scale = 0.0;
+        else
+          newEntry.scale = scale;
+        newEntry.kernelPeak = multiscale::MultiScaleTransforms::KernelPeakValue(
+            scale, minWidthHeight, _scaleShape);
+
+        scale *= 2.0;
+        ++scaleIndex;
+      } while (scale < minWidthHeight * 0.5 &&
+               (_maxScales == 0 || scaleIndex < _maxScales));
+    } else {
+      while (!_scaleInfos.empty() &&
+             _scaleInfos.back().scale >= minWidthHeight * 0.5) {
+        _logReceiver->Info
+            << "Scale size " << _scaleInfos.back().scale
+            << " does not fit in cleaning region: removing scale.\n";
+        _scaleInfos.erase(_scaleInfos.begin() + _scaleInfos.size() - 1);
+      }
+    }
+  }
+  if (!_manualScaleList.empty() && _scaleInfos.empty()) {
+    std::sort(_manualScaleList.begin(), _manualScaleList.end());
+    for (size_t scaleIndex = 0; scaleIndex != _manualScaleList.size();
+         ++scaleIndex) {
+      _scaleInfos.push_back(ScaleInfo());
+      ScaleInfo& newEntry = _scaleInfos.back();
+      newEntry.scale = _manualScaleList[scaleIndex];
+      newEntry.kernelPeak = multiscale::MultiScaleTransforms::KernelPeakValue(
+          newEntry.scale, minWidthHeight, _scaleShape);
+    }
+  }
+}
+
+void MultiScaleAlgorithm::convolvePSFs(std::unique_ptr<Image[]>& convolvedPSFs,
+                                       const Image& psf, Image& scratch,
+                                       bool isIntegrated) {
+  multiscale::MultiScaleTransforms msTransforms(psf.Width(), psf.Height(),
+                                                _scaleShape);
+  msTransforms.SetThreadCount(_threadCount);
+  convolvedPSFs.reset(new Image[_scaleInfos.size()]);
+  if (isIntegrated) _logReceiver->Info << "Scale info:\n";
+  const double firstAutoScaleSize = _beamSizeInPixels * 2.0;
+  for (size_t scaleIndex = 0; scaleIndex != _scaleInfos.size(); ++scaleIndex) {
+    ScaleInfo& scaleEntry = _scaleInfos[scaleIndex];
+
+    convolvedPSFs[scaleIndex] = psf;
+
+    if (isIntegrated) {
+      if (scaleEntry.scale != 0.0)
+        msTransforms.Transform(convolvedPSFs[scaleIndex], scratch,
+                               scaleEntry.scale);
+
+      scaleEntry.psfPeak =
+          convolvedPSFs[scaleIndex]
+                       [psf.Width() / 2 + (psf.Height() / 2) * psf.Width()];
+      // We normalize this factor to 1 for scale 0, so:
+      // factor = (psf / kernel) / (psf0 / kernel0) = psf * kernel0 / (kernel *
+      // psf0)
+      // scaleEntry.biasFactor = std::max(1.0,
+      //	scaleEntry.psfPeak * scaleInfos[0].kernelPeak /
+      //	(scaleEntry.kernelPeak * scaleInfos[0].psfPeak));
+      double expTerm;
+      if (scaleEntry.scale == 0.0 || _scaleInfos.size() < 2)
+        expTerm = 0.0;
+      else
+        expTerm = std::log2(scaleEntry.scale / firstAutoScaleSize);
+      scaleEntry.biasFactor =
+          std::pow(_multiscaleScaleBias, -double(expTerm)) * 1.0;
+
+      // I tried this, but wasn't perfect:
+      // _gain * _scaleInfos[0].kernelPeak / scaleEntry.kernelPeak;
+      scaleEntry.gain = _gain / scaleEntry.psfPeak;
+
+      scaleEntry.isActive = true;
+
+      if (scaleEntry.scale == 0.0) {
+        convolvedPSFs[scaleIndex] = psf;
+      }
+
+      _logReceiver->Info << "- Scale " << round(scaleEntry.scale)
+                         << ", bias factor="
+                         << round(scaleEntry.biasFactor * 10.0) / 10.0
+                         << ", psfpeak=" << scaleEntry.psfPeak
+                         << ", gain=" << scaleEntry.gain
+                         << ", kernel peak=" << scaleEntry.kernelPeak << '\n';
+    } else {
+      if (scaleEntry.scale != 0.0)
+        msTransforms.Transform(convolvedPSFs[scaleIndex], scratch,
+                               scaleEntry.scale);
+    }
+  }
+}
+
+void MultiScaleAlgorithm::findActiveScaleConvolvedMaxima(
+    const ImageSet& imageSet, Image& integratedScratch, Image& scratch,
+    bool reportRMS, ThreadedDeconvolutionTools* tools) {
+  multiscale::MultiScaleTransforms msTransforms(imageSet.Width(),
+                                                imageSet.Height(), _scaleShape);
+  // ImageBufferAllocator::Ptr convolvedImage;
+  //_allocator.Allocate(_width*_height, convolvedImage);
+  imageSet.GetLinearIntegrated(integratedScratch);
+  aocommon::UVector<float> transformScales;
+  aocommon::UVector<size_t> transformIndices;
+  std::vector<aocommon::UVector<bool>> transformScaleMasks;
+  for (size_t scaleIndex = 0; scaleIndex != _scaleInfos.size(); ++scaleIndex) {
+    ScaleInfo& scaleEntry = _scaleInfos[scaleIndex];
+    if (scaleEntry.isActive) {
+      if (scaleEntry.scale == 0) {
+        // Don't convolve scale 0: this is the delta function scale
+        findPeakDirect(integratedScratch, scratch, scaleIndex);
+        if (reportRMS)
+          scaleEntry.rms = ThreadedDeconvolutionTools::RMS(
+              integratedScratch, imageSet.Width() * imageSet.Height());
+      } else {
+        transformScales.push_back(scaleEntry.scale);
+        transformIndices.push_back(scaleIndex);
+        if (_usePerScaleMasks)
+          transformScaleMasks.push_back(_scaleMasks[scaleIndex]);
+      }
+    }
+  }
+  std::vector<ThreadedDeconvolutionTools::PeakData> results;
+
+  tools->FindMultiScalePeak(&msTransforms, integratedScratch, transformScales,
+                            results, _allowNegativeComponents, _cleanMask,
+                            transformScaleMasks, _cleanBorderRatio,
+                            _rmsFactorImage, reportRMS);
+
+  for (size_t i = 0; i != results.size(); ++i) {
+    ScaleInfo& scaleEntry = _scaleInfos[transformIndices[i]];
+    scaleEntry.maxNormalizedImageValue =
+        results[i].normalizedValue.value_or(0.0);
+    scaleEntry.maxUnnormalizedImageValue =
+        results[i].unnormalizedValue.value_or(0.0);
+    scaleEntry.maxImageValueX = results[i].x;
+    scaleEntry.maxImageValueY = results[i].y;
+    if (reportRMS) scaleEntry.rms = results[i].rms;
+  }
+  if (reportRMS) {
+    _logReceiver->Info << "RMS per scale: {";
+    for (size_t scaleIndex = 0; scaleIndex != _scaleInfos.size();
+         ++scaleIndex) {
+      ScaleInfo& scaleEntry = _scaleInfos[scaleIndex];
+      if (scaleIndex != 0) _logReceiver->Info << ", ";
+      _logReceiver->Info << round(scaleEntry.scale) << ": "
+                         << FluxDensity::ToNiceString(scaleEntry.rms);
+    }
+    _logReceiver->Info << "}\n";
+  }
+}
+
+bool MultiScaleAlgorithm::selectMaximumScale(size_t& scaleWithPeak) {
+  // Find max component
+  std::map<float, size_t> peakToScaleMap;
+  for (size_t i = 0; i != _scaleInfos.size(); ++i) {
+    if (_scaleInfos[i].isActive) {
+      float maxVal = std::fabs(_scaleInfos[i].maxUnnormalizedImageValue *
+                               _scaleInfos[i].biasFactor);
+      peakToScaleMap.insert(std::make_pair(maxVal, i));
+    }
+  }
+  if (peakToScaleMap.empty()) {
+    scaleWithPeak = size_t(-1);
+    return false;
+  } else {
+    std::map<float, size_t>::const_reverse_iterator mapIter =
+        peakToScaleMap.rbegin();
+    scaleWithPeak = mapIter->second;
+    return true;
+  }
+}
+
+void MultiScaleAlgorithm::activateScales(size_t scaleWithLastPeak) {
+  for (size_t i = 0; i != _scaleInfos.size(); ++i) {
+    bool doActivate =
+        i == scaleWithLastPeak ||
+        /*i == runnerUp ||*/ std::fabs(
+            _scaleInfos[i].maxUnnormalizedImageValue) *
+                _scaleInfos[i].biasFactor >
+            std::fabs(
+                _scaleInfos[scaleWithLastPeak].maxUnnormalizedImageValue) *
+                (1.0 - _gain) * _scaleInfos[scaleWithLastPeak].biasFactor;
+    if (!_scaleInfos[i].isActive && doActivate) {
+      _logReceiver->Debug << "Scale " << _scaleInfos[i].scale
+                          << " is now significant and is activated.\n";
+      _scaleInfos[i].isActive = true;
+    } else if (_scaleInfos[i].isActive && !doActivate) {
+      _logReceiver->Debug << "Scale " << _scaleInfos[i].scale
+                          << " is insignificant and is deactivated.\n";
+      _scaleInfos[i].isActive = false;
+    }
+  }
+}
+
+void MultiScaleAlgorithm::measureComponentValues(
+    aocommon::UVector<float>& componentValues, size_t scaleIndex,
+    ImageSet& imageSet) {
+  const ScaleInfo& scale = _scaleInfos[scaleIndex];
+  componentValues.resize(imageSet.size());
+  _logReceiver->Debug << "Measuring " << scale.maxImageValueX << ','
+                      << scale.maxImageValueY << ", scale " << scale.scale
+                      << ", integrated=" << scale.maxUnnormalizedImageValue
+                      << ":";
+  for (size_t i = 0; i != imageSet.size(); ++i) {
+    componentValues[i] = imageSet[i][scale.maxImageValueX +
+                                     scale.maxImageValueY * imageSet.Width()];
+    _logReceiver->Debug << ' ' << componentValues[i];
+  }
+  _logReceiver->Debug << '\n';
+}
+
+void MultiScaleAlgorithm::addComponentToModel(ImageSet& modelSet,
+                                              size_t imgIndex,
+                                              size_t scaleWithPeak,
+                                              float componentValue) {
+  const size_t x = _scaleInfos[scaleWithPeak].maxImageValueX;
+  const size_t y = _scaleInfos[scaleWithPeak].maxImageValueY;
+  float* modelData = modelSet.Data(imgIndex);
+  if (_scaleInfos[scaleWithPeak].scale == 0.0) {
+    modelData[x + modelSet.Width() * y] += componentValue;
+  } else {
+    multiscale::MultiScaleTransforms::AddShapeComponent(
+        modelData, modelSet.Width(), modelSet.Height(),
+        _scaleInfos[scaleWithPeak].scale, x, y, componentValue, _scaleShape);
+  }
+
+  _scaleInfos[scaleWithPeak].nComponentsCleaned++;
+  _scaleInfos[scaleWithPeak].totalFluxCleaned += componentValue;
+
+  if (_trackPerScaleMasks) {
+    _scaleMasks[scaleWithPeak][x + modelSet.Width() * y] = true;
+  }
+}
+
+void MultiScaleAlgorithm::findPeakDirect(const aocommon::Image& image,
+                                         aocommon::Image& scratch,
+                                         size_t scaleIndex) {
+  ScaleInfo& scaleInfo = _scaleInfos[scaleIndex];
+  const size_t horBorderSize = std::round(image.Width() * _cleanBorderRatio);
+  const size_t vertBorderSize = std::round(image.Height() * _cleanBorderRatio);
+  const float* actualImage;
+  if (_rmsFactorImage.Empty()) {
+    actualImage = image.Data();
+  } else {
+    scratch = image;
+    scratch *= _rmsFactorImage;
+    actualImage = scratch.Data();
+  }
+
+  std::optional<float> maxValue;
+  if (_usePerScaleMasks)
+    maxValue = math::PeakFinder::FindWithMask(
+        actualImage, image.Width(), image.Height(), scaleInfo.maxImageValueX,
+        scaleInfo.maxImageValueY, _allowNegativeComponents, 0, image.Height(),
+        _scaleMasks[scaleIndex].data(), horBorderSize, vertBorderSize);
+  else if (_cleanMask == nullptr)
+    maxValue = math::PeakFinder::Find(
+        actualImage, image.Width(), image.Height(), scaleInfo.maxImageValueX,
+        scaleInfo.maxImageValueY, _allowNegativeComponents, 0, image.Height(),
+        horBorderSize, vertBorderSize);
+  else
+    maxValue = math::PeakFinder::FindWithMask(
+        actualImage, image.Width(), image.Height(), scaleInfo.maxImageValueX,
+        scaleInfo.maxImageValueY, _allowNegativeComponents, 0, image.Height(),
+        _cleanMask, horBorderSize, vertBorderSize);
+
+  scaleInfo.maxUnnormalizedImageValue = maxValue.value_or(0.0);
+  if (_rmsFactorImage.Empty())
+    scaleInfo.maxNormalizedImageValue = maxValue.value_or(0.0);
+  else
+    scaleInfo.maxNormalizedImageValue =
+        maxValue.value_or(0.0) /
+        _rmsFactorImage[scaleInfo.maxImageValueX +
+                        scaleInfo.maxImageValueY * image.Width()];
+}
+
+static size_t calculateGoodFFTSize(size_t n) {
+  size_t bestfac = 2 * n;
+  /* NOTE: Starting from f2=2 here instead from f2=1 as usual, because the
+                  result needs to be even. */
+  for (size_t f2 = 2; f2 < bestfac; f2 *= 2)
+    for (size_t f23 = f2; f23 < bestfac; f23 *= 3)
+      for (size_t f235 = f23; f235 < bestfac; f235 *= 5)
+        for (size_t f2357 = f235; f2357 < bestfac; f2357 *= 7)
+          if (f2357 >= n) bestfac = f2357;
+  return bestfac;
+}
+
+void MultiScaleAlgorithm::getConvolutionDimensions(
+    size_t scaleIndex, size_t width, size_t height, size_t& width_result,
+    size_t& height_result) const {
+  double scale = _scaleInfos[scaleIndex].scale;
+  // The factor of 1.5 comes from some superficial experience with diverging
+  // runs. It's supposed to be a balance between diverging runs caused by
+  // insufficient padding on one hand, and taking up too much memory on the
+  // other. I've seen divergence when padding=1.1, width=1500, max scale=726
+  // and conv width=1650. Divergence occurred on scale 363. Was solved with conv
+  // width=2250. 2250 = 1.1*(363*factor + 1500)  --> factor = 1.5 And solved
+  // with conv width=2000. 2000 = 1.1*(363*factor + 1500)  --> factor = 0.8
+  width_result = ceil(_convolutionPadding * (scale * 1.5 + width));
+  height_result = ceil(_convolutionPadding * (scale * 1.5 + height));
+  width_result = calculateGoodFFTSize(width_result);
+  height_result = calculateGoodFFTSize(height_result);
+}
+}  // namespace radler::algorithms
\ No newline at end of file
diff --git a/cpp/algorithms/multiscale_algorithm.h b/cpp/algorithms/multiscale_algorithm.h
new file mode 100644
index 00000000..2877dfa8
--- /dev/null
+++ b/cpp/algorithms/multiscale_algorithm.h
@@ -0,0 +1,138 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_ALGORITHMS_MULTISCALE_ALGORITHM_H_
+#define RADLER_ALGORITHMS_MULTISCALE_ALGORITHM_H_
+
+#include <vector>
+
+#include <aocommon/cloned_ptr.h>
+#include <aocommon/image.h>
+#include <aocommon/uvector.h>
+
+#include "component_list.h"
+#include "deconvolution_algorithm.h"
+#include "image_set.h"
+#include "algorithms/threaded_deconvolution_tools.h"
+#include "algorithms/multiscale/multiscale_transforms.h"
+
+namespace radler::algorithms {
+
+class MultiScaleAlgorithm : public DeconvolutionAlgorithm {
+ public:
+  MultiScaleAlgorithm(double beamSize, double pixelScaleX, double pixelScaleY);
+  ~MultiScaleAlgorithm();
+
+  std::unique_ptr<DeconvolutionAlgorithm> Clone() const final override {
+    return std::make_unique<MultiScaleAlgorithm>(*this);
+  }
+
+  void SetManualScaleList(const std::vector<double>& scaleList) {
+    _manualScaleList = scaleList;
+  }
+
+  float ExecuteMajorIteration(ImageSet& dataImage, ImageSet& modelImage,
+                              const std::vector<aocommon::Image>& psfImages,
+                              bool& reachedMajorThreshold) final override;
+
+  void SetAutoMaskMode(bool trackPerScaleMasks, bool usePerScaleMasks) {
+    _trackPerScaleMasks = trackPerScaleMasks;
+    _usePerScaleMasks = usePerScaleMasks;
+  }
+  void SetTrackComponents(bool trackComponents) {
+    _trackComponents = trackComponents;
+  }
+  void SetUseFastSubMinorLoop(bool fastSubMinorLoop) {
+    _fastSubMinorLoop = fastSubMinorLoop;
+  }
+  void SetMultiscaleScaleBias(float bias) { _multiscaleScaleBias = bias; }
+  void SetMultiscaleGain(float gain) { _multiscaleGain = gain; }
+  void SetConvolutionPadding(float padding) {
+    assert(padding >= 1.0);
+    _convolutionPadding = padding;
+  }
+  void SetShape(multiscale::Shape shape) { _scaleShape = shape; }
+  size_t ScaleCount() const { return _scaleInfos.size(); }
+  void ClearComponentList() { _componentList.reset(); }
+  ComponentList& GetComponentList() { return *_componentList; }
+  const ComponentList& GetComponentList() const { return *_componentList; }
+  float ScaleSize(size_t scaleIndex) const {
+    return _scaleInfos[scaleIndex].scale;
+  }
+  size_t GetScaleMaskCount() const { return _scaleMasks.size(); }
+  void SetScaleMaskCount(size_t n) { _scaleMasks.resize(n); }
+  aocommon::UVector<bool>& GetScaleMask(size_t index) {
+    return _scaleMasks[index];
+  }
+  void SetMaxScales(size_t maxScales) { _maxScales = maxScales; }
+
+ private:
+  float _convolutionPadding;
+  double _beamSizeInPixels;
+  float _multiscaleScaleBias;
+  float _multiscaleGain;
+  multiscale::Shape _scaleShape;
+  size_t _maxScales;
+  // ThreadedDeconvolutionTools* _tools;
+
+  struct ScaleInfo {
+    ScaleInfo()
+        : scale(0.0),
+          psfPeak(0.0),
+          kernelPeak(0.0),
+          biasFactor(0.0),
+          gain(0.0),
+          maxNormalizedImageValue(0.0),
+          maxUnnormalizedImageValue(0.0),
+          rms(0.0),
+          maxImageValueX(0),
+          maxImageValueY(0),
+          isActive(false),
+          nComponentsCleaned(0),
+          totalFluxCleaned(0.0) {}
+
+    float scale;
+    float psfPeak, kernelPeak, biasFactor, gain;
+
+    /**
+     * The difference between the normalized and unnormalized value is
+     * that the unnormalized value is relative to the RMS factor.
+     */
+    float maxNormalizedImageValue, maxUnnormalizedImageValue;
+    float rms;
+    size_t maxImageValueX, maxImageValueY;
+    bool isActive;
+    size_t nComponentsCleaned;
+    float totalFluxCleaned;
+  };
+  std::vector<MultiScaleAlgorithm::ScaleInfo> _scaleInfos;
+  std::vector<double> _manualScaleList;
+
+  bool _trackPerScaleMasks, _usePerScaleMasks, _fastSubMinorLoop,
+      _trackComponents;
+  std::vector<aocommon::UVector<bool>> _scaleMasks;
+  aocommon::cloned_ptr<ComponentList> _componentList;
+
+  void initializeScaleInfo(size_t minWidthHeight);
+  void convolvePSFs(std::unique_ptr<aocommon::Image[]>& convolvedPSFs,
+                    const aocommon::Image& psf, aocommon::Image& scratch,
+                    bool isIntegrated);
+  void findActiveScaleConvolvedMaxima(const ImageSet& imageSet,
+                                      aocommon::Image& integratedScratch,
+                                      aocommon::Image& scratch, bool reportRMS,
+                                      ThreadedDeconvolutionTools* tools);
+  bool selectMaximumScale(size_t& scaleWithPeak);
+  void activateScales(size_t scaleWithLastPeak);
+  void measureComponentValues(aocommon::UVector<float>& componentValues,
+                              size_t scaleIndex, ImageSet& imageSet);
+  void addComponentToModel(ImageSet& modelSet, size_t imgIndex,
+                           size_t scaleWithPeak, float componentValue);
+
+  void findPeakDirect(const aocommon::Image& image, aocommon::Image& scratch,
+                      size_t scaleIndex);
+
+  void getConvolutionDimensions(size_t scaleIndex, size_t width, size_t height,
+                                size_t& width_out, size_t& height_out) const;
+};
+}  // namespace radler::algorithms
+#endif  // RADLER_ALGORITHMS_MULTISCALE_ALGORITHM_H_
diff --git a/cpp/algorithms/parallel_deconvolution.cc b/cpp/algorithms/parallel_deconvolution.cc
new file mode 100644
index 00000000..910f24b9
--- /dev/null
+++ b/cpp/algorithms/parallel_deconvolution.cc
@@ -0,0 +1,436 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "algorithms/parallel_deconvolution.h"
+
+#include <aocommon/parallelfor.h>
+#include <aocommon/units/fluxdensity.h>
+
+#include <schaapcommon/fft/convolution.h>
+
+#include "algorithms/multiscale_algorithm.h"
+#include "math/dijkstrasplitter.h"
+
+using aocommon::Image;
+using aocommon::Logger;
+
+namespace radler::algorithms {
+
+ParallelDeconvolution::ParallelDeconvolution(
+    const DeconvolutionSettings& deconvolutionSettings)
+    : _horImages(0),
+      _verImages(0),
+      _settings(deconvolutionSettings),
+      _allocator(nullptr),
+      _mask(nullptr),
+      _trackPerScaleMasks(false),
+      _usePerScaleMasks(false) {
+  // Make all FFTWF plan calls inside ParallelDeconvolution
+  // thread safe.
+  schaapcommon::fft::MakeFftwfPlannerThreadSafe();
+}
+
+ParallelDeconvolution::~ParallelDeconvolution() {}
+
+ComponentList ParallelDeconvolution::GetComponentList(
+    const DeconvolutionTable& table) const {
+  // TODO make this work with subimages
+  ComponentList list;
+  if (_settings.useMultiscale) {
+    // If no parallel deconvolution was used, the component list must be
+    // retrieved from the deconvolution algorithm.
+    if (_algorithms.size() == 1) {
+      list = static_cast<MultiScaleAlgorithm*>(_algorithms.front().get())
+                 ->GetComponentList();
+    } else {
+      list = *_componentList;
+    }
+  } else {
+    const size_t w = _settings.trimmedImageWidth;
+    const size_t h = _settings.trimmedImageHeight;
+    ImageSet modelSet(table, _settings.squaredJoins,
+                      _settings.linkedPolarizations, w, h);
+    modelSet.LoadAndAverage(false);
+    list = ComponentList(w, h, modelSet);
+  }
+  list.MergeDuplicates();
+  return list;
+}
+
+const DeconvolutionAlgorithm& ParallelDeconvolution::MaxScaleCountAlgorithm()
+    const {
+  if (_settings.useMultiscale) {
+    MultiScaleAlgorithm* maxAlgorithm =
+        static_cast<MultiScaleAlgorithm*>(_algorithms.front().get());
+    for (size_t i = 1; i != _algorithms.size(); ++i) {
+      MultiScaleAlgorithm* mAlg =
+          static_cast<MultiScaleAlgorithm*>(_algorithms[i].get());
+      if (mAlg->ScaleCount() > maxAlgorithm->ScaleCount()) {
+        maxAlgorithm = mAlg;
+      }
+    }
+    return *maxAlgorithm;
+  } else {
+    return FirstAlgorithm();
+  }
+}
+
+void ParallelDeconvolution::SetAlgorithm(
+    std::unique_ptr<DeconvolutionAlgorithm> algorithm) {
+  if (_settings.parallelDeconvolutionMaxSize == 0) {
+    _algorithms.resize(1);
+    _algorithms.front() = std::move(algorithm);
+  } else {
+    const size_t width = _settings.trimmedImageWidth;
+    const size_t height = _settings.trimmedImageHeight;
+    size_t maxSubImageSize = _settings.parallelDeconvolutionMaxSize;
+    _horImages = (width + maxSubImageSize - 1) / maxSubImageSize,
+    _verImages = (height + maxSubImageSize - 1) / maxSubImageSize;
+    _algorithms.resize(_horImages * _verImages);
+    _algorithms.front() = std::move(algorithm);
+    size_t threadsPerAlg =
+        (_settings.parallelDeconvolutionMaxThreads + _algorithms.size() - 1) /
+        _algorithms.size();
+    _algorithms.front()->SetThreadCount(threadsPerAlg);
+    Logger::Debug << "Parallel deconvolution will use " << _algorithms.size()
+                  << " subimages.\n";
+    for (size_t i = 1; i != _algorithms.size(); ++i)
+      _algorithms[i] = _algorithms.front()->Clone();
+  }
+}
+
+void ParallelDeconvolution::SetRMSFactorImage(Image&& image) {
+  if (_settings.parallelDeconvolutionMaxSize == 0)
+    _algorithms.front()->SetRMSFactorImage(std::move(image));
+  else
+    _rmsImage = std::move(image);
+}
+
+void ParallelDeconvolution::SetThreshold(double threshold) {
+  for (auto& alg : _algorithms) alg->SetThreshold(threshold);
+}
+
+void ParallelDeconvolution::SetAutoMaskMode(bool trackPerScaleMasks,
+                                            bool usePerScaleMasks) {
+  _trackPerScaleMasks = trackPerScaleMasks;
+  _usePerScaleMasks = usePerScaleMasks;
+  for (auto& alg : _algorithms) {
+    class MultiScaleAlgorithm& algorithm =
+        static_cast<class MultiScaleAlgorithm&>(*alg);
+    algorithm.SetAutoMaskMode(trackPerScaleMasks, usePerScaleMasks);
+  }
+}
+
+void ParallelDeconvolution::SetCleanMask(const bool* mask) {
+  if (_algorithms.size() == 1)
+    _algorithms.front()->SetCleanMask(mask);
+  else
+    _mask = mask;
+}
+
+void ParallelDeconvolution::SetSpectrallyForcedImages(
+    std::vector<Image>&& images) {
+  if (_algorithms.size() == 1)
+    _algorithms.front()->SetSpectrallyForcedImages(std::move(images));
+  else
+    _spectrallyForcedImages = std::move(images);
+}
+
+void ParallelDeconvolution::runSubImage(
+    SubImage& subImg, ImageSet& dataImage, const ImageSet& modelImage,
+    ImageSet& resultModel, const std::vector<aocommon::Image>& psfImages,
+    double majorIterThreshold, bool findPeakOnly, std::mutex& mutex) {
+  const size_t width = _settings.trimmedImageWidth;
+  const size_t height = _settings.trimmedImageHeight;
+
+  std::unique_ptr<ImageSet> subModel, subData;
+  {
+    std::lock_guard<std::mutex> lock(mutex);
+    subData = dataImage.Trim(subImg.x, subImg.y, subImg.x + subImg.width,
+                             subImg.y + subImg.height, width);
+    // Because the model of this subimage might extend outside of its boundaries
+    // (because of multiscale components), the model is placed back on the image
+    // by adding its values. This requires that values outside the boundary are
+    // set to zero at this point, otherwise multiple subimages could add the
+    // same sources.
+    subModel = modelImage.TrimMasked(
+        subImg.x, subImg.y, subImg.x + subImg.width, subImg.y + subImg.height,
+        width, subImg.boundaryMask.data());
+  }
+
+  // Construct the smaller psfs
+  std::vector<Image> subPsfs;
+  subPsfs.reserve(psfImages.size());
+  for (size_t i = 0; i != psfImages.size(); ++i) {
+    subPsfs.emplace_back(psfImages[i].Trim(subImg.width, subImg.height));
+  }
+  _algorithms[subImg.index]->SetCleanMask(subImg.mask.data());
+
+  // Construct smaller RMS image if necessary
+  if (!_rmsImage.Empty()) {
+    Image subRmsImage =
+        _rmsImage.TrimBox(subImg.x, subImg.y, subImg.width, subImg.height);
+    _algorithms[subImg.index]->SetRMSFactorImage(std::move(subRmsImage));
+  }
+
+  // If a forced spectral image is active, trim it to the subimage size
+  if (!_spectrallyForcedImages.empty()) {
+    std::vector<Image> subSpectralImages(_spectrallyForcedImages.size());
+    for (size_t i = 0; i != _spectrallyForcedImages.size(); ++i) {
+      subSpectralImages[i] = _spectrallyForcedImages[i].TrimBox(
+          subImg.x, subImg.y, subImg.width, subImg.height);
+    }
+    _algorithms[subImg.index]->SetSpectrallyForcedImages(
+        std::move(subSpectralImages));
+  }
+
+  size_t maxNIter = _algorithms[subImg.index]->MaxNIter();
+  if (findPeakOnly)
+    _algorithms[subImg.index]->SetMaxNIter(0);
+  else
+    _algorithms[subImg.index]->SetMajorIterThreshold(majorIterThreshold);
+
+  if (_usePerScaleMasks || _trackPerScaleMasks) {
+    std::lock_guard<std::mutex> lock(mutex);
+    MultiScaleAlgorithm& msAlg =
+        static_cast<class MultiScaleAlgorithm&>(*_algorithms[subImg.index]);
+    // During the first iteration, msAlg will not have scales/masks yet and the
+    // nr scales has also not been determined yet.
+    if (!_scaleMasks.empty()) {
+      // Here we set the scale mask for the multiscale algorithm.
+      // The maximum number of scales in the previous iteration can be found by
+      // _scaleMasks.size() Not all msAlgs might have used that many scales, so
+      // we have to take this into account
+      msAlg.SetScaleMaskCount(
+          std::max(msAlg.GetScaleMaskCount(), _scaleMasks.size()));
+      for (size_t i = 0; i != msAlg.GetScaleMaskCount(); ++i) {
+        aocommon::UVector<bool>& output = msAlg.GetScaleMask(i);
+        output.assign(subImg.width * subImg.height, false);
+        if (i < _scaleMasks.size())
+          Image::TrimBox(output.data(), subImg.x, subImg.y, subImg.width,
+                         subImg.height, _scaleMasks[i].data(), width, height);
+      }
+    }
+  }
+
+  subImg.peak = _algorithms[subImg.index]->ExecuteMajorIteration(
+      *subData, *subModel, subPsfs, subImg.reachedMajorThreshold);
+
+  // Since this was an RMS image specifically for this subimage size, we free it
+  // immediately
+  _algorithms[subImg.index]->SetRMSFactorImage(Image());
+
+  if (_trackPerScaleMasks) {
+    std::lock_guard<std::mutex> lock(mutex);
+    MultiScaleAlgorithm& msAlg =
+        static_cast<class MultiScaleAlgorithm&>(*_algorithms[subImg.index]);
+    if (_scaleMasks.empty()) {
+      _scaleMasks.resize(msAlg.ScaleCount());
+      for (aocommon::UVector<bool>& scaleMask : _scaleMasks)
+        scaleMask.assign(width * height, false);
+    }
+    for (size_t i = 0; i != msAlg.ScaleCount(); ++i) {
+      const aocommon::UVector<bool>& msMask = msAlg.GetScaleMask(i);
+      if (i < _scaleMasks.size())
+        Image::CopyMasked(_scaleMasks[i].data(), subImg.x, subImg.y, width,
+                          msMask.data(), subImg.width, subImg.height,
+                          subImg.boundaryMask.data());
+    }
+  }
+
+  if (_settings.saveSourceList && _settings.useMultiscale) {
+    std::lock_guard<std::mutex> lock(mutex);
+    MultiScaleAlgorithm& algorithm =
+        static_cast<MultiScaleAlgorithm&>(*_algorithms[subImg.index]);
+    if (!_componentList)
+      _componentList.reset(new ComponentList(
+          width, height, algorithm.ScaleCount(), dataImage.size()));
+    _componentList->Add(algorithm.GetComponentList(), subImg.x, subImg.y);
+    algorithm.ClearComponentList();
+  }
+
+  if (findPeakOnly) {
+    _algorithms[subImg.index]->SetMaxNIter(maxNIter);
+  } else {
+    std::lock_guard<std::mutex> lock(mutex);
+    dataImage.CopyMasked(*subData, subImg.x, subImg.y,
+                         subImg.boundaryMask.data());
+    resultModel.AddSubImage(*subModel, subImg.x, subImg.y);
+  }
+}
+
+void ParallelDeconvolution::ExecuteMajorIteration(
+    ImageSet& dataImage, ImageSet& modelImage,
+    const std::vector<aocommon::Image>& psfImages,
+    bool& reachedMajorThreshold) {
+  if (_algorithms.size() == 1) {
+    aocommon::ForwardingLogReceiver fwdReceiver;
+    _algorithms.front()->SetLogReceiver(fwdReceiver);
+    _algorithms.front()->ExecuteMajorIteration(dataImage, modelImage, psfImages,
+                                               reachedMajorThreshold);
+  } else {
+    executeParallelRun(dataImage, modelImage, psfImages, reachedMajorThreshold);
+  }
+}
+
+void ParallelDeconvolution::executeParallelRun(
+    ImageSet& dataImage, ImageSet& modelImage,
+    const std::vector<aocommon::Image>& psfImages,
+    bool& reachedMajorThreshold) {
+  const size_t width = dataImage.Width();
+  const size_t height = dataImage.Height();
+  const size_t avgHSubImageSize = width / _horImages;
+  const size_t avgVSubImageSize = height / _verImages;
+
+  Image image(width, height);
+  Image dividingLine(width, height, 0.0);
+  aocommon::UVector<bool> largeScratchMask(width * height);
+  dataImage.GetLinearIntegrated(image);
+
+  math::DijkstraSplitter divisor(width, height);
+
+  struct VerticalArea {
+    aocommon::UVector<bool> mask;
+    size_t x, width;
+  };
+  std::vector<VerticalArea> verticalAreas(_horImages);
+
+  Logger::Info << "Calculating edge paths...\n";
+  aocommon::ParallelFor<size_t> splitLoop(_settings.threadCount);
+
+  // Divide into columns (i.e. construct the vertical lines)
+  splitLoop.Run(1, _horImages, [&](size_t divNr, size_t) {
+    size_t splitStart = width * divNr / _horImages - avgHSubImageSize / 4,
+           splitEnd = width * divNr / _horImages + avgHSubImageSize / 4;
+    divisor.DivideVertically(image.Data(), dividingLine.Data(), splitStart,
+                             splitEnd);
+  });
+  for (size_t divNr = 0; divNr != _horImages; ++divNr) {
+    size_t midX = divNr * width / _horImages + avgHSubImageSize / 2;
+    VerticalArea& area = verticalAreas[divNr];
+    divisor.FloodVerticalArea(dividingLine.Data(), midX,
+                              largeScratchMask.data(), area.x, area.width);
+    area.mask.resize(area.width * height);
+    Image::TrimBox(area.mask.data(), area.x, 0, area.width, height,
+                   largeScratchMask.data(), width, height);
+  }
+
+  // Make the rows (horizontal lines)
+  dividingLine = 0.0f;
+  splitLoop.Run(1, _verImages, [&](size_t divNr, size_t) {
+    size_t splitStart = height * divNr / _verImages - avgVSubImageSize / 4,
+           splitEnd = height * divNr / _verImages + avgVSubImageSize / 4;
+    divisor.DivideHorizontally(image.Data(), dividingLine.Data(), splitStart,
+                               splitEnd);
+  });
+
+  Logger::Info << "Calculating bounding boxes and submasks...\n";
+
+  // Find the bounding boxes and clean masks for each subimage
+  aocommon::UVector<bool> mask(width * height);
+  std::vector<SubImage> subImages;
+  for (size_t y = 0; y != _verImages; ++y) {
+    size_t midY = y * height / _verImages + avgVSubImageSize / 2;
+    size_t hAreaY, hAreaWidth;
+    divisor.FloodHorizontalArea(dividingLine.Data(), midY,
+                                largeScratchMask.data(), hAreaY, hAreaWidth);
+
+    for (size_t x = 0; x != _horImages; ++x) {
+      subImages.emplace_back();
+      SubImage& subImage = subImages.back();
+      subImage.index = subImages.size() - 1;
+      const VerticalArea& vArea = verticalAreas[x];
+      divisor.GetBoundingMask(vArea.mask.data(), vArea.x, vArea.width,
+                              largeScratchMask.data(), mask.data(), subImage.x,
+                              subImage.y, subImage.width, subImage.height);
+      Logger::Debug << "Subimage " << subImages.size() << " at (" << subImage.x
+                    << "," << subImage.y << ") - ("
+                    << subImage.x + subImage.width << ","
+                    << subImage.y + subImage.height << ")\n";
+      subImage.mask.resize(subImage.width * subImage.height);
+      Image::TrimBox(subImage.mask.data(), subImage.x, subImage.y,
+                     subImage.width, subImage.height, mask.data(), width,
+                     height);
+      subImage.boundaryMask = subImage.mask;
+      // If a user mask is active, take the union of that mask with the boundary
+      // mask (note that 'mask' is reused as a scratch space)
+      if (_mask != nullptr) {
+        Image::TrimBox(mask.data(), subImage.x, subImage.y, subImage.width,
+                       subImage.height, _mask, width, height);
+        for (size_t i = 0; i != subImage.mask.size(); ++i)
+          subImage.mask[i] = subImage.mask[i] && mask[i];
+      }
+    }
+  }
+  verticalAreas.clear();
+
+  // Initialize loggers
+  std::mutex mutex;
+  _logs.Initialize(_horImages, _verImages);
+  for (size_t i = 0; i != _algorithms.size(); ++i)
+    _algorithms[i]->SetLogReceiver(_logs[i]);
+
+  // Find the starting peak over all subimages
+  aocommon::ParallelFor<size_t> loop(_settings.parallelDeconvolutionMaxThreads);
+  ImageSet resultModel(modelImage, modelImage.Width(), modelImage.Height());
+  resultModel = 0.0;
+  loop.Run(0, _algorithms.size(), [&](size_t index, size_t) {
+    _logs.Activate(index);
+    runSubImage(subImages[index], dataImage, modelImage, resultModel, psfImages,
+                0.0, true, mutex);
+    _logs.Deactivate(index);
+
+    _logs[index].Mute(false);
+    _logs[index].Info << "Sub-image " << index << " returned peak position.\n";
+    _logs[index].Mute(true);
+  });
+  double maxValue = 0.0;
+  size_t indexOfMax = 0;
+  for (SubImage& img : subImages) {
+    if (img.peak > maxValue) {
+      maxValue = img.peak;
+      indexOfMax = img.index;
+    }
+  }
+  Logger::Info << "Subimage " << (indexOfMax + 1) << " has maximum peak of "
+               << aocommon::units::FluxDensity::ToNiceString(maxValue) << ".\n";
+  double mIterThreshold = maxValue * (1.0 - _settings.deconvolutionMGain);
+
+  // Run the deconvolution
+  loop.Run(0, _algorithms.size(), [&](size_t index, size_t) {
+    _logs.Activate(index);
+    runSubImage(subImages[index], dataImage, modelImage, resultModel, psfImages,
+                mIterThreshold, false, mutex);
+    _logs.Deactivate(index);
+
+    _logs[index].Mute(false);
+    _logs[index].Info << "Sub-image " << index
+                      << " finished its deconvolution iteration.\n";
+    _logs[index].Mute(true);
+  });
+  modelImage.SetImages(std::move(resultModel));
+
+  _rmsImage.Reset();
+
+  size_t subImagesFinished = 0;
+  reachedMajorThreshold = false;
+  bool reachedMaxNIter = false;
+  for (SubImage& img : subImages) {
+    if (!img.reachedMajorThreshold) ++subImagesFinished;
+    if (_algorithms[img.index]->IterationNumber() >=
+        _algorithms[img.index]->MaxNIter())
+      reachedMaxNIter = true;
+  }
+  Logger::Info << subImagesFinished << " / " << subImages.size()
+               << " sub-images finished";
+  reachedMajorThreshold = (subImagesFinished != subImages.size());
+  if (reachedMajorThreshold && !reachedMaxNIter)
+    Logger::Info << ": Continue next major iteration.\n";
+  else if (reachedMajorThreshold && reachedMaxNIter) {
+    Logger::Info << ", but nr. of iterations reached at least once: "
+                    "Deconvolution finished.\n";
+    reachedMajorThreshold = false;
+  } else
+    Logger::Info << ": Deconvolution finished.\n";
+}
+}  // namespace radler::algorithms
\ No newline at end of file
diff --git a/cpp/algorithms/parallel_deconvolution.h b/cpp/algorithms/parallel_deconvolution.h
new file mode 100644
index 00000000..22377f1b
--- /dev/null
+++ b/cpp/algorithms/parallel_deconvolution.h
@@ -0,0 +1,105 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_ALGORITHMS_PARALLEL_DECONVOLUTION_H_
+#define RADLER_ALGORITHMS_PARALLEL_DECONVOLUTION_H_
+
+#include <memory>
+#include <mutex>
+#include <vector>
+
+#include <aocommon/image.h>
+#include <aocommon/uvector.h>
+
+#include "component_list.h"
+#include "deconvolution_settings.h"
+#include "image_set.h"
+#include "algorithms/deconvolution_algorithm.h"
+#include "logging/subimage_logset.h"
+
+namespace radler::algorithms {
+
+class ParallelDeconvolution {
+ public:
+  ParallelDeconvolution(const DeconvolutionSettings& deconvolutionSettings);
+
+  ~ParallelDeconvolution();
+
+  DeconvolutionAlgorithm& FirstAlgorithm() { return *_algorithms.front(); }
+  const DeconvolutionAlgorithm& FirstAlgorithm() const {
+    return *_algorithms.front();
+  }
+
+  ComponentList GetComponentList(const DeconvolutionTable& table) const;
+
+  /**
+   * @brief Same as @c FirstAlgorithm , except that for a multi-scale clean
+   * the algorithm with the maximum number of scale counts is returned.
+   */
+  const DeconvolutionAlgorithm& MaxScaleCountAlgorithm() const;
+
+  void SetAllocator(class ImageBufferAllocator* allocator) {
+    _allocator = allocator;
+  }
+
+  void SetAlgorithm(std::unique_ptr<DeconvolutionAlgorithm> algorithm);
+
+  void SetRMSFactorImage(aocommon::Image&& image);
+
+  void SetThreshold(double threshold);
+
+  bool IsInitialized() const { return !_algorithms.empty(); }
+
+  void SetAutoMaskMode(bool trackPerScaleMasks, bool usePerScaleMasks);
+
+  void SetCleanMask(const bool* mask);
+
+  void SetSpectrallyForcedImages(std::vector<aocommon::Image>&& images);
+
+  void ExecuteMajorIteration(ImageSet& dataImage, ImageSet& modelImage,
+                             const std::vector<aocommon::Image>& psfImages,
+                             bool& reachedMajorThreshold);
+
+  void FreeDeconvolutionAlgorithms() {
+    _algorithms.clear();
+    _mask = nullptr;
+  }
+
+ private:
+  void executeParallelRun(ImageSet& dataImage, ImageSet& modelImage,
+                          const std::vector<aocommon::Image>& psfImages,
+                          bool& reachedMajorThreshold);
+
+  struct SubImage {
+    size_t index, x, y, width, height;
+    // Mask to be used during deconvoution (combines user mask with the
+    // boundary mask)
+    aocommon::UVector<bool> mask;
+    // Selects the pixels inside this subimage
+    aocommon::UVector<bool> boundaryMask;
+    double peak;
+    bool reachedMajorThreshold;
+  };
+
+  void runSubImage(SubImage& subImg, ImageSet& dataImage,
+                   const ImageSet& modelImage, ImageSet& resultModel,
+                   const std::vector<aocommon::Image>& psfImages,
+                   double majorIterThreshold, bool findPeakOnly,
+                   std::mutex& mutex);
+
+  std::vector<std::unique_ptr<DeconvolutionAlgorithm>> _algorithms;
+  logging::SubImageLogSet _logs;
+  size_t _horImages;
+  size_t _verImages;
+  // Radler::_settings outlives ParallelDeconvolution::_settings
+  const DeconvolutionSettings& _settings;
+  ImageBufferAllocator* _allocator;
+  const bool* _mask;
+  std::vector<aocommon::Image> _spectrallyForcedImages;
+  bool _trackPerScaleMasks, _usePerScaleMasks;
+  std::vector<aocommon::UVector<bool>> _scaleMasks;
+  std::unique_ptr<class ComponentList> _componentList;
+  aocommon::Image _rmsImage;
+};
+}  // namespace radler::algorithms
+#endif
diff --git a/cpp/algorithms/python_deconvolution.cc b/cpp/algorithms/python_deconvolution.cc
new file mode 100644
index 00000000..c5801c09
--- /dev/null
+++ b/cpp/algorithms/python_deconvolution.cc
@@ -0,0 +1,277 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "algorithms/python_deconvolution.h"
+
+#include <pybind11/attr.h>
+#include <pybind11/embed.h>
+#include <pybind11/eval.h>
+#include <pybind11/numpy.h>
+#include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
+
+namespace radler::algorithms {
+
+struct PyChannel {
+  double frequency, weight;
+};
+
+class PySpectralFitter {
+ public:
+  PySpectralFitter(schaapcommon::fitters::SpectralFitter& fitter)
+      : _fitter(fitter) {}
+
+  pybind11::array_t<double> fit(pybind11::array_t<double> values, size_t x,
+                                size_t y) {
+    if (values.ndim() != 1)
+      throw std::runtime_error(
+          "spectral_fitter.fit(): Invalid dimensions of values array");
+    if (size_t(values.shape()[0]) != _fitter.NFrequencies())
+      throw std::runtime_error(
+          "spectral_fitter.fit(): Incorrect size of values array");
+    aocommon::UVector<float> vec(_fitter.NFrequencies());
+    pybind11::buffer_info info = values.request();
+    const unsigned char* buffer = static_cast<const unsigned char*>(info.ptr);
+    for (size_t i = 0; i != _fitter.NFrequencies(); ++i) {
+      vec[i] = *reinterpret_cast<const double*>(buffer + info.strides[0] * i);
+    }
+    std::vector<float> result;
+    _fitter.Fit(result, vec.data(), x, y);
+
+    pybind11::buffer_info resultBuf(
+        nullptr,  // ask NumPy to allocate
+        sizeof(double), pybind11::format_descriptor<double>::value, 1,
+        {ptrdiff_t(_fitter.NTerms())}, {sizeof(double)}  // Stride
+    );
+    pybind11::array_t<double> pyResult(resultBuf);
+    std::copy_n(result.data(), _fitter.NTerms(),
+                static_cast<double*>(pyResult.request(true).ptr));
+    return pyResult;
+  }
+
+  pybind11::array_t<double> fit_and_evaluate(pybind11::array_t<double> values,
+                                             size_t x, size_t y) {
+    if (values.ndim() != 1)
+      throw std::runtime_error(
+          "spectral_fitter.fit_and_evaluate(): Invalid dimensions of values "
+          "array");
+    if (size_t(values.shape()[0]) != _fitter.NFrequencies())
+      throw std::runtime_error(
+          "spectral_fitter.fit_and_evaluate(): Incorrect size of values array");
+    aocommon::UVector<float> vec(_fitter.NFrequencies());
+    pybind11::buffer_info info = values.request();
+    const unsigned char* buffer = static_cast<const unsigned char*>(info.ptr);
+    for (size_t i = 0; i != _fitter.NFrequencies(); ++i) {
+      vec[i] = *reinterpret_cast<const double*>(buffer + info.strides[0] * i);
+    }
+
+    std::vector<float> fittingScratch;
+    _fitter.FitAndEvaluate(vec.data(), x, y, fittingScratch);
+
+    pybind11::buffer_info resultBuf(
+        nullptr,  // ask NumPy to allocate
+        sizeof(double), pybind11::format_descriptor<double>::value, 1,
+        {ptrdiff_t(_fitter.NFrequencies())}, {sizeof(double)}  // Stride
+    );
+    pybind11::array_t<double> pyResult(resultBuf);
+    std::copy_n(vec.data(), _fitter.NFrequencies(),
+                static_cast<double*>(pyResult.request(true).ptr));
+    return pyResult;
+  }
+
+ private:
+  schaapcommon::fitters::SpectralFitter& _fitter;
+};
+
+struct PyMetaData {
+ public:
+  PyMetaData(schaapcommon::fitters::SpectralFitter& _spectral_fitter)
+      : spectral_fitter(_spectral_fitter) {}
+
+  std::vector<PyChannel> channels;
+  size_t iteration_number;
+  double final_threshold;
+  double gain;
+  size_t max_iterations;
+  double major_iter_threshold;
+  double mgain;
+  PySpectralFitter spectral_fitter;
+  bool square_joined_channels;
+};
+
+PythonDeconvolution::PythonDeconvolution(const std::string& filename)
+    : _filename(filename), _guard(new pybind11::scoped_interpreter()) {
+  pybind11::module main = pybind11::module::import("__main__");
+  pybind11::object scope = main.attr("__dict__");
+  pybind11::eval_file(_filename, scope);
+  _deconvolveFunction = std::make_unique<pybind11::function>(
+      main.attr("deconvolve").cast<pybind11::function>());
+
+  pybind11::class_<PyChannel>(main, "Channel")
+      .def_readwrite("frequency", &PyChannel::frequency)
+      .def_readwrite("weight", &PyChannel::weight);
+
+  pybind11::class_<PyMetaData>(main, "MetaData")
+      .def_readonly("channels", &PyMetaData::channels)
+      .def_readonly("final_threshold", &PyMetaData::final_threshold)
+      .def_readwrite("iteration_number", &PyMetaData::iteration_number)
+      .def_readonly("gain", &PyMetaData::gain)
+      .def_readonly("max_iterations", &PyMetaData::max_iterations)
+      .def_readonly("major_iter_threshold", &PyMetaData::major_iter_threshold)
+      .def_readonly("mgain", &PyMetaData::mgain)
+      .def_readonly("spectral_fitter", &PyMetaData::spectral_fitter)
+      .def_readonly("square_joined_channels",
+                    &PyMetaData::square_joined_channels);
+
+  pybind11::class_<PySpectralFitter>(main, "SpectralFitter")
+      .def("fit", &PySpectralFitter::fit)
+      .def("fit_and_evaluate", &PySpectralFitter::fit_and_evaluate);
+}
+
+PythonDeconvolution::PythonDeconvolution(const PythonDeconvolution& other)
+    : _filename(other._filename),
+      _guard(other._guard),
+      _deconvolveFunction(
+          std::make_unique<pybind11::function>(*other._deconvolveFunction)) {}
+
+PythonDeconvolution::~PythonDeconvolution() {}
+
+void PythonDeconvolution::setBuffer(const ImageSet& imageSet, double* ptr) {
+  size_t nFreq = imageSet.NDeconvolutionChannels();
+  size_t nPol = imageSet.size() / imageSet.NDeconvolutionChannels();
+
+  for (size_t freq = 0; freq != nFreq; ++freq) {
+    for (size_t pol = 0; pol != nPol; ++pol) {
+      const aocommon::Image& image = imageSet[freq * nPol + pol];
+      std::copy_n(image.Data(), image.Size(), ptr);
+      ptr += image.Size();
+    }
+  }
+}
+
+void PythonDeconvolution::getBuffer(ImageSet& imageSet, const double* ptr) {
+  size_t nFreq = imageSet.NDeconvolutionChannels();
+  size_t nPol = imageSet.size() / imageSet.NDeconvolutionChannels();
+
+  for (size_t freq = 0; freq != nFreq; ++freq) {
+    for (size_t pol = 0; pol != nPol; ++pol) {
+      const size_t imageIndex = freq * nPol + pol;
+      float* img = imageSet.Data(imageIndex);
+      const size_t imageSize = imageSet[imageIndex].Size();
+      std::copy_n(ptr, imageSize, img);
+      ptr += imageSize;
+      ;
+    }
+  }
+}
+
+void PythonDeconvolution::setPsf(const std::vector<aocommon::Image>& psfs,
+                                 double* pyPtr, size_t width, size_t height) {
+  size_t nFreq = psfs.size();
+
+  for (size_t freq = 0; freq != nFreq; ++freq) {
+    const float* psf = psfs[freq].Data();
+
+    for (size_t y = 0; y != height; ++y) {
+      for (size_t x = 0; x != width; ++x) pyPtr[x] = psf[x];
+
+      pyPtr += width;
+      psf += width;
+    }
+  }
+}
+
+float PythonDeconvolution::ExecuteMajorIteration(
+    ImageSet& dirtySet, ImageSet& modelSet,
+    const std::vector<aocommon::Image>& psfs, bool& reachedMajorThreshold) {
+  const size_t width = dirtySet.Width();
+  const size_t height = dirtySet.Height();
+  size_t nFreq = dirtySet.NDeconvolutionChannels();
+  size_t nPol = dirtySet.size() / dirtySet.NDeconvolutionChannels();
+
+  pybind11::object result;
+
+  // A new context block is started to destroy the python data arrays asap
+  {
+    // Create Residual array
+    pybind11::buffer_info residualBuf(
+        nullptr,  // ask NumPy to allocate
+        sizeof(double), pybind11::format_descriptor<double>::value, 4,
+        {ptrdiff_t(nFreq), ptrdiff_t(nPol), ptrdiff_t(height),
+         ptrdiff_t(width)},
+        {sizeof(double) * width * height * nPol,
+         sizeof(double) * width * height, sizeof(double) * width,
+         sizeof(double)}  // Strides
+    );
+    pybind11::array_t<double> pyResiduals(residualBuf);
+    setBuffer(dirtySet, static_cast<double*>(pyResiduals.request(true).ptr));
+
+    // Create Model array
+    pybind11::buffer_info modelBuf(
+        nullptr, sizeof(double), pybind11::format_descriptor<double>::value, 4,
+        {ptrdiff_t(nFreq), ptrdiff_t(nPol), ptrdiff_t(height),
+         ptrdiff_t(width)},
+        {sizeof(double) * width * height * nPol,
+         sizeof(double) * width * height, sizeof(double) * width,
+         sizeof(double)});
+    pybind11::array_t<double> pyModel(modelBuf);
+    setBuffer(modelSet, static_cast<double*>(pyModel.request(true).ptr));
+
+    // Create PSF array
+    pybind11::buffer_info psfBuf(
+        nullptr, sizeof(double), pybind11::format_descriptor<double>::value, 3,
+        {ptrdiff_t(nFreq), ptrdiff_t(height), ptrdiff_t(width)},
+        {sizeof(double) * width * height, sizeof(double) * width,
+         sizeof(double)});
+    pybind11::array_t<double> pyPsfs(psfBuf);
+    setPsf(psfs, static_cast<double*>(pyPsfs.request(true).ptr), width, height);
+
+    PyMetaData meta(_spectralFitter);
+    meta.channels.resize(_spectralFitter.NFrequencies());
+    for (size_t i = 0; i != _spectralFitter.NFrequencies(); ++i) {
+      meta.channels[i].frequency = _spectralFitter.Frequency(i);
+      meta.channels[i].weight = _spectralFitter.Weight(i);
+    }
+    meta.gain = _gain;
+    meta.iteration_number = _iterationNumber;
+    meta.major_iter_threshold = _majorIterThreshold;
+    meta.max_iterations = _maxIter;
+    meta.mgain = _mGain;
+    meta.final_threshold = _threshold;
+
+    // Run the python code
+    result = (*_deconvolveFunction)(std::move(pyResiduals), std::move(pyModel),
+                                    std::move(pyPsfs), &meta);
+
+    _iterationNumber = meta.iteration_number;
+  }
+
+  // Extract the results
+  pybind11::object resultDict;
+  try {
+    resultDict = result.cast<pybind11::dict>();
+  } catch (std::exception&) {
+    throw std::runtime_error(
+        "In python deconvolution code: Return value of deconvolve() should be "
+        "a dictionary");
+  }
+  const bool isComplete =
+      resultDict.contains("residual") && resultDict.contains("model") &&
+      resultDict.contains("level") && resultDict.contains("continue");
+  if (!isComplete)
+    throw std::runtime_error(
+        "In python deconvolution code: Dictionary returned by deconvolve() is "
+        "missing items; should have 'residual', 'model', 'level' and "
+        "'continue'");
+  pybind11::array_t<double> residualRes =
+      resultDict["residual"].cast<pybind11::array_t<double>>();
+  getBuffer(dirtySet, static_cast<const double*>(residualRes.request().ptr));
+  pybind11::array_t<double> modelRes =
+      resultDict["model"].cast<pybind11::array_t<double>>();
+  getBuffer(modelSet, static_cast<const double*>(modelRes.request().ptr));
+
+  double level = resultDict["level"].cast<double>();
+  reachedMajorThreshold = resultDict["continue"].cast<bool>();
+  return level;
+}
+}  // namespace radler::algorithms
\ No newline at end of file
diff --git a/cpp/algorithms/python_deconvolution.h b/cpp/algorithms/python_deconvolution.h
new file mode 100644
index 00000000..7f37ca6a
--- /dev/null
+++ b/cpp/algorithms/python_deconvolution.h
@@ -0,0 +1,52 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_ALGORITHMS_PYTHON_DECONVOLUTION_H_
+#define RADLER_ALGORITHMS_PYTHON_DECONVOLUTION_H_
+
+#include <aocommon/uvector.h>
+
+#include <schaapcommon/fitters/spectralfitter.h>
+
+#include "image_set.h"
+#include "algorithms/deconvolution_algorithm.h"
+
+namespace pybind11 {
+// Forward declarations to keep pybind11 symbols internal.
+class scoped_interpreter;
+class function;
+}  // namespace pybind11
+
+namespace radler::algorithms {
+
+class PythonDeconvolution : public DeconvolutionAlgorithm {
+ public:
+  PythonDeconvolution(const std::string& filename);
+
+  PythonDeconvolution(const PythonDeconvolution& other);
+
+  ~PythonDeconvolution() override;
+
+  float ExecuteMajorIteration(ImageSet& dirtySet, ImageSet& modelSet,
+                              const std::vector<aocommon::Image>& psfs,
+                              bool& reachedMajorThreshold) final override;
+
+  virtual std::unique_ptr<DeconvolutionAlgorithm> Clone() const final override {
+    return std::make_unique<PythonDeconvolution>(*this);
+  }
+
+ private:
+  std::string _filename;
+  // A Python interpreter can not be restarted, so the interpreter
+  // needs to live for the entire run
+  std::shared_ptr<pybind11::scoped_interpreter> _guard;
+  std::unique_ptr<pybind11::function> _deconvolveFunction;
+
+  void setBuffer(const ImageSet& imageSet, double* pyPtr);
+  void setPsf(const std::vector<aocommon::Image>& psfs, double* pyPtr,
+              size_t width, size_t height);
+  void getBuffer(ImageSet& imageSet, const double* pyPtr);
+};
+}  // namespace radler::algorithms
+
+#endif  // RADLER_ALGORITHMS_PYTHON_DECONVOLUTION_H_
diff --git a/cpp/algorithms/simple_clean.cc b/cpp/algorithms/simple_clean.cc
new file mode 100644
index 00000000..ee19c851
--- /dev/null
+++ b/cpp/algorithms/simple_clean.cc
@@ -0,0 +1,179 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "algorithms/simple_clean.h"
+
+#include <algorithm>
+
+#ifdef __SSE__
+#define USE_INTRINSICS
+#endif
+
+#ifdef USE_INTRINSICS
+#include <emmintrin.h>
+#include <immintrin.h>
+#endif
+
+namespace radler {
+namespace algorithms {
+void SimpleClean::SubtractImage(float* image, const float* psf, size_t width,
+                                size_t height, size_t x, size_t y,
+                                float factor) {
+  size_t startX, startY, endX, endY;
+  int offsetX = (int)x - width / 2, offsetY = (int)y - height / 2;
+
+  if (offsetX > 0)
+    startX = offsetX;
+  else
+    startX = 0;
+
+  if (offsetY > 0)
+    startY = offsetY;
+  else
+    startY = 0;
+
+  endX = x + width / 2;
+  if (endX > width) endX = width;
+
+  bool isAligned = ((endX - startX) % 2) == 0;
+  if (!isAligned) --endX;
+
+  endY = y + height / 2;
+  if (endY > height) endY = height;
+
+  for (size_t ypos = startY; ypos != endY; ++ypos) {
+    float* imageIter = image + ypos * width + startX;
+    const float* psfIter = psf + (ypos - offsetY) * width + startX - offsetX;
+    for (size_t xpos = startX; xpos != endX; xpos++) {
+      // I've SSE-ified this, but it didn't improve speed at all :-/
+      // (Compiler probably already did it)
+      *imageIter -= (*psfIter * factor);
+      //*(imageIter+1) = *(imageIter+1) - (*(psfIter+1) * factor);
+      ++imageIter;
+      ++psfIter;
+    }
+  }
+}
+
+void SimpleClean::PartialSubtractImage(float* image, const float* psf,
+                                       size_t width, size_t height, size_t x,
+                                       size_t y, float factor, size_t startY,
+                                       size_t endY) {
+  size_t startX, endX;
+  int offsetX = (int)x - width / 2, offsetY = (int)y - height / 2;
+
+  if (offsetX > 0)
+    startX = offsetX;
+  else
+    startX = 0;
+
+  if (offsetY > (int)startY) startY = offsetY;
+
+  endX = x + width / 2;
+  if (endX > width) endX = width;
+
+  bool isAligned = ((endX - startX) % 2) == 0;
+  if (!isAligned) --endX;
+
+  endY = std::min(y + height / 2, endY);
+
+  for (size_t ypos = startY; ypos < endY; ++ypos) {
+    float* imageIter = image + ypos * width + startX;
+    const float* psfIter = psf + (ypos - offsetY) * width + startX - offsetX;
+    for (size_t xpos = startX; xpos != endX; xpos += 2) {
+      *imageIter = *imageIter - (*psfIter * factor);
+      *(imageIter + 1) = *(imageIter + 1) - (*(psfIter + 1) * factor);
+      imageIter += 2;
+      psfIter += 2;
+    }
+    if (!isAligned) *imageIter -= *psfIter * factor;
+  }
+}
+
+void SimpleClean::PartialSubtractImage(float* image, size_t imgWidth,
+                                       size_t /*imgHeight*/, const float* psf,
+                                       size_t psfWidth, size_t psfHeight,
+                                       size_t x, size_t y, float factor,
+                                       size_t startY, size_t endY) {
+  size_t startX, endX;
+  int offsetX = (int)x - psfWidth / 2, offsetY = (int)y - psfHeight / 2;
+
+  if (offsetX > 0)
+    startX = offsetX;
+  else
+    startX = 0;
+
+  if (offsetY > (int)startY) startY = offsetY;
+
+  endX = std::min(x + psfWidth / 2, imgWidth);
+
+  bool isAligned = ((endX - startX) % 2) == 0;
+  if (!isAligned) --endX;
+
+  endY = std::min(y + psfHeight / 2, endY);
+
+  for (size_t ypos = startY; ypos < endY; ++ypos) {
+    float* imageIter = image + ypos * imgWidth + startX;
+    const float* psfIter = psf + (ypos - offsetY) * psfWidth + startX - offsetX;
+    for (size_t xpos = startX; xpos != endX; xpos += 2) {
+      *imageIter = *imageIter - (*psfIter * factor);
+      *(imageIter + 1) = *(imageIter + 1) - (*(psfIter + 1) * factor);
+      imageIter += 2;
+      psfIter += 2;
+    }
+    if (!isAligned) *imageIter -= *psfIter * factor;
+  }
+}
+
+#if defined __AVX__ && defined USE_INTRINSICS
+void SimpleClean::PartialSubtractImageAVX(double* image, size_t imgWidth,
+                                          size_t /*imgHeight*/,
+                                          const double* psf, size_t psfWidth,
+                                          size_t psfHeight, size_t x, size_t y,
+                                          double factor, size_t startY,
+                                          size_t endY) {
+  size_t startX, endX;
+  int offsetX = (int)x - psfWidth / 2, offsetY = (int)y - psfHeight / 2;
+
+  if (offsetX > 0)
+    startX = offsetX;
+  else
+    startX = 0;
+
+  if (offsetY > (int)startY) startY = offsetY;
+
+  endX = std::min(x + psfWidth / 2, imgWidth);
+
+  size_t unAlignedCount = (endX - startX) % 4;
+  endX -= unAlignedCount;
+
+  endY = std::min(y + psfHeight / 2, endY);
+
+  const __m256d mFactor = _mm256_set1_pd(-factor);
+  for (size_t ypos = startY; ypos < endY; ++ypos) {
+    double* imageIter = image + ypos * imgWidth + startX;
+    const double* psfIter =
+        psf + (ypos - offsetY) * psfWidth + startX - offsetX;
+    for (size_t xpos = startX; xpos != endX; xpos += 4) {
+      __m256d imgVal = _mm256_loadu_pd(imageIter),
+              psfVal = _mm256_loadu_pd(psfIter);
+#ifdef __FMA__
+      _mm256_storeu_pd(imageIter, _mm256_fmadd_pd(psfVal, mFactor, imgVal));
+#else
+      _mm256_storeu_pd(imageIter,
+                       _mm256_add_pd(imgVal, _mm256_mul_pd(psfVal, mFactor)));
+#endif
+      imageIter += 4;
+      psfIter += 4;
+    }
+    for (size_t xpos = endX; xpos != endX + unAlignedCount; ++xpos) {
+      *imageIter -= *psfIter * factor;
+      ++imageIter;
+      ++psfIter;
+    }
+  }
+}
+
+#endif
+}  // namespace algorithms
+}  // namespace radler
diff --git a/cpp/algorithms/simple_clean.h b/cpp/algorithms/simple_clean.h
new file mode 100644
index 00000000..e906d388
--- /dev/null
+++ b/cpp/algorithms/simple_clean.h
@@ -0,0 +1,40 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_ALGORITHMS_SIMPLE_CLEAN_H_
+#define RADLER_ALGORITHMS_SIMPLE_CLEAN_H_
+
+#include <cstring>
+
+#ifdef __SSE__
+#define USE_INTRINSICS
+#endif
+
+namespace radler::algorithms {
+
+class SimpleClean {
+ public:
+  SimpleClean() = delete;
+  static void SubtractImage(float* image, const float* psf, size_t width,
+                            size_t height, size_t x, size_t y, float factor);
+
+  static void PartialSubtractImage(float* image, const float* psf, size_t width,
+                                   size_t height, size_t x, size_t y,
+                                   float factor, size_t startY, size_t endY);
+
+  static void PartialSubtractImage(float* image, size_t imgWidth,
+                                   size_t imgHeight, const float* psf,
+                                   size_t psfWidth, size_t psfHeight, size_t x,
+                                   size_t y, float factor, size_t startY,
+                                   size_t endY);
+
+#if defined __AVX__ && defined USE_INTRINSICS
+  static void PartialSubtractImageAVX(double* image, size_t imgWidth,
+                                      size_t imgHeight, const double* psf,
+                                      size_t psfWidth, size_t psfHeight,
+                                      size_t x, size_t y, double factor,
+                                      size_t startY, size_t endY);
+#endif
+};
+}  // namespace radler::algorithms
+#endif  // RADLER_ALGORITHMS_SIMPLE_CLEAN_H_
diff --git a/cpp/algorithms/subminor_loop.cc b/cpp/algorithms/subminor_loop.cc
new file mode 100644
index 00000000..2d1fdd00
--- /dev/null
+++ b/cpp/algorithms/subminor_loop.cc
@@ -0,0 +1,232 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "subminor_loop.h"
+
+#include <schaapcommon/fft/convolution.h>
+
+using aocommon::Image;
+
+namespace radler::algorithms {
+
+template <bool AllowNegatives>
+size_t SubMinorModel::GetMaxComponent(Image& scratch, float& maxValue) const {
+  _residual->GetLinearIntegrated(scratch);
+  if (!_rmsFactorImage.Empty()) {
+    for (size_t i = 0; i != size(); ++i) scratch[i] *= _rmsFactorImage[i];
+  }
+  size_t maxComponent = 0;
+  maxValue = scratch[0];
+  for (size_t i = 0; i != size(); ++i) {
+    float value;
+    if (AllowNegatives)
+      value = std::fabs(scratch[i]);
+    else
+      value = scratch[i];
+    if (value > maxValue) {
+      maxComponent = i;
+      maxValue = value;
+    }
+  }
+  maxValue = scratch[maxComponent];  // If it was negative, make sure a negative
+                                     // value is returned
+  return maxComponent;
+}
+
+std::optional<float> SubMinorLoop::Run(
+    ImageSet& convolvedResidual,
+    const std::vector<aocommon::Image>& twiceConvolvedPsfs) {
+  _subMinorModel = SubMinorModel(_width, _height);
+
+  findPeakPositions(convolvedResidual);
+
+  _subMinorModel.MakeSets(convolvedResidual);
+  if (!_rmsFactorImage.Empty())
+    _subMinorModel.MakeRMSFactorImage(_rmsFactorImage);
+  _logReceiver.Debug << "Number of components selected > " << _threshold << ": "
+                     << _subMinorModel.size() << '\n';
+
+  if (_subMinorModel.size() == 0) return std::optional<float>();
+
+  Image scratch(_subMinorModel.size(), 1);
+  float maxValue;
+  size_t maxComponent = _subMinorModel.GetMaxComponent(
+      scratch, maxValue, _allowNegativeComponents);
+  std::vector<float> fittingScratch;
+
+  while (std::fabs(maxValue) > _threshold &&
+         _currentIteration < _maxIterations &&
+         (!_stopOnNegativeComponent || maxValue >= 0.0)) {
+    aocommon::UVector<float> componentValues(_subMinorModel.Residual().size());
+    for (size_t imgIndex = 0; imgIndex != _subMinorModel.Residual().size();
+         ++imgIndex)
+      componentValues[imgIndex] =
+          _subMinorModel.Residual()[imgIndex][maxComponent] * _gain;
+    _fluxCleaned += maxValue * _gain;
+
+    const size_t x = _subMinorModel.X(maxComponent),
+                 y = _subMinorModel.Y(maxComponent);
+
+    if (_fitter)
+      _fitter->FitAndEvaluate(componentValues.data(), x, y, fittingScratch);
+
+    for (size_t imgIndex = 0; imgIndex != _subMinorModel.Model().size();
+         ++imgIndex)
+      _subMinorModel.Model().Data(imgIndex)[maxComponent] +=
+          componentValues[imgIndex];
+
+    /*
+      Commented out because even in verbose mode this is a bit too verbose, but
+    useful in case divergence occurs: _logReceiver.Debug << x << ", " << y << "
+    " << maxValue << " -> "; for(size_t imgIndex=0;
+    imgIndex!=_clarkModel.Model().size(); ++imgIndex) _logReceiver.Debug <<
+    componentValues[imgIndex] << ' '; _logReceiver.Debug << '\n';
+    */
+    for (size_t imgIndex = 0; imgIndex != _subMinorModel.Residual().size();
+         ++imgIndex) {
+      float* image = _subMinorModel.Residual().Data(imgIndex);
+      const aocommon::Image& psf =
+          twiceConvolvedPsfs[_subMinorModel.Residual().PSFIndex(imgIndex)];
+      float psfFactor = componentValues[imgIndex];
+      for (size_t px = 0; px != _subMinorModel.size(); ++px) {
+        int psfX = _subMinorModel.X(px) - x + _width / 2;
+        int psfY = _subMinorModel.Y(px) - y + _height / 2;
+        if (psfX >= 0 && psfX < int(_width) && psfY >= 0 && psfY < int(_height))
+          image[px] -= psf[psfX + psfY * _width] * psfFactor;
+      }
+    }
+
+    maxComponent = _subMinorModel.GetMaxComponent(scratch, maxValue,
+                                                  _allowNegativeComponents);
+    ++_currentIteration;
+  }
+  return maxValue;
+}
+
+void SubMinorModel::MakeSets(const ImageSet& residualSet) {
+  _residual = std::make_unique<ImageSet>(residualSet, size(), 1);
+  _model = std::make_unique<ImageSet>(residualSet, size(), 1);
+  *_model = 0.0;
+  for (size_t imgIndex = 0; imgIndex != _model->size(); ++imgIndex) {
+    const float* sourceResidual = residualSet[imgIndex].Data();
+    float* destResidual = _residual->Data(imgIndex);
+    for (size_t pxIndex = 0; pxIndex != size(); ++pxIndex) {
+      size_t srcIndex =
+          _positions[pxIndex].second * _width + _positions[pxIndex].first;
+      destResidual[pxIndex] = sourceResidual[srcIndex];
+    }
+  }
+}
+
+void SubMinorModel::MakeRMSFactorImage(Image& rmsFactorImage) {
+  _rmsFactorImage = Image(size(), 1);
+  for (size_t pxIndex = 0; pxIndex != size(); ++pxIndex) {
+    size_t srcIndex =
+        _positions[pxIndex].second * _width + _positions[pxIndex].first;
+    _rmsFactorImage[pxIndex] = rmsFactorImage[srcIndex];
+  }
+}
+
+void SubMinorLoop::findPeakPositions(ImageSet& convolvedResidual) {
+  Image integratedScratch(_width, _height);
+  convolvedResidual.GetLinearIntegrated(integratedScratch);
+
+  if (!_rmsFactorImage.Empty()) {
+    integratedScratch *= _rmsFactorImage;
+  }
+
+  const size_t xiStart = _horizontalBorder,
+               xiEnd = std::max<long>(xiStart, _width - _horizontalBorder),
+               yiStart = _verticalBorder,
+               yiEnd = std::max<long>(yiStart, _height - _verticalBorder);
+
+  if (_mask) {
+    for (size_t y = yiStart; y != yiEnd; ++y) {
+      const bool* maskPtr = _mask + y * _width;
+      float* imagePtr = integratedScratch.Data() + y * _width;
+      for (size_t x = xiStart; x != xiEnd; ++x) {
+        float value;
+        if (_allowNegativeComponents)
+          value = fabs(imagePtr[x]);
+        else
+          value = imagePtr[x];
+        if (value >= _threshold && maskPtr[x]) _subMinorModel.AddPosition(x, y);
+      }
+    }
+  } else {
+    for (size_t y = yiStart; y != yiEnd; ++y) {
+      float* imagePtr = integratedScratch.Data() + y * _width;
+      for (size_t x = xiStart; x != xiEnd; ++x) {
+        float value;
+        if (_allowNegativeComponents)
+          value = fabs(imagePtr[x]);
+        else
+          value = imagePtr[x];
+        if (value >= _threshold) _subMinorModel.AddPosition(x, y);
+      }
+    }
+  }
+}
+
+void SubMinorLoop::GetFullIndividualModel(size_t imageIndex,
+                                          float* individualModelImg) const {
+  std::fill(individualModelImg, individualModelImg + _width * _height, 0.0);
+  const float* data = _subMinorModel.Model()[imageIndex].Data();
+  for (size_t px = 0; px != _subMinorModel.size(); ++px) {
+    individualModelImg[_subMinorModel.FullIndex(px)] = data[px];
+  }
+}
+
+void SubMinorLoop::CorrectResidualDirty(float* scratchA, float* scratchB,
+                                        float* scratchC, size_t imageIndex,
+                                        float* residual,
+                                        const float* singleConvolvedPsf) const {
+  // Get padded kernel in scratchB
+  Image::Untrim(scratchA, _paddedWidth, _paddedHeight, singleConvolvedPsf,
+                _width, _height);
+  schaapcommon::fft::PrepareConvolutionKernel(scratchB, scratchA, _paddedWidth,
+                                              _paddedHeight, _threadCount);
+
+  // Get padded model image in scratchA
+  GetFullIndividualModel(imageIndex, scratchC);
+  Image::Untrim(scratchA, _paddedWidth, _paddedHeight, scratchC, _width,
+                _height);
+
+  // Convolve and store in scratchA
+  schaapcommon::fft::Convolve(scratchA, scratchB, _paddedWidth, _paddedHeight,
+                              _threadCount);
+
+  // Trim the result into scratchC
+  Image::Trim(scratchC, _width, _height, scratchA, _paddedWidth, _paddedHeight);
+
+  for (size_t i = 0; i != _width * _height; ++i) residual[i] -= scratchC[i];
+}
+
+void SubMinorLoop::UpdateAutoMask(bool* mask) const {
+  for (size_t imageIndex = 0; imageIndex != _subMinorModel.Model().size();
+       ++imageIndex) {
+    const aocommon::Image& image = _subMinorModel.Model()[imageIndex];
+    for (size_t px = 0; px != _subMinorModel.size(); ++px) {
+      if (image[px] != 0.0) mask[_subMinorModel.FullIndex(px)] = true;
+    }
+  }
+}
+
+void SubMinorLoop::UpdateComponentList(ComponentList& list,
+                                       size_t scaleIndex) const {
+  aocommon::UVector<float> values(_subMinorModel.Model().size());
+  for (size_t px = 0; px != _subMinorModel.size(); ++px) {
+    bool isNonZero = false;
+    for (size_t imageIndex = 0; imageIndex != _subMinorModel.Model().size();
+         ++imageIndex) {
+      values[imageIndex] = _subMinorModel.Model()[imageIndex][px];
+      if (values[imageIndex] != 0.0) isNonZero = true;
+    }
+    if (isNonZero) {
+      size_t posIndex = _subMinorModel.FullIndex(px);
+      size_t x = posIndex % _width, y = posIndex / _width;
+      list.Add(x, y, scaleIndex, values.data());
+    }
+  }
+}
+}  // namespace radler::algorithms
\ No newline at end of file
diff --git a/cpp/algorithms/subminor_loop.h b/cpp/algorithms/subminor_loop.h
new file mode 100644
index 00000000..0228a922
--- /dev/null
+++ b/cpp/algorithms/subminor_loop.h
@@ -0,0 +1,209 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_ALGORITHMS_SUB_MINOR_LOOP_H_
+#define RADLER_ALGORITHMS_SUB_MINOR_LOOP_H_
+
+#include <cstring>
+#include <optional>
+#include <vector>
+
+#include <aocommon/image.h>
+#include <aocommon/logger.h>
+
+#include "component_list.h"
+#include "image_set.h"
+
+namespace radler::algorithms {
+/**
+ * In multi-scale, a subminor optimized loop looks like this:
+ *
+ * IterateAndMakeModel():
+ * - Make a set S with positions of all the components larger than 'threshold',
+ * which are also in the mask
+ * - Find the largest component in S
+ * Loop {
+ * - Measure the largest component per frequency (from S)
+ * - Store the model component in S
+ * - Subtract this component multiplied with the twice convolved PSF and gain
+ * from all components in S (per individual image)
+ * - Find the new largest component in S
+ * }
+ *
+ * CorrectResidualDirty():
+ * For each individual image {
+ * - Put the model components from S onto a full image (using
+ * GetFullIndividualModel())
+ * - Convolve the model with the SingleConvolvedPSF
+ * - Subtract the convolved model from the residual
+ * }
+ *
+ * Finalization:
+ * - Put the model components from S onto a full image (using
+ * GetFullIndividualModel())
+ * - Convolve the model image with the scale kernel
+ * - Add the model components to the full model
+ *
+ * A subminor loop has some correspondance with the so-called Clark
+ * optimization. However, this implementation has some differences, e.g. by
+ * collecting a list of threshold components prior of entering the subminor
+ * loop.
+ */
+
+class SubMinorModel {
+ public:
+  SubMinorModel(size_t width, size_t /*height*/) : _width(width) {}
+
+  void AddPosition(size_t x, size_t y) {
+    _positions.push_back(std::make_pair(x, y));
+  }
+
+  /**
+   * Return number of selected pixels.
+   */
+  size_t size() const { return _positions.size(); }
+
+  void MakeSets(const ImageSet& templateSet);
+  void MakeRMSFactorImage(aocommon::Image& rmsFactorImage);
+
+  ImageSet& Residual() { return *_residual; }
+  const ImageSet& Residual() const { return *_residual; }
+
+  ImageSet& Model() { return *_model; }
+  const ImageSet& Model() const { return *_model; }
+
+  size_t X(size_t index) const { return _positions[index].first; }
+  size_t Y(size_t index) const { return _positions[index].second; }
+  size_t FullIndex(size_t index) const { return X(index) + Y(index) * _width; }
+  template <bool AllowNegatives>
+  size_t GetMaxComponent(aocommon::Image& scratch, float& maxValue) const;
+  size_t GetMaxComponent(aocommon::Image& scratch, float& maxValue,
+                         bool allowNegatives) const {
+    if (allowNegatives)
+      return GetMaxComponent<true>(scratch, maxValue);
+    else
+      return GetMaxComponent<false>(scratch, maxValue);
+  }
+
+ private:
+  std::vector<std::pair<size_t, size_t>> _positions;
+  std::unique_ptr<ImageSet> _residual, _model;
+  aocommon::Image _rmsFactorImage;
+  size_t _width;
+};
+
+class SubMinorLoop {
+ public:
+  SubMinorLoop(size_t width, size_t height, size_t convolutionWidth,
+               size_t convolutionHeight, aocommon::LogReceiver& logReceiver)
+      : _width(width),
+        _height(height),
+        _paddedWidth(convolutionWidth),
+        _paddedHeight(convolutionHeight),
+        _threshold(0.0),
+        _consideredPixelThreshold(0.0),
+        _gain(0.0),
+        _horizontalBorder(0),
+        _verticalBorder(0),
+        _currentIteration(0),
+        _maxIterations(0),
+        _allowNegativeComponents(true),
+        _stopOnNegativeComponent(false),
+        _mask(nullptr),
+        _fitter(nullptr),
+        _subMinorModel(width, height),
+        _fluxCleaned(0.0),
+        _logReceiver(logReceiver),
+        _threadCount(1) {}
+
+  /**
+   * @param threshold The threshold to which this subminor run should clean
+   * @param consideredPixelThreshold The threshold that is used to determine
+   * whether a pixel is considered. Typically, this is similar to threshold, but
+   * it can be set lower if it is important that all peak values are below the
+   * threshold, as otherwise some pixels might not be considered but get
+   * increased by the cleaning, thereby stay above the threshold. This is
+   * important for making multi-scale clean efficient near a stopping threshold.
+   */
+  void SetThreshold(float threshold, float consideredPixelThreshold) {
+    _threshold = threshold;
+    _consideredPixelThreshold = consideredPixelThreshold;
+  }
+
+  void SetIterationInfo(size_t currentIteration, size_t maxIterations) {
+    _currentIteration = currentIteration;
+    _maxIterations = maxIterations;
+  }
+
+  void SetGain(float gain) { _gain = gain; }
+
+  void SetAllowNegativeComponents(bool allowNegativeComponents) {
+    _allowNegativeComponents = allowNegativeComponents;
+  }
+
+  void SetStopOnNegativeComponent(bool stopOnNegativeComponent) {
+    _stopOnNegativeComponent = stopOnNegativeComponent;
+  }
+
+  void SetSpectralFitter(const schaapcommon::fitters::SpectralFitter* fitter) {
+    _fitter = fitter;
+  }
+
+  void SetCleanBorders(size_t horizontalBorder, size_t verticalBorder) {
+    _horizontalBorder = horizontalBorder;
+    _verticalBorder = verticalBorder;
+  }
+
+  void SetMask(const bool* mask) { _mask = mask; }
+
+  void SetRMSFactorImage(const aocommon::Image& image) {
+    _rmsFactorImage = image;
+  }
+
+  void SetThreadCount(size_t threadCount) { _threadCount = threadCount; }
+
+  size_t CurrentIteration() const { return _currentIteration; }
+
+  float FluxCleaned() const { return _fluxCleaned; }
+
+  std::optional<float> Run(
+      ImageSet& convolvedResidual,
+      const std::vector<aocommon::Image>& twiceConvolvedPsfs);
+
+  /**
+   * The produced model is convolved with the given psf, and the result is
+   * subtracted from the given residual image. To be called after Run(). After
+   * this method, the residual will hold the result of the subminor loop run.
+   * scratchA and scratchB need to be able to store the full padded image
+   * (_untrimmedWidth x _untrimmedHeight). scratchC only needs to store the
+   * trimmed size (_width x _height).
+   */
+  void CorrectResidualDirty(float* scratchA, float* scratchB, float* scratchC,
+                            size_t imageIndex, float* residual,
+                            const float* singleConvolvedPsf) const;
+
+  void GetFullIndividualModel(size_t imageIndex,
+                              float* individualModelImg) const;
+
+  void UpdateAutoMask(bool* mask) const;
+
+  void UpdateComponentList(ComponentList& list, size_t scaleIndex) const;
+
+ private:
+  void findPeakPositions(ImageSet& convolvedResidual);
+
+  size_t _width, _height, _paddedWidth, _paddedHeight;
+  float _threshold, _consideredPixelThreshold, _gain;
+  size_t _horizontalBorder, _verticalBorder;
+  size_t _currentIteration, _maxIterations;
+  bool _allowNegativeComponents, _stopOnNegativeComponent;
+  const bool* _mask;
+  const schaapcommon::fitters::SpectralFitter* _fitter;
+  SubMinorModel _subMinorModel;
+  float _fluxCleaned;
+  aocommon::Image _rmsFactorImage;
+  aocommon::LogReceiver& _logReceiver;
+  size_t _threadCount;
+};
+}  // namespace radler::algorithms
+#endif  // RADLER_ALGORITHMS_SUB_MINOR_LOOP_H_
diff --git a/cpp/algorithms/test/CMakeLists.txt b/cpp/algorithms/test/CMakeLists.txt
new file mode 100644
index 00000000..eaef1206
--- /dev/null
+++ b/cpp/algorithms/test/CMakeLists.txt
@@ -0,0 +1,8 @@
+# Copyright (C) 2020 ASTRON (Netherlands Institute for Radio Astronomy)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+include(${PROJECT_SOURCE_DIR}/cmake/unittest.cmake)
+
+add_definitions(-DBOOST_ERROR_CODE_HEADER_ONLY)
+
+add_unittest(radler_algorithms runtests.cc test_simple_clean.cc)
diff --git a/cpp/algorithms/test/runtests.cc b/cpp/algorithms/test/runtests.cc
new file mode 100644
index 00000000..294636fe
--- /dev/null
+++ b/cpp/algorithms/test/runtests.cc
@@ -0,0 +1,6 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#define BOOST_TEST_MODULE radler_algorithms
+
+#include <boost/test/unit_test.hpp>
\ No newline at end of file
diff --git a/cpp/algorithms/test/test_simple_clean.cc b/cpp/algorithms/test/test_simple_clean.cc
new file mode 100644
index 00000000..9e77d346
--- /dev/null
+++ b/cpp/algorithms/test/test_simple_clean.cc
@@ -0,0 +1,54 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <boost/test/unit_test.hpp>
+
+#include "algorithms/simple_clean.h"
+
+#include <aocommon/uvector.h>
+
+#include <random>
+
+namespace radler::algorithms {
+BOOST_AUTO_TEST_SUITE(simple_clean)
+
+template <typename NumT>
+struct NoiseFixture {
+  NoiseFixture() : n(2048), psf(n * n, 0.0), img(n * n), normal_dist(0.0, 1.0) {
+    mt.seed(42);
+    for (size_t i = 0; i != n * n; ++i) {
+      img[i] = normal_dist(mt);
+    }
+  }
+
+  size_t n;
+  aocommon::UVector<NumT> psf, img;
+  std::mt19937 mt;
+  std::normal_distribution<NumT> normal_dist;
+};
+
+const size_t nRepeats =
+    3; /* This should be set to 100 to assert the performance */
+
+BOOST_AUTO_TEST_CASE(partialSubtractImagePerformance) {
+  NoiseFixture<float> f;
+  size_t x = f.n / 2, y = f.n / 2;
+  for (size_t repeat = 0; repeat != nRepeats; ++repeat)
+    SimpleClean::PartialSubtractImage(f.img.data(), f.n, f.n, f.psf.data(), f.n,
+                                      f.n, x, y, 0.5, 0, f.n / 2);
+  BOOST_CHECK(true);
+}
+
+#if defined __AVX__ && !defined FORCE_NON_AVX
+BOOST_AUTO_TEST_CASE(partialSubtractImageAVXPerformance) {
+  NoiseFixture<double> f;
+  size_t x = f.n / 2, y = f.n / 2;
+  for (size_t repeat = 0; repeat != nRepeats; ++repeat)
+    SimpleClean::PartialSubtractImageAVX(f.img.data(), f.n, f.n, f.psf.data(),
+                                         f.n, f.n, x, y, 0.5, 0, f.n / 2);
+  BOOST_CHECK(true);
+}
+#endif
+
+BOOST_AUTO_TEST_SUITE_END()
+}  // namespace radler::algorithms
\ No newline at end of file
diff --git a/cpp/algorithms/threaded_deconvolution_tools.cc b/cpp/algorithms/threaded_deconvolution_tools.cc
new file mode 100644
index 00000000..33b4b59f
--- /dev/null
+++ b/cpp/algorithms/threaded_deconvolution_tools.cc
@@ -0,0 +1,197 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "algorithms/threaded_deconvolution_tools.h"
+
+#include <memory>
+
+#include <aocommon/image.h>
+
+#include "algorithms/simple_clean.h"
+#include "math/peak_finder.h"
+
+using aocommon::Image;
+
+namespace radler::algorithms {
+
+ThreadedDeconvolutionTools::ThreadedDeconvolutionTools(size_t threadCount)
+    : _taskLanes(threadCount),
+      _resultLanes(threadCount),
+      _threadCount(threadCount) {
+  for (size_t i = 0; i != _threadCount; ++i) {
+    _taskLanes[i].resize(1);
+    _resultLanes[i].resize(1);
+    _threadGroup.emplace_back(&ThreadedDeconvolutionTools::threadFunc, this,
+                              &_taskLanes[i], &_resultLanes[i]);
+  }
+}
+
+ThreadedDeconvolutionTools::~ThreadedDeconvolutionTools() {
+  for (size_t i = 0; i != _threadCount; ++i) {
+    _taskLanes[i].write_end();
+  }
+
+  for (std::thread& t : _threadGroup) t.join();
+}
+
+void ThreadedDeconvolutionTools::SubtractImage(float* image,
+                                               const aocommon::Image& psf,
+                                               size_t x, size_t y,
+                                               float factor) {
+  for (size_t thr = 0; thr != _threadCount; ++thr) {
+    auto task = std::make_unique<SubtractionTask>();
+    task->image = image;
+    task->psf = &psf;
+    task->x = x;
+    task->y = y;
+    task->factor = factor;
+    task->startY = psf.Height() * thr / _threadCount;
+    task->endY = psf.Height() * (thr + 1) / _threadCount;
+    if (thr == _threadCount - 1) {
+      (*task)();
+    } else {
+      _taskLanes[thr].write(std::move(task));
+    }
+  }
+  for (size_t thr = 0; thr != _threadCount - 1; ++thr) {
+    std::unique_ptr<ThreadResult> result;
+    _resultLanes[thr].read(result);
+  }
+}
+
+std::unique_ptr<ThreadedDeconvolutionTools::ThreadResult>
+ThreadedDeconvolutionTools::SubtractionTask::operator()() {
+  SimpleClean::PartialSubtractImage(image, psf->Data(), psf->Width(),
+                                    psf->Height(), x, y, factor, startY, endY);
+  return std::unique_ptr<ThreadedDeconvolutionTools::ThreadResult>();
+}
+
+void ThreadedDeconvolutionTools::FindMultiScalePeak(
+    multiscale::MultiScaleTransforms* msTransforms, const Image& image,
+    const aocommon::UVector<float>& scales,
+    std::vector<ThreadedDeconvolutionTools::PeakData>& results,
+    bool allowNegativeComponents, const bool* mask,
+    const std::vector<aocommon::UVector<bool>>& scaleMasks, float borderRatio,
+    const Image& rmsFactorImage, bool calculateRMS) {
+  size_t imageIndex = 0;
+  size_t nextThread = 0;
+  size_t resultIndex = 0;
+
+  results.resize(scales.size());
+
+  size_t size = std::min(scales.size(), _threadCount);
+  std::vector<Image> imageData;
+  std::vector<Image> scratchData;
+  imageData.reserve(size);
+  scratchData.reserve(size);
+  for (size_t i = 0; i != size; ++i) {
+    imageData.emplace_back(msTransforms->Width(), msTransforms->Height());
+    scratchData.emplace_back(msTransforms->Width(), msTransforms->Height());
+  }
+
+  while (imageIndex < scales.size()) {
+    std::unique_ptr<FindMultiScalePeakTask> task(new FindMultiScalePeakTask());
+    task->msTransforms = msTransforms;
+    imageData[nextThread] = image;
+    task->image = &imageData[nextThread];
+    task->scratch = &scratchData[nextThread];
+    task->scale = scales[imageIndex];
+    task->allowNegativeComponents = allowNegativeComponents;
+    if (scaleMasks.empty())
+      task->mask = mask;
+    else
+      task->mask = scaleMasks[imageIndex].data();
+    task->borderRatio = borderRatio;
+    task->calculateRMS = calculateRMS;
+    task->rmsFactorImage = &rmsFactorImage;
+    _taskLanes[nextThread].write(std::move(task));
+
+    ++nextThread;
+    if (nextThread == _threadCount) {
+      for (size_t thr = 0; thr != nextThread; ++thr) {
+        std::unique_ptr<ThreadResult> result;
+        _resultLanes[thr].read(result);
+        results[resultIndex].normalizedValue =
+            static_cast<FindMultiScalePeakResult&>(*result).normalizedValue;
+        results[resultIndex].unnormalizedValue =
+            static_cast<FindMultiScalePeakResult&>(*result).unnormalizedValue;
+        results[resultIndex].x =
+            static_cast<FindMultiScalePeakResult&>(*result).x;
+        results[resultIndex].y =
+            static_cast<FindMultiScalePeakResult&>(*result).y;
+        results[resultIndex].rms =
+            static_cast<FindMultiScalePeakResult&>(*result).rms;
+        ++resultIndex;
+      }
+      nextThread = 0;
+    }
+    ++imageIndex;
+  }
+  for (size_t thr = 0; thr != nextThread; ++thr) {
+    std::unique_ptr<ThreadResult> result;
+    _resultLanes[thr].read(result);
+    results[resultIndex].unnormalizedValue =
+        static_cast<FindMultiScalePeakResult&>(*result).unnormalizedValue;
+    results[resultIndex].normalizedValue =
+        static_cast<FindMultiScalePeakResult&>(*result).normalizedValue;
+    results[resultIndex].x = static_cast<FindMultiScalePeakResult&>(*result).x;
+    results[resultIndex].y = static_cast<FindMultiScalePeakResult&>(*result).y;
+    results[resultIndex].rms =
+        static_cast<FindMultiScalePeakResult&>(*result).rms;
+    ++resultIndex;
+  }
+}
+
+std::unique_ptr<ThreadedDeconvolutionTools::ThreadResult>
+ThreadedDeconvolutionTools::FindMultiScalePeakTask::operator()() {
+  msTransforms->Transform(*image, *scratch, scale);
+  const size_t width = msTransforms->Width();
+  const size_t height = msTransforms->Height();
+  const size_t scaleBorder = size_t(std::ceil(scale * 0.5));
+  const size_t horBorderSize =
+      std::max<size_t>(std::round(width * borderRatio), scaleBorder);
+  const size_t vertBorderSize =
+      std::max<size_t>(std::round(height * borderRatio), scaleBorder);
+  std::unique_ptr<FindMultiScalePeakResult> result(
+      new FindMultiScalePeakResult());
+  if (calculateRMS)
+    result->rms = RMS(*image, width * height);
+  else
+    result->rms = -1.0;
+  if (rmsFactorImage->Empty()) {
+    if (mask == nullptr)
+      result->unnormalizedValue = math::PeakFinder::Find(
+          image->Data(), width, height, result->x, result->y,
+          allowNegativeComponents, 0, height, horBorderSize, vertBorderSize);
+    else
+      result->unnormalizedValue = math::PeakFinder::FindWithMask(
+          image->Data(), width, height, result->x, result->y,
+          allowNegativeComponents, 0, height, mask, horBorderSize,
+          vertBorderSize);
+
+    result->normalizedValue = result->unnormalizedValue;
+  } else {
+    for (size_t i = 0; i != rmsFactorImage->Size(); ++i)
+      (*scratch)[i] = (*image)[i] * (*rmsFactorImage)[i];
+
+    if (mask == nullptr)
+      result->unnormalizedValue = math::PeakFinder::Find(
+          scratch->Data(), width, height, result->x, result->y,
+          allowNegativeComponents, 0, height, horBorderSize, vertBorderSize);
+    else
+      result->unnormalizedValue = math::PeakFinder::FindWithMask(
+          scratch->Data(), width, height, result->x, result->y,
+          allowNegativeComponents, 0, height, mask, horBorderSize,
+          vertBorderSize);
+
+    if (result->unnormalizedValue) {
+      result->normalizedValue =
+          (*result->unnormalizedValue) /
+          (*rmsFactorImage)[result->x + result->y * width];
+    } else {
+      result->normalizedValue.reset();
+    }
+  }
+  return result;
+}
+}  // namespace radler::algorithms
\ No newline at end of file
diff --git a/cpp/algorithms/threaded_deconvolution_tools.h b/cpp/algorithms/threaded_deconvolution_tools.h
new file mode 100644
index 00000000..034ac0b5
--- /dev/null
+++ b/cpp/algorithms/threaded_deconvolution_tools.h
@@ -0,0 +1,99 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_ALGORITHMS_THREADED_DECONVOLUTION_TOOLS_H_
+#define RADLER_ALGORITHMS_THREADED_DECONVOLUTION_TOOLS_H_
+
+#include <cmath>
+#include <optional>
+#include <thread>
+#include <vector>
+
+#include <aocommon/image.h>
+#include <aocommon/lane.h>
+#include <aocommon/uvector.h>
+
+#include "algorithms/multiscale/multiscale_transforms.h"
+
+namespace radler::algorithms {
+
+class ThreadedDeconvolutionTools {
+ public:
+  explicit ThreadedDeconvolutionTools(size_t threadCount);
+  ~ThreadedDeconvolutionTools();
+
+  struct PeakData {
+    std::optional<float> normalizedValue, unnormalizedValue;
+    float rms;
+    size_t x, y;
+  };
+
+  void SubtractImage(float* image, const aocommon::Image& psf, size_t x,
+                     size_t y, float factor);
+
+  void FindMultiScalePeak(
+      multiscale::MultiScaleTransforms* msTransforms,
+      const aocommon::Image& image, const aocommon::UVector<float>& scales,
+      std::vector<PeakData>& results, bool allowNegativeComponents,
+      const bool* mask, const std::vector<aocommon::UVector<bool>>& scaleMasks,
+      float borderRatio, const aocommon::Image& rmsFactorImage,
+      bool calculateRMS);
+
+  static float RMS(const aocommon::Image& image, size_t n) {
+    float result = 0.0;
+    for (size_t i = 0; i != n; ++i) result += image[i] * image[i];
+    return std::sqrt(result / float(n));
+  }
+
+ private:
+  struct ThreadResult {};
+  struct FindMultiScalePeakResult : public ThreadResult {
+    std::optional<float> unnormalizedValue, normalizedValue;
+    float rms;
+    size_t x, y;
+  };
+
+  struct ThreadTask {
+    virtual std::unique_ptr<ThreadResult> operator()() = 0;
+    virtual ~ThreadTask() {}
+  };
+  struct SubtractionTask : public ThreadTask {
+    virtual std::unique_ptr<ThreadResult> operator()();
+
+    float* image;
+    const aocommon::Image* psf;
+    size_t x, y;
+    float factor;
+    size_t startY, endY;
+  };
+
+  struct FindMultiScalePeakTask : public ThreadTask {
+    virtual std::unique_ptr<ThreadResult> operator()();
+
+    multiscale::MultiScaleTransforms* msTransforms;
+    aocommon::Image* image;
+    aocommon::Image* scratch;
+    float scale;
+    bool allowNegativeComponents;
+    const bool* mask;
+    float borderRatio;
+    bool calculateRMS;
+    const aocommon::Image* rmsFactorImage;
+  };
+
+  std::vector<aocommon::Lane<std::unique_ptr<ThreadTask>>> _taskLanes;
+  std::vector<aocommon::Lane<std::unique_ptr<ThreadResult>>> _resultLanes;
+  size_t _threadCount;
+  std::vector<std::thread> _threadGroup;
+
+  void threadFunc(aocommon::Lane<std::unique_ptr<ThreadTask>>* taskLane,
+                  aocommon::Lane<std::unique_ptr<ThreadResult>>* resultLane) {
+    std::unique_ptr<ThreadTask> task;
+    while (taskLane->read(task)) {
+      std::unique_ptr<ThreadResult> result = (*task)();
+      resultLane->write(std::move(result));
+    }
+  }
+};
+}  // namespace radler::algorithms
+#endif
diff --git a/cpp/component_list.cc b/cpp/component_list.cc
new file mode 100644
index 00000000..3f41b1d2
--- /dev/null
+++ b/cpp/component_list.cc
@@ -0,0 +1,158 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "component_list.h"
+
+#include "algorithms/deconvolution_algorithm.h"
+#include "algorithms/multiscale_algorithm.h"
+
+#include <aocommon/imagecoordinates.h>
+
+#include "radler.h"
+#include "utils/write_model.h"
+
+using aocommon::ImageCoordinates;
+
+namespace radler {
+void ComponentList::WriteSources(const Radler& radler,
+                                 const std::string& filename,
+                                 long double pixel_scale_x,
+                                 long double pixel_scale_y,
+                                 long double phase_centre_ra,
+                                 long double phase_centre_dec) const {
+  const algorithms::DeconvolutionAlgorithm& deconvolution_algorithm =
+      radler.MaxScaleCountAlgorithm();
+  if (const auto* multiscale_algorithm =
+          dynamic_cast<const algorithms::MultiScaleAlgorithm*>(
+              &deconvolution_algorithm)) {
+    Write(filename, *multiscale_algorithm, pixel_scale_x, pixel_scale_y,
+          phase_centre_ra, phase_centre_dec);
+  } else {
+    WriteSingleScale(filename, deconvolution_algorithm, pixel_scale_x,
+                     pixel_scale_y, phase_centre_ra, phase_centre_dec);
+  }
+}
+
+void ComponentList::Write(const std::string& filename,
+                          const algorithms::MultiScaleAlgorithm& multiscale,
+                          long double pixelScaleX, long double pixelScaleY,
+                          long double phaseCentreRA,
+                          long double phaseCentreDec) const {
+  aocommon::UVector<double> scaleSizes(NScales());
+  for (size_t scaleIndex = 0; scaleIndex != NScales(); ++scaleIndex)
+    scaleSizes[scaleIndex] = multiscale.ScaleSize(scaleIndex);
+  write(filename, multiscale.Fitter(), scaleSizes, pixelScaleX, pixelScaleY,
+        phaseCentreRA, phaseCentreDec);
+}
+
+void ComponentList::WriteSingleScale(
+    const std::string& filename,
+    const algorithms::DeconvolutionAlgorithm& algorithm,
+    long double pixelScaleX, long double pixelScaleY, long double phaseCentreRA,
+    long double phaseCentreDec) const {
+  aocommon::UVector<double> scaleSizes(1, 0);
+  write(filename, algorithm.Fitter(), scaleSizes, pixelScaleX, pixelScaleY,
+        phaseCentreRA, phaseCentreDec);
+}
+
+void ComponentList::write(const std::string& filename,
+                          const schaapcommon::fitters::SpectralFitter& fitter,
+                          const aocommon::UVector<double>& scaleSizes,
+                          long double pixelScaleX, long double pixelScaleY,
+                          long double phaseCentreRA,
+                          long double phaseCentreDec) const {
+  if (_componentsAddedSinceLastMerge != 0) {
+    throw std::runtime_error(
+        "ComponentList::write called while there are yet unmerged components. "
+        "Run ComponentList::MergeDuplicates() first.");
+  }
+
+  if (fitter.Mode() == schaapcommon::fitters::SpectralFittingMode::NoFitting &&
+      _nFrequencies > 1)
+    throw std::runtime_error(
+        "Can't write component list, because you have not specified a spectral "
+        "fitting method. You probably want to add '-fit-spectral-pol'.");
+
+  std::ofstream file(filename);
+  bool useLogSI = false;
+  switch (fitter.Mode()) {
+    case schaapcommon::fitters::SpectralFittingMode::NoFitting:
+    case schaapcommon::fitters::SpectralFittingMode::Polynomial:
+      useLogSI = false;
+      break;
+    case schaapcommon::fitters::SpectralFittingMode::LogPolynomial:
+      useLogSI = true;
+      break;
+  }
+  utils::WriteHeaderForSpectralTerms(file, fitter.ReferenceFrequency());
+  std::vector<float> terms;
+  for (size_t scaleIndex = 0; scaleIndex != NScales(); ++scaleIndex) {
+    const ScaleList& list = _listPerScale[scaleIndex];
+    size_t componentIndex = 0;
+    const double scale = scaleSizes[scaleIndex];
+    // Using the FWHM formula for a Gaussian
+    const double
+        fwhm =
+            2.0L * sqrtl(2.0L * logl(2.0L)) *
+            algorithms::multiscale::MultiScaleTransforms::GaussianSigma(scale),
+        scaleFWHML = fwhm * pixelScaleX * (180.0 * 60.0 * 60.0 / M_PI),
+        scaleFWHMM = fwhm * pixelScaleY * (180.0 * 60.0 * 60.0 / M_PI);
+    size_t valueIndex = 0;
+    for (size_t index = 0; index != list.positions.size(); ++index) {
+      const size_t x = list.positions[index].x;
+      const size_t y = list.positions[index].y;
+      aocommon::UVector<float> spectrum(_nFrequencies);
+      for (size_t frequency = 0; frequency != _nFrequencies; ++frequency) {
+        spectrum[frequency] = list.values[valueIndex];
+        ++valueIndex;
+      }
+      if (_nFrequencies == 1)
+        terms.assign(1, spectrum[0]);
+      else
+        fitter.Fit(terms, spectrum.data(), x, y);
+      long double l, m;
+      ImageCoordinates::XYToLM<long double>(x, y, pixelScaleX, pixelScaleY,
+                                            _width, _height, l, m);
+      long double ra, dec;
+      ImageCoordinates::LMToRaDec(l, m, phaseCentreRA, phaseCentreDec, ra, dec);
+      std::ostringstream name;
+      name << 's' << scaleIndex << 'c' << componentIndex;
+      if (scale == 0.0)
+        radler::utils::WritePolynomialPointComponent(
+            file, name.str(), ra, dec, useLogSI, terms,
+            fitter.ReferenceFrequency());
+      else {
+        radler::utils::WritePolynomialGaussianComponent(
+            file, name.str(), ra, dec, useLogSI, terms,
+            fitter.ReferenceFrequency(), scaleFWHML, scaleFWHMM, 0.0);
+      }
+      ++componentIndex;
+    }
+  }
+}
+
+void ComponentList::loadFromImageSet(ImageSet& imageSet, size_t scaleIndex) {
+  _componentsAddedSinceLastMerge = 0;
+  _listPerScale[scaleIndex].positions.clear();
+  _listPerScale[scaleIndex].values.clear();
+  for (size_t y = 0; y != _height; ++y) {
+    const size_t rowIndex = y * _width;
+    for (size_t x = 0; x != _width; ++x) {
+      const size_t posIndex = rowIndex + x;
+      bool isNonZero = false;
+      for (size_t imageIndex = 0; imageIndex != imageSet.size(); ++imageIndex) {
+        if (imageSet[imageIndex][posIndex] != 0.0) {
+          isNonZero = true;
+          break;
+        }
+      }
+      if (isNonZero) {
+        _listPerScale[scaleIndex].positions.emplace_back(x, y);
+        for (size_t imageIndex = 0; imageIndex != imageSet.size(); ++imageIndex)
+          _listPerScale[scaleIndex].values.push_back(
+              imageSet[imageIndex][posIndex]);
+      }
+    }
+  }
+}
+}  // namespace radler
\ No newline at end of file
diff --git a/cpp/component_list.h b/cpp/component_list.h
new file mode 100644
index 00000000..a22e3764
--- /dev/null
+++ b/cpp/component_list.h
@@ -0,0 +1,234 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_COMPONENT_LIST_H_
+#define RADLER_COMPONENT_LIST_H_
+
+#include <vector>
+
+#include <aocommon/image.h>
+#include <aocommon/uvector.h>
+
+#include <schaapcommon/fitters/spectralfitter.h>
+
+#include "image_set.h"
+
+namespace radler {
+class Radler;
+
+namespace algorithms {
+class DeconvolutionAlgorithm;
+class MultiScaleAlgorithm;
+}  // namespace algorithms
+
+class ComponentList {
+ public:
+  ComponentList()
+      : _width(0),
+        _height(0),
+        _nFrequencies(0),
+        _componentsAddedSinceLastMerge(0),
+        _maxComponentsBeforeMerge(0),
+        _listPerScale() {}
+
+  /**
+   * Constructor for single-scale clean
+   */
+  ComponentList(size_t width, size_t height, ImageSet& imageSet)
+      : _width(width),
+        _height(height),
+        _nFrequencies(imageSet.size()),
+        _componentsAddedSinceLastMerge(0),
+        _maxComponentsBeforeMerge(100000),
+        _listPerScale(1) {
+    loadFromImageSet(imageSet, 0);
+  }
+
+  /**
+   * Constructor for multi-scale clean
+   */
+  ComponentList(size_t width, size_t height, size_t nScales,
+                size_t nFrequencies)
+      : _width(width),
+        _height(height),
+        _nFrequencies(nFrequencies),
+        _componentsAddedSinceLastMerge(0),
+        _maxComponentsBeforeMerge(100000),
+        _listPerScale(nScales) {}
+
+  struct Position {
+    Position(size_t _x, size_t _y) : x(_x), y(_y) {}
+    size_t x, y;
+  };
+
+  void Add(size_t x, size_t y, size_t scaleIndex, const float* values) {
+    _listPerScale[scaleIndex].values.push_back(values, values + _nFrequencies);
+    _listPerScale[scaleIndex].positions.emplace_back(x, y);
+    ++_componentsAddedSinceLastMerge;
+    if (_componentsAddedSinceLastMerge >= _maxComponentsBeforeMerge)
+      MergeDuplicates();
+  }
+
+  void Add(const ComponentList& other, int offsetX, int offsetY) {
+    assert(other._nFrequencies == _nFrequencies);
+    if (other.NScales() > NScales()) SetNScales(other.NScales());
+    for (size_t scale = 0; scale != other.NScales(); ++scale) {
+      const ScaleList& list = other._listPerScale[scale];
+      for (size_t i = 0; i != list.positions.size(); ++i) {
+        Add(list.positions[i].x + offsetX, list.positions[i].y + offsetY, scale,
+            &list.values[i * _nFrequencies]);
+      }
+    }
+  }
+
+  void WriteSources(const Radler& radler, const std::string& filename,
+                    long double pixel_scale_x, long double pixel_scale_y,
+                    long double phase_centre_ra,
+                    long double phase_centre_dec) const;
+
+  /**
+   * @brief Write component lists over all scales, typically
+   * used for writing components of a multiscale clean.
+   */
+  void Write(const std::string& filename,
+             const algorithms::MultiScaleAlgorithm& multiscale,
+             long double pixelScaleX, long double pixelScaleY,
+             long double phaseCentreRA, long double phaseCentreDec) const;
+
+  void WriteSingleScale(const std::string& filename,
+                        const algorithms::DeconvolutionAlgorithm& algorithm,
+                        long double pixelScaleX, long double pixelScaleY,
+                        long double phaseCentreRA,
+                        long double phaseCentreDec) const;
+
+  void MergeDuplicates() {
+    if (_componentsAddedSinceLastMerge != 0) {
+      for (size_t scaleIndex = 0; scaleIndex != _listPerScale.size();
+           ++scaleIndex) {
+        mergeDuplicates(scaleIndex);
+      }
+      _componentsAddedSinceLastMerge = 0;
+    }
+  }
+
+  void Clear() {
+    for (ScaleList& list : _listPerScale) {
+      list.positions.clear();
+      list.values.clear();
+    }
+  }
+
+  size_t Width() const { return _width; }
+  size_t Height() const { return _height; }
+
+  size_t ComponentCount(size_t scaleIndex) const {
+    return _listPerScale[scaleIndex].positions.size();
+  }
+
+  void GetComponent(size_t scaleIndex, size_t index, size_t& x, size_t& y,
+                    float* values) const {
+    assert(scaleIndex < _listPerScale.size());
+    assert(index < _listPerScale[scaleIndex].positions.size());
+    x = _listPerScale[scaleIndex].positions[index].x;
+    y = _listPerScale[scaleIndex].positions[index].y;
+    for (size_t f = 0; f != _nFrequencies; ++f)
+      values[f] = _listPerScale[scaleIndex].values[index * _nFrequencies + f];
+  }
+
+  /**
+   * @brief Multiply the components for a given scale index, position index and
+   * channel index with corresponding (primary beam) correction factors.
+   */
+  inline void MultiplyScaleComponent(size_t scaleIndex, size_t positionIndex,
+                                     size_t channel, double correctionFactor) {
+    assert(scaleIndex < _listPerScale.size());
+    assert(positionIndex < _listPerScale[scaleIndex].positions.size());
+    assert(channel < _nFrequencies);
+    float& value = _listPerScale[scaleIndex]
+                       .values[channel + positionIndex * _nFrequencies];
+    value *= correctionFactor;
+  }
+
+  /**
+   * @brief Get vector of positions per scale index.
+   */
+  const aocommon::UVector<Position>& GetPositions(size_t scaleIndex) const {
+    assert(scaleIndex < _listPerScale.size());
+    return _listPerScale[scaleIndex].positions;
+  }
+
+  size_t NScales() const { return _listPerScale.size(); }
+
+  size_t NFrequencies() const { return _nFrequencies; }
+
+  void SetNScales(size_t nScales) { _listPerScale.resize(nScales); }
+
+ private:
+  struct ScaleList {
+    /**
+     * This list contains nFrequencies values for each
+     * component, such that _positions[i] corresponds with the values
+     * starting at _values[i * _nFrequencies].
+     */
+    aocommon::UVector<float> values;
+    aocommon::UVector<Position> positions;
+  };
+
+  void write(const std::string& filename,
+             const schaapcommon::fitters::SpectralFitter& fitter,
+             const aocommon::UVector<double>& scaleSizes,
+             long double pixelScaleX, long double pixelScaleY,
+             long double phaseCentreRA, long double phaseCentreDec) const;
+
+  void loadFromImageSet(ImageSet& imageSet, size_t scaleIndex);
+
+  void mergeDuplicates(size_t scaleIndex) {
+    ScaleList& list = _listPerScale[scaleIndex];
+    aocommon::UVector<float> newValues;
+    aocommon::UVector<Position> newPositions;
+
+    std::vector<aocommon::Image> images(_nFrequencies);
+    for (aocommon::Image& image : images)
+      image = aocommon::Image(_width, _height, 0.0);
+    size_t valueIndex = 0;
+    for (size_t index = 0; index != list.positions.size(); ++index) {
+      size_t position =
+          list.positions[index].x + list.positions[index].y * _width;
+      for (size_t frequency = 0; frequency != _nFrequencies; ++frequency) {
+        images[frequency][position] += list.values[valueIndex];
+        valueIndex++;
+      }
+    }
+
+    list.values.clear();
+    list.positions.clear();
+
+    for (size_t imageIndex = 0; imageIndex != images.size(); ++imageIndex) {
+      aocommon::Image& image = images[imageIndex];
+      size_t posIndex = 0;
+      for (size_t y = 0; y != _height; ++y) {
+        for (size_t x = 0; x != _width; ++x) {
+          if (image[posIndex] != 0.0) {
+            for (size_t i = 0; i != images.size(); ++i) {
+              newValues.push_back(images[i][posIndex]);
+              images[i][posIndex] = 0.0;
+            }
+            newPositions.emplace_back(x, y);
+          }
+          ++posIndex;
+        }
+      }
+    }
+    std::swap(_listPerScale[scaleIndex].values, newValues);
+    std::swap(_listPerScale[scaleIndex].positions, newPositions);
+  }
+
+  size_t _width;
+  size_t _height;
+  size_t _nFrequencies;
+  size_t _componentsAddedSinceLastMerge;
+  size_t _maxComponentsBeforeMerge;
+  std::vector<ScaleList> _listPerScale;
+};
+}  // namespace radler
+#endif
diff --git a/cpp/deconvolution_settings.h b/cpp/deconvolution_settings.h
new file mode 100644
index 00000000..2a8cda16
--- /dev/null
+++ b/cpp/deconvolution_settings.h
@@ -0,0 +1,155 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_DECONVOLUTION_SETTINGS_H_
+#define RADLER_DECONVOLUTION_SETTINGS_H_
+
+#include <set>
+
+#include <aocommon/polarization.h>
+#include <aocommon/system.h>
+
+#include <schaapcommon/fitters/spectralfitter.h>
+
+#include "algorithms/multiscale/multiscale_transforms.h"
+
+namespace radler {
+/**
+ * @brief The value of LocalRmsMethod describes how the RMS map
+ * should be used.
+ */
+enum class LocalRmsMethod { kNone, kRmsWindow, kRmsAndMinimumWindow };
+
+struct DeconvolutionSettings {
+  DeconvolutionSettings();
+
+  /**
+   * @{
+   * Settings that are duplicates from top level settings, and also used outside
+   * deconvolution.
+   */
+  size_t trimmedImageWidth;
+  size_t trimmedImageHeight;
+  size_t channelsOut;
+  double pixelScaleX;
+  double pixelScaleY;
+  size_t threadCount;
+  std::string prefixName;
+  /** @} */
+
+  /**
+   * @{
+   * These settings strictly pertain to deconvolution only.
+   */
+  std::set<aocommon::PolarizationEnum> linkedPolarizations;
+  size_t parallelDeconvolutionMaxSize;
+  size_t parallelDeconvolutionMaxThreads;
+  double deconvolutionThreshold;
+  double deconvolutionGain;
+  double deconvolutionMGain;
+  bool autoDeconvolutionThreshold;
+  bool autoMask;
+  double autoDeconvolutionThresholdSigma;
+  double autoMaskSigma;
+  LocalRmsMethod localRMSMethod;
+  double localRMSWindow;
+  std::string localRMSImage;
+  bool saveSourceList;
+  size_t deconvolutionIterationCount;
+  size_t majorIterationCount;
+  bool allowNegativeComponents;
+  bool stopOnNegativeComponents;
+  bool useMultiscale;
+  bool useSubMinorOptimization;
+  bool squaredJoins;
+  double spectralCorrectionFrequency;
+  std::vector<float> spectralCorrection;
+  bool multiscaleFastSubMinorLoop;
+  double multiscaleGain;
+  double multiscaleDeconvolutionScaleBias;
+  size_t multiscaleMaxScales;
+  double multiscaleConvolutionPadding;
+  std::vector<double> multiscaleScaleList;
+  algorithms::multiscale::Shape multiscaleShapeFunction;
+  double deconvolutionBorderRatio;
+  std::string fitsDeconvolutionMask;
+  std::string casaDeconvolutionMask;
+  bool horizonMask;
+  double horizonMaskDistance;
+  std::string pythonDeconvolutionFilename;
+  bool useMoreSaneDeconvolution;
+  bool useIUWTDeconvolution;
+  bool iuwtSNRTest;
+  std::string moreSaneLocation;
+  std::string moreSaneArgs;
+  std::vector<double> moreSaneSigmaLevels;
+  schaapcommon::fitters::SpectralFittingMode spectralFittingMode;
+  size_t spectralFittingTerms;
+  std::string forcedSpectrumFilename;
+  /**
+   * The number of channels used during deconvolution. This can be used to
+   * image with more channels than deconvolution. Before deconvolution,
+   * channels are averaged, and after deconvolution they are interpolated.
+   * If it is 0, all channels should be used.
+   */
+  size_t deconvolutionChannelCount;
+  /** @} */
+};
+
+inline DeconvolutionSettings::DeconvolutionSettings()
+    : trimmedImageWidth(0),
+      trimmedImageHeight(0),
+      channelsOut(1),
+      pixelScaleX(0.0),
+      pixelScaleY(0.0),
+      threadCount(aocommon::system::ProcessorCount()),
+      prefixName("wsclean"),
+      linkedPolarizations(),
+      parallelDeconvolutionMaxSize(0),
+      parallelDeconvolutionMaxThreads(0),
+      deconvolutionThreshold(0.0),
+      deconvolutionGain(0.1),
+      deconvolutionMGain(1.0),
+      autoDeconvolutionThreshold(false),
+      autoMask(false),
+      autoDeconvolutionThresholdSigma(0.0),
+      autoMaskSigma(0.0),
+      localRMSMethod(LocalRmsMethod::kNone),
+      localRMSWindow(25.0),
+      localRMSImage(),
+      saveSourceList(false),
+      deconvolutionIterationCount(0),
+      majorIterationCount(20),
+      allowNegativeComponents(true),
+      stopOnNegativeComponents(false),
+      useMultiscale(false),
+      useSubMinorOptimization(true),
+      squaredJoins(false),
+      spectralCorrectionFrequency(0.0),
+      spectralCorrection(),
+      multiscaleFastSubMinorLoop(true),
+      multiscaleGain(0.2),
+      multiscaleDeconvolutionScaleBias(0.6),
+      multiscaleMaxScales(0),
+      multiscaleConvolutionPadding(1.1),
+      multiscaleScaleList(),
+      multiscaleShapeFunction(
+          algorithms::multiscale::Shape::TaperedQuadraticShape),
+      deconvolutionBorderRatio(0.0),
+      fitsDeconvolutionMask(),
+      casaDeconvolutionMask(),
+      horizonMask(false),
+      horizonMaskDistance(0.0),
+      pythonDeconvolutionFilename(),
+      useMoreSaneDeconvolution(false),
+      useIUWTDeconvolution(false),
+      iuwtSNRTest(false),
+      moreSaneLocation(),
+      moreSaneArgs(),
+      spectralFittingMode(
+          schaapcommon::fitters::SpectralFittingMode::NoFitting),
+      spectralFittingTerms(0),
+      forcedSpectrumFilename(),
+      deconvolutionChannelCount(0) {}
+}  // namespace radler
+#endif
diff --git a/cpp/deconvolution_table.cc b/cpp/deconvolution_table.cc
new file mode 100644
index 00000000..4928b76a
--- /dev/null
+++ b/cpp/deconvolution_table.cc
@@ -0,0 +1,39 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "deconvolution_table.h"
+
+#include <algorithm>
+#include <cassert>
+
+namespace radler {
+
+DeconvolutionTable::DeconvolutionTable(int n_original_groups,
+                                       int n_deconvolution_groups,
+                                       int channel_index_offset)
+    : entries_(),
+      channel_index_offset_(channel_index_offset),
+      original_groups_(std::max(n_original_groups, 1)),
+      deconvolution_groups_((n_deconvolution_groups <= 0)
+                                ? original_groups_.size()
+                                : std::min(original_groups_.size(),
+                                           size_t(n_deconvolution_groups))) {
+  // Create an entry in deconvolution_groups for each original group.
+  for (int i = 0; i < n_original_groups; ++i) {
+    int deconvolution_index =
+        i * deconvolution_groups_.size() / n_original_groups;
+    deconvolution_groups_[deconvolution_index].push_back(i);
+  }
+}
+
+void DeconvolutionTable::AddEntry(
+    std::unique_ptr<DeconvolutionTableEntry> entry) {
+  const size_t original_channel_index = entry->original_channel_index;
+  assert(original_channel_index < original_groups_.size());
+
+  entry->index = entries_.size();
+  entries_.push_back(std::move(entry));
+
+  original_groups_[original_channel_index].push_back(entries_.back().get());
+}
+}  // namespace radler
diff --git a/cpp/deconvolution_table.h b/cpp/deconvolution_table.h
new file mode 100644
index 00000000..41386fb1
--- /dev/null
+++ b/cpp/deconvolution_table.h
@@ -0,0 +1,159 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_DECONVOLUTION_TABLE_H_
+#define RADLER_DECONVOLUTION_TABLE_H_
+
+#include "deconvolution_table_entry.h"
+
+#include <functional>
+#include <memory>
+#include <vector>
+
+namespace radler {
+/**
+ * The DeconvolutionTable contains DeconvolutionTableEntry's and groups entries
+ * that have the same squaredDeconvolutionIndex.
+ */
+class DeconvolutionTable {
+ public:
+  using Entries = std::vector<std::unique_ptr<DeconvolutionTableEntry>>;
+  using Group = std::vector<const DeconvolutionTableEntry*>;
+
+  /**
+   * Iterator-like class which (only) supports a range-based loop over entries.
+   *
+   * Dereferencing this class yields a reference to the actual object instead
+   * of a reference to the pointer for the object.
+   */
+  class EntryIteratorLite {
+    using BaseIterator = Entries::const_iterator;
+
+   public:
+    explicit EntryIteratorLite(BaseIterator base_iterator)
+        : base_iterator_(base_iterator) {}
+
+    const DeconvolutionTableEntry& operator*() const {
+      return **base_iterator_;
+    }
+    EntryIteratorLite& operator++() {
+      ++base_iterator_;
+      return *this;
+    }
+    bool operator!=(const EntryIteratorLite& other) const {
+      return base_iterator_ != other.base_iterator_;
+    }
+    bool operator==(const EntryIteratorLite& other) const {
+      return base_iterator_ == other.base_iterator_;
+    }
+
+   private:
+    BaseIterator base_iterator_;
+  };
+
+  /**
+   * @brief Constructs a new DeconvolutionTable object.
+   *
+   * @param n_original_groups The number of original channel groups. When adding
+   * entries, their original channel index must be less than the number of
+   * original groups. If the value is zero or less, one group is used.
+   * @param n_deconvolution_groups The number of deconvolution groups.
+   * A deconvolution group consist of one or more channel groups, which are then
+   * joinedly deconvolved.
+   * If the value is zero or less, or larger than the number of original groups,
+   * all channels are deconvolved separately.
+   * @param channel_index_offset The index of the first channel in the caller.
+   * Must be >= 0.
+   */
+  explicit DeconvolutionTable(int n_original_groups, int n_deconvolution_groups,
+                              int channel_index_offset = 0);
+
+  /**
+   * @return The table entries, grouped by their original channel index.
+   * @see AddEntry()
+   */
+  const std::vector<Group>& OriginalGroups() const { return original_groups_; }
+
+  /**
+   * @return The original group indices for each deconvolution group.
+   */
+  const std::vector<std::vector<int>>& DeconvolutionGroups() const {
+    return deconvolution_groups_;
+  }
+
+  /**
+   * Find the first group of original channels, given a deconvolution group
+   * index.
+   *
+   * @param deconvolution_index Index for a deconvolution group. Must be less
+   * than the number of deconvolution groups.
+   * @return A reference to the first original group for the deconvolution
+   * group.
+   */
+  const Group& FirstOriginalGroup(size_t deconvolution_index) const {
+    return original_groups_[deconvolution_groups_[deconvolution_index].front()];
+  }
+
+  /**
+   * begin() and end() allow writing range-based loops over all entries.
+   * @{
+   */
+  EntryIteratorLite begin() const {
+    return EntryIteratorLite(entries_.begin());
+  }
+  EntryIteratorLite end() const { return EntryIteratorLite(entries_.end()); }
+  /** @} */
+
+  /**
+   * @brief Adds an entry to the table.
+   *
+   * The original channel index of the entry determines the original group for
+   * the entry. It must be less than the number of original channel groups, as
+   * given in the constructor.
+   *
+   * @param entry A new entry.
+   */
+  void AddEntry(std::unique_ptr<DeconvolutionTableEntry> entry);
+
+  /**
+   * @return A reference to the first entry.
+   */
+  const DeconvolutionTableEntry& Front() const { return *entries_.front(); }
+
+  /**
+   * @return The number of entries in the table.
+   */
+  size_t Size() const { return entries_.size(); }
+
+  /**
+   * @return The channel index offset, which was set in the constructor.
+   */
+  size_t GetChannelIndexOffset() const { return channel_index_offset_; }
+
+ private:
+  Entries entries_;
+
+  /**
+   * A user of the DeconvolutionTable may use different channel indices than
+   * the DeconvolutionTable. This offset is the difference between those
+   * indices.
+   * For example, with three channels, the DeconvolutionTable indices are always
+   * 0, 1, and 2. When the user indices are 4, 5, and 6, this offset will be 4.
+   */
+  const std::size_t channel_index_offset_;
+
+  /**
+   * An original group has entries with equal original channel indices.
+   */
+  std::vector<Group> original_groups_;
+
+  /**
+   * A deconvolution group consists of one or more original groups, which
+   * are deconvolved together. Each entry contains the indices of the original
+   * groups that are part of the deconvolution group.
+   */
+  std::vector<std::vector<int>> deconvolution_groups_;
+};
+}  // namespace radler
+
+#endif
diff --git a/cpp/deconvolution_table_entry.h b/cpp/deconvolution_table_entry.h
new file mode 100644
index 00000000..125cfdb2
--- /dev/null
+++ b/cpp/deconvolution_table_entry.h
@@ -0,0 +1,67 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_DECONVOLUTION_TABLE_ENTRY_H_
+#define RADLER_DECONVOLUTION_TABLE_ENTRY_H_
+
+#include <memory>
+#include <vector>
+
+#include <aocommon/imageaccessor.h>
+#include <aocommon/polarization.h>
+
+namespace radler {
+struct DeconvolutionTableEntry {
+  double CentralFrequency() const {
+    return 0.5 * (band_start_frequency + band_end_frequency);
+  }
+
+  /**
+   * Index of the entry in its DeconvolutionTable.
+   */
+  size_t index = 0;
+
+  /**
+   * Note that mses might have overlapping frequencies.
+   */
+  double band_start_frequency = 0.0;
+  double band_end_frequency = 0.0;
+
+  aocommon::PolarizationEnum polarization = aocommon::PolarizationEnum::StokesI;
+
+  /**
+   * Entries with equal original channel indices are 'joinedly' deconvolved by
+   * adding their squared flux density values together. Normally, all the
+   * polarizations from a single channel / timestep form such a group.
+   *
+   * When the number of deconvolution channels is less than the number of
+   * original channels, entries in multiple groups are 'joinedly' deconvolved.
+   */
+  size_t original_channel_index = 0;
+  size_t original_interval_index = 0;
+
+  /**
+   * A number that scales with the estimated inverse-variance of the image. It
+   * can be used when averaging images or fitting functions through the images
+   * to get the optimal sensitivity. It is set after the first inversion.
+   */
+  double image_weight = 0.0;
+
+  /**
+   * Image accessor for the PSF image for this entry. This accessor is only used
+   * for the first entry of each channel group.
+   */
+  std::unique_ptr<aocommon::ImageAccessor> psf_accessor;
+
+  /**
+   * Image accessor for the model image for this entry.
+   */
+  std::unique_ptr<aocommon::ImageAccessor> model_accessor;
+
+  /**
+   * Image accessor for the residual image for this entry.
+   */
+  std::unique_ptr<aocommon::ImageAccessor> residual_accessor;
+};
+}  // namespace radler
+#endif
diff --git a/cpp/demo/CMakeLists.txt b/cpp/demo/CMakeLists.txt
new file mode 100644
index 00000000..dbfb393f
--- /dev/null
+++ b/cpp/demo/CMakeLists.txt
@@ -0,0 +1,7 @@
+# Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+add_executable(multiscale_example multiscale_example.cc)
+target_link_libraries(multiscale_example PRIVATE radler)
+target_include_directories(multiscale_example
+                           PRIVATE $<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/cpp>)
diff --git a/cpp/demo/multiscale_example.cc b/cpp/demo/multiscale_example.cc
new file mode 100644
index 00000000..761b134d
--- /dev/null
+++ b/cpp/demo/multiscale_example.cc
@@ -0,0 +1,105 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// NOTE:
+// - This file hasn't been tested on real input.
+// - Rather than being a demo/example, this file might be the basis for a
+// multiscale algorithm test.
+
+#include <iostream>
+#include <memory>
+
+#include <aocommon/image.h>
+#include <aocommon/imageaccessor.h>
+#include <aocommon/fits/fitsreader.h>
+#include <aocommon/fits/fitswriter.h>
+
+#include <schaapcommon/fitters/spectralfitter.h>
+
+#include "image_set.h"
+#include "deconvolution_table.h"
+#include "algorithms/multiscale_algorithm.h"
+
+namespace {
+
+class MinimalImageAccessor final : public aocommon::ImageAccessor {
+ public:
+  MinimalImageAccessor(const aocommon::Image& image,
+                       aocommon::FitsWriter writer,
+                       const std::string& output_fits)
+      : _image(image), _writer(writer), _output_fits(output_fits) {}
+  ~MinimalImageAccessor() override {}
+
+  void Load(aocommon::Image& image) const override { image = _image; }
+
+  void Store(const aocommon::Image& image) override {
+    _writer.Write(_output_fits, image.Data());
+  }
+
+ private:
+  const aocommon::Image _image;
+  const aocommon::FitsWriter _writer;
+  const std::string _output_fits;
+};
+}  // namespace
+
+int main(int argc, char* argv[]) {
+  if (argc != 3) {
+    std::cout << "Syntax: mscaleexample <image> <psf>\n";
+  } else {
+    aocommon::FitsReader imgReader(argv[1]);
+    aocommon::FitsReader psfReader(argv[2]);
+    const double beamScale =
+        imgReader.BeamMajorAxisRad() / imgReader.PixelSizeX();
+    const size_t width = imgReader.ImageWidth();
+    const size_t height = imgReader.ImageHeight();
+    const size_t n_channels = 1;
+
+    aocommon::Image image(width, height);
+    aocommon::Image psf(width, height);
+    imgReader.Read(image.Data());
+    psfReader.Read(psf.Data());
+    aocommon::Image model(width, height, 0.0);
+
+    aocommon::FitsWriter writer(imgReader);
+
+    std::unique_ptr<radler::DeconvolutionTable> table =
+        std::make_unique<radler::DeconvolutionTable>(n_channels, n_channels);
+
+    auto e = std::make_unique<radler::DeconvolutionTableEntry>();
+    e->polarization = imgReader.Polarization();
+    e->band_start_frequency = imgReader.Frequency();
+    e->band_end_frequency = imgReader.Frequency();
+    e->image_weight = 1.0;
+    e->psf_accessor =
+        std::make_unique<MinimalImageAccessor>(psf, writer, "psf.fits");
+    e->model_accessor =
+        std::make_unique<MinimalImageAccessor>(model, writer, "model.fits");
+    e->residual_accessor =
+        std::make_unique<MinimalImageAccessor>(image, writer, "residual.fits");
+    table->AddEntry(std::move(e));
+
+    radler::ImageSet residualSet(*table, false, {}, width, height);
+    radler::ImageSet modelSet(*table, false, {}, width, height);
+
+    const double gain = 0.1;
+    const bool allowNegativeComponents = true;
+    const double borderRatio = 0.05;
+    bool reachedThreshold = false;
+
+    radler::algorithms::MultiScaleAlgorithm algorithm(
+        beamScale, imgReader.PixelSizeX(), imgReader.PixelSizeY());
+    algorithm.SetGain(gain);
+    algorithm.SetAllowNegativeComponents(allowNegativeComponents);
+    algorithm.SetCleanBorderRatio(borderRatio);
+    algorithm.ExecuteMajorIteration(residualSet, modelSet, {psf},
+                                    reachedThreshold);
+
+    residualSet.AssignAndStoreResidual();
+    modelSet.InterpolateAndStoreModel(
+        schaapcommon::fitters::SpectralFitter(
+            schaapcommon::fitters::SpectralFittingMode::NoFitting, 0),
+        1);
+  }
+  return 0;
+}
diff --git a/cpp/image_set.cc b/cpp/image_set.cc
new file mode 100644
index 00000000..00aa3b7c
--- /dev/null
+++ b/cpp/image_set.cc
@@ -0,0 +1,464 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "image_set.h"
+
+#include <cassert>
+
+#include <aocommon/logger.h>
+#include <aocommon/staticfor.h>
+
+using aocommon::Image;
+using aocommon::Logger;
+
+namespace radler {
+
+namespace {
+void assignMultiply(aocommon::Image& lhs, const aocommon::Image& rhs,
+                    float factor) {
+  // As this function is used in cases where rhs.Size() is larger than
+  // lhs.Size(), this method can't be easily migrated to aocommon. Maybe
+  // consider a stricter enforcement of lhs.Size() and rhs.Size() to be equal?
+  const size_t image_size = lhs.Size();
+  assert(rhs.Size() >= image_size);
+  for (size_t i = 0; i != image_size; ++i) lhs[i] = rhs[i] * factor;
+}
+}  // namespace
+
+ImageSet::ImageSet(
+    const DeconvolutionTable& table, bool squared_joins,
+    const std::set<aocommon::PolarizationEnum>& linked_polarizations,
+    size_t width, size_t height)
+    : _images(),
+      _squareJoinedChannels(squared_joins),
+      _deconvolutionTable(table),
+      _imageIndexToPSFIndex(),
+      _linkedPolarizations(linked_polarizations) {
+  const size_t nPol = table.OriginalGroups().front().size();
+  const size_t nImages = nPol * NDeconvolutionChannels();
+  assert(nImages >= 1);
+  _images.reserve(nImages);
+  for (size_t i = 0; i < nImages; ++i) {
+    _images.emplace_back(width, height);
+  }
+  _imageIndexToPSFIndex.resize(nImages);
+
+  initializePolFactor();
+  initializeIndices();
+  aocommon::UVector<double> frequencies;
+  CalculateDeconvolutionFrequencies(table, frequencies, _weights);
+}
+
+ImageSet::ImageSet(const ImageSet& image_set, size_t width, size_t height)
+    : ImageSet(image_set._deconvolutionTable, image_set._squareJoinedChannels,
+               image_set._linkedPolarizations, width, height) {}
+
+void ImageSet::initializeIndices() {
+  _entryIndexToImageIndex.reserve(_deconvolutionTable.Size());
+  size_t imgIndex = 0;
+  for (const std::vector<int>& group :
+       _deconvolutionTable.DeconvolutionGroups()) {
+    const size_t deconvolutionChannelStartIndex = imgIndex;
+    for (const int originalIndex : group) {
+      imgIndex = deconvolutionChannelStartIndex;
+
+      for ([[maybe_unused]] const DeconvolutionTableEntry* entry :
+           _deconvolutionTable.OriginalGroups()[originalIndex]) {
+        assert(entry->index == _entryIndexToImageIndex.size());
+        _entryIndexToImageIndex.push_back(imgIndex);
+
+        ++imgIndex;
+      }
+    }
+  }
+
+  for (size_t chIndex = 0; chIndex != NDeconvolutionChannels(); ++chIndex) {
+    const DeconvolutionTable::Group& originalGroup =
+        _deconvolutionTable.FirstOriginalGroup(chIndex);
+    for (const DeconvolutionTableEntry* entry : originalGroup) {
+      const size_t imageIndex = _entryIndexToImageIndex[entry->index];
+      _imageIndexToPSFIndex[imageIndex] = chIndex;
+    }
+  }
+}
+
+void ImageSet::SetImages(ImageSet&& source) {
+  _images = std::move(source._images);
+  // Note: 'source' becomes invalid now: Since its _images becomes empty,
+  // Width() and Height() will fail. Move semantics allow this case, though:
+  // The state of 'source' is unknown and the destructor will not fail.
+}
+
+void ImageSet::LoadAndAverage(bool use_residual_image) {
+  for (Image& image : _images) {
+    image = 0.0;
+  }
+
+  Image scratch(Width(), Height());
+
+  aocommon::UVector<double> averagedWeights(_images.size(), 0.0);
+  size_t imgIndex = 0;
+  for (const std::vector<int>& group :
+       _deconvolutionTable.DeconvolutionGroups()) {
+    const size_t deconvolutionChannelStartIndex = imgIndex;
+    for (const int originalIndex : group) {
+      // The next loop iterates over the polarizations. The logic in the next
+      // loop makes sure that images of the same polarizations and that belong
+      // to the same deconvolution channel are averaged together.
+      imgIndex = deconvolutionChannelStartIndex;
+      for (const DeconvolutionTableEntry* entry_ptr :
+           _deconvolutionTable.OriginalGroups()[originalIndex]) {
+        if (use_residual_image) {
+          entry_ptr->residual_accessor->Load(scratch);
+        } else {
+          entry_ptr->model_accessor->Load(scratch);
+        }
+        _images[imgIndex].AddWithFactor(scratch, entry_ptr->image_weight);
+        averagedWeights[imgIndex] += entry_ptr->image_weight;
+        ++imgIndex;
+      }
+    }
+  }
+
+  for (size_t i = 0; i != _images.size(); ++i) {
+    _images[i] *= 1.0 / averagedWeights[i];
+  }
+}
+
+std::vector<aocommon::Image> ImageSet::LoadAndAveragePSFs() {
+  const size_t image_size = Width() * Height();
+
+  std::vector<aocommon::Image> psfImages;
+  psfImages.reserve(NDeconvolutionChannels());
+  for (size_t i = 0; i < NDeconvolutionChannels(); ++i) {
+    psfImages.emplace_back(Width(), Height(), 0.0);
+  }
+
+  Image scratch(Width(), Height());
+
+  aocommon::UVector<double> averagedWeights(NDeconvolutionChannels(), 0.0);
+  for (size_t groupIndex = 0; groupIndex != NOriginalChannels(); ++groupIndex) {
+    const size_t chIndex =
+        (groupIndex * NDeconvolutionChannels()) / NOriginalChannels();
+    const DeconvolutionTable::Group& channelGroup =
+        _deconvolutionTable.OriginalGroups()[groupIndex];
+    const DeconvolutionTableEntry& entry = *channelGroup.front();
+    const double inputChannelWeight = entry.image_weight;
+    entry.psf_accessor->Load(scratch);
+    for (size_t i = 0; i != image_size; ++i) {
+      psfImages[chIndex][i] += scratch[i] * inputChannelWeight;
+    }
+    averagedWeights[chIndex] += inputChannelWeight;
+  }
+
+  for (size_t chIndex = 0; chIndex != NDeconvolutionChannels(); ++chIndex) {
+    const double factor =
+        averagedWeights[chIndex] == 0.0 ? 0.0 : 1.0 / averagedWeights[chIndex];
+    for (size_t i = 0; i != image_size; ++i) {
+      psfImages[chIndex][i] *= factor;
+    }
+  }
+
+  return psfImages;
+}
+
+void ImageSet::InterpolateAndStoreModel(
+    const schaapcommon::fitters::SpectralFitter& fitter, size_t threadCount) {
+  if (NDeconvolutionChannels() == NOriginalChannels()) {
+    size_t imgIndex = 0;
+    for (const DeconvolutionTableEntry& e : _deconvolutionTable) {
+      e.model_accessor->Store(_images[imgIndex]);
+      ++imgIndex;
+    }
+  } else {
+    Logger::Info << "Interpolating from " << NDeconvolutionChannels() << " to "
+                 << NOriginalChannels() << " channels...\n";
+
+    // TODO should use spectralimagefitter to do the interpolation of images;
+    // here we should just unpack the data structure
+
+    // The following loop will make an 'image' with at each pixel
+    // the terms of the fit. By doing this first, it is not necessary
+    // to have all channel images in memory at the same time.
+    // TODO: this assumes that polarizations are not joined!
+    size_t nTerms = fitter.NTerms();
+    aocommon::UVector<float> termsImage(Width() * Height() * nTerms);
+    aocommon::StaticFor<size_t> loop(threadCount);
+    loop.Run(0, Height(), [&](size_t yStart, size_t yEnd) {
+      aocommon::UVector<float> spectralPixel(NDeconvolutionChannels());
+      std::vector<float> termsPixel;
+      for (size_t y = yStart; y != yEnd; ++y) {
+        size_t px = y * Width();
+        for (size_t x = 0; x != Width(); ++x) {
+          bool isZero = true;
+          for (size_t s = 0; s != _images.size(); ++s) {
+            float value = _images[s][px];
+            spectralPixel[s] = value;
+            isZero = isZero && (value == 0.0);
+          }
+          float* termsPtr = &termsImage[px * nTerms];
+          // Skip fitting if it is zero; most of model images will be zero, so
+          // this can save a lot of time.
+          if (isZero) {
+            std::fill_n(termsPtr, nTerms, 0.0);
+          } else {
+            fitter.Fit(termsPixel, spectralPixel.data(), x, y);
+            std::copy_n(termsPixel.cbegin(), nTerms, termsPtr);
+          }
+          ++px;
+        }
+      }
+    });
+
+    // Now that we know the fit for each pixel, evaluate the function for each
+    // pixel of each output channel.
+    Image scratch(Width(), Height());
+    for (const DeconvolutionTableEntry& e : _deconvolutionTable) {
+      double freq = e.CentralFrequency();
+      loop.Run(0, Width() * Height(), [&](size_t pxStart, size_t pxEnd) {
+        std::vector<float> termsPixel;
+        for (size_t px = pxStart; px != pxEnd; ++px) {
+          const float* termsPtr = &termsImage[px * nTerms];
+          termsPixel.assign(termsPtr, termsPtr + nTerms);
+          scratch[px] = fitter.Evaluate(termsPixel, freq);
+        }
+      });
+
+      e.model_accessor->Store(scratch);
+    }
+  }
+}
+
+void ImageSet::AssignAndStoreResidual() {
+  Logger::Info << "Assigning from " << NDeconvolutionChannels() << " to "
+               << NOriginalChannels() << " channels...\n";
+
+  size_t imgIndex = 0;
+  for (const std::vector<int>& group :
+       _deconvolutionTable.DeconvolutionGroups()) {
+    const size_t deconvolutionChannelStartIndex = imgIndex;
+    for (const int originalIndex : group) {
+      imgIndex = deconvolutionChannelStartIndex;
+
+      for (const DeconvolutionTableEntry* entry :
+           _deconvolutionTable.OriginalGroups()[originalIndex]) {
+        entry->residual_accessor->Store(_images[imgIndex]);
+        ++imgIndex;
+      }
+    }
+  }
+}
+
+void ImageSet::getSquareIntegratedWithNormalChannels(Image& dest,
+                                                     Image& scratch) const {
+  // In case only one frequency channel is used, we do not have to use
+  // 'scratch', which saves copying and normalizing the data.
+  if (NDeconvolutionChannels() == 1) {
+    const DeconvolutionTable::Group& originalGroup =
+        _deconvolutionTable.OriginalGroups().front();
+    if (originalGroup.size() == 1) {
+      const DeconvolutionTableEntry& entry = *originalGroup.front();
+      dest = entryToImage(entry);
+    } else {
+      const bool useAllPolarizations = _linkedPolarizations.empty();
+      bool isFirst = true;
+      for (const DeconvolutionTableEntry* entry_ptr : originalGroup) {
+        if (useAllPolarizations ||
+            _linkedPolarizations.count(entry_ptr->polarization) != 0) {
+          if (isFirst) {
+            dest = entryToImage(*entry_ptr);
+            dest.Square();
+            isFirst = false;
+          } else {
+            dest.AddSquared(entryToImage(*entry_ptr));
+          }
+        }
+      }
+      dest.SqrtWithFactor(std::sqrt(_polarizationNormalizationFactor));
+    }
+  } else {
+    double weightSum = 0.0;
+    bool isFirstChannel = true;
+
+    for (size_t chIndex = 0; chIndex != NDeconvolutionChannels(); ++chIndex) {
+      const DeconvolutionTable::Group& originalGroup =
+          _deconvolutionTable.FirstOriginalGroup(chIndex);
+      const double groupWeight = _weights[chIndex];
+      // if the groupWeight is zero, the image might contain NaNs, so we
+      // shouldn't add it to the total in that case.
+      if (groupWeight != 0.0) {
+        weightSum += groupWeight;
+        if (originalGroup.size() == 1) {
+          const DeconvolutionTableEntry& entry = *originalGroup.front();
+          scratch = entryToImage(entry);
+        } else {
+          const bool useAllPolarizations = _linkedPolarizations.empty();
+          bool isFirstPolarization = true;
+          for (const DeconvolutionTableEntry* entry_ptr : originalGroup) {
+            if (useAllPolarizations ||
+                _linkedPolarizations.count(entry_ptr->polarization) != 0) {
+              if (isFirstPolarization) {
+                scratch = entryToImage(*entry_ptr);
+                scratch.Square();
+                isFirstPolarization = false;
+              } else {
+                scratch.AddSquared(entryToImage(*entry_ptr));
+              }
+            }
+          }
+          scratch.Sqrt();
+        }
+      }
+
+      if (isFirstChannel) {
+        assignMultiply(dest, scratch, groupWeight);
+        isFirstChannel = false;
+      } else {
+        dest.AddWithFactor(scratch, groupWeight);
+      }
+    }
+    dest *= std::sqrt(_polarizationNormalizationFactor) / weightSum;
+  }
+}
+
+void ImageSet::getSquareIntegratedWithSquaredChannels(Image& dest) const {
+  bool isFirst = true;
+  const bool useAllPolarizations = _linkedPolarizations.empty();
+  double weightSum = 0.0;
+
+  for (size_t chIndex = 0; chIndex != NDeconvolutionChannels(); ++chIndex) {
+    const double groupWeight = _weights[chIndex];
+    if (groupWeight != 0.0) {
+      weightSum += groupWeight;
+      const DeconvolutionTable::Group& originalGroup =
+          _deconvolutionTable.FirstOriginalGroup(chIndex);
+      for (const DeconvolutionTableEntry* entry_ptr : originalGroup) {
+        if (useAllPolarizations ||
+            _linkedPolarizations.count(entry_ptr->polarization) != 0) {
+          if (isFirst) {
+            dest = entryToImage(*entry_ptr);
+            dest.SquareWithFactor(groupWeight);
+            isFirst = false;
+          } else {
+            dest.AddSquared(entryToImage(*entry_ptr), groupWeight);
+          }
+        }
+      }
+    }
+  }
+
+  if (weightSum > 0.0) {
+    dest.SqrtWithFactor(
+        std::sqrt(_polarizationNormalizationFactor / weightSum));
+  } else {
+    // Effectively multiplying with a 0.0 weighting factor
+    dest = 0.0;
+  }
+}
+
+void ImageSet::getLinearIntegratedWithNormalChannels(Image& dest) const {
+  const bool useAllPolarizations = _linkedPolarizations.empty();
+  if (_deconvolutionTable.DeconvolutionGroups().size() == 1 &&
+      _deconvolutionTable.OriginalGroups().front().size() == 1) {
+    const DeconvolutionTable::Group& originalGroup =
+        _deconvolutionTable.OriginalGroups().front();
+    const DeconvolutionTableEntry& entry = *originalGroup.front();
+    dest = entryToImage(entry);
+  } else {
+    bool isFirst = true;
+    double weightSum = 0.0;
+    for (size_t chIndex = 0; chIndex != NDeconvolutionChannels(); ++chIndex) {
+      const DeconvolutionTable::Group& originalGroup =
+          _deconvolutionTable.FirstOriginalGroup(chIndex);
+      const double groupWeight = _weights[chIndex];
+      // if the groupWeight is zero, the image might contain NaNs, so we
+      // shouldn't add it to the total in that case.
+      if (groupWeight != 0.0) {
+        weightSum += groupWeight;
+        for (const DeconvolutionTableEntry* entry_ptr : originalGroup) {
+          if (useAllPolarizations ||
+              _linkedPolarizations.count(entry_ptr->polarization) != 0) {
+            if (isFirst) {
+              assignMultiply(dest, entryToImage(*entry_ptr), groupWeight);
+              isFirst = false;
+            } else {
+              dest.AddWithFactor(entryToImage(*entry_ptr), groupWeight);
+            }
+          }
+        }
+      }
+    }
+    if (weightSum > 0.0)
+      dest *= _polarizationNormalizationFactor / weightSum;
+    else
+      dest = 0.0;
+  }
+}
+
+void ImageSet::CalculateDeconvolutionFrequencies(
+    const DeconvolutionTable& groupTable,
+    aocommon::UVector<double>& frequencies, aocommon::UVector<float>& weights) {
+  const size_t nInputChannels = groupTable.OriginalGroups().size();
+  const size_t nDeconvolutionChannels = groupTable.DeconvolutionGroups().size();
+  frequencies.assign(nDeconvolutionChannels, 0.0);
+  weights.assign(nDeconvolutionChannels, 0.0);
+  std::vector<double> unweightedFrequencies(nDeconvolutionChannels, 0.0);
+  std::vector<size_t> counts(nDeconvolutionChannels, 0);
+  for (size_t i = 0; i != nInputChannels; ++i) {
+    const DeconvolutionTableEntry& entry =
+        *groupTable.OriginalGroups()[i].front();
+    const double freq = entry.CentralFrequency();
+    const double weight = entry.image_weight;
+    const size_t deconvolutionChannel =
+        i * nDeconvolutionChannels / nInputChannels;
+
+    frequencies[deconvolutionChannel] += freq * weight;
+    weights[deconvolutionChannel] += weight;
+
+    unweightedFrequencies[deconvolutionChannel] += freq;
+    ++counts[deconvolutionChannel];
+  }
+  for (size_t i = 0; i != nDeconvolutionChannels; ++i) {
+    // Even when there is no data for a given frequency and the weight
+    // is zero, it is still desirable to have a proper value for the frequency
+    // (e.g. for extrapolating flux).
+    if (weights[i] > 0.0)
+      frequencies[i] /= weights[i];
+    else
+      frequencies[i] = unweightedFrequencies[i] / counts[i];
+  }
+}
+
+void ImageSet::GetIntegratedPSF(Image& dest,
+                                const std::vector<aocommon::Image>& psfs) {
+  assert(psfs.size() == NDeconvolutionChannels());
+
+  const size_t image_size = Width() * Height();
+
+  if (NDeconvolutionChannels() == 1) {
+    dest = psfs.front();
+  } else {
+    bool isFirst = true;
+    double weightSum = 0.0;
+    for (size_t channel = 0; channel != NDeconvolutionChannels(); ++channel) {
+      assert(psfs[channel].Size() == image_size);
+
+      const double groupWeight = _weights[channel];
+      // if the groupWeight is zero, the image might contain NaNs, so we
+      // shouldn't add it to the total in that case.
+      if (groupWeight != 0.0) {
+        weightSum += groupWeight;
+        if (isFirst) {
+          dest = psfs[channel];
+          dest *= groupWeight;
+          isFirst = false;
+        } else {
+          dest.AddWithFactor(psfs[channel], groupWeight);
+        }
+      }
+    }
+    const double factor = weightSum == 0.0 ? 0.0 : 1.0 / weightSum;
+    dest *= factor;
+  }
+}
+}  // namespace radler
\ No newline at end of file
diff --git a/cpp/image_set.h b/cpp/image_set.h
new file mode 100644
index 00000000..b15fcc93
--- /dev/null
+++ b/cpp/image_set.h
@@ -0,0 +1,337 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_IMAGE_SET_H_
+#define RADLER_IMAGE_SET_H_
+
+#include <map>
+#include <memory>
+#include <vector>
+
+#include <aocommon/image.h>
+#include <aocommon/uvector.h>
+
+#include <schaapcommon/fitters/spectralfitter.h>
+
+#include "deconvolution_table.h"
+
+namespace radler {
+
+class ImageSet {
+ public:
+  ImageSet(const DeconvolutionTable& table, bool squared_joins,
+           const std::set<aocommon::PolarizationEnum>& linked_polarizations,
+           size_t width, size_t height);
+
+  /**
+   * Constructs an ImageSet with the same settings as an existing ImageSet
+   * object, but a different image size.
+   *
+   * @param image_set An existing ImageSet.
+   * @param width The image width for the new ImageSet.
+   * @param height The image height for the new ImageSet.
+   */
+  ImageSet(const ImageSet& image_set, size_t width, size_t height);
+
+  ImageSet(const ImageSet&) = delete;
+  ImageSet& operator=(const ImageSet&) = delete;
+
+  aocommon::Image Release(size_t imageIndex) {
+    return std::move(_images[imageIndex]);
+  }
+
+  void SetImage(size_t imageIndex, aocommon::Image&& data) {
+    assert(data.Width() == Width() && data.Height() == Height());
+    _images[imageIndex] = std::move(data);
+  }
+
+  /**
+   * Replace the images of this ImageSet. The images may be of a different size.
+   * Both ImageSets are expected to be for the same deconvolution configuration:
+   * besides the images and their dimension, no fields are changed.
+   */
+  void SetImages(ImageSet&& source);
+
+  /**
+   * @param use_residual_images: True: Load residual images. False: Load model
+   * images.
+   */
+  void LoadAndAverage(bool use_residual_images);
+
+  std::vector<aocommon::Image> LoadAndAveragePSFs();
+
+  void InterpolateAndStoreModel(
+      const schaapcommon::fitters::SpectralFitter& fitter, size_t threadCount);
+
+  void AssignAndStoreResidual();
+
+  /**
+   * This function will calculate the integration over all images, squaring
+   * images that are in the same squared-image group. For example, with
+   * a squared group of [I, Q, ..] and another group [I2, Q2, ...], this
+   * will calculate:
+   *
+   * sqrt(I^2 + Q^2 + ..) + sqrt(I2^2 + Q2^2 ..) + ..
+   * ----------------------------------------------
+   *           1          +           1          + ..
+   *
+   * If the 'squared groups' are of size 1, the average of the groups will be
+   * returned (i.e., without square-rooting the square).
+   *
+   * If the squared joining option is set in the provided wsclean settings, the
+   * behaviour of this method changes. In that case, it will return the square
+   * root of the average squared value:
+   *
+   *       I^2 + Q^2 + ..  +  I2^2 + Q2^2 ..  + ..
+   * sqrt( --------------------------------------- )
+   *            1          +        1         + ..
+   *
+   * These formulae are such that the values will have normal flux values.
+   * @param dest Pre-allocated output array that will be filled with the
+   * integrated image.
+   * @param scratch Pre-allocated scratch space, same size as image.
+   */
+  void GetSquareIntegrated(aocommon::Image& dest,
+                           aocommon::Image& scratch) const {
+    if (_squareJoinedChannels)
+      getSquareIntegratedWithSquaredChannels(dest);
+    else
+      getSquareIntegratedWithNormalChannels(dest, scratch);
+  }
+
+  /**
+   * This function will calculate the 'linear' integration over all images,
+   * unless joined channels are requested to be squared. The method will return
+   * the weighted average of all images. Normally,
+   * @ref GetSquareIntegrated
+   * should be used for peak finding, but in case negative values should remain
+   * negative, such as with multiscale (otherwise a sidelobe will be fitted with
+   * large scales), this function can be used.
+   * @param dest Pre-allocated output array that will be filled with the average
+   * values.
+   */
+  void GetLinearIntegrated(aocommon::Image& dest) const {
+    if (_squareJoinedChannels)
+      getSquareIntegratedWithSquaredChannels(dest);
+    else
+      getLinearIntegratedWithNormalChannels(dest);
+  }
+
+  void GetIntegratedPSF(aocommon::Image& dest,
+                        const std::vector<aocommon::Image>& psfs);
+
+  size_t NOriginalChannels() const {
+    return _deconvolutionTable.OriginalGroups().size();
+  }
+
+  size_t PSFCount() const { return NDeconvolutionChannels(); }
+
+  size_t NDeconvolutionChannels() const {
+    return _deconvolutionTable.DeconvolutionGroups().size();
+  }
+
+  ImageSet& operator=(float val) {
+    for (aocommon::Image& image : _images) image = val;
+    return *this;
+  }
+
+  /**
+   * Exposes image data.
+   *
+   * ImageSet only exposes a non-const pointer to the image data. When exposing
+   * non-const reference to the images themselves, the user could change the
+   * image size and violate the invariant that all images have equal sizes.
+   * @param index An image index.
+   * @return A non-const pointer to the data area for the image.
+   */
+  float* Data(size_t index) { return _images[index].Data(); }
+
+  /**
+   * Exposes the images in the image set.
+   *
+   * Creating a non-const version of this operator is not desirable. See Data().
+   *
+   * @param index An image index.
+   * @return A const reference to the image with the given index.
+   */
+  const aocommon::Image& operator[](size_t index) const {
+    return _images[index];
+  }
+
+  size_t size() const { return _images.size(); }
+
+  size_t PSFIndex(size_t imageIndex) const {
+    return _imageIndexToPSFIndex[imageIndex];
+  }
+
+  const DeconvolutionTable& Table() const { return _deconvolutionTable; }
+
+  std::unique_ptr<ImageSet> Trim(size_t x1, size_t y1, size_t x2, size_t y2,
+                                 size_t oldWidth) const {
+    auto p = std::make_unique<ImageSet>(*this, x2 - x1, y2 - y1);
+    for (size_t i = 0; i != _images.size(); ++i) {
+      copySmallerPart(_images[i], p->_images[i], x1, y1, x2, y2, oldWidth);
+    }
+    return p;
+  }
+
+  /**
+   * Like Trim(), but only copies values that are in the mask. All other values
+   * are set to zero.
+   * @param mask A mask of size (x2-x1) x (y2-y1)
+   */
+  std::unique_ptr<ImageSet> TrimMasked(size_t x1, size_t y1, size_t x2,
+                                       size_t y2, size_t oldWidth,
+                                       const bool* mask) const {
+    std::unique_ptr<ImageSet> p = Trim(x1, y1, x2, y2, oldWidth);
+    for (aocommon::Image& image : p->_images) {
+      for (size_t pixel = 0; pixel != image.Size(); ++pixel) {
+        if (!mask[pixel]) image[pixel] = 0.0;
+      }
+    }
+    return p;
+  }
+
+  void CopyMasked(const ImageSet& fromImageSet, size_t toX, size_t toY,
+                  const bool* fromMask) {
+    for (size_t i = 0; i != _images.size(); ++i) {
+      aocommon::Image::CopyMasked(
+          _images[i].Data(), toX, toY, _images[i].Width(),
+          fromImageSet._images[i].Data(), fromImageSet._images[i].Width(),
+          fromImageSet._images[i].Height(), fromMask);
+    }
+  }
+
+  /**
+   * Place all images in @c from onto the images in this ImageSet at a
+   * given position. The dimensions of @c from can be smaller or equal
+   * to ones in this.
+   */
+  void AddSubImage(const ImageSet& from, size_t toX, size_t toY) {
+    for (size_t i = 0; i != _images.size(); ++i) {
+      aocommon::Image::AddSubImage(_images[i].Data(), toX, toY,
+                                   _images[i].Width(), from._images[i].Data(),
+                                   from._images[i].Width(),
+                                   from._images[i].Height());
+    }
+  }
+
+  ImageSet& operator*=(float factor) {
+    for (aocommon::Image& image : _images) image *= factor;
+    return *this;
+  }
+
+  ImageSet& operator+=(const ImageSet& other) {
+    for (size_t i = 0; i != size(); ++i) _images[i] += other._images[i];
+    return *this;
+  }
+
+  void FactorAdd(ImageSet& rhs, double factor) {
+    for (size_t i = 0; i != size(); ++i)
+      _images[i].AddWithFactor(rhs._images[i], factor);
+  }
+
+  bool SquareJoinedChannels() const { return _squareJoinedChannels; }
+
+  const std::set<aocommon::PolarizationEnum>& LinkedPolarizations() const {
+    return _linkedPolarizations;
+  }
+
+  size_t Width() const { return _images.front().Width(); }
+
+  size_t Height() const { return _images.front().Height(); }
+
+  static void CalculateDeconvolutionFrequencies(
+      const DeconvolutionTable& groupTable,
+      aocommon::UVector<double>& frequencies,
+      aocommon::UVector<float>& weights);
+
+ private:
+  void initializeIndices();
+
+  void initializePolFactor() {
+    const DeconvolutionTable::Group& firstChannelGroup =
+        _deconvolutionTable.OriginalGroups().front();
+    std::set<aocommon::PolarizationEnum> pols;
+    bool all_stokes_without_i = true;
+    for (const DeconvolutionTableEntry* entry : firstChannelGroup) {
+      if (_linkedPolarizations.empty() ||
+          _linkedPolarizations.count(entry->polarization) != 0) {
+        if (!aocommon::Polarization::IsStokes(entry->polarization) ||
+            entry->polarization == aocommon::Polarization::StokesI)
+          all_stokes_without_i = false;
+        pols.insert(entry->polarization);
+      }
+    }
+    const bool isDual =
+        pols.size() == 2 && aocommon::Polarization::HasDualPolarization(pols);
+    const bool isFull =
+        pols.size() == 4 &&
+        (aocommon::Polarization::HasFullLinearPolarization(pols) ||
+         aocommon::Polarization::HasFullCircularPolarization(pols));
+    if (all_stokes_without_i)
+      _polarizationNormalizationFactor = 1.0 / pols.size();
+    else if (isDual || isFull)
+      _polarizationNormalizationFactor = 0.5;
+    else
+      _polarizationNormalizationFactor = 1.0;
+  }
+
+  static void copySmallerPart(const aocommon::Image& input,
+                              aocommon::Image& output, size_t x1, size_t y1,
+                              size_t x2, size_t y2, size_t oldWidth) {
+    size_t newWidth = x2 - x1;
+    for (size_t y = y1; y != y2; ++y) {
+      const float* oldPtr = &input[y * oldWidth];
+      float* newPtr = &output[(y - y1) * newWidth];
+      for (size_t x = x1; x != x2; ++x) {
+        newPtr[x - x1] = oldPtr[x];
+      }
+    }
+  }
+
+  static void copyToLarger(aocommon::Image& to, size_t toX, size_t toY,
+                           size_t toWidth, const aocommon::Image& from,
+                           size_t fromWidth, size_t fromHeight) {
+    for (size_t y = 0; y != fromHeight; ++y) {
+      std::copy(from.Data() + y * fromWidth, from.Data() + (y + 1) * fromWidth,
+                to.Data() + toX + (toY + y) * toWidth);
+    }
+  }
+
+  static void copyToLarger(aocommon::Image& to, size_t toX, size_t toY,
+                           size_t toWidth, const aocommon::Image& from,
+                           size_t fromWidth, size_t fromHeight,
+                           const bool* fromMask) {
+    for (size_t y = 0; y != fromHeight; ++y) {
+      for (size_t x = 0; x != fromWidth; ++x) {
+        if (fromMask[y * fromWidth + x])
+          to[toX + (toY + y) * toWidth + x] = from[y * fromWidth + x];
+      }
+    }
+  }
+
+  void getSquareIntegratedWithNormalChannels(aocommon::Image& dest,
+                                             aocommon::Image& scratch) const;
+
+  void getSquareIntegratedWithSquaredChannels(aocommon::Image& dest) const;
+
+  void getLinearIntegratedWithNormalChannels(aocommon::Image& dest) const;
+
+  const aocommon::Image& entryToImage(
+      const DeconvolutionTableEntry& entry) const {
+    return _images[_entryIndexToImageIndex[entry.index]];
+  }
+
+  std::vector<aocommon::Image> _images;
+  // Weight of each deconvolution channels
+  aocommon::UVector<float> _weights;
+  bool _squareJoinedChannels;
+  const DeconvolutionTable& _deconvolutionTable;
+  std::vector<size_t> _entryIndexToImageIndex;
+  aocommon::UVector<size_t> _imageIndexToPSFIndex;
+  float _polarizationNormalizationFactor;
+  std::set<aocommon::PolarizationEnum> _linkedPolarizations;
+};
+}  // namespace radler
+#endif  // RADLER_IMAGE_SET_H_
diff --git a/cpp/logging/controllable_log.h b/cpp/logging/controllable_log.h
new file mode 100644
index 00000000..9a1fea35
--- /dev/null
+++ b/cpp/logging/controllable_log.h
@@ -0,0 +1,67 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_LOGGING_CONTROLLABLE_LOG_H_
+#define RADLER_LOGGING_CONTROLLABLE_LOG_H_
+
+#include <mutex>
+#include <string>
+#include <vector>
+
+#include <aocommon/logger.h>
+
+namespace radler::logging {
+
+class ControllableLog final : public aocommon::LogReceiver {
+ public:
+  ControllableLog(std::mutex* mutex)
+      : _mutex(mutex), _isMuted(false), _isActive(true) {}
+
+  virtual ~ControllableLog() {}
+
+  void Mute(bool mute) { _isMuted = mute; }
+  bool IsMuted() const { return _isMuted; }
+
+  void Activate(bool active) { _isActive = active; }
+  bool IsActive() const { return _isActive; }
+
+  void SetTag(const std::string& tag) { _tag = tag; }
+  void SetOutputOnce(const std::string& str) { _outputOnce = str; }
+
+ private:
+  void Output(enum aocommon::Logger::LoggerLevel level,
+              const std::string& str) override {
+    if (!str.empty()) {
+      std::lock_guard<std::mutex> lock(*_mutex);
+
+      bool skip = ((level == aocommon::Logger::kDebugLevel ||
+                    level == aocommon::Logger::kInfoLevel) &&
+                   _isMuted) ||
+                  (level == aocommon::Logger::kDebugLevel &&
+                   !aocommon::Logger::IsVerbose());
+
+      if (!skip) {
+        _lineBuffer += str;
+        if (_lineBuffer.back() == '\n') {
+          if (!_outputOnce.empty()) {
+            Forward(level, _outputOnce);
+            _outputOnce.clear();
+          }
+          Forward(level, _tag);
+          Forward(level, _lineBuffer);
+          _lineBuffer.clear();
+        }
+      }
+    }
+  }
+
+  std::mutex* _mutex;
+  std::string _tag;
+  bool _isMuted;
+  bool _isActive;
+  std::string _lineBuffer;
+  std::string _outputOnce;
+};
+}  // namespace radler::logging
+
+#endif
diff --git a/cpp/logging/subimage_logset.h b/cpp/logging/subimage_logset.h
new file mode 100644
index 00000000..6f8396b6
--- /dev/null
+++ b/cpp/logging/subimage_logset.h
@@ -0,0 +1,104 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_LOGGING_SUBIMAGELOGSET_H_
+#define RADLER_LOGGING_SUBIMAGELOGSET_H_
+
+#include "logging/controllable_log.h"
+
+namespace radler::logging {
+/**
+ * @brief Thread safe logger for the set of subimages in the deconvolution.
+ */
+class SubImageLogSet {
+ public:
+  SubImageLogSet() : n_horizontal_(0), n_vertical_(0) {}
+
+  void Initialize(size_t n_horizontal, size_t n_vertical) {
+    std::lock_guard<std::mutex> lock(output_mutex_);
+    n_horizontal_ = n_horizontal;
+    n_vertical_ = n_vertical;
+    const size_t n = n_horizontal * n_vertical;
+    logs_.clear();
+    logs_.reserve(n);
+    for (size_t i = 0; i != n; ++i) {
+      logs_.emplace_back(&output_mutex_);
+      logs_[i].SetTag("P" + std::to_string(i) + ": ");
+      logs_[i].Mute(true);
+      logs_[i].Activate(false);
+    }
+  }
+
+  ControllableLog& operator[](size_t index) { return logs_[index]; }
+
+  void Activate(size_t index) {
+    std::lock_guard<std::mutex> o_lock(output_mutex_);
+    if (!logs_[index].IsActive()) {
+      logs_[index].Activate(true);
+
+      UnmuteMostCentral();
+    }
+  }
+
+  void Deactivate(size_t index) {
+    std::lock_guard<std::mutex> o_lock(output_mutex_);
+    if (logs_[index].IsActive()) {
+      logs_[index].Mute(true);
+      logs_[index].SetOutputOnce(std::string());
+      logs_[index].Activate(false);
+
+      UnmuteMostCentral();
+    }
+  }
+
+ private:
+  void UnmuteMostCentral() {
+    size_t unmuted_log = logs_.size();
+    for (size_t i = 0; i != logs_.size(); ++i) {
+      if (!logs_[i].IsMuted()) unmuted_log = i;
+      logs_[i].SetTag("P" + std::to_string(i) + ": ");
+      logs_[i].Mute(true);
+    }
+
+    // Find an active subimage that is as close as possible to
+    // the centre (since these often contain the most interesting info)
+    bool found = false;
+    size_t si_x = 0;
+    size_t si_y = 0;
+    size_t si_d = 0;
+    for (size_t y = 0; y != n_vertical_; ++y) {
+      for (size_t x = 0; x != n_horizontal_; ++x) {
+        const size_t index = y * n_horizontal_ + x;
+        if (logs_[index].IsActive()) {
+          const int dx = int(x) - int(n_horizontal_) / 2;
+          const int dy = int(y) - int(n_vertical_) / 2;
+          const size_t dist_squared = dx * dx + dy * dy;
+          if (!found || dist_squared < si_d) {
+            si_x = x;
+            si_y = y;
+            si_d = dist_squared;
+            found = true;
+          }
+        }
+      }
+    }
+
+    if (found) {
+      const size_t index = si_y * n_horizontal_ + si_x;
+      logs_[index].Mute(false);
+      logs_[index].SetTag(" ");
+
+      if (index != unmuted_log) {
+        logs_[index].SetOutputOnce("Switching to output of subimage " +
+                                   std::to_string(index) + "\n");
+      }
+    }
+  }
+
+  std::mutex output_mutex_;
+  std::vector<ControllableLog> logs_;
+  size_t n_horizontal_;
+  size_t n_vertical_;
+};
+}  // namespace radler::logging
+#endif
\ No newline at end of file
diff --git a/cpp/math/dijkstrasplitter.h b/cpp/math/dijkstrasplitter.h
new file mode 100644
index 00000000..6aaf768f
--- /dev/null
+++ b/cpp/math/dijkstrasplitter.h
@@ -0,0 +1,320 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_MATH_DIJKSTRASPLITTER_H_
+#define RADLER_MATH_DIJKSTRASPLITTER_H_
+
+#include <aocommon/uvector.h>
+
+#include <algorithm>
+#include <cmath>
+#include <limits>
+#include <queue>
+
+namespace radler::math {
+
+class DijkstraSplitter {
+ public:
+  DijkstraSplitter(size_t width, size_t height)
+      : _width(width), _height(height) {}
+
+  struct Coord {
+    Coord() = default;
+    Coord(size_t _x, size_t _y) : x(_x), y(_y) {}
+    size_t x, y;
+  };
+
+  struct Visit {
+    float distance;
+    Coord to, from;
+    bool operator<(const Visit& rhs) const { return distance > rhs.distance; }
+  };
+
+  void AddVerticalDivider(const float* image, float* scratch, float* output,
+                          size_t x1, size_t x2) const {
+    DivideVertically(image, scratch, x1, x2);
+    for (size_t y = 0; y != _height; ++y) {
+      for (size_t i = y * _width + x1; i != y * _width + x2; ++i)
+        output[i] += scratch[i];
+    }
+  }
+
+  /**
+   * Find the shortest vertical path through an image. The path is
+   * constrained to lie between vertical lines given by x1 and x2.
+   * The output is set to 1 for pixels that are part of the path, and
+   * set to 0 otherwise. The reason it's a floating point is because
+   * it is also used as scratch.
+   */
+  void DivideVertically(const float* image, float* output, size_t x1,
+                        size_t x2) const {
+    using visitset = std::priority_queue<Visit>;
+    visitset visits;
+
+    for (size_t x = x1; x != x2; ++x) {
+      Visit visit;
+      visit.distance = 0.0;
+      visit.to = Coord(x, 0);
+      visit.from = Coord(x, 0);
+      visits.push(visit);
+    }
+    aocommon::UVector<Coord> path((x2 - x1) * _height);
+    fillColumns(output, x1, x2, std::numeric_limits<float>::max());
+    Visit visit;
+    while (!visits.empty()) {
+      visit = visits.top();
+      visits.pop();
+      size_t x = visit.to.x, y = visit.to.y;
+      if (y == _height) break;
+      float curDistance = output[x + y * _width];
+      float newDistance = visit.distance + std::fabs(image[x + y * _width]);
+      if (newDistance < curDistance) {
+        output[x + y * _width] = newDistance;
+        path[x - x1 + y * (x2 - x1)] = visit.from;
+        visit.distance = newDistance;
+        visit.from = visit.to;
+        if (x > x1) {
+          visit.to = Coord(x - 1, y + 1);
+          visits.push(visit);
+          visit.to = Coord(x - 1, y);
+          visits.push(visit);
+        }
+        visit.to = Coord(x, y + 1);
+        visits.push(visit);
+        if (x < x2 - 1) {
+          visit.to = Coord(x + 1, y + 1);
+          visits.push(visit);
+          visit.to = Coord(x + 1, y);
+          visits.push(visit);
+        }
+      }
+    }
+    fillColumns(output, x1, x2, 0.0);
+    Coord pCoord = visit.from;
+    while (pCoord.y > 0) {
+      output[pCoord.x + pCoord.y * _width] = 1.0;
+      pCoord = path[pCoord.x - x1 + pCoord.y * (x2 - x1)];
+    }
+    output[pCoord.x] = 1.0;
+  }
+
+  void AddHorizontalDivider(const float* image, float* scratch, float* output,
+                            size_t y1, size_t y2) const {
+    DivideHorizontally(image, scratch, y1, y2);
+    for (size_t i = y1 * _width; i != y2 * _width; ++i) output[i] += scratch[i];
+  }
+
+  /**
+   * Like DivideVertically, but for horizontal paths. The path is
+   * constrained to lie between horizontal lines given by y1 and y2.
+   */
+  void DivideHorizontally(const float* image, float* output, size_t y1,
+                          size_t y2) const {
+    using visitset = std::priority_queue<Visit>;
+    visitset visits;
+
+    for (size_t y = y1; y != y2; ++y) {
+      Visit visit;
+      visit.distance = 0.0;
+      visit.to = Coord(0, y);
+      visit.from = Coord(0, y);
+      visits.push(visit);
+    }
+    aocommon::UVector<Coord> path(_width * (y2 - y1));
+    std::fill(output + y1 * _width, output + y2 * _width,
+              std::numeric_limits<float>::max());
+    Visit visit;
+    while (!visits.empty()) {
+      visit = visits.top();
+      visits.pop();
+      size_t x = visit.to.x, y = visit.to.y;
+      if (x == _width) break;
+      float curDistance = output[x + y * _width];
+      float newDistance = visit.distance + std::fabs(image[x + y * _width]);
+      if (newDistance < curDistance) {
+        output[x + y * _width] = newDistance;
+        path[x + (y - y1) * _width] = visit.from;
+        visit.distance = newDistance;
+        visit.from = visit.to;
+        if (y > y1) {
+          visit.to = Coord(x + 1, y - 1);
+          visits.push(visit);
+          visit.to = Coord(x, y - 1);
+          visits.push(visit);
+        }
+        visit.to = Coord(x + 1, y);
+        visits.push(visit);
+        if (y < y2 - 1) {
+          visit.to = Coord(x + 1, y + 1);
+          visits.push(visit);
+          visit.to = Coord(x, y + 1);
+          visits.push(visit);
+        }
+      }
+    }
+    std::fill(output + y1 * _width, output + y2 * _width, 0.0);
+    Coord pCoord = visit.from;
+    while (pCoord.x > 0) {
+      output[pCoord.x + pCoord.y * _width] = 1.0;
+      pCoord = path[pCoord.x + (pCoord.y - y1) * _width];
+    }
+    output[pCoord.y * _width] = 1.0;
+  }
+
+  /**
+   * Mask the space between (typically) two vertical divisions.
+   * @param subdivision An image that is the result of earlier calls
+   * to DivideVertically().
+   * @param subImgX An x-position that is in between the two splits.
+   * @param mask A mask image for which pixels will be set to true if
+   *   and only if they are part of the area specified by the
+   *   two divisions.
+   * @param [out] x The left side of the bounding box of the divisions.
+   * @param [out] subWidth The bounding width of the two divisions.
+   */
+  void FloodVerticalArea(const float* subdivision, size_t subImgX, bool* mask,
+                         size_t& x, size_t& subWidth) const {
+    std::fill(mask, mask + _width * _height, false);
+    x = _width;
+    size_t x2 = 0;
+    for (size_t y = 0; y != _height; ++y) {
+      const float* dRow = &subdivision[y * _width];
+      bool* maskRow = &mask[y * _width];
+      int xIter = subImgX;
+      // Move to the left until a border is hit
+      while (xIter >= 0 && dRow[xIter] == 0.0) {
+        maskRow[xIter] = true;
+        --xIter;
+      }
+      // Continue to the left through the border
+      while (xIter >= 0 && dRow[xIter] != 0.0) {
+        maskRow[xIter] = true;
+        --xIter;
+      }
+      x = std::min<size_t>(x, xIter + 1);
+      xIter = subImgX + 1;
+      // Move to the right until a border is hit
+      while (size_t(xIter) < _width && dRow[xIter] == 0.0) {
+        maskRow[xIter] = true;
+        ++xIter;
+      }
+      x2 = std::max<size_t>(x2, xIter);
+    }
+    if (x2 < x)
+      subWidth = 0;
+    else
+      subWidth = x2 - x;
+  }
+
+  /**
+   * Like FloodVerticalArea(), but for horizontal flooding.
+   */
+  void FloodHorizontalArea(const float* subdivision, size_t subImgY, bool* mask,
+                           size_t& y, size_t& subHeight) const {
+    std::fill(mask, mask + _width * _height, false);
+    y = _height;
+    size_t y2 = 0;
+    for (size_t x = 0; x != _width; ++x) {
+      int yIter = subImgY;
+      // Move up until a border is hit
+      while (yIter >= 0 && subdivision[yIter * _width + x] == 0.0) {
+        mask[yIter * _width + x] = true;
+        --yIter;
+      }
+      // Continue to the left through the border
+      while (yIter >= 0 && subdivision[yIter * _width + x] != 0.0) {
+        mask[yIter * _width + x] = true;
+        --yIter;
+      }
+      y = std::min<size_t>(y, yIter + 1);
+      yIter = subImgY + 1;
+      // Move to the right until a border is hit
+      while (size_t(yIter) < _height &&
+             subdivision[yIter * _width + x] == 0.0) {
+        mask[yIter * _width + x] = true;
+        ++yIter;
+      }
+      y2 = std::max<size_t>(y2, yIter);
+    }
+    if (y2 < y)
+      subHeight = 0;
+    else
+      subHeight = y2 - y;
+  }
+
+  /**
+   * Combines a horizontally and vertically filled area and extracts a
+   * single mask where the areas overlap.
+   * @param vMask Mask returned by FloodHorizontalArea(), but trimmed
+   * to have the specified width.
+   * @param vMaskX x-value returned by FloodHorizontalArea().
+   * @param vMaskWidth Width return by FloodHorizontalArea(), and width
+   * of vMask.
+   * @param hMask Mask returned by FloodVerticalArea().
+   * @param [in,out] mask Result
+   * @param [out] subX Bounding box x-value
+   * @param [out] subY Bounding box y-value
+   * @param [out] subWidth Bounding box width
+   * @param [out] subHeight Bounding box height
+   */
+  void GetBoundingMask(const bool* vMask, size_t vMaskX, size_t vMaskWidth,
+                       const bool* hMask, bool* mask, size_t& subX,
+                       size_t& subY, size_t& subWidth,
+                       size_t& subHeight) const {
+    subX = vMaskWidth + vMaskX;
+    subY = _height;
+    size_t subX2 = 0, subY2 = 0;
+    for (size_t y = 0; y != _height; ++y) {
+      const bool* vMaskRow = &vMask[y * vMaskWidth];
+      const bool* hMaskRow = &hMask[y * _width];
+      bool* maskRow = &mask[y * _width];
+      for (size_t x = 0; x != vMaskWidth; ++x) {
+        size_t hx = x + vMaskX;
+        if (vMaskRow[x] && hMaskRow[hx]) {
+          maskRow[hx] = true;
+          subX = std::min(hx, subX);
+          subY = std::min(y, subY);
+          subX2 = std::max(hx, subX2);
+          subY2 = std::max(y, subY2);
+        } else
+          maskRow[hx] = false;
+      }
+    }
+    if (subX2 < subX) {
+      subWidth = 0;
+      subHeight = 0;
+    } else {
+      subWidth = subX2 + 1 - subX;
+      subHeight = subY2 + 1 - subY;
+    }
+    // If dimensions start off even, keep subimages even too
+    if (_width % 2 == 0) {
+      if (subWidth % 2 != 0) {
+        ++subWidth;
+        if (subWidth + subX >= _width) --subX;
+      }
+    }
+    if (_height % 2 == 0) {
+      if (subHeight % 2 != 0) {
+        ++subHeight;
+        if (subHeight + subY >= _height) --subY;
+      }
+    }
+  }
+
+ private:
+  /**
+   * This function sets a rectangular area given by 0 <= y < height and xStart
+   * <= x < xEnd.
+   */
+  void fillColumns(float* output, size_t xStart, size_t xEnd,
+                   float newValue) const {
+    for (size_t y = 0; y != _height; ++y) {
+      std::fill(output + _width * y + xStart, output + _width * y + xEnd,
+                newValue);
+    }
+  }
+  size_t _width, _height;
+};
+}  // namespace radler::math
+#endif
diff --git a/cpp/math/peak_finder.cc b/cpp/math/peak_finder.cc
new file mode 100644
index 00000000..6ef3e1f0
--- /dev/null
+++ b/cpp/math/peak_finder.cc
@@ -0,0 +1,262 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "math/peak_finder.h"
+
+#ifdef __SSE__
+#define USE_INTRINSICS
+#endif
+
+#ifdef USE_INTRINSICS
+#include <emmintrin.h>
+#include <immintrin.h>
+#endif
+
+#include <limits>
+
+namespace radler::math {
+
+std::optional<float> PeakFinder::Simple(const float* image, size_t width,
+                                        size_t height, size_t& x, size_t& y,
+                                        bool allowNegativeComponents,
+                                        size_t startY, size_t endY,
+                                        size_t horizontalBorder,
+                                        size_t verticalBorder) {
+  float peakMax = std::numeric_limits<float>::min();
+  size_t peakIndex = width * height;
+
+  size_t xiStart = horizontalBorder, xiEnd = width - horizontalBorder;
+  size_t yiStart = std::max(startY, verticalBorder),
+         yiEnd = std::min(endY, height - verticalBorder);
+  if (xiEnd < xiStart) xiEnd = xiStart;
+  if (yiEnd < yiStart) yiEnd = yiStart;
+
+  for (size_t yi = yiStart; yi != yiEnd; ++yi) {
+    size_t index = yi * width + xiStart;
+    for (size_t xi = xiStart; xi != xiEnd; ++xi) {
+      float value = image[index];
+      if (allowNegativeComponents) value = std::fabs(value);
+      if (value > peakMax) {
+        peakIndex = index;
+        peakMax = std::fabs(value);
+      }
+      ++value;
+      ++index;
+    }
+  }
+  if (peakIndex == width * height) {
+    x = width;
+    y = height;
+    return std::optional<float>();
+  } else {
+    x = peakIndex % width;
+    y = peakIndex / width;
+    return image[x + y * width];
+  }
+}
+
+std::optional<double> PeakFinder::Simple(const double* image, size_t width,
+                                         size_t height, size_t& x, size_t& y,
+                                         bool allowNegativeComponents,
+                                         size_t startY, size_t endY,
+                                         size_t horizontalBorder,
+                                         size_t verticalBorder) {
+  double peakMax = std::numeric_limits<double>::min();
+  size_t peakIndex = width * height;
+
+  size_t xiStart = horizontalBorder, xiEnd = width - horizontalBorder;
+  size_t yiStart = std::max(startY, verticalBorder),
+         yiEnd = std::min(endY, height - verticalBorder);
+  if (xiEnd < xiStart) xiEnd = xiStart;
+  if (yiEnd < yiStart) yiEnd = yiStart;
+
+  for (size_t yi = yiStart; yi != yiEnd; ++yi) {
+    size_t index = yi * width + xiStart;
+    for (size_t xi = xiStart; xi != xiEnd; ++xi) {
+      double value = image[index];
+      if (allowNegativeComponents) value = std::fabs(value);
+      if (value > peakMax) {
+        peakIndex = index;
+        peakMax = std::fabs(value);
+      }
+      ++value;
+      ++index;
+    }
+  }
+  if (peakIndex == width * height) {
+    x = width;
+    y = height;
+    return {};
+  } else {
+    x = peakIndex % width;
+    y = peakIndex / width;
+    return image[x + y * width];
+  }
+}
+
+std::optional<float> PeakFinder::FindWithMask(
+    const float* image, size_t width, size_t height, size_t& x, size_t& y,
+    bool allowNegativeComponents, size_t startY, size_t endY,
+    const bool* cleanMask, size_t horizontalBorder, size_t verticalBorder) {
+  float peakMax = std::numeric_limits<float>::min();
+  x = width;
+  y = height;
+
+  size_t xiStart = horizontalBorder, xiEnd = width - horizontalBorder;
+  size_t yiStart = std::max(startY, verticalBorder),
+         yiEnd = std::min(endY, height - verticalBorder);
+  if (xiEnd < xiStart) xiEnd = xiStart;
+  if (yiEnd < yiStart) yiEnd = yiStart;
+
+  for (size_t yi = yiStart; yi != yiEnd; ++yi) {
+    const float* imgIter = &image[yi * width + xiStart];
+    const bool* cleanMaskPtr = &cleanMask[yi * width + xiStart];
+    for (size_t xi = xiStart; xi != xiEnd; ++xi) {
+      float value = *imgIter;
+      if (allowNegativeComponents) value = std::fabs(value);
+      if (value > peakMax && *cleanMaskPtr) {
+        x = xi;
+        y = yi;
+        peakMax = std::fabs(value);
+      }
+      ++imgIter;
+      ++cleanMaskPtr;
+    }
+  }
+  if (y == height)
+    return std::optional<float>();
+  else
+    return image[x + y * width];
+}
+
+#if defined __AVX__ && defined USE_INTRINSICS && !defined FORCE_NON_AVX
+template <bool AllowNegativeComponent>
+std::optional<double> PeakFinder::AVX(const double* image, size_t width,
+                                      size_t height, size_t& x, size_t& y,
+                                      size_t startY, size_t endY,
+                                      size_t horizontalBorder,
+                                      size_t verticalBorder) {
+  double peakMax = std::numeric_limits<double>::min();
+  size_t peakIndex = 0;
+
+  __m256d mPeakMax = _mm256_set1_pd(peakMax);
+
+  size_t xiStart = horizontalBorder, xiEnd = width - horizontalBorder;
+  size_t yiStart = std::max(startY, verticalBorder),
+         yiEnd = std::min(endY, height - verticalBorder);
+  if (xiEnd < xiStart) xiEnd = xiStart;
+  if (yiEnd < yiStart) yiEnd = yiStart;
+
+  for (size_t yi = yiStart; yi != yiEnd; ++yi) {
+    size_t index = yi * width + xiStart;
+    const double* const endPtr = image + yi * width + xiEnd - 4;
+    const double* i = image + index;
+    for (; i < endPtr; i += 4) {
+      __m256d val = _mm256_loadu_pd(i);
+      if (AllowNegativeComponent) {
+        __m256d negVal = _mm256_sub_pd(_mm256_set1_pd(0.0), val);
+        val = _mm256_max_pd(val, negVal);
+      }
+      int mask = _mm256_movemask_pd(_mm256_cmp_pd(val, mPeakMax, _CMP_GT_OQ));
+      if (mask != 0) {
+        for (size_t di = 0; di != 4; ++di) {
+          double value = i[di];
+          if (AllowNegativeComponent) value = std::fabs(value);
+          if (value > peakMax) {
+            peakIndex = index + di;
+            peakMax = std::fabs(i[di]);
+            mPeakMax = _mm256_set1_pd(peakMax);
+          }
+        }
+      }
+      index += 4;
+    }
+    for (; i != endPtr + 4; ++i) {
+      double value = *i;
+      if (AllowNegativeComponent) value = std::fabs(value);
+      if (value > peakMax) {
+        peakIndex = index;
+        peakMax = std::fabs(*i);
+      }
+      ++index;
+    }
+  }
+  x = peakIndex % width;
+  y = peakIndex / width;
+  return image[x + y * width];
+}
+
+template std::optional<double> PeakFinder::AVX<false>(
+    const double* image, size_t width, size_t height, size_t& x, size_t& y,
+    size_t startY, size_t endY, size_t horizontalBorder, size_t verticalBorder);
+template std::optional<double> PeakFinder::AVX<true>(
+    const double* image, size_t width, size_t height, size_t& x, size_t& y,
+    size_t startY, size_t endY, size_t horizontalBorder, size_t verticalBorder);
+
+template <bool AllowNegativeComponent>
+std::optional<float> PeakFinder::AVX(const float* image, size_t width,
+                                     size_t height, size_t& x, size_t& y,
+                                     size_t startY, size_t endY,
+                                     size_t horizontalBorder,
+                                     size_t verticalBorder) {
+  float peakMax = std::numeric_limits<float>::min();
+  size_t peakIndex = 0;
+
+  __m256 mPeakMax = _mm256_set1_ps(peakMax);
+
+  size_t xiStart = horizontalBorder, xiEnd = width - horizontalBorder;
+  size_t yiStart = std::max(startY, verticalBorder),
+         yiEnd = std::min(endY, height - verticalBorder);
+  if (xiEnd < xiStart) xiEnd = xiStart;
+  if (yiEnd < yiStart) yiEnd = yiStart;
+
+  for (size_t yi = yiStart; yi != yiEnd; ++yi) {
+    size_t index = yi * width + xiStart;
+    const float* const endPtr = image + yi * width + xiEnd - 8;
+    const float* i = image + index;
+    for (; i < endPtr; i += 8) {
+      __m256 val = _mm256_loadu_ps(i);
+      if (AllowNegativeComponent) {
+        __m256 negVal = _mm256_sub_ps(_mm256_set1_ps(0.0), val);
+        val = _mm256_max_ps(val, negVal);
+      }
+      int mask = _mm256_movemask_ps(_mm256_cmp_ps(val, mPeakMax, _CMP_GT_OQ));
+      if (mask != 0) {
+        for (size_t di = 0; di != 8; ++di) {
+          double value = i[di];
+          if (AllowNegativeComponent) value = std::fabs(value);
+          if (value > peakMax) {
+            peakIndex = index + di;
+            peakMax = std::fabs(i[di]);
+            mPeakMax = _mm256_set1_ps(peakMax);
+          }
+        }
+      }
+      index += 8;
+    }
+    for (; i != endPtr + 8; ++i) {
+      double value = *i;
+      if (AllowNegativeComponent) value = std::fabs(value);
+      if (value > peakMax) {
+        peakIndex = index;
+        peakMax = std::fabs(*i);
+      }
+      ++index;
+    }
+  }
+  x = peakIndex % width;
+  y = peakIndex / width;
+  return image[x + y * width];
+}
+
+template std::optional<float> PeakFinder::AVX<false>(
+    const float* image, size_t width, size_t height, size_t& x, size_t& y,
+    size_t startY, size_t endY, size_t horizontalBorder, size_t verticalBorder);
+template std::optional<float> PeakFinder::AVX<true>(
+    const float* image, size_t width, size_t height, size_t& x, size_t& y,
+    size_t startY, size_t endY, size_t horizontalBorder, size_t verticalBorder);
+
+#else
+#warning "Not using AVX optimized version of FindPeak()!"
+#endif  // __AVX__
+}  // namespace radler::math
\ No newline at end of file
diff --git a/cpp/math/peak_finder.h b/cpp/math/peak_finder.h
new file mode 100644
index 00000000..5ffba1ba
--- /dev/null
+++ b/cpp/math/peak_finder.h
@@ -0,0 +1,129 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_COMPONENTS_PEAK_FINDER_H_
+#define RADLER_COMPONENTS_PEAK_FINDER_H_
+
+#include <cmath>
+#include <cstring>
+#include <optional>
+
+#ifdef __SSE__
+#define USE_INTRINSICS
+#endif
+
+namespace radler::math {
+
+class PeakFinder {
+ public:
+  PeakFinder() = delete;
+
+  static std::optional<float> Simple(const float* image, size_t width,
+                                     size_t height, size_t& x, size_t& y,
+                                     bool allowNegativeComponents,
+                                     size_t startY, size_t endY,
+                                     size_t horizontalBorder,
+                                     size_t verticalBorder);
+
+  static std::optional<double> Simple(const double* image, size_t width,
+                                      size_t height, size_t& x, size_t& y,
+                                      bool allowNegativeComponents,
+                                      size_t startY, size_t endY,
+                                      size_t horizontalBorder,
+                                      size_t verticalBorder);
+
+#if defined __AVX__ && defined USE_INTRINSICS && !defined FORCE_NON_AVX
+  template <bool AllowNegativeComponent>
+  static std::optional<float> AVX(const float* image, size_t width,
+                                  size_t height, size_t& x, size_t& y,
+                                  size_t startY, size_t endY,
+                                  size_t horizontalBorder,
+                                  size_t verticalBorder);
+
+  static std::optional<float> AVX(const float* image, size_t width,
+                                  size_t height, size_t& x, size_t& y,
+                                  bool allowNegativeComponents, size_t startY,
+                                  size_t endY, size_t horizontalBorder,
+                                  size_t verticalBorder) {
+    if (allowNegativeComponents)
+      return AVX<true>(image, width, height, x, y, startY, endY,
+                       horizontalBorder, verticalBorder);
+    else
+      return AVX<false>(image, width, height, x, y, startY, endY,
+                        horizontalBorder, verticalBorder);
+  }
+
+  template <bool AllowNegativeComponent>
+  static std::optional<double> AVX(const double* image, size_t width,
+                                   size_t height, size_t& x, size_t& y,
+                                   size_t startY, size_t endY,
+                                   size_t horizontalBorder,
+                                   size_t verticalBorder);
+
+  static std::optional<double> AVX(const double* image, size_t width,
+                                   size_t height, size_t& x, size_t& y,
+                                   bool allowNegativeComponents, size_t startY,
+                                   size_t endY, size_t horizontalBorder,
+                                   size_t verticalBorder) {
+    if (allowNegativeComponents)
+      return AVX<true>(image, width, height, x, y, startY, endY,
+                       horizontalBorder, verticalBorder);
+    else
+      return AVX<false>(image, width, height, x, y, startY, endY,
+                        horizontalBorder, verticalBorder);
+  }
+#endif
+
+  /**
+   * Find peaks with a relative border ratio.
+   */
+  template <typename NumT>
+  static std::optional<NumT> Find(const NumT* image, size_t width,
+                                  size_t height, size_t& x, size_t& y,
+                                  bool allowNegativeComponents, size_t startY,
+                                  size_t endY, float borderRatio) {
+    return Find(image, width, height, x, y, allowNegativeComponents, startY,
+                endY, round(width * borderRatio), round(height * borderRatio));
+  }
+
+  /**
+   * Find peaks with a fixed border.
+   */
+  template <typename NumT>
+  static std::optional<NumT> Find(const NumT* image, size_t width,
+                                  size_t height, size_t& x, size_t& y,
+                                  bool allowNegativeComponents, size_t startY,
+                                  size_t endY, size_t horizontalBorder,
+                                  size_t verticalBorder) {
+#if defined __AVX__ && defined USE_INTRINSICS && !defined FORCE_NON_AVX
+    return AVX(image, width, height, x, y, allowNegativeComponents, startY,
+               endY, horizontalBorder, verticalBorder);
+#else
+    return Simple(image, width, height, x, y, allowNegativeComponents, startY,
+                  endY, horizontalBorder, verticalBorder);
+#endif
+  }
+
+  static std::optional<float> FindWithMask(const float* image, size_t width,
+                                           size_t height, size_t& x, size_t& y,
+                                           bool allowNegativeComponents,
+                                           const bool* cleanMask);
+
+  static std::optional<float> FindWithMask(const float* image, size_t width,
+                                           size_t height, size_t& x, size_t& y,
+                                           bool allowNegativeComponents,
+                                           size_t startY, size_t endY,
+                                           const bool* cleanMask,
+                                           float borderRatio) {
+    return FindWithMask(image, width, height, x, y, allowNegativeComponents,
+                        startY, endY, cleanMask, round(width * borderRatio),
+                        round(height * borderRatio));
+  }
+
+  static std::optional<float> FindWithMask(
+      const float* image, size_t width, size_t height, size_t& x, size_t& y,
+      bool allowNegativeComponents, size_t startY, size_t endY,
+      const bool* cleanMask, size_t horizontalBorder, size_t verticalBorder);
+};
+}  // namespace radler::math
+#endif
diff --git a/cpp/math/rms_image.cc b/cpp/math/rms_image.cc
new file mode 100644
index 00000000..dbf0ec12
--- /dev/null
+++ b/cpp/math/rms_image.cc
@@ -0,0 +1,96 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "math/rms_image.h"
+
+#include <aocommon/image.h>
+#include <aocommon/staticfor.h>
+
+#include <schaapcommon/fft/restoreimage.h>
+
+using aocommon::Image;
+
+namespace radler::math {
+
+void RMSImage::Make(Image& rmsOutput, const Image& inputImage,
+                    double windowSize, long double beamMaj, long double beamMin,
+                    long double beamPA, long double pixelScaleL,
+                    long double pixelScaleM, size_t threadCount) {
+  Image image(inputImage);
+  image.Square();
+  rmsOutput = Image(image.Width(), image.Height(), 0.0);
+
+  schaapcommon::fft::RestoreImage(rmsOutput.Data(), image.Data(), image.Width(),
+                                  image.Height(), beamMaj * windowSize,
+                                  beamMin * windowSize, beamPA, pixelScaleL,
+                                  pixelScaleM, threadCount);
+
+  const double s = std::sqrt(2.0 * M_PI);
+  const long double sigmaMaj = beamMaj / (2.0L * sqrtl(2.0L * logl(2.0L)));
+  const long double sigmaMin = beamMin / (2.0L * sqrtl(2.0L * logl(2.0L)));
+  const double norm = 1.0 / (s * sigmaMaj / pixelScaleL * windowSize * s *
+                             sigmaMin / pixelScaleL * windowSize);
+  for (auto& val : rmsOutput) val = std::sqrt(val * norm);
+}
+
+void RMSImage::SlidingMinimum(Image& output, const Image& input,
+                              size_t windowSize, size_t threadCount) {
+  const size_t width = input.Width();
+  output = Image(width, input.Height());
+  Image temp(output);
+
+  aocommon::StaticFor<size_t> loop(threadCount);
+
+  loop.Run(0, input.Height(), [&](size_t yStart, size_t yEnd) {
+    for (size_t y = yStart; y != yEnd; ++y) {
+      float* outRowptr = &temp[y * width];
+      const float* inRowptr = &input[y * width];
+      for (size_t x = 0; x != width; ++x) {
+        size_t left = std::max(x, windowSize / 2) - windowSize / 2;
+        size_t right = std::min(x, width - windowSize / 2) + windowSize / 2;
+        outRowptr[x] = *std::min_element(inRowptr + left, inRowptr + right);
+      }
+    }
+  });
+
+  loop.Run(0, width, [&](size_t xStart, size_t xEnd) {
+    aocommon::UVector<float> vals;
+    for (size_t x = xStart; x != xEnd; ++x) {
+      for (size_t y = 0; y != input.Height(); ++y) {
+        size_t top = std::max(y, windowSize / 2) - windowSize / 2;
+        size_t bottom =
+            std::min(y, input.Height() - windowSize / 2) + windowSize / 2;
+        vals.clear();
+        for (size_t winY = top; winY != bottom; ++winY)
+          vals.push_back(temp[winY * width + x]);
+        output[y * width + x] = *std::min_element(vals.begin(), vals.end());
+      }
+    }
+  });
+}
+
+void RMSImage::SlidingMaximum(Image& output, const Image& input,
+                              size_t windowSize, size_t threadCount) {
+  Image flipped(input);
+  flipped.Negate();
+  SlidingMinimum(output, flipped, windowSize, threadCount);
+  output.Negate();
+}
+
+void RMSImage::MakeWithNegativityLimit(
+    Image& rmsOutput, const Image& inputImage, double windowSize,
+    long double beamMaj, long double beamMin, long double beamPA,
+    long double pixelScaleL, long double pixelScaleM, size_t threadCount) {
+  Make(rmsOutput, inputImage, windowSize, beamMaj, beamMin, beamPA, pixelScaleL,
+       pixelScaleM, threadCount);
+  Image slidingMinimum(inputImage.Width(), inputImage.Height());
+  double beamInPixels = std::max(beamMaj / pixelScaleL, 1.0L);
+  SlidingMinimum(slidingMinimum, inputImage, windowSize * beamInPixels,
+                 threadCount);
+  for (size_t i = 0; i != rmsOutput.Size(); ++i) {
+    rmsOutput[i] = std::max<float>(rmsOutput[i],
+                                   std::abs(slidingMinimum[i]) * (1.5 / 5.0));
+  }
+}
+
+}  // namespace radler::math
diff --git a/cpp/math/rms_image.h b/cpp/math/rms_image.h
new file mode 100644
index 00000000..cd5bee38
--- /dev/null
+++ b/cpp/math/rms_image.h
@@ -0,0 +1,35 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_MATH_RMS_IMAGE_H_
+#define RADLER_MATH_RMS_IMAGE_H_
+
+#include <aocommon/image.h>
+
+namespace radler::math {
+class RMSImage {
+ public:
+  static void Make(aocommon::Image& rmsOutput,
+                   const aocommon::Image& inputImage, double windowSize,
+                   long double beamMaj, long double beamMin, long double beamPA,
+                   long double pixelScaleL, long double pixelScaleM,
+                   size_t threadCount);
+
+  static void SlidingMinimum(aocommon::Image& output,
+                             const aocommon::Image& input, size_t windowSize,
+                             size_t threadCount);
+
+  static void SlidingMaximum(aocommon::Image& output,
+                             const aocommon::Image& input, size_t windowSize,
+                             size_t threadCount);
+
+  static void MakeWithNegativityLimit(aocommon::Image& rmsOutput,
+                                      const aocommon::Image& inputImage,
+                                      double windowSize, long double beamMaj,
+                                      long double beamMin, long double beamPA,
+                                      long double pixelScaleL,
+                                      long double pixelScaleM,
+                                      size_t threadCount);
+};
+}  // namespace radler::math
+#endif  // RADLER_MATH_RMS_IMAGE_H_
diff --git a/cpp/math/test/CMakeLists.txt b/cpp/math/test/CMakeLists.txt
new file mode 100644
index 00000000..b3d1d7b2
--- /dev/null
+++ b/cpp/math/test/CMakeLists.txt
@@ -0,0 +1,9 @@
+# Copyright (C) 2020 ASTRON (Netherlands Institute for Radio Astronomy)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+include(${PROJECT_SOURCE_DIR}/cmake/unittest.cmake)
+
+add_definitions(-DBOOST_ERROR_CODE_HEADER_ONLY)
+
+add_unittest(radler_math runtests.cc test_dijkstra_splitter.cc
+             test_peak_finder.cc)
diff --git a/cpp/math/test/runtests.cc b/cpp/math/test/runtests.cc
new file mode 100644
index 00000000..fbab4717
--- /dev/null
+++ b/cpp/math/test/runtests.cc
@@ -0,0 +1,6 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#define BOOST_TEST_MODULE radler_math
+
+#include <boost/test/unit_test.hpp>
\ No newline at end of file
diff --git a/cpp/math/test/test_dijkstra_splitter.cc b/cpp/math/test/test_dijkstra_splitter.cc
new file mode 100644
index 00000000..462bb584
--- /dev/null
+++ b/cpp/math/test/test_dijkstra_splitter.cc
@@ -0,0 +1,539 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "math/dijkstrasplitter.h"
+
+#include <random>
+
+#include <aocommon/image.h>
+
+#include <boost/test/unit_test.hpp>
+
+using aocommon::Image;
+
+namespace radler {
+
+using math::DijkstraSplitter;
+
+BOOST_AUTO_TEST_SUITE(dijkstra_splitter)
+
+Image MakeImage(size_t width, const std::string& str) {
+  const size_t height = str.size() / width;
+  BOOST_CHECK_EQUAL(width * height, str.size());
+  Image image(width, height);
+
+  for (size_t y = 0; y != height; ++y) {
+    for (size_t x = 0; x != width; ++x) {
+      if (str[x + y * width] == 'X')
+        image[x + y * width] = 0.1f;
+      else
+        image[x + y * width] = 10.0f;
+    }
+  }
+  return image;
+}
+
+std::string PathStr(const Image& pathImage) {
+  std::ostringstream str;
+  for (size_t y = 0; y != pathImage.Height(); ++y) {
+    for (size_t x = 0; x != pathImage.Width(); ++x) {
+      if (pathImage[y * pathImage.Width() + x] == 0.0f)
+        str << ' ';
+      else
+        str << 'X';
+    }
+    str << '\n';
+  }
+  return str.str();
+}
+
+std::string PathStr(const aocommon::UVector<bool>& mask, size_t width) {
+  size_t height = mask.size() / width;
+  BOOST_CHECK_EQUAL(mask.size(), width * height);
+  std::ostringstream str;
+  for (size_t y = 0; y != height; ++y) {
+    for (size_t x = 0; x != width; ++x) {
+      if (mask[y * width + x])
+        str << 'X';
+      else
+        str << ' ';
+    }
+    str << '\n';
+  }
+  return str.str();
+}
+
+std::string InputColumnStr(const Image& pathImage, size_t x) {
+  std::ostringstream str;
+  for (size_t y = 0; y != pathImage.Height(); ++y) {
+    if (pathImage[y * pathImage.Width() + x] == 10.0f)
+      str << ' ';
+    else
+      str << 'X';
+  }
+  return str.str();
+}
+
+std::string InputRowStr(const Image& pathImage, size_t y) {
+  std::ostringstream str;
+  for (size_t x = 0; x != pathImage.Width(); ++x) {
+    if (pathImage[y * pathImage.Width() + x] == 10.0f)
+      str << ' ';
+    else
+      str << 'X';
+  }
+  return str.str();
+}
+
+BOOST_AUTO_TEST_CASE(vertical) {
+  Image image = MakeImage(10,
+                          "X         "
+                          " X        "
+                          "  X       "
+                          "   XXX    "
+                          "     X    "
+                          "         X"
+                          "   X      "
+                          "    XXXX  "
+                          "        X "
+                          "      XX  ");
+  Image output(image.Width(), image.Height(), 0.0f);
+  const DijkstraSplitter splitter(image.Width(), image.Height());
+  splitter.DivideVertically(image.Data(), output.Data(), 0, image.Width());
+
+  BOOST_CHECK_EQUAL(PathStr(output),
+                    "X         \n"
+                    " X        \n"
+                    "  X       \n"
+                    "   XX     \n"
+                    "     X    \n"
+                    "    X     \n"
+                    "   X      \n"
+                    "    XXXX  \n"
+                    "        X \n"
+                    "       X  \n");
+}
+
+BOOST_AUTO_TEST_CASE(vertical_constrained) {
+  Image input = MakeImage(10,
+                          " X  X     "
+                          " X        "
+                          "  X       "
+                          "   XXX    "
+                          "     X    "
+                          "XX       X"
+                          "  XX      "
+                          "    XXXX  "
+                          "        X "
+                          "      XX  ");
+  Image output(input);
+  const DijkstraSplitter splitter(input.Width(), input.Height());
+  splitter.DivideVertically(input.Data(), output.Data(), 2, 8);
+
+  BOOST_CHECK_EQUAL(PathStr(output),
+                    "XX  X   XX\n"
+                    "XX X    XX\n"
+                    "XXX     XX\n"
+                    "XX XX   XX\n"
+                    "XX   X  XX\n"
+                    "XX  X   XX\n"
+                    "XX X    XX\n"
+                    "XX  X   XX\n"
+                    "XX   X  XX\n"
+                    "XX    X XX\n");
+  // The input shouldn't have changed for the columns that were excluded:
+  BOOST_CHECK_EQUAL(InputColumnStr(output, 0), "     X    ");
+  BOOST_CHECK_EQUAL(InputColumnStr(output, 1), "XX   X    ");
+  BOOST_CHECK_EQUAL(InputColumnStr(output, 8), "        X ");
+  BOOST_CHECK_EQUAL(InputColumnStr(output, 9), "     X    ");
+}
+
+BOOST_AUTO_TEST_CASE(horizontal) {
+  Image input = MakeImage(10,
+                          "    X     "
+                          "          "
+                          "  X       "
+                          "   XXXXXX "
+                          "     X    "
+                          " X   X   X"
+                          " X    X   "
+                          " X     X  "
+                          " X      X "
+                          "X     XX X");
+  Image output(input.Width(), input.Height(), 0.0f);
+  const DijkstraSplitter splitter(input.Width(), input.Height());
+  splitter.DivideHorizontally(input.Data(), output.Data(), 0, input.Width());
+
+  BOOST_CHECK_EQUAL(PathStr(output),
+                    "          \n"
+                    "          \n"
+                    "          \n"
+                    "   XX     \n"
+                    "  X  X    \n"
+                    " X   X    \n"
+                    " X    X   \n"
+                    " X     X  \n"
+                    " X      X \n"
+                    "X        X\n");
+}
+
+BOOST_AUTO_TEST_CASE(horizontal_constrained) {
+  Image image = MakeImage(10,
+                          "  XXX     "
+                          " XXXXXX   "
+                          " X     XXX"
+                          "X   XXX   "
+                          "   XX     "
+                          "X        X"
+                          "XX        "
+                          "  X      X"
+                          "   XXXXXX "
+                          "    XXXX  ");
+  Image output(image);
+  const DijkstraSplitter splitter(image.Width(), image.Height());
+  splitter.DivideHorizontally(image.Data(), output.Data(), 2, 8);
+
+  BOOST_CHECK_EQUAL(PathStr(output),
+                    "XXXXXXXXXX\n"
+                    "XXXXXXXXXX\n"
+                    " X     XXX\n"
+                    "X X XXX   \n"
+                    "   X      \n"
+                    "          \n"
+                    "          \n"
+                    "          \n"
+                    "XXXXXXXXXX\n"
+                    "XXXXXXXXXX\n");
+  // The input shouldn't have changed for the rows that were excluded:
+  BOOST_CHECK_EQUAL(InputRowStr(output, 0), "  XXX     ");
+  BOOST_CHECK_EQUAL(InputRowStr(output, 1), " XXXXXX   ");
+  BOOST_CHECK_EQUAL(InputRowStr(output, 8), "   XXXXXX ");
+  BOOST_CHECK_EQUAL(InputRowStr(output, 9), "    XXXX  ");
+}
+
+BOOST_AUTO_TEST_CASE(flood_vertical_area) {
+  const size_t width = 9, height = 9;
+  Image image = MakeImage(width,
+                          "   X     "
+                          "    X    "
+                          "    X    "
+                          "   X     "
+                          "  X      "
+                          "   XXX   "
+                          "      X  "
+                          "      X  "
+                          "      X  ");
+  DijkstraSplitter splitter(width, height);
+  Image scratch(image), dividingLines(width, height, 0.0f);
+  splitter.AddVerticalDivider(image.Data(), scratch.Data(),
+                              dividingLines.Data(), 2, 7);
+
+  BOOST_CHECK_EQUAL(PathStr(dividingLines),
+                    "   X     \n"
+                    "    X    \n"
+                    "    X    \n"
+                    "   X     \n"
+                    "  X      \n"
+                    "   XXX   \n"
+                    "      X  \n"
+                    "      X  \n"
+                    "      X  \n");
+
+  aocommon::UVector<bool> mask(width * height);
+  size_t subX, subWidth;
+  splitter.FloodVerticalArea(dividingLines.Data(), 1, mask.data(), subX,
+                             subWidth);
+  BOOST_CHECK_EQUAL(subX, 0u);
+  BOOST_CHECK_EQUAL(subWidth, 6u);
+  BOOST_CHECK_EQUAL(PathStr(mask, width),
+                    "XXX      \n"
+                    "XXXX     \n"
+                    "XXXX     \n"
+                    "XXX      \n"
+                    "XX       \n"
+                    "XXX      \n"
+                    "XXXXXX   \n"
+                    "XXXXXX   \n"
+                    "XXXXXX   \n");
+
+  splitter.FloodVerticalArea(dividingLines.Data(), 7, mask.data(), subX,
+                             subWidth);
+  BOOST_CHECK_EQUAL(subX, 2u);
+  BOOST_CHECK_EQUAL(subWidth, 7u);
+  BOOST_CHECK_EQUAL(PathStr(mask, width),
+                    "   XXXXXX\n"
+                    "    XXXXX\n"
+                    "    XXXXX\n"
+                    "   XXXXXX\n"
+                    "  XXXXXXX\n"
+                    "   XXXXXX\n"
+                    "      XXX\n"
+                    "      XXX\n"
+                    "      XXX\n");
+}
+
+BOOST_AUTO_TEST_CASE(flood_horizontal_area) {
+  const size_t width = 9, height = 9;
+  Image image = MakeImage(width,
+                          "         "
+                          "         "
+                          "  XX    X"
+                          " X  X  X "
+                          " X   X X "
+                          " X   X X "
+                          "X     X  "
+                          "         "
+                          "         ");
+  DijkstraSplitter splitter(width, height);
+  Image scratch(image), dividingLines(width, height, 0.0f);
+  splitter.AddHorizontalDivider(image.Data(), scratch.Data(),
+                                dividingLines.Data(), 2, 7);
+
+  BOOST_CHECK_EQUAL(PathStr(dividingLines),
+                    "         \n"
+                    "         \n"
+                    "  XX    X\n"
+                    " X  X  X \n"
+                    " X   X X \n"
+                    " X   X X \n"
+                    "X     X  \n"
+                    "         \n"
+                    "         \n");
+
+  aocommon::UVector<bool> mask(width * height);
+  size_t subY, subHeight;
+  splitter.FloodHorizontalArea(dividingLines.Data(), 1, mask.data(), subY,
+                               subHeight);
+  BOOST_CHECK_EQUAL(subY, 0u);
+  BOOST_CHECK_EQUAL(subHeight, 6u);
+  BOOST_CHECK_EQUAL(PathStr(mask, width),
+                    "XXXXXXXXX\n"
+                    "XXXXXXXXX\n"
+                    "XX  XXXX \n"
+                    "X    XX  \n"
+                    "X     X  \n"
+                    "X     X  \n"
+                    "         \n"
+                    "         \n"
+                    "         \n");
+
+  splitter.FloodHorizontalArea(dividingLines.Data(), 7, mask.data(), subY,
+                               subHeight);
+  BOOST_CHECK_EQUAL(subY, 2u);
+  BOOST_CHECK_EQUAL(subHeight, 7u);
+  BOOST_CHECK_EQUAL(PathStr(mask, width),
+                    "         \n"
+                    "         \n"
+                    "  XX    X\n"
+                    " XXXX  XX\n"
+                    " XXXXX XX\n"
+                    " XXXXX XX\n"
+                    "XXXXXXXXX\n"
+                    "XXXXXXXXX\n"
+                    "XXXXXXXXX\n");
+}
+
+BOOST_AUTO_TEST_CASE(get_bounding_mask) {
+  const size_t width = 9, height = 9;
+  Image image = MakeImage(width,
+                          "    X    "
+                          "    X    "
+                          "    X    "
+                          "    X    "
+                          "XXXXXXXXX"
+                          "    X    "
+                          "    X    "
+                          "    X    "
+                          "    X    ");
+  DijkstraSplitter splitter(width, height);
+  Image dividingLines(width, height, 0.0f);
+  splitter.DivideVertically(image.Data(), dividingLines.Data(), 3, 6);
+
+  aocommon::UVector<bool> mask(width * height);
+  size_t subXL, subXR, subY, subWidthL, subWidthR, subHeight;
+
+  splitter.FloodVerticalArea(dividingLines.Data(), 1, mask.data(), subXL,
+                             subWidthL);
+  BOOST_CHECK_EQUAL(subXL, 0u);
+  BOOST_CHECK_EQUAL(subWidthL, 4u);
+  aocommon::UVector<bool> maskL(subWidthL * height);
+  Image::TrimBox(maskL.data(), subXL, 0, subWidthL, height, mask.data(), width,
+                 height);
+  BOOST_CHECK_EQUAL(PathStr(maskL, subWidthL),
+                    "XXXX\n"
+                    "XXXX\n"
+                    "XXXX\n"
+                    "XXXX\n"
+                    "XXX \n"
+                    "XXXX\n"
+                    "XXXX\n"
+                    "XXXX\n"
+                    "XXXX\n");
+
+  splitter.FloodVerticalArea(dividingLines.Data(), 7, mask.data(), subXR,
+                             subWidthR);
+  BOOST_CHECK_EQUAL(subXR, 3u);
+  BOOST_CHECK_EQUAL(subWidthR, 6u);
+  aocommon::UVector<bool> maskR(subWidthR * height);
+  Image::TrimBox(maskR.data(), subXR, 0, subWidthR, height, mask.data(), width,
+                 height);
+  BOOST_CHECK_EQUAL(PathStr(maskR, subWidthR),
+                    " XXXXX\n"
+                    " XXXXX\n"
+                    " XXXXX\n"
+                    " XXXXX\n"
+                    "XXXXXX\n"
+                    " XXXXX\n"
+                    " XXXXX\n"
+                    " XXXXX\n"
+                    " XXXXX\n");
+
+  dividingLines = 0.0f;
+  splitter.DivideHorizontally(image.Data(), dividingLines.Data(), 3, 6);
+
+  splitter.FloodHorizontalArea(dividingLines.Data(), 1, mask.data(), subY,
+                               subHeight);
+  BOOST_CHECK_EQUAL(subY, 0u);
+  BOOST_CHECK_EQUAL(subHeight, 4u);
+  BOOST_CHECK_EQUAL(PathStr(mask, width),
+                    "XXXXXXXXX\n"
+                    "XXXXXXXXX\n"
+                    "XXXXXXXXX\n"
+                    "XXXX XXXX\n"
+                    "         \n"
+                    "         \n"
+                    "         \n"
+                    "         \n"
+                    "         \n");
+
+  aocommon::UVector<bool> output(width * height, false);
+  size_t maskX, maskY, maskWidth, maskHeight;
+
+  splitter.GetBoundingMask(maskL.data(), subXL, subWidthL, mask.data(),
+                           output.data(), maskX, maskY, maskWidth, maskHeight);
+  BOOST_CHECK_EQUAL(maskX, 0u);
+  BOOST_CHECK_EQUAL(maskY, 0u);
+  BOOST_CHECK_EQUAL(maskWidth, 4u);
+  BOOST_CHECK_EQUAL(maskHeight, 4u);
+  BOOST_CHECK_EQUAL(PathStr(output, width),
+                    "XXXX     \n"
+                    "XXXX     \n"
+                    "XXXX     \n"
+                    "XXXX     \n"
+                    "         \n"
+                    "         \n"
+                    "         \n"
+                    "         \n"
+                    "         \n");
+
+  output.assign(width * height, false);
+  splitter.GetBoundingMask(maskR.data(), subXR, subWidthR, mask.data(),
+                           output.data(), maskX, maskY, maskWidth, maskHeight);
+  BOOST_CHECK_EQUAL(maskX, 4u);
+  BOOST_CHECK_EQUAL(maskY, 0u);
+  BOOST_CHECK_EQUAL(maskWidth, 5u);
+  BOOST_CHECK_EQUAL(maskHeight, 4u);
+  BOOST_CHECK_EQUAL(PathStr(output, width),
+                    "    XXXXX\n"
+                    "    XXXXX\n"
+                    "    XXXXX\n"
+                    "     XXXX\n"
+                    "         \n"
+                    "         \n"
+                    "         \n"
+                    "         \n"
+                    "         \n");
+
+  splitter.FloodHorizontalArea(dividingLines.Data(), 7, mask.data(), subY,
+                               subHeight);
+  BOOST_CHECK_EQUAL(subY, 3u);
+  BOOST_CHECK_EQUAL(subHeight, 6u);
+
+  output.assign(width * height, false);
+  splitter.GetBoundingMask(maskL.data(), subXL, subWidthL, mask.data(),
+                           output.data(), maskX, maskY, maskWidth, maskHeight);
+  BOOST_CHECK_EQUAL(maskX, 0u);
+  BOOST_CHECK_EQUAL(maskY, 4u);
+  BOOST_CHECK_EQUAL(maskWidth, 4u);
+  BOOST_CHECK_EQUAL(maskHeight, 5u);
+  BOOST_CHECK_EQUAL(PathStr(output, width),
+                    "         \n"
+                    "         \n"
+                    "         \n"
+                    "         \n"
+                    "XXX      \n"
+                    "XXXX     \n"
+                    "XXXX     \n"
+                    "XXXX     \n"
+                    "XXXX     \n");
+
+  output.assign(width * height, false);
+  splitter.GetBoundingMask(maskR.data(), subXR, subWidthR, mask.data(),
+                           output.data(), maskX, maskY, maskWidth, maskHeight);
+  BOOST_CHECK_EQUAL(maskX, 3u);
+  BOOST_CHECK_EQUAL(maskY, 3u);
+  BOOST_CHECK_EQUAL(maskWidth, 6u);
+  BOOST_CHECK_EQUAL(maskHeight, 6u);
+  BOOST_CHECK_EQUAL(PathStr(output, width),
+                    "         \n"
+                    "         \n"
+                    "         \n"
+                    "    X    \n"
+                    "   XXXXXX\n"
+                    "    XXXXX\n"
+                    "    XXXXX\n"
+                    "    XXXXX\n"
+                    "    XXXXX\n");
+}
+
+BOOST_AUTO_TEST_CASE(get_bounding_mask_on_noise) {
+  const size_t width = 80, height = 80;
+  Image image(width, height);
+  std::mt19937 rnd;
+  std::normal_distribution<float> gaus(0.0f, 1.0f);
+
+  for (size_t repeat = 0; repeat != 1000; ++repeat) {
+    for (size_t i = 0; i != width * height; ++i) image[i] = gaus(rnd);
+
+    DijkstraSplitter splitter(width, height);
+    Image dividingLinesV(width, height, 0.0f);
+    Image dividingLinesH(width, height, 0.0f);
+    splitter.DivideVertically(image.Data(), dividingLinesV.Data(), width / 4,
+                              width * 3 / 4);
+    splitter.DivideHorizontally(image.Data(), dividingLinesH.Data(), height / 4,
+                                height * 3 / 4);
+
+    aocommon::UVector<bool> maskL(width * height), maskR(width * height),
+        maskT(width * height), maskB(width * height),
+        mask1(width * height, false), mask2(width * height, false),
+        mask3(width * height, false), mask4(width * height, false);
+    size_t subX, subY, subWidth, subHeight;
+    splitter.FloodVerticalArea(dividingLinesV.Data(), width / 8, maskL.data(),
+                               subX, subWidth);
+    splitter.FloodVerticalArea(dividingLinesV.Data(), width * 7 / 8,
+                               maskR.data(), subX, subWidth);
+    splitter.FloodHorizontalArea(dividingLinesH.Data(), width / 8, maskT.data(),
+                                 subY, subHeight);
+    splitter.FloodHorizontalArea(dividingLinesH.Data(), width * 7 / 8,
+                                 maskB.data(), subY, subHeight);
+    splitter.GetBoundingMask(maskL.data(), 0, width, maskT.data(), mask1.data(),
+                             subX, subY, subWidth, subHeight);
+    splitter.GetBoundingMask(maskR.data(), 0, width, maskT.data(), mask2.data(),
+                             subX, subY, subWidth, subHeight);
+    splitter.GetBoundingMask(maskL.data(), 0, width, maskB.data(), mask3.data(),
+                             subX, subY, subWidth, subHeight);
+    splitter.GetBoundingMask(maskR.data(), 0, width, maskB.data(), mask4.data(),
+                             subX, subY, subWidth, subHeight);
+    for (size_t i = 0; i != mask1.size(); ++i) {
+      size_t n = 0;
+      if (mask1[n]) ++n;
+      if (mask2[n]) ++n;
+      if (mask3[n]) ++n;
+      if (mask4[n]) ++n;
+      BOOST_CHECK_EQUAL(n, 1u);
+    }
+  }
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+}  // namespace radler
\ No newline at end of file
diff --git a/cpp/math/test/test_peak_finder.cc b/cpp/math/test/test_peak_finder.cc
new file mode 100644
index 00000000..116ae1f3
--- /dev/null
+++ b/cpp/math/test/test_peak_finder.cc
@@ -0,0 +1,220 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <boost/test/unit_test.hpp>
+
+#include "math/peak_finder.h"
+
+#include <aocommon/uvector.h>
+
+#include <random>
+
+namespace radler {
+
+using math::PeakFinder;
+
+BOOST_AUTO_TEST_SUITE(peak_finder)
+
+const size_t nRepeats =
+    3; /* This should be set to 100 to assert the performance */
+
+#if defined __AVX__ && !defined FORCE_NON_AVX
+template <typename NumT>
+struct CleanTestFixture {
+  size_t x, y;
+  aocommon::UVector<NumT> img;
+
+  CleanTestFixture(size_t n = 16) : x(size_t(-1)), y(size_t(-1)), img(n, 0) {}
+  void findPeak(size_t width = 4, size_t height = 2, size_t ystart = 0,
+                size_t yend = 2) {
+    PeakFinder::AVX(img.data(), width, height, x, y, true, ystart, yend, 0, 0);
+  }
+};
+#endif
+
+struct NoiseFixture {
+  NoiseFixture() : n(2048), psf(n * n, 0.0), img(n * n), normal_dist(0.0, 1.0) {
+    mt.seed(42);
+    for (size_t i = 0; i != n * n; ++i) {
+      img[i] = normal_dist(mt);
+    }
+  }
+
+  size_t n;
+  aocommon::UVector<double> psf, img;
+  std::mt19937 mt;
+  std::normal_distribution<double> normal_dist;
+};
+
+#if defined __AVX__ && !defined FORCE_NON_AVX
+BOOST_AUTO_TEST_CASE(findPeakAVX1Double) {
+  CleanTestFixture<double> f;
+  f.img[0] = 1;
+  f.findPeak();
+  BOOST_CHECK_EQUAL(f.x, 0u);
+  BOOST_CHECK_EQUAL(f.y, 0u);
+}
+
+BOOST_AUTO_TEST_CASE(findPeakAVX2Double) {
+  CleanTestFixture<double> f;
+  f.img[0] = 1;
+  f.img[1] = 2;
+  f.findPeak();
+  BOOST_CHECK_EQUAL(f.x, 1u);
+  BOOST_CHECK_EQUAL(f.y, 0u);
+}
+
+BOOST_AUTO_TEST_CASE(findPeakAVX3Double) {
+  CleanTestFixture<double> f;
+  f.img[0] = 1;
+  f.img[1] = 2;
+  f.img[4] = 3;
+  f.findPeak();
+  BOOST_CHECK_EQUAL(f.x, 0u);
+  BOOST_CHECK_EQUAL(f.y, 1u);
+}
+
+BOOST_AUTO_TEST_CASE(findPeakAVX4Double) {
+  CleanTestFixture<double> f;
+  f.img[0] = 1;
+  f.img[1] = 2;
+  f.img[4] = 3;
+  f.img[7] = 4;
+  f.findPeak();
+  BOOST_CHECK_EQUAL(f.x, 3u);
+  BOOST_CHECK_EQUAL(f.y, 1u);
+}
+
+BOOST_AUTO_TEST_CASE(findPeakAVX5Double) {
+  CleanTestFixture<double> f;
+  f.img[0] = 1;
+  f.img[1] = 2;
+  f.img[4] = 3;
+  f.img[7] = 4;
+  f.img[15] = 6;
+  f.findPeak(4, 4, 0, 4);
+  BOOST_CHECK_EQUAL(f.x, 3u);
+  BOOST_CHECK_EQUAL(f.y, 3u);
+}
+
+BOOST_AUTO_TEST_CASE(findPeakAVX6Double) {
+  CleanTestFixture<double> f;
+  f.img[0] = 1;
+  f.img[1] = 2;
+  f.img[4] = 3;
+  f.img[7] = 4;
+  f.img[15] = 6;
+  f.img[14] = 5;
+  f.findPeak(3, 5, 0, 5);
+  BOOST_CHECK_EQUAL(f.x, 2u);
+  BOOST_CHECK_EQUAL(f.y, 4u);
+}
+
+BOOST_AUTO_TEST_CASE(findPeakAVX1Single) {
+  CleanTestFixture<float> f(36);
+  f.img[0] = 1;
+  f.findPeak(6, 6, 0, 6);
+  BOOST_CHECK_EQUAL(f.x, 0u);
+  BOOST_CHECK_EQUAL(f.y, 0u);
+}
+
+BOOST_AUTO_TEST_CASE(findPeakAVX2Single) {
+  CleanTestFixture<float> f(36);
+  f.img[0] = 1;
+  f.img[1] = 2;
+  f.findPeak(6, 6, 0, 6);
+  BOOST_CHECK_EQUAL(f.x, 1u);
+  BOOST_CHECK_EQUAL(f.y, 0u);
+}
+
+BOOST_AUTO_TEST_CASE(findPeakAVX3Single) {
+  CleanTestFixture<float> f(36);
+  f.img[0] = 1;
+  f.img[1] = 2;
+  f.img[6] = 3;
+  f.findPeak(6, 6, 0, 6);
+  BOOST_CHECK_EQUAL(f.x, 0u);
+  BOOST_CHECK_EQUAL(f.y, 1u);
+}
+
+BOOST_AUTO_TEST_CASE(findPeakAVX4Single) {
+  CleanTestFixture<float> f(36);
+  f.img[0] = 1;
+  f.img[1] = 2;
+  f.img[6] = 3;
+  f.img[9] = 4;
+  f.findPeak(6, 6, 0, 6);
+  BOOST_CHECK_EQUAL(f.x, 3u);
+  BOOST_CHECK_EQUAL(f.y, 1u);
+}
+
+BOOST_AUTO_TEST_CASE(findPeakAVX5Single) {
+  CleanTestFixture<float> f(36);
+  f.img[0] = 1;
+  f.img[1] = 2;
+  f.img[6] = 3;
+  f.img[9] = 4;
+  f.img[35] = 6;
+  f.findPeak(6, 6, 0, 6);
+  BOOST_CHECK_EQUAL(f.x, 5u);
+  BOOST_CHECK_EQUAL(f.y, 5u);
+}
+
+BOOST_AUTO_TEST_CASE(findPeakAVX6Single) {
+  CleanTestFixture<float> f(36);
+  f.img[0] = 1;
+  f.img[1] = 2;
+  f.img[6] = 3;
+  f.img[9] = 4;
+  f.img[35] = 6;
+  f.findPeak(2, 18, 0, 18);
+  BOOST_CHECK_EQUAL(f.x, 1u);
+  BOOST_CHECK_EQUAL(f.y, 17u);
+}
+
+BOOST_AUTO_TEST_CASE(findPeakAVX7Single) {
+  CleanTestFixture<float> f(38);
+  f.img[0] = 1;
+  f.img[1] = 2;
+  f.img[6] = 3;
+  f.img[9] = 4;
+  f.img[35] = 6;
+  f.img[37] = 7;
+  f.findPeak(6, 6, 0, 6);
+  BOOST_CHECK_EQUAL(f.x, 5u);
+  BOOST_CHECK_EQUAL(f.y, 5u);
+}
+
+#endif
+
+BOOST_AUTO_TEST_CASE(findPeakPerformanceDouble) {
+  NoiseFixture f;
+  for (size_t repeat = 0; repeat != nRepeats; ++repeat) {
+    size_t x, y;
+    PeakFinder::Find(f.img.data(), f.n, f.n, x, y, true, 0, f.n / 2, 0.0);
+  }
+  BOOST_CHECK(true);
+}
+
+BOOST_AUTO_TEST_CASE(findPeakSimplePerformanceDouble) {
+  NoiseFixture f;
+  for (size_t repeat = 0; repeat != nRepeats; ++repeat) {
+    size_t x, y;
+    PeakFinder::Simple(f.img.data(), f.n, f.n, x, y, true, 0, f.n / 2, 0, 0);
+  }
+  BOOST_CHECK(true);
+}
+
+#if defined __AVX__ && !defined FORCE_NON_AVX
+BOOST_AUTO_TEST_CASE(findPeakAVXPerformanceDouble) {
+  NoiseFixture f;
+  for (size_t repeat = 0; repeat != nRepeats; ++repeat) {
+    size_t x, y;
+    PeakFinder::AVX(f.img.data(), f.n, f.n, x, y, true, 0, f.n / 2, 0, 0);
+  }
+  BOOST_CHECK(true);
+}
+#endif
+
+BOOST_AUTO_TEST_SUITE_END()
+}  // namespace radler
diff --git a/cpp/radler.cc b/cpp/radler.cc
new file mode 100644
index 00000000..9a23ab04
--- /dev/null
+++ b/cpp/radler.cc
@@ -0,0 +1,391 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "radler.h"
+
+#include <aocommon/fits/fitsreader.h>
+#include <aocommon/image.h>
+#include <aocommon/imagecoordinates.h>
+#include <aocommon/logger.h>
+#include <aocommon/units/fluxdensity.h>
+
+#include <schaapcommon/fft/convolution.h>
+
+#include "algorithms/generic_clean.h"
+#include "algorithms/iuwt_deconvolution.h"
+#include "algorithms/more_sane.h"
+#include "algorithms/multiscale_algorithm.h"
+#include "algorithms/parallel_deconvolution.h"
+#include "algorithms/python_deconvolution.h"
+#include "algorithms/simple_clean.h"
+
+#include "image_set.h"
+#include "math/rms_image.h"
+#include "utils/casa_mask_reader.h"
+
+using aocommon::FitsReader;
+using aocommon::FitsWriter;
+using aocommon::Image;
+using aocommon::ImageCoordinates;
+using aocommon::Logger;
+using aocommon::units::FluxDensity;
+
+namespace radler {
+
+Radler::Radler(const DeconvolutionSettings& deconvolutionSettings)
+    : _settings(deconvolutionSettings),
+      _table(),
+      _parallelDeconvolution(
+          std::make_unique<algorithms::ParallelDeconvolution>(_settings)),
+      _autoMaskIsFinished(false),
+      _imgWidth(_settings.trimmedImageWidth),
+      _imgHeight(_settings.trimmedImageHeight),
+      _pixelScaleX(_settings.pixelScaleX),
+      _pixelScaleY(_settings.pixelScaleY),
+      _autoMask(),
+      _beamSize(0.0) {
+  // Ensure that all FFTWF plan calls inside Radler are
+  // thread safe.
+  schaapcommon::fft::MakeFftwfPlannerThreadSafe();
+}
+
+Radler::~Radler() { FreeDeconvolutionAlgorithms(); }
+
+ComponentList Radler::GetComponentList() const {
+  return _parallelDeconvolution->GetComponentList(*_table);
+}
+
+const algorithms::DeconvolutionAlgorithm& Radler::MaxScaleCountAlgorithm()
+    const {
+  return _parallelDeconvolution->MaxScaleCountAlgorithm();
+}
+
+void Radler::Perform(bool& reachedMajorThreshold, size_t majorIterationNr) {
+  assert(_table);
+
+  Logger::Info.Flush();
+  Logger::Info << " == Deconvolving (" << majorIterationNr << ") ==\n";
+
+  ImageSet residualSet(*_table, _settings.squaredJoins,
+                       _settings.linkedPolarizations, _imgWidth, _imgHeight);
+  ImageSet modelSet(*_table, _settings.squaredJoins,
+                    _settings.linkedPolarizations, _imgWidth, _imgHeight);
+
+  Logger::Debug << "Loading residual images...\n";
+  residualSet.LoadAndAverage(true);
+  Logger::Debug << "Loading model images...\n";
+  modelSet.LoadAndAverage(false);
+
+  Image integrated(_imgWidth, _imgHeight);
+  residualSet.GetLinearIntegrated(integrated);
+  Logger::Debug << "Calculating standard deviation...\n";
+  double stddev = integrated.StdDevFromMAD();
+  Logger::Info << "Estimated standard deviation of background noise: "
+               << FluxDensity::ToNiceString(stddev) << '\n';
+  if (_settings.autoMask && _autoMaskIsFinished) {
+    // When we are in the second phase of automasking, don't use
+    // the RMS background anymore
+    _parallelDeconvolution->SetRMSFactorImage(Image());
+  } else {
+    if (!_settings.localRMSImage.empty()) {
+      Image rmsImage(_imgWidth, _imgHeight);
+      FitsReader reader(_settings.localRMSImage);
+      reader.Read(rmsImage.Data());
+      // Normalize the RMS image
+      stddev = rmsImage.Min();
+      Logger::Info << "Lowest RMS in image: "
+                   << FluxDensity::ToNiceString(stddev) << '\n';
+      if (stddev <= 0.0)
+        throw std::runtime_error(
+            "RMS image can only contain values > 0, but contains values <= "
+            "0.0");
+      for (float& value : rmsImage) {
+        if (value != 0.0) value = stddev / value;
+      }
+      _parallelDeconvolution->SetRMSFactorImage(std::move(rmsImage));
+    } else if (_settings.localRMSMethod != LocalRmsMethod::kNone) {
+      Logger::Debug << "Constructing local RMS image...\n";
+      Image rmsImage;
+      // TODO this should use full beam parameters
+      switch (_settings.localRMSMethod) {
+        case LocalRmsMethod::kNone:
+          assert(false);
+          break;
+        case LocalRmsMethod::kRmsWindow:
+          math::RMSImage::Make(rmsImage, integrated, _settings.localRMSWindow,
+                               _beamSize, _beamSize, 0.0, _pixelScaleX,
+                               _pixelScaleY, _settings.threadCount);
+          break;
+        case LocalRmsMethod::kRmsAndMinimumWindow:
+          math::RMSImage::MakeWithNegativityLimit(
+              rmsImage, integrated, _settings.localRMSWindow, _beamSize,
+              _beamSize, 0.0, _pixelScaleX, _pixelScaleY,
+              _settings.threadCount);
+          break;
+      }
+      // Normalize the RMS image relative to the threshold so that Jy remains
+      // Jy.
+      stddev = rmsImage.Min();
+      Logger::Info << "Lowest RMS in image: "
+                   << FluxDensity::ToNiceString(stddev) << '\n';
+      for (float& value : rmsImage) {
+        if (value != 0.0) value = stddev / value;
+      }
+      _parallelDeconvolution->SetRMSFactorImage(std::move(rmsImage));
+    }
+  }
+  if (_settings.autoMask && !_autoMaskIsFinished)
+    _parallelDeconvolution->SetThreshold(std::max(
+        stddev * _settings.autoMaskSigma, _settings.deconvolutionThreshold));
+  else if (_settings.autoDeconvolutionThreshold)
+    _parallelDeconvolution->SetThreshold(
+        std::max(stddev * _settings.autoDeconvolutionThresholdSigma,
+                 _settings.deconvolutionThreshold));
+  integrated.Reset();
+
+  Logger::Debug << "Loading PSFs...\n";
+  const std::vector<aocommon::Image> psfImages =
+      residualSet.LoadAndAveragePSFs();
+
+  if (_settings.useMultiscale) {
+    if (_settings.autoMask) {
+      if (_autoMaskIsFinished)
+        _parallelDeconvolution->SetAutoMaskMode(false, true);
+      else
+        _parallelDeconvolution->SetAutoMaskMode(true, false);
+    }
+  } else {
+    if (_settings.autoMask && _autoMaskIsFinished) {
+      if (_autoMask.empty()) {
+        _autoMask.resize(_imgWidth * _imgHeight);
+        for (size_t imgIndex = 0; imgIndex != modelSet.size(); ++imgIndex) {
+          const aocommon::Image& image = modelSet[imgIndex];
+          for (size_t i = 0; i != _imgWidth * _imgHeight; ++i) {
+            _autoMask[i] = (image[i] == 0.0) ? false : true;
+          }
+        }
+      }
+      _parallelDeconvolution->SetCleanMask(_autoMask.data());
+    }
+  }
+
+  _parallelDeconvolution->ExecuteMajorIteration(
+      residualSet, modelSet, psfImages, reachedMajorThreshold);
+
+  if (!reachedMajorThreshold && _settings.autoMask && !_autoMaskIsFinished) {
+    Logger::Info << "Auto-masking threshold reached; continuing next major "
+                    "iteration with deeper threshold and mask.\n";
+    _autoMaskIsFinished = true;
+    reachedMajorThreshold = true;
+  }
+
+  if (_settings.majorIterationCount != 0 &&
+      majorIterationNr >= _settings.majorIterationCount) {
+    reachedMajorThreshold = false;
+    Logger::Info << "Maximum number of major iterations was reached: not "
+                    "continuing deconvolution.\n";
+  }
+
+  if (_settings.deconvolutionIterationCount != 0 &&
+      _parallelDeconvolution->FirstAlgorithm().IterationNumber() >=
+          _settings.deconvolutionIterationCount) {
+    reachedMajorThreshold = false;
+    Logger::Info
+        << "Maximum number of minor deconvolution iterations was reached: not "
+           "continuing deconvolution.\n";
+  }
+
+  residualSet.AssignAndStoreResidual();
+  modelSet.InterpolateAndStoreModel(
+      _parallelDeconvolution->FirstAlgorithm().Fitter(), _settings.threadCount);
+}
+
+void Radler::InitializeDeconvolutionAlgorithm(
+    std::unique_ptr<DeconvolutionTable> table, double beamSize,
+    size_t threadCount) {
+  _beamSize = beamSize;
+  _autoMaskIsFinished = false;
+  _autoMask.clear();
+  FreeDeconvolutionAlgorithms();
+  _table = std::move(table);
+  if (_table->OriginalGroups().empty())
+    throw std::runtime_error("Nothing to clean");
+
+  if (!std::isfinite(_beamSize)) {
+    Logger::Warn << "No proper beam size available in deconvolution!\n";
+    _beamSize = 0.0;
+  }
+
+  std::unique_ptr<class algorithms::DeconvolutionAlgorithm> algorithm;
+
+  if (!_settings.pythonDeconvolutionFilename.empty()) {
+    algorithm.reset(new algorithms::PythonDeconvolution(
+        _settings.pythonDeconvolutionFilename));
+  } else if (_settings.useMoreSaneDeconvolution) {
+    algorithm.reset(new algorithms::MoreSane(
+        _settings.moreSaneLocation, _settings.moreSaneArgs,
+        _settings.moreSaneSigmaLevels, _settings.prefixName));
+  } else if (_settings.useIUWTDeconvolution) {
+    algorithms::IUWTDeconvolution* method = new algorithms::IUWTDeconvolution;
+    method->SetUseSNRTest(_settings.iuwtSNRTest);
+    algorithm.reset(method);
+  } else if (_settings.useMultiscale) {
+    algorithms::MultiScaleAlgorithm* msAlgorithm =
+        new algorithms::MultiScaleAlgorithm(beamSize, _pixelScaleX,
+                                            _pixelScaleY);
+    msAlgorithm->SetManualScaleList(_settings.multiscaleScaleList);
+    msAlgorithm->SetMultiscaleScaleBias(
+        _settings.multiscaleDeconvolutionScaleBias);
+    msAlgorithm->SetMaxScales(_settings.multiscaleMaxScales);
+    msAlgorithm->SetMultiscaleGain(_settings.multiscaleGain);
+    msAlgorithm->SetShape(_settings.multiscaleShapeFunction);
+    msAlgorithm->SetTrackComponents(_settings.saveSourceList);
+    msAlgorithm->SetConvolutionPadding(_settings.multiscaleConvolutionPadding);
+    msAlgorithm->SetUseFastSubMinorLoop(_settings.multiscaleFastSubMinorLoop);
+    algorithm.reset(msAlgorithm);
+  } else {
+    algorithm.reset(
+        new algorithms::GenericClean(_settings.useSubMinorOptimization));
+  }
+
+  algorithm->SetMaxNIter(_settings.deconvolutionIterationCount);
+  algorithm->SetThreshold(_settings.deconvolutionThreshold);
+  algorithm->SetGain(_settings.deconvolutionGain);
+  algorithm->SetMGain(_settings.deconvolutionMGain);
+  algorithm->SetCleanBorderRatio(_settings.deconvolutionBorderRatio);
+  algorithm->SetAllowNegativeComponents(_settings.allowNegativeComponents);
+  algorithm->SetStopOnNegativeComponents(_settings.stopOnNegativeComponents);
+  algorithm->SetThreadCount(threadCount);
+  algorithm->SetSpectralFittingMode(_settings.spectralFittingMode,
+                                    _settings.spectralFittingTerms);
+
+  ImageSet::CalculateDeconvolutionFrequencies(*_table, _channelFrequencies,
+                                              _channelWeights);
+  algorithm->InitializeFrequencies(_channelFrequencies, _channelWeights);
+  _parallelDeconvolution->SetAlgorithm(std::move(algorithm));
+
+  if (!_settings.forcedSpectrumFilename.empty()) {
+    Logger::Debug << "Reading " << _settings.forcedSpectrumFilename << ".\n";
+    FitsReader reader(_settings.forcedSpectrumFilename);
+    if (reader.ImageWidth() != _imgWidth || reader.ImageHeight() != _imgHeight)
+      throw std::runtime_error(
+          "The image width of the forced spectrum fits file does not match the "
+          "imaging size");
+    std::vector<Image> terms(1);
+    terms[0] = Image(_imgWidth, _imgHeight);
+    reader.Read(terms[0].Data());
+    _parallelDeconvolution->SetSpectrallyForcedImages(std::move(terms));
+  }
+
+  readMask(*_table);
+}
+
+void Radler::FreeDeconvolutionAlgorithms() {
+  _parallelDeconvolution->FreeDeconvolutionAlgorithms();
+  _table.reset();
+}
+
+bool Radler::IsInitialized() const {
+  return _parallelDeconvolution->IsInitialized();
+}
+
+size_t Radler::IterationNumber() const {
+  return _parallelDeconvolution->FirstAlgorithm().IterationNumber();
+}
+
+void Radler::RemoveNaNsInPSF(float* psf, size_t width, size_t height) {
+  float* endPtr = psf + width * height;
+  while (psf != endPtr) {
+    if (!std::isfinite(*psf)) *psf = 0.0;
+    ++psf;
+  }
+}
+
+void Radler::readMask(const DeconvolutionTable& groupTable) {
+  bool hasMask = false;
+  if (!_settings.fitsDeconvolutionMask.empty()) {
+    FitsReader maskReader(_settings.fitsDeconvolutionMask, true, true);
+    if (maskReader.ImageWidth() != _imgWidth ||
+        maskReader.ImageHeight() != _imgHeight)
+      throw std::runtime_error(
+          "Specified Fits file mask did not have same dimensions as output "
+          "image!");
+    aocommon::UVector<float> maskData(_imgWidth * _imgHeight);
+    if (maskReader.NFrequencies() == 1) {
+      Logger::Debug << "Reading mask '" << _settings.fitsDeconvolutionMask
+                    << "'...\n";
+      maskReader.Read(maskData.data());
+    } else if (maskReader.NFrequencies() == _settings.channelsOut) {
+      Logger::Debug << "Reading mask '" << _settings.fitsDeconvolutionMask
+                    << "' (" << (groupTable.Front().original_channel_index + 1)
+                    << ")...\n";
+      maskReader.ReadIndex(maskData.data(),
+                           groupTable.Front().original_channel_index);
+    } else {
+      std::stringstream msg;
+      msg << "The number of frequencies in the specified fits mask ("
+          << maskReader.NFrequencies()
+          << ") does not match the number of requested output channels ("
+          << _settings.channelsOut << ")";
+      throw std::runtime_error(msg.str());
+    }
+    _cleanMask.assign(_imgWidth * _imgHeight, false);
+    for (size_t i = 0; i != _imgWidth * _imgHeight; ++i)
+      _cleanMask[i] = (maskData[i] != 0.0);
+
+    hasMask = true;
+  } else if (!_settings.casaDeconvolutionMask.empty()) {
+    if (_cleanMask.empty()) {
+      Logger::Info << "Reading CASA mask '" << _settings.casaDeconvolutionMask
+                   << "'...\n";
+      _cleanMask.assign(_imgWidth * _imgHeight, false);
+      utils::CasaMaskReader maskReader(_settings.casaDeconvolutionMask);
+      if (maskReader.Width() != _imgWidth || maskReader.Height() != _imgHeight)
+        throw std::runtime_error(
+            "Specified CASA mask did not have same dimensions as output "
+            "image!");
+      maskReader.Read(_cleanMask.data());
+    }
+
+    hasMask = true;
+  }
+
+  if (_settings.horizonMask) {
+    if (!hasMask) {
+      _cleanMask.assign(_imgWidth * _imgHeight, true);
+      hasMask = true;
+    }
+
+    double fovSq = M_PI_2 - _settings.horizonMaskDistance;
+    if (fovSq < 0.0) fovSq = 0.0;
+    if (fovSq <= M_PI_2)
+      fovSq = std::sin(fovSq);
+    else  // a negative horizon distance was given
+      fovSq = 1.0 - _settings.horizonMaskDistance;
+    fovSq = fovSq * fovSq;
+    bool* ptr = _cleanMask.data();
+
+    for (size_t y = 0; y != _imgHeight; ++y) {
+      for (size_t x = 0; x != _imgWidth; ++x) {
+        double l, m;
+        ImageCoordinates::XYToLM(x, y, _pixelScaleX, _pixelScaleY, _imgWidth,
+                                 _imgHeight, l, m);
+        if (l * l + m * m >= fovSq) *ptr = false;
+        ++ptr;
+      }
+    }
+
+    Logger::Info << "Saving horizon mask...\n";
+    Image image(_imgWidth, _imgHeight);
+    for (size_t i = 0; i != _imgWidth * _imgHeight; ++i)
+      image[i] = _cleanMask[i] ? 1.0 : 0.0;
+
+    FitsWriter writer;
+    writer.SetImageDimensions(_imgWidth, _imgHeight, _settings.pixelScaleX,
+                              _settings.pixelScaleY);
+    writer.Write(_settings.prefixName + "-horizon-mask.fits", image.Data());
+  }
+
+  if (hasMask) _parallelDeconvolution->SetCleanMask(_cleanMask.data());
+}
+}  // namespace radler
\ No newline at end of file
diff --git a/cpp/radler.h b/cpp/radler.h
new file mode 100644
index 00000000..a1fde1ea
--- /dev/null
+++ b/cpp/radler.h
@@ -0,0 +1,80 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_RADLER_H_
+#define RADLER_RADLER_H_
+
+#include <cstring>
+
+#include <aocommon/polarization.h>
+#include <aocommon/uvector.h>
+
+#include "component_list.h"
+#include "deconvolution_settings.h"
+
+namespace radler {
+
+class DeconvolutionTable;
+struct DeconvolutionTableEntry;
+namespace algorithms {
+class ParallelDeconvolution;
+}
+
+/**
+ * @brief Main interfacing class of the Radio Astronomical Deconvolution
+ * Library.
+ *
+ */
+class Radler {
+ public:
+  explicit Radler(const DeconvolutionSettings& deconvolutionSettings);
+  ~Radler();
+
+  ComponentList GetComponentList() const;
+
+  /**
+   * @brief Exposes a const reference to either the first algorithm, or - in
+   * case of a multiscale clean - the algorithm with the maximum number of scale
+   * counts.
+   */
+  const algorithms::DeconvolutionAlgorithm& MaxScaleCountAlgorithm() const;
+
+  void Perform(bool& reachedMajorThreshold, size_t majorIterationNr);
+
+  void InitializeDeconvolutionAlgorithm(
+      std::unique_ptr<DeconvolutionTable> table, double beamSize,
+      size_t threadCount);
+
+  void FreeDeconvolutionAlgorithms();
+
+  bool IsInitialized() const;
+  //  { return _parallelDeconvolution.IsInitialized(); }
+
+  /// Return IterationNumber of the underlying \c DeconvolutionAlgorithm
+  size_t IterationNumber() const;
+
+  static void RemoveNaNsInPSF(float* psf, size_t width, size_t height);
+
+ private:
+  void readMask(const DeconvolutionTable& groupTable);
+
+  const DeconvolutionSettings _settings;
+
+  std::unique_ptr<DeconvolutionTable> _table;
+
+  std::unique_ptr<algorithms::ParallelDeconvolution> _parallelDeconvolution;
+
+  aocommon::UVector<bool> _cleanMask;
+
+  bool _autoMaskIsFinished;
+  aocommon::UVector<double> _channelFrequencies;
+  aocommon::UVector<float> _channelWeights;
+  size_t _imgWidth;
+  size_t _imgHeight;
+  double _pixelScaleX;
+  double _pixelScaleY;
+  aocommon::UVector<bool> _autoMask;
+  double _beamSize;
+};
+}  // namespace radler
+#endif
diff --git a/cpp/test/CMakeLists.txt b/cpp/test/CMakeLists.txt
new file mode 100644
index 00000000..fcf54609
--- /dev/null
+++ b/cpp/test/CMakeLists.txt
@@ -0,0 +1,9 @@
+# Copyright (C) 2020 ASTRON (Netherlands Institute for Radio Astronomy)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+include(${PROJECT_SOURCE_DIR}/cmake/unittest.cmake)
+
+add_definitions(-DBOOST_ERROR_CODE_HEADER_ONLY)
+
+add_unittest(radler_main runtests.cc test_component_list.cc
+             test_deconvolution_table.cc test_image_set.cc)
diff --git a/cpp/test/runtests.cc b/cpp/test/runtests.cc
new file mode 100644
index 00000000..17278478
--- /dev/null
+++ b/cpp/test/runtests.cc
@@ -0,0 +1,6 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#define BOOST_TEST_MODULE radler_main
+
+#include <boost/test/unit_test.hpp>
\ No newline at end of file
diff --git a/cpp/test/smartptr.h b/cpp/test/smartptr.h
new file mode 100644
index 00000000..3a5fde54
--- /dev/null
+++ b/cpp/test/smartptr.h
@@ -0,0 +1,54 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_TEST_UNIQUE_PTR_H_
+#define RADLER_TEST_UNIQUE_PTR_H_
+
+#include <boost/test/unit_test.hpp>
+
+namespace radler::test {
+
+/**
+ * Helper class for using unique pointers in tests.
+ *
+ * Use case: When using unique pointers, tests often also store raw pointers
+ * for accessing objects after std::moving their unique pointer.
+ *
+ * This class supports this common pattern: Instead of storing both a unique
+ * pointer and a raw pointer, tests can use a single test::UniquePtr variable.
+ *
+ * The perfect forwarding constructor also simplifies initialization:
+ * "std::unique_ptr<X> x(new X(...));" becomes: "test::UniquePtr<X> x(...);"
+ *
+ * @tparam T Object type for the unique pointer.
+ */
+template <class T>
+class UniquePtr {
+ public:
+  /**
+   * Constructor. It uses perfect forwarding for calling T's constructor.
+   */
+  template <typename... Args>
+  UniquePtr(Args&&... args)
+      : _unique(new T(std::forward<Args>(args)...)), _raw(_unique.get()) {}
+
+  /**
+   * Extract the unique_ptr from the helper class.
+   * The test code may only call this function once.
+   */
+  std::unique_ptr<T> take() {
+    BOOST_TEST_REQUIRE(static_cast<bool>(_unique));
+    return std::move(_unique);
+  }
+
+  T& operator*() { return *_raw; }
+  T* operator->() { return _raw; }
+  T* get() { return _raw; }
+
+ private:
+  std::unique_ptr<T> _unique;
+  T* const _raw;
+};
+
+}  // namespace radler::test
+#endif  // RADLER_TEST_UNIQUE_PTR_H_
\ No newline at end of file
diff --git a/cpp/test/test_component_list.cc b/cpp/test/test_component_list.cc
new file mode 100644
index 00000000..e6affbbd
--- /dev/null
+++ b/cpp/test/test_component_list.cc
@@ -0,0 +1,110 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <boost/test/unit_test.hpp>
+
+#include "component_list.h"
+
+namespace radler {
+
+BOOST_AUTO_TEST_SUITE(component_list)
+
+struct ComponentListFixture {
+  ComponentListFixture() : list(512, 512, 4, 3) {
+    aocommon::UVector<float> values = {1.0, 2.0, 3.0};
+    list.Add(256, 256, 1, values.data());
+    values = {5.0, 6.0, 7.0};
+    list.Add(256, 256, 1, values.data());
+    values = {8.0, 9.0, 10.0};
+    list.Add(511, 511, 0, values.data());
+    values = {11.0, 12.0, 13.0};
+    list.Add(13, 42, 3, values.data());
+
+    list.MergeDuplicates();
+  }
+
+  ComponentList list;
+  aocommon::UVector<float> values;
+};
+
+BOOST_FIXTURE_TEST_CASE(adding_values, ComponentListFixture) {
+  BOOST_CHECK_EQUAL(list.ComponentCount(0), 1u);
+  BOOST_CHECK_EQUAL(list.ComponentCount(1), 1u);
+  BOOST_CHECK_EQUAL(list.ComponentCount(2), 0u);
+  BOOST_CHECK_EQUAL(list.ComponentCount(3), 1u);
+
+  size_t x;
+  size_t y;
+  aocommon::UVector<float> values(3);
+
+  list.GetComponent(0, 0, x, y, values.data());
+  BOOST_CHECK_EQUAL(x, 511u);
+  BOOST_CHECK_EQUAL(y, 511u);
+  BOOST_CHECK_CLOSE_FRACTION(values[0], 8.0, 1e-5);
+  BOOST_CHECK_CLOSE_FRACTION(values[1], 9.0, 1e-5);
+  BOOST_CHECK_CLOSE_FRACTION(values[2], 10.0, 1e-5);
+
+  list.GetComponent(1, 0, x, y, values.data());
+  BOOST_CHECK_EQUAL(x, 256u);
+  BOOST_CHECK_EQUAL(y, 256u);
+  BOOST_CHECK_CLOSE_FRACTION(values[0], 6.0, 1e-5);
+  BOOST_CHECK_CLOSE_FRACTION(values[1], 8.0, 1e-5);
+  BOOST_CHECK_CLOSE_FRACTION(values[2], 10.0, 1e-5);
+
+  list.GetComponent(3, 0, x, y, values.data());
+  BOOST_CHECK_EQUAL(x, 13u);
+  BOOST_CHECK_EQUAL(y, 42u);
+  BOOST_CHECK_CLOSE_FRACTION(values[0], 11.0, 1e-5);
+  BOOST_CHECK_CLOSE_FRACTION(values[1], 12.0, 1e-5);
+  BOOST_CHECK_CLOSE_FRACTION(values[2], 13.0, 1e-5);
+}
+
+BOOST_FIXTURE_TEST_CASE(get_position, ComponentListFixture) {
+  BOOST_CHECK_EQUAL(list.GetPositions(0).size(), 1u);
+  BOOST_CHECK_EQUAL(list.GetPositions(1).size(), 1u);
+  BOOST_CHECK_EQUAL(list.GetPositions(2).size(), 0u);
+  BOOST_CHECK_EQUAL(list.GetPositions(3).size(), 1u);
+
+  BOOST_CHECK_EQUAL(list.GetPositions(0)[0].x, 511u);
+  BOOST_CHECK_EQUAL(list.GetPositions(0)[0].y, 511u);
+
+  BOOST_CHECK_EQUAL(list.GetPositions(1)[0].x, 256u);
+  BOOST_CHECK_EQUAL(list.GetPositions(1)[0].y, 256u);
+
+  BOOST_CHECK_EQUAL(list.GetPositions(3)[0].x, 13u);
+  BOOST_CHECK_EQUAL(list.GetPositions(3)[0].y, 42u);
+}
+
+BOOST_FIXTURE_TEST_CASE(multiply_scale_component, ComponentListFixture) {
+  for (size_t i = 0; i != list.NScales(); ++i) {
+    // Second scale is empty (captured in DEBUG mode via an assert)
+    if (i != 2u) {
+      for (size_t j = 0; j != list.NFrequencies(); ++j) {
+        list.MultiplyScaleComponent(i, 0, j, static_cast<double>(j + 1u));
+      }
+    }
+  }
+
+  size_t x;
+  size_t y;
+  aocommon::UVector<float> values(3);
+
+  list.GetComponent(0, 0, x, y, values.data());
+  BOOST_CHECK_CLOSE_FRACTION(values[0], 8.0, 1e-5);
+  BOOST_CHECK_CLOSE_FRACTION(values[1], 18.0, 1e-5);
+  BOOST_CHECK_CLOSE_FRACTION(values[2], 30.0, 1e-5);
+
+  list.GetComponent(1, 0, x, y, values.data());
+  BOOST_CHECK_CLOSE_FRACTION(values[0], 6.0, 1e-5);
+  BOOST_CHECK_CLOSE_FRACTION(values[1], 16.0, 1e-5);
+  BOOST_CHECK_CLOSE_FRACTION(values[2], 30.0, 1e-5);
+
+  list.GetComponent(3, 0, x, y, values.data());
+  BOOST_CHECK_CLOSE_FRACTION(values[0], 11.0, 1e-5);
+  BOOST_CHECK_CLOSE_FRACTION(values[1], 24.0, 1e-5);
+  BOOST_CHECK_CLOSE_FRACTION(values[2], 39.0, 1e-5);
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+}  // namespace radler
diff --git a/cpp/test/test_deconvolution_table.cc b/cpp/test/test_deconvolution_table.cc
new file mode 100644
index 00000000..b3ba8551
--- /dev/null
+++ b/cpp/test/test_deconvolution_table.cc
@@ -0,0 +1,96 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "deconvolution_table.h"
+
+#include <array>
+
+#include <boost/test/unit_test.hpp>
+
+#include "test/smartptr.h"
+
+namespace radler {
+
+BOOST_AUTO_TEST_SUITE(deconvolutiontable)
+
+BOOST_AUTO_TEST_CASE(constructor) {
+  const size_t kTableSize = 42;
+
+  DeconvolutionTable table(kTableSize, kTableSize);
+
+  BOOST_TEST(table.OriginalGroups().size() == kTableSize);
+  for (const DeconvolutionTable::Group& group : table.OriginalGroups()) {
+    BOOST_TEST(group.empty());
+  }
+
+  BOOST_TEST_REQUIRE(table.DeconvolutionGroups().size() == kTableSize);
+  for (int index = 0; index < int(kTableSize); ++index) {
+    BOOST_TEST(table.DeconvolutionGroups()[index] ==
+               std::vector<int>(1, index));
+  }
+
+  BOOST_TEST((table.begin() == table.end()));
+  BOOST_TEST(table.Size() == 0);
+}
+
+BOOST_AUTO_TEST_CASE(single_deconvolution_group) {
+  DeconvolutionTable table(7, 1);
+  const std::vector<std::vector<int>> kExpectedGroups{{0, 1, 2, 3, 4, 5, 6}};
+  BOOST_TEST_REQUIRE(table.DeconvolutionGroups() == kExpectedGroups);
+}
+
+BOOST_AUTO_TEST_CASE(multiple_deconvolution_groups) {
+  DeconvolutionTable table(7, 3);
+  const std::vector<std::vector<int>> kExpectedGroups{
+      {0, 1, 2}, {3, 4}, {5, 6}};
+  BOOST_TEST_REQUIRE(table.DeconvolutionGroups() == kExpectedGroups);
+}
+
+BOOST_AUTO_TEST_CASE(too_many_deconvolution_groups) {
+  DeconvolutionTable table(7, 42);
+  const std::vector<std::vector<int>> kExpectedGroups{{0}, {1}, {2}, {3},
+                                                      {4}, {5}, {6}};
+  BOOST_TEST_REQUIRE(table.DeconvolutionGroups() == kExpectedGroups);
+}
+
+BOOST_AUTO_TEST_CASE(add_entries) {
+  DeconvolutionTable table(3, 1);
+
+  std::array<test::UniquePtr<DeconvolutionTableEntry>, 3> entries;
+  entries[0]->original_channel_index = 1;
+  entries[1]->original_channel_index = 0;
+  entries[2]->original_channel_index = 1;
+
+  for (test::UniquePtr<DeconvolutionTableEntry>& entry : entries) {
+    table.AddEntry(entry.take());
+    // table.AddEntry(std::move(entry));
+  }
+
+  // Check if the OriginalGroups have the correct size and correct entries.
+  const std::vector<DeconvolutionTable::Group>& original_groups =
+      table.OriginalGroups();
+  BOOST_TEST_REQUIRE(original_groups.size() == 3);
+
+  BOOST_TEST_REQUIRE(original_groups[0].size() == 1);
+  BOOST_TEST_REQUIRE(original_groups[1].size() == 2);
+  BOOST_TEST(original_groups[2].empty());
+
+  BOOST_TEST(original_groups[0][0] == entries[1].get());
+  BOOST_TEST(original_groups[1][0] == entries[0].get());
+  BOOST_TEST(original_groups[1][1] == entries[2].get());
+
+  // Check if a range based loop, which uses begin() and end(), yields the
+  // entries.
+  size_t index = 0;
+  for (const DeconvolutionTableEntry& entry : table) {
+    BOOST_TEST(&entry == entries[index].get());
+    ++index;
+  }
+
+  // Finally, check Front() and Size().
+  BOOST_TEST(&table.Front() == entries.front().get());
+  BOOST_TEST(table.Size() == entries.size());
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+}  // namespace radler
\ No newline at end of file
diff --git a/cpp/test/test_image_set.cc b/cpp/test/test_image_set.cc
new file mode 100644
index 00000000..74c67999
--- /dev/null
+++ b/cpp/test/test_image_set.cc
@@ -0,0 +1,495 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "image_set.h"
+
+#include <memory>
+
+#include <boost/test/unit_test.hpp>
+
+#include <aocommon/image.h>
+#include <aocommon/polarization.h>
+
+#include <schaapcommon/fitters/spectralfitter.h>
+
+using aocommon::Image;
+using aocommon::PolarizationEnum;
+
+namespace radler {
+
+namespace {
+
+/**
+ * @brief Dummy image accessor that doesn't allow doing anything.
+ */
+class DummyImageAccessor final : public aocommon::ImageAccessor {
+ public:
+  DummyImageAccessor() {}
+  ~DummyImageAccessor() override {}
+
+  void Load(Image&) const override {
+    throw std::logic_error("Unexpected DummyImageAccessor::Load() call");
+  }
+
+  void Store(const Image&) override {
+    throw std::logic_error("Unexpected DummyImageAccessor::Store() call");
+  }
+};
+
+/**
+ * @brief Mimimal image accessor that only admits loading an image. Required
+ * to test @c LoadAndAverage functionality.
+ */
+class LoadOnlyImageAccessor final : public aocommon::ImageAccessor {
+ public:
+  LoadOnlyImageAccessor(const aocommon::Image& image) : _image(image) {}
+  ~LoadOnlyImageAccessor() override {}
+
+  void Load(Image& image) const override { image = _image; }
+
+  void Store(const Image&) override {
+    throw std::logic_error("Unexpected MimimalImageAccessor::Store() call");
+  }
+
+ private:
+  const aocommon::Image _image;
+};
+
+}  // namespace
+
+struct ImageSetFixtureBase {
+  ImageSetFixtureBase() {}
+
+  void initTable(size_t n_original_channels, size_t n_deconvolution_channels) {
+    table = std::make_unique<DeconvolutionTable>(n_original_channels,
+                                                 n_deconvolution_channels);
+  }
+
+  void addToImageSet(size_t outChannel, PolarizationEnum pol,
+                     size_t frequencyMHz, double imageWeight = 1.0) {
+    auto e = std::make_unique<DeconvolutionTableEntry>();
+    e->original_channel_index = outChannel;
+    e->polarization = pol;
+    e->band_start_frequency = frequencyMHz;
+    e->band_end_frequency = frequencyMHz;
+    e->image_weight = imageWeight;
+    e->psf_accessor = std::make_unique<DummyImageAccessor>();
+    e->model_accessor = std::make_unique<LoadOnlyImageAccessor>(modelImage);
+    e->residual_accessor = std::make_unique<DummyImageAccessor>();
+    table->AddEntry(std::move(e));
+  }
+
+  void checkLinearValue(size_t index, float value, const ImageSet& dset) {
+    Image dest(2, 2, 1.0);
+    dset.GetLinearIntegrated(dest);
+    BOOST_CHECK_CLOSE_FRACTION(dest[index], value, 1e-6);
+  }
+
+  void checkSquaredValue(size_t index, float value, const ImageSet& dset) {
+    Image dest(2, 2, 1.0), scratch(2, 2);
+    dset.GetSquareIntegrated(dest, scratch);
+    BOOST_CHECK_CLOSE_FRACTION(dest[index], value, 1e-6);
+  }
+
+  std::unique_ptr<DeconvolutionTable> table;
+  aocommon::Image modelImage;
+};
+
+template <size_t NDeconvolutionChannels>
+struct ImageSetFixture : public ImageSetFixtureBase {
+  ImageSetFixture() {
+    initTable(2, NDeconvolutionChannels);
+    addToImageSet(0, aocommon::Polarization::XX, 100);
+    addToImageSet(0, aocommon::Polarization::YY, 100);
+    addToImageSet(1, aocommon::Polarization::XX, 200);
+    addToImageSet(1, aocommon::Polarization::YY, 200);
+  }
+};
+
+BOOST_AUTO_TEST_SUITE(imageset)
+
+BOOST_FIXTURE_TEST_CASE(constructor_1, ImageSetFixture<1>) {
+  const bool kSquareJoinedChannels = false;
+  const std::set<aocommon::PolarizationEnum> kLinkedPolarizations;
+  ImageSet dset(*table, kSquareJoinedChannels, kLinkedPolarizations, 2, 2);
+  BOOST_CHECK_EQUAL(&dset.Table(), table.get());
+  BOOST_CHECK_EQUAL(dset.NOriginalChannels(), 2u);
+  BOOST_CHECK_EQUAL(dset.PSFCount(), 1u);
+  BOOST_CHECK_EQUAL(dset.NDeconvolutionChannels(), 1u);
+  BOOST_CHECK_EQUAL(dset.SquareJoinedChannels(), kSquareJoinedChannels);
+  BOOST_CHECK(dset.LinkedPolarizations() == kLinkedPolarizations);
+}
+
+BOOST_FIXTURE_TEST_CASE(constructor_2, ImageSetFixture<2>) {
+  const bool kSquareJoinedChannels = true;
+  const std::set<aocommon::PolarizationEnum> kLinkedPolarizations{
+      aocommon::PolarizationEnum::StokesI, aocommon::PolarizationEnum::StokesQ,
+      aocommon::PolarizationEnum::StokesU, aocommon::PolarizationEnum::StokesV};
+  ImageSet dset(*table, kSquareJoinedChannels, kLinkedPolarizations, 2, 2);
+  BOOST_CHECK_EQUAL(dset.NOriginalChannels(), 2u);
+  BOOST_CHECK_EQUAL(dset.PSFCount(), 2u);
+  BOOST_CHECK_EQUAL(dset.NDeconvolutionChannels(), 2u);
+  BOOST_CHECK_EQUAL(dset.SquareJoinedChannels(), kSquareJoinedChannels);
+  BOOST_CHECK(dset.LinkedPolarizations() == kLinkedPolarizations);
+}
+
+BOOST_FIXTURE_TEST_CASE(xxNormalization, ImageSetFixtureBase) {
+  initTable(1, 1);
+  addToImageSet(0, aocommon::Polarization::XX, 100);
+  ImageSet dset(*table, false, {aocommon::Polarization::XX}, 2, 2);
+  dset = 0.0;
+  dset.Data(0)[1] = 5.0;
+  checkLinearValue(1, 5.0, dset);
+  checkSquaredValue(1, 5.0, dset);
+}
+
+BOOST_FIXTURE_TEST_CASE(iNormalization, ImageSetFixtureBase) {
+  initTable(1, 1);
+  addToImageSet(0, aocommon::Polarization::StokesI, 100);
+  ImageSet dset(*table, false, {aocommon::Polarization::StokesI}, 2, 2);
+  dset = 0.0;
+  dset.Data(0)[2] = 6.0;
+  checkLinearValue(2, 6.0, dset);
+  checkSquaredValue(2, 6.0, dset);
+}
+
+BOOST_FIXTURE_TEST_CASE(i_2channel_Normalization, ImageSetFixtureBase) {
+  initTable(2, 2);
+  addToImageSet(0, aocommon::Polarization::StokesI, 100);
+  addToImageSet(1, aocommon::Polarization::StokesI, 200);
+  ImageSet dset(*table, false, {aocommon::Polarization::StokesI}, 2, 2);
+  dset = 0.0;
+  dset.Data(0)[0] = 12.0;
+  dset.Data(1)[0] = 13.0;
+  checkLinearValue(0, 12.5, dset);
+  checkSquaredValue(0, 12.5, dset);
+}
+
+BOOST_FIXTURE_TEST_CASE(i_2channel_NaNs, ImageSetFixtureBase) {
+  initTable(2, 2);
+  addToImageSet(0, aocommon::Polarization::StokesI, 100, 0.0);
+  addToImageSet(1, aocommon::Polarization::StokesI, 200, 1.0);
+  ImageSet dset(*table, false, {aocommon::Polarization::StokesI}, 2, 2);
+  dset = 0.0;
+  dset.Data(0)[0] = std::numeric_limits<float>::quiet_NaN();
+  dset.Data(1)[0] = 42.0;
+  checkLinearValue(0, 42.0f, dset);
+  checkSquaredValue(0, 42.0f, dset);
+}
+
+BOOST_FIXTURE_TEST_CASE(xxyyNormalization, ImageSetFixtureBase) {
+  initTable(1, 1);
+  addToImageSet(0, aocommon::Polarization::XX, 100);
+  addToImageSet(0, aocommon::Polarization::YY, 100);
+  const std::set<PolarizationEnum> kLinkedPolarizations{
+      aocommon::Polarization::XX, aocommon::Polarization::YY};
+  ImageSet dset(*table, false, kLinkedPolarizations, 2, 2);
+  dset = 0.0;
+  dset.Data(0)[3] = 7.0;
+  dset.Data(1)[3] = 8.0;
+  checkLinearValue(3, 7.5, dset);
+  dset.Data(0)[3] = -7.0;
+  checkSquaredValue(3, std::sqrt((7.0 * 7.0 + 8.0 * 8.0) * 0.5), dset);
+}
+
+BOOST_FIXTURE_TEST_CASE(iqNormalization, ImageSetFixtureBase) {
+  initTable(1, 1);
+  addToImageSet(0, aocommon::Polarization::StokesI, 100);
+  addToImageSet(0, aocommon::Polarization::StokesQ, 100);
+  const std::set<PolarizationEnum> kLinkedPolarizations{
+      aocommon::Polarization::StokesI, aocommon::Polarization::StokesQ};
+  ImageSet dset(*table, false, kLinkedPolarizations, 2, 2);
+  dset = 0.0;
+  dset.Data(0)[0] = 6.0;
+  dset.Data(1)[0] = -1.0;
+  checkLinearValue(0, 5.0, dset);
+  checkSquaredValue(0, std::sqrt(6.0 * 6.0 + -1.0 * -1.0), dset);
+}
+
+BOOST_FIXTURE_TEST_CASE(linkedINormalization, ImageSetFixtureBase) {
+  initTable(1, 1);
+  addToImageSet(0, aocommon::Polarization::StokesI, 100);
+  addToImageSet(0, aocommon::Polarization::StokesQ, 100);
+  ImageSet dset(*table, false, {aocommon::Polarization::StokesI}, 2, 2);
+  dset = 0.0;
+  dset.Data(0)[0] = 3.0;
+  dset.Data(1)[0] = -1.0;
+  checkLinearValue(0, 3.0, dset);
+  checkSquaredValue(0, 3.0, dset);
+}
+
+BOOST_FIXTURE_TEST_CASE(iquvNormalization, ImageSetFixtureBase) {
+  initTable(1, 1);
+  addToImageSet(0, aocommon::Polarization::StokesI, 100);
+  addToImageSet(0, aocommon::Polarization::StokesQ, 100);
+  addToImageSet(0, aocommon::Polarization::StokesU, 100);
+  addToImageSet(0, aocommon::Polarization::StokesV, 100);
+  const std::set<PolarizationEnum> kLinkedPolarizations{
+      aocommon::Polarization::StokesI, aocommon::Polarization::StokesQ,
+      aocommon::Polarization::StokesU, aocommon::Polarization::StokesV};
+  ImageSet dset(*table, false, kLinkedPolarizations, 2, 2);
+  dset = 0.0;
+  dset.Data(0)[0] = 9.0;
+  dset.Data(1)[0] = 0.2;
+  dset.Data(2)[0] = 0.2;
+  dset.Data(3)[0] = 0.2;
+  checkLinearValue(0, 9.6, dset);
+  checkSquaredValue(0, std::sqrt(9.0 * 9.0 + 3.0 * 0.2 * 0.2), dset);
+}
+
+BOOST_FIXTURE_TEST_CASE(xx_xy_yx_yyNormalization, ImageSetFixtureBase) {
+  initTable(1, 1);
+  addToImageSet(0, aocommon::Polarization::XX, 100);
+  addToImageSet(0, aocommon::Polarization::XY, 100);
+  addToImageSet(0, aocommon::Polarization::YX, 100);
+  addToImageSet(0, aocommon::Polarization::YY, 100);
+  const std::set<PolarizationEnum> kLinkedPolarizations{
+      aocommon::Polarization::XX, aocommon::Polarization::XY,
+      aocommon::Polarization::YX, aocommon::Polarization::YY};
+  ImageSet dset(*table, false, kLinkedPolarizations, 2, 2);
+  dset = 0.0;
+  dset.Data(0)[1] = 10.0;
+  dset.Data(1)[1] = 0.25;
+  dset.Data(2)[1] = 0.25;
+  dset.Data(3)[1] = 10.0;
+  checkLinearValue(1, 10.25f, dset);
+  checkSquaredValue(
+      1, std::sqrt((10.0f * 10.0f * 2.0f + 0.25f * 0.25f * 2.0f) * 0.5f), dset);
+}
+
+BOOST_FIXTURE_TEST_CASE(xx_xy_yx_yy_2channel_Normalization,
+                        ImageSetFixtureBase) {
+  initTable(2, 2);
+  addToImageSet(0, aocommon::Polarization::XX, 100);
+  addToImageSet(0, aocommon::Polarization::XY, 100);
+  addToImageSet(0, aocommon::Polarization::YX, 100);
+  addToImageSet(0, aocommon::Polarization::YY, 100);
+  addToImageSet(1, aocommon::Polarization::XX, 200);
+  addToImageSet(1, aocommon::Polarization::XY, 200);
+  addToImageSet(1, aocommon::Polarization::YX, 200);
+  addToImageSet(1, aocommon::Polarization::YY, 200);
+  const std::set<PolarizationEnum> kLinkedPolarizations{
+      aocommon::Polarization::XX, aocommon::Polarization::XY,
+      aocommon::Polarization::YX, aocommon::Polarization::YY};
+  ImageSet dset(*table, false, kLinkedPolarizations, 2, 2);
+  dset = 0.0;
+  dset.Data(0)[2] = 5.0;
+  dset.Data(1)[2] = 0.1;
+  dset.Data(2)[2] = 0.2;
+  dset.Data(3)[2] = 6.0;
+  dset.Data(4)[2] = 7.0;
+  dset.Data(5)[2] = 0.3;
+  dset.Data(6)[2] = 0.4;
+  dset.Data(7)[2] = 8.0;
+  double sqVal1 = 0.0, sqVal2 = 0.0;
+  for (size_t i = 0; i != 4; ++i) {
+    sqVal1 += dset[i][2] * dset[i][2];
+    sqVal2 += dset[i + 4][2] * dset[i + 4][2];
+  }
+  checkLinearValue(2, 27.0 * 0.25, dset);
+  checkSquaredValue(
+      2, (std::sqrt(sqVal1 * 0.5) + std::sqrt(sqVal2 * 0.5)) * 0.5, dset);
+}
+
+BOOST_FIXTURE_TEST_CASE(qu_squared_2channel_Normalization,
+                        ImageSetFixtureBase) {
+  initTable(2, 2);
+  addToImageSet(0, aocommon::Polarization::StokesQ, 100);
+  addToImageSet(0, aocommon::Polarization::StokesU, 100);
+  addToImageSet(1, aocommon::Polarization::StokesQ, 100);
+  addToImageSet(1, aocommon::Polarization::StokesU, 100);
+  const std::set<PolarizationEnum> kJoinedPolarizations{
+      aocommon::Polarization::StokesQ, aocommon::Polarization::StokesU};
+  const bool kSquaredJoins = true;
+  ImageSet dset(*table, kSquaredJoins, kJoinedPolarizations, 2, 2);
+  dset = 0.0;
+  const size_t kCheckedPixel = 2;
+  dset.Data(0)[kCheckedPixel] = 5.0;
+  dset.Data(1)[kCheckedPixel] = 6.0;
+  dset.Data(2)[kCheckedPixel] = 7.0;
+  dset.Data(3)[kCheckedPixel] = 8.0;
+  double sqVal = 0.0;
+  for (size_t i = 0; i != 4; ++i) {
+    sqVal += dset[i][kCheckedPixel] * dset[i][kCheckedPixel];
+  }
+  checkSquaredValue(kCheckedPixel, std::sqrt(sqVal / 4.0), dset);
+}
+
+BOOST_FIXTURE_TEST_CASE(linked_xx_yy_2channel_Normalization,
+                        ImageSetFixtureBase) {
+  initTable(2, 2);
+  addToImageSet(0, aocommon::Polarization::XX, 100);
+  addToImageSet(0, aocommon::Polarization::XY, 100);
+  addToImageSet(0, aocommon::Polarization::YX, 100);
+  addToImageSet(0, aocommon::Polarization::YY, 100);
+  addToImageSet(1, aocommon::Polarization::XX, 200);
+  addToImageSet(1, aocommon::Polarization::XY, 200);
+  addToImageSet(1, aocommon::Polarization::YX, 200);
+  addToImageSet(1, aocommon::Polarization::YY, 200);
+  const std::set<PolarizationEnum> kLinkedPolarizations{
+      aocommon::Polarization::XX, aocommon::Polarization::YY};
+  ImageSet dset(*table, false, kLinkedPolarizations, 2, 2);
+  dset = 0.0;
+  dset.Data(0)[2] = 7.5;
+  dset.Data(1)[2] = 0.1;
+  dset.Data(2)[2] = -0.2;
+  dset.Data(3)[2] = 6.5;
+  dset.Data(4)[2] = 8.5;
+  dset.Data(5)[2] = 0.3;
+  dset.Data(6)[2] = -0.4;
+  dset.Data(7)[2] = 9.5;
+  double sqVal1 = dset[0][2] * dset[0][2] + dset[3][2] * dset[3][2],
+         sqVal2 = dset[4][2] * dset[4][2] + dset[7][2] * dset[7][2];
+  checkLinearValue(2, 32.0 * 0.25, dset);
+  checkSquaredValue(
+      2, (std::sqrt(sqVal1 * 0.5) + std::sqrt(sqVal2 * 0.5)) * 0.5, dset);
+}
+
+BOOST_FIXTURE_TEST_CASE(linked_xx_2channel_Normalization, ImageSetFixtureBase) {
+  initTable(2, 2);
+  addToImageSet(0, aocommon::Polarization::XX, 100);
+  addToImageSet(0, aocommon::Polarization::XY, 100);
+  addToImageSet(0, aocommon::Polarization::YX, 100);
+  addToImageSet(0, aocommon::Polarization::YY, 100);
+  addToImageSet(1, aocommon::Polarization::XX, 200);
+  addToImageSet(1, aocommon::Polarization::XY, 200);
+  addToImageSet(1, aocommon::Polarization::YX, 200);
+  addToImageSet(1, aocommon::Polarization::YY, 200);
+  ImageSet dset(*table, false, {aocommon::Polarization::XX}, 2, 2);
+  dset = 0.0;
+  dset.Data(0)[2] = 7.5;
+  dset.Data(1)[2] = 0.1;
+  dset.Data(2)[2] = -0.2;
+  dset.Data(3)[2] = 6.5;
+  dset.Data(4)[2] = 8.5;
+  dset.Data(5)[2] = 0.3;
+  dset.Data(6)[2] = -0.4;
+  dset.Data(7)[2] = 9.5;
+  double sqVal1 = dset[0][2] * dset[0][2], sqVal2 = dset[4][2] * dset[4][2];
+  checkLinearValue(2, 32.0 * 0.25, dset);
+  checkSquaredValue(2, (std::sqrt(sqVal1) + std::sqrt(sqVal2)) * 0.5, dset);
+}
+
+BOOST_FIXTURE_TEST_CASE(deconvchannels_normalization, ImageSetFixtureBase) {
+  initTable(4, 2);
+  addToImageSet(0, aocommon::Polarization::StokesI, 100, 1);
+  addToImageSet(1, aocommon::Polarization::StokesI, 200, 1);
+  addToImageSet(2, aocommon::Polarization::StokesI, 300, 2);
+  addToImageSet(3, aocommon::Polarization::StokesI, 400, 2);
+  ImageSet dset(*table, false, {aocommon::Polarization::StokesI}, 2, 2);
+  dset = 0.0;
+  dset.Data(0)[0] = 10.0;
+  dset.Data(1)[0] = 13.0;
+  checkLinearValue(0, 12.0, dset);
+  checkSquaredValue(0, 12.0, dset);
+}
+
+BOOST_FIXTURE_TEST_CASE(deconvchannels_zeroweight, ImageSetFixtureBase) {
+  initTable(4, 2);
+  addToImageSet(0, aocommon::Polarization::StokesI, 100, 1);
+  addToImageSet(1, aocommon::Polarization::StokesI, 200, 0);
+  addToImageSet(2, aocommon::Polarization::StokesI, 300, 2);
+  addToImageSet(3, aocommon::Polarization::StokesI, 400, 2);
+  ImageSet dset(*table, false, {aocommon::Polarization::StokesI}, 2, 2);
+  dset = 0.0;
+  dset.Data(0)[0] = 10.0;
+  dset.Data(1)[0] = 5.0;
+  checkLinearValue(0, 6.0, dset);
+  checkSquaredValue(0, 6.0, dset);
+}
+
+BOOST_FIXTURE_TEST_CASE(deconvchannels_divisor, ImageSetFixtureBase) {
+  initTable(16, 3);
+  for (size_t ch = 0; ch != table->OriginalGroups().size(); ++ch) {
+    addToImageSet(ch, aocommon::Polarization::StokesI, 100 + ch, 1);
+  }
+  ImageSet dset(*table, false, {aocommon::Polarization::StokesI}, 2, 2);
+  dset = 0.0;
+  for (size_t ch = 0; ch != table->DeconvolutionGroups().size(); ++ch) {
+    dset.Data(ch)[0] = 7.0;
+  }
+  checkLinearValue(0, 7.0, dset);
+  checkSquaredValue(0, 7.0, dset);
+
+  BOOST_CHECK_EQUAL(dset.PSFIndex(0), 0u);
+  BOOST_CHECK_EQUAL(dset.PSFIndex(1), 1u);
+  BOOST_CHECK_EQUAL(dset.PSFIndex(2), 2u);
+}
+
+BOOST_FIXTURE_TEST_CASE(psfindex, ImageSetFixtureBase) {
+  initTable(4, 2);
+  for (size_t ch = 0; ch != table->OriginalGroups().size(); ++ch) {
+    addToImageSet(ch, aocommon::Polarization::XX, 100, 1);
+    addToImageSet(ch, aocommon::Polarization::XY, 200, 0);
+    addToImageSet(ch, aocommon::Polarization::YX, 300, 2);
+    addToImageSet(ch, aocommon::Polarization::YY, 400, 2);
+  }
+  const std::set<PolarizationEnum> kLinkedPolarizations{
+      aocommon::Polarization::XX, aocommon::Polarization::XY,
+      aocommon::Polarization::YX, aocommon::Polarization::YY};
+  ImageSet dset(*table, false, kLinkedPolarizations, 2, 2);
+
+  BOOST_CHECK_EQUAL(dset.PSFIndex(0), 0u);
+  BOOST_CHECK_EQUAL(dset.PSFIndex(1), 0u);
+  BOOST_CHECK_EQUAL(dset.PSFIndex(2), 0u);
+  BOOST_CHECK_EQUAL(dset.PSFIndex(3), 0u);
+  BOOST_CHECK_EQUAL(dset.PSFIndex(4), 1u);
+  BOOST_CHECK_EQUAL(dset.PSFIndex(5), 1u);
+  BOOST_CHECK_EQUAL(dset.PSFIndex(6), 1u);
+  BOOST_CHECK_EQUAL(dset.PSFIndex(7), 1u);
+}
+
+BOOST_FIXTURE_TEST_CASE(load_and_average, ImageSetFixtureBase) {
+  initTable(6, 2);
+  const size_t nPol = 2;
+  const PolarizationEnum pols[nPol] = {PolarizationEnum::XX,
+                                       PolarizationEnum::YY};
+  const size_t width = 7;
+  const size_t height = 9;
+  const std::vector<double> weights{4.0, 4.0, 0.0, 0.0, 1.0, 1.0};
+  Image storedImage(width, height);
+  for (size_t ch = 0; ch != table->OriginalGroups().size(); ++ch) {
+    for (size_t p = 0; p != nPol; ++p) {
+      size_t index = ch * nPol + p;
+      storedImage = (1 << index);  // assign the entire image to 2^index
+      modelImage = storedImage;
+      addToImageSet(ch, pols[p], 100 + ch, weights[ch]);
+    }
+  }
+  const std::set<PolarizationEnum> kLinkedPolarizations{
+      aocommon::Polarization::XX, aocommon::Polarization::YY};
+
+  ImageSet imageSet(*table, false, kLinkedPolarizations, width, height);
+  imageSet.LoadAndAverage(false);
+  // The first image has all values set to 2^0, the second image 2^1, etc...
+  // The XX polarizations of deconvolution channel 1 consists of
+  // images 0, 2 and 4. These have been weighted with 4, 4, 0:
+  BOOST_CHECK_CLOSE_FRACTION(imageSet[0 * nPol + 0][0],
+                             double(1 * 4 + 4 * 4 + 16 * 0) / 8.0, 1e-6);
+  // The YY polarizations consists of images 1, 3 and 5, weights 4, 4, 0:
+  BOOST_CHECK_CLOSE_FRACTION(imageSet[0 * nPol + 1][0],
+                             double(2 * 4 + 8 * 4 + 32 * 0) / 8.0, 1e-6);
+  // The XX polarizations of deconvolution channel 2 consists of images 6, 8 and
+  // 10 Weights 0, 1, 1
+  BOOST_CHECK_CLOSE_FRACTION(imageSet[1 * nPol + 0][0],
+                             double(64 * 0 + 256 * 1 + 1024 * 1) / 2.0, 1e-6);
+  // YY: images 7, 9, 10, weights 0, 1, 1
+  BOOST_CHECK_CLOSE_FRACTION(imageSet[1 * nPol + 1][0],
+                             double(128 * 0 + 512 * 1 + 2048 * 1) / 2.0, 1e-6);
+
+  // The total linear integrated sum should be a complete
+  // weighting of all input channels
+  Image linearIntegrated(width, height);
+  imageSet.GetLinearIntegrated(linearIntegrated);
+  BOOST_CHECK_CLOSE_FRACTION(
+      linearIntegrated[0],
+      double(1 * 4 + 4 * 4 + 16 * 0 + 2 * 4 + 8 * 4 + 32 * 0 + 64 * 0 +
+             256 * 1 + 1024 * 1 + 128 * 0 + 512 * 1 + 2048 * 1) /
+          20.0,
+      1e-6);
+
+  BOOST_CHECK_THROW(imageSet.LoadAndAverage(true), std::logic_error);
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+}  // namespace radler
diff --git a/cpp/utils/application.h b/cpp/utils/application.h
new file mode 100644
index 00000000..2610a98a
--- /dev/null
+++ b/cpp/utils/application.h
@@ -0,0 +1,49 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_UTILS_APPLICATION_H_
+#define RADLER_UTILS_APPLICATION_H_
+
+#include <aocommon/logger.h>
+
+#include <sys/types.h>
+#include <sys/wait.h>
+
+#include <unistd.h>
+
+namespace radler::utils {
+
+class Application {
+ public:
+  static void Run(const std::string& commandLine) {
+    aocommon::Logger::Info << "Running: " << commandLine << '\n';
+    const char* commandLineCStr = commandLine.c_str();
+    int pid = vfork();
+    switch (pid) {
+      case -1:  // Error
+        throw std::runtime_error(
+            "Could not vfork() new process for executing command line "
+            "application");
+      case 0:  // Child
+        execl("/bin/sh", "sh", "-c", commandLineCStr, NULL);
+        _exit(127);
+    }
+    // Wait for process to terminate
+    int pStatus;
+    do {
+      int pidReturn;
+      do {
+        pidReturn = waitpid(pid, &pStatus, 0);
+      } while (pidReturn == -1 && errno == EINTR);
+    } while (!WIFEXITED(pStatus) && !WIFSIGNALED(pStatus));
+    if (WIFEXITED(pStatus)) {
+      // all good
+      // const int exitStatus = WEXITSTATUS(pStatus);
+    } else {
+      throw std::runtime_error(
+          "Running command line application returned an error");
+    }
+  }
+};
+}  // namespace radler::utils
+#endif  // RADLER_UTILS_APPLICATION_H_
diff --git a/cpp/utils/casa_mask_reader.cc b/cpp/utils/casa_mask_reader.cc
new file mode 100644
index 00000000..13cc3f56
--- /dev/null
+++ b/cpp/utils/casa_mask_reader.cc
@@ -0,0 +1,40 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "utils/casa_mask_reader.h"
+
+#include <casacore/tables/Tables/Table.h>
+#include <casacore/tables/Tables/ArrayColumn.h>
+
+#include <aocommon/uvector.h>
+
+namespace radler::utils {
+
+CasaMaskReader::CasaMaskReader(const std::string& path) : _path(path) {
+  casacore::Table table(path);
+  casacore::ROArrayColumn<float> mapCol(table, "map");
+  casacore::IPosition shape = mapCol.shape(0);
+  _width = shape(0);
+  _height = shape(1);
+  _nPolarizations = shape(2);
+  _nChannels = shape(3);
+}
+
+void CasaMaskReader::Read(bool* mask) {
+  casacore::Table table(_path);
+  casacore::ROArrayColumn<float> mapCol(table, "map");
+  casacore::Array<float> data(mapCol.get(0));
+  for (size_t i = 0; i != _width * _height; ++i) mask[i] = false;
+  casacore::Array<float>::contiter iter = data.cbegin();
+  bool* maskPtr = mask;
+  for (size_t j = 0; j != _nChannels * _nPolarizations; ++j) {
+    for (size_t y = 0; y != _height; ++y) {
+      for (size_t x = 0; x != _width; ++x) {
+        *maskPtr = *maskPtr || (*iter != 0.0);
+        ++iter;
+        ++maskPtr;
+      }
+    }
+  }
+}
+}  // namespace radler::utils
\ No newline at end of file
diff --git a/cpp/utils/casa_mask_reader.h b/cpp/utils/casa_mask_reader.h
new file mode 100644
index 00000000..0c8ccda0
--- /dev/null
+++ b/cpp/utils/casa_mask_reader.h
@@ -0,0 +1,26 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_UTILS_CASA_MASK_READER_H_
+#define RADLER_UTILS_CASA_MASK_READER_H_
+
+#include <string>
+
+namespace radler::utils {
+class CasaMaskReader {
+ public:
+  explicit CasaMaskReader(const std::string& path);
+
+  void Read(bool* mask);
+
+  size_t Width() const { return _width; }
+  size_t Height() const { return _height; }
+  size_t NPolarizations() const { return _nPolarizations; }
+  size_t NChannels() const { return _nChannels; }
+
+ private:
+  std::string _path;
+  size_t _width, _height, _nPolarizations, _nChannels;
+};
+}  // namespace radler::utils
+#endif
diff --git a/cpp/utils/write_model.h b/cpp/utils/write_model.h
new file mode 100644
index 00000000..6a3bfa91
--- /dev/null
+++ b/cpp/utils/write_model.h
@@ -0,0 +1,61 @@
+// Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RADLER_UTILS_MODEL_WRITE_MODEL_H_
+#define RADLER_UTILS_MODEL_WRITE_MODEL_H_
+
+#include <fstream>
+
+#include <boost/filesystem/operations.hpp>
+
+#include <aocommon/radeccoord.h>
+#include <aocommon/uvector.h>
+
+namespace radler::utils {
+inline void WriteHeaderForSpectralTerms(std::ostream& stream,
+                                        double reference_frequency) {
+  stream.precision(15);
+  stream << "Format = Name, Type, Ra, Dec, I, SpectralIndex, LogarithmicSI, "
+            "ReferenceFrequency='"
+         << reference_frequency << "', MajorAxis, MinorAxis, Orientation\n";
+}
+
+inline void AddSiTerms(std::ostream& stream,
+                       const std::vector<float>& si_terms) {
+  assert(!si_terms.empty());
+  stream << si_terms.front() << ",[";
+  if (si_terms.size() >= 2) {
+    stream << si_terms[1];
+    for (size_t i = 2; i != si_terms.size(); ++i) {
+      stream << ',' << si_terms[i];
+    }
+  }
+  stream << ']';
+}
+
+inline void WritePolynomialPointComponent(
+    std::ostream& stream, const std::string& name, long double ra,
+    long double dec, bool use_log_si,
+    const std::vector<float>& polarization_terms,
+    double reference_frequency_hz) {
+  stream << name << ",POINT," << aocommon::RaDecCoord::RAToString(ra, ':')
+         << ',' << aocommon::RaDecCoord::DecToString(dec, '.') << ',';
+  AddSiTerms(stream, polarization_terms);
+  stream << "," << (use_log_si ? "true" : "false") << ","
+         << reference_frequency_hz << ",,,\n";
+}
+
+inline void WritePolynomialGaussianComponent(
+    std::ostream& stream, const std::string& name, long double ra,
+    long double dec, bool use_log_si,
+    const std::vector<float>& polarization_terms, double reference_frequency_hz,
+    double maj, double min, double position_angle) {
+  stream << name << ",GAUSSIAN," << aocommon::RaDecCoord::RAToString(ra, ':')
+         << ',' << aocommon::RaDecCoord::DecToString(dec, '.') << ',';
+  AddSiTerms(stream, polarization_terms);
+  stream << "," << (use_log_si ? "true" : "false") << ","
+         << reference_frequency_hz << "," << maj << ',' << min << ','
+         << position_angle << "\n";
+}
+}  // namespace radler::utils
+#endif
diff --git a/docker/ubuntu_20_04_base b/docker/ubuntu_20_04_base
new file mode 100644
index 00000000..12d94cfb
--- /dev/null
+++ b/docker/ubuntu_20_04_base
@@ -0,0 +1,17 @@
+# Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# base
+FROM ubuntu:20.04
+RUN export DEBIAN_FRONTEND=noninteractive && apt-get update && apt-get upgrade -y && \
+# install astronomy packages
+    apt-get -y install libcfitsio-dev wcslib-dev libfftw3-dev libgsl-dev \
+# install misc packages
+    wget git make cmake g++ doxygen \
+# install dependencies
+    libboost-all-dev libhdf5-dev libpython3-dev python3-pip \
+    casacore-dev casacore-tools clang-format-12 \
+# The formatter needs a binary named 'clang-format', not 'clang-format-12'.
+# Same for clang-tidy-12.
+    && ln -sf clang-format-12 /usr/bin/clang-format \
+    && python3 -m pip install gcovr==5.0 cmake-format
\ No newline at end of file
diff --git a/docker/ubuntu_22_04_base b/docker/ubuntu_22_04_base
new file mode 100644
index 00000000..ccaf7009
--- /dev/null
+++ b/docker/ubuntu_22_04_base
@@ -0,0 +1,17 @@
+# Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# base
+FROM ubuntu:22.04
+RUN export DEBIAN_FRONTEND=noninteractive && apt-get update && apt-get upgrade -y && \
+# install astronomy packages
+    apt-get -y install libcfitsio-dev wcslib-dev libfftw3-dev libgsl-dev \
+# install misc packages
+    wget git make cmake g++ doxygen \
+# install dependencies
+    libboost-all-dev libhdf5-dev libpython3-dev python3-pip \
+    casacore-dev casacore-tools clang-format-12 \
+# The formatter needs a binary named 'clang-format', not 'clang-format-12'.
+# Same for clang-tidy-12.
+    && ln -sf clang-format-12 /usr/bin/clang-format \
+    && python3 -m pip install gcovr==5.0 cmake-format
\ No newline at end of file
diff --git a/external/aocommon b/external/aocommon
new file mode 160000
index 00000000..415a44c7
--- /dev/null
+++ b/external/aocommon
@@ -0,0 +1 @@
+Subproject commit 415a44c78bc23509e74930f318a495ac3e4dd0ab
diff --git a/external/pybind11 b/external/pybind11
new file mode 160000
index 00000000..914c06fb
--- /dev/null
+++ b/external/pybind11
@@ -0,0 +1 @@
+Subproject commit 914c06fb252b6cc3727d0eedab6736e88a3fcb01
diff --git a/external/schaapcommon b/external/schaapcommon
new file mode 160000
index 00000000..f62867e3
--- /dev/null
+++ b/external/schaapcommon
@@ -0,0 +1 @@
+Subproject commit f62867e3e52bbccd0a1dbf059f829d8a9947e968
diff --git a/scripts/run-format.sh b/scripts/run-format.sh
new file mode 100755
index 00000000..f0f1c49d
--- /dev/null
+++ b/scripts/run-format.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+# Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+#The directory that contains the source files.
+SOURCE_DIR=$(dirname "$0")/..
+
+#Directories that must be excluded from formatting. These paths are
+#relative to SOURCE_DIR.
+EXCLUDE_DIRS=(external build CMake)
+
+#The patterns of the C++ source files, which clang-format should format.
+CXX_SOURCES=(*.cc *.h)
+
+#The patterns of the CMake source files, which cmake-format should format.
+CMAKE_SOURCES=(CMakeLists.txt *.cmake)
+
+#End script configuration.
+
+#The common formatting script has further documentation.
+source $(dirname "$0")/../external/aocommon/scripts/format.sh
-- 
GitLab